Self-hosted multi-platform broadcast newsroom for activist collectives: publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style column wall with hostile-content-safe rendering, live control room with Auto Director, bridged chat governance with polls and quorum, distributed sandboxed transcode workers, encrypted content-addressed archive, monthly analytics. MIT licensed. See README.md and docs/.
48 KiB
Merged Design — Group Review & Signoff for Posts
Status: design for implementation (merge of three proposals + user decisions v2, pending audit)
Date: 2026-10-07 (v2: connection-scoped approval delegation added per user decision)
Inputs: runs/wp5/proposal-opus.md (rigor/defense-in-depth), runs/wp5/proposal-sol.md (submission lifecycle), runs/wp5/proposal-glm.md (minimal surface), spec runs/wp5/spec-review-workflow.md, user decisions 2026-10-07
Provenance legend: [OP], [SOL], [GLM] mark which proposal a decision comes from; [ALL] = all three converged; [MERGE] = this document's synthesis.
1. Executive summary — what this design is
- [OP] The foundation is fixed first. Eight pre-existing defects (§2) are repaired in a prerequisite migration because the review feature multiplies each of them. Most critically: the scheduler currently publishes without checking draft state, and the audit hash does not cover the
difffield (so "snapshot hash in the audit log" would not be tamper-evident). - [ALL] Group roles are a column on
group_members—member | editor | reviewer, mutually exclusive by construction. The existing epoch trigger fires on UPDATE, so role changes invalidate sessions for free. - [GLM+OP] Approvals attach to immutable version rows with a database-computed hash.
post_versions.content_hashis a Postgres generated column — it can never disagree with the content [GLM]. Votes copy the hash they approve; the scheduler publishes the approved version's bytes, never the mutable draft [OP]. [AUDIT #24] The defence order is explicit: §6.6 full-reset is the primary defence; the §4.2 schedule-time hash check is a backstop whose refusal is a 409STALE_VERSIONdirecting the author to re-submit — never a silent second path, never an approved-but-unschedulable draft. - [SOL+GLM] The review cycle is the unit of approval. One
review_cyclesrow per (draft, round) pins version, group (nullable +group_namesnapshot [AUDIT #16]), quorum, deadline, and status. Quorum config (workspace default + group override) is snapshotted at submit — config changes never move goalposts mid-review [ALL]. - [ALL] A single deny is a veto — cycle closes
denied, draft →needs_changes(replaces today's terminalrejected, unlocking resubmit). Deny asks for a reason in the UI (optional but suggested [USER v2]; recorded in thread + audit when given). Any content edit supersedes the cycle; resubmit always starts at 0/N. - [GLM] Chat is polling first (4 s cursor endpoint), over [SOL]'s unified event log: chat messages and system events are rows in one
review_eventstable. Schema is transport-agnostic; SSE doorbells [OP] are a later upgrade. - [GLM] Deadlocks are refused at the door — with one defined exception [AUDIT #17]: submit runs an eligibility preflight ("not enough eligible reviewers (0) for quorum (2)") and 400s when quorum ≥ 2 is unreachable; when N=1 and 0 eligible reviewers exist, the cycle is still created, directly in
blocked(blocked-at-submit), so the admin override always has a cycle to act on and quorum 1 never silently self-approves a solo author. A distinctblockedstate handles mid-review reviewer-pool shrinkage. Admin override is a separate, reason-required action that writes no vote row [SOL+OP]. - [GLM]
publishedis the archive; moderation is flags, not states. Hide/delete are admin-only soft flags; the scheduler'sscheduled→publishedwrite needs zero changes. - [USER v2] Approval authority is per-account (connection), chosen when the account is added. Every connection declares one of two approval modes:
system— approvals flow through the group/workspace reviewer chain (the default), ordelegated— the account's owner (whoever added/controls it) names the specific people who approve what goes out from that account. This scales to hundreds of accounts, each with its own approver pool. Quorum size and admin control remain group settings (§3.4).
New states: primed [SOL], needs_changes [ALL], expired [ALL], blocked [GLM]. The state machine is enforced twice: CAS UPDATEs in the service and a DB legal-transition trigger (defense in depth) [ALL].
2. Prerequisite fixes (WP5.0) — from [OP], all verified real
| # | Where | Defect | Fix |
|---|---|---|---|
| D1 | apps/scheduler/src/activities/publish.activity.ts:136-141 |
claim() joins drafts but never checks d.state; publishes any pending schedule row's d.post |
claim requires state='scheduled' (plus the pending scheduled_publications row); publish post_versions.post of approved_version_id (post-011) |
| D2 | publish.activity.ts:79 |
claim runs outside try; "not pending" throw retries forever (no retry limit) |
move inside try; use non-retryable application error |
| D3 | 001_tenants.sql:92-98 |
audit_hash excludes diff — values recorded in diff are not tamper-evident |
migration 010: hash v2 covers diff; verify both versions |
| D4 | publishing.service.ts:136+ |
audit written after commit — crash window loses audit rows | same-tx audit_append via withTenant client (groups pattern, groups.service.ts:123-133) |
| D5 | publishing.controller.ts:113-129 + rbac.guard.ts:64-74 |
schedule takes connectionId from body; RequireGroupAccess only checks :connectionId params |
validate body connection against group scope via userCanUseConnection - kept as the API-level guard; 4.1(3) adds the composite FK + seal-time trigger as the DB backstop [SECURITY #9] |
| D6 | realtime/gateway.ts:20-29 |
epoch compared against raw bigint string (always "stale"); membership query omits status='active' |
normalize to text compare; add status filter (kept even though we don't build WS now) |
| D7 | api-client.ts:90 + guard |
any epoch bump 401s every user and the client drops the token — no re-issue | POST /auth/reissue: re-read live authz, mint fresh token; client retries once on epoch-401 |
| D8 | 002_rbac_seed.sql:9,25,35 |
publication.delete seeded to Owner/Admin/Publisher, unenforced |
do not reuse it for moderation; new dedicated perms (§3) |
Migrations: 010_audit_hash_v2.sql (D3: hash_version column, audit_hash_v2 covering diff, advisory-lock append serialization, dual-version audit_verify_chain) — full SQL in proposal-opus.md §2.2.
3. Roles & permissions (R2)
3.1 Group roles [ALL]
ALTER TABLE group_members
ADD COLUMN review_role text NOT NULL DEFAULT 'member'
CHECK (review_role IN ('member', 'editor', 'reviewer'));
- member: author drafts, chat in review threads.
- editor: edit any group post's content; submit for review; cannot approve/deny/publish.
- reviewer: approve/deny (reason suggested, optional [USER v2]); cannot edit content.
- Mutually exclusive single column = structural separation of duties. Role changes bump the authz epoch (trigger already fires on UPDATE,
008_groups.sql:79-80); the service returns a{token}replacement for the caller (existing pattern,groups.service.ts:226-269) [ALL]. - Workspace roles remain the floor: any review role still needs
draft.viewvia active membership.
3.2 New permissions [MERGE]
| permission | granted to | purpose |
|---|---|---|
review.manage |
Owner, Admin | set workspace/group quorum, stale_after |
approval.override |
Owner, Admin | reason-required override of a blocked/expired cycle (writes no vote) |
moderation.hide |
Owner, Admin | hide a post (flag) |
moderation.delete |
Owner, Admin | soft-delete a post (flag) |
Author exclusion is structural: eligibility SQL excludes the author, every row in draft_contributors [OP], and (for override) any contributor. Sealed-by also excluded [OP].
3.4 Group settings [USER v2]
Groups carry the review settings (writable by review.manage holders): quorum (review_approvals_required, NULL = inherit workspace default) and admin control (review_admin_control: whether Owner/Admin may exempt their own posts from review or override a stuck cycle in this group).
3.5 Connection approval modes [USER v2 — per-account delegation]
Every account (connection) declares how its approvals work, set/changed by the connection's controller (its owner / whoever added it) or workspace admins:
system(default): the group's reviewer chain approves (§3.1).delegated: the controller names a dedicated approver pool for that account (connection_approvers). Posts targeting this account need signoff from that pool — same quorum N from the group settings, same structural author/contributor exclusion. Group reviewers do not count for a delegated account unless also named in its pool.- Scale target: hundreds of connections, each with its own few approvers — "100 of accounts, and there are people per account who actually approve what goes out."
- Admin override/exempt still applies per the group's
review_admin_control, regardless of mode.
3.3 Vote visibility [USER v2 — supersedes the merged blind default]
Fully transparent by default. Everyone in the group sees the complete vote ledger during review: who approved, who denied, who is yet to vote ("2/3 — Ana ✓, Ben ✓, Chi pending"). Deny reasons (when given) are visible inline in the thread. No server-side identity masking. A blind_votes group setting remains available for teams that want blind review later, but transparency is the default posture: [USER] "votes etc super transparent".
4. Data model — 011_review_workflow.sql
(Numbering: 009_video.sql already exists [OP, GLM caught this].)
4.1 Tables
-- (1) group roles — §3.1
-- (2) quorum config [GLM]
CREATE TABLE workspace_review_settings (
workspace_id uuid PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
approvals_required int NOT NULL DEFAULT 1 CHECK (approvals_required BETWEEN 1 AND 5), -- [AUDIT #23] 0 is refused: no transition closes a required=0 cycle (deadlock-by-config)
stale_after_days int NOT NULL DEFAULT 90 CHECK (stale_after_days BETWEEN 1 AND 3650),
blind_votes boolean NOT NULL DEFAULT true,
updated_by uuid REFERENCES users(id),
updated_at timestamptz NOT NULL DEFAULT now()
);
ALTER TABLE groups
ADD COLUMN review_approvals_required int CHECK (review_approvals_required BETWEEN 1 AND 5), -- [AUDIT #23] same floor as the workspace default
ADD COLUMN review_blind_votes boolean, -- NULL = inherit workspace default
ADD COLUMN review_admin_control boolean NOT NULL DEFAULT true; -- [USER v2]
-- Precedence resolved ONCE at submit: group row ?? workspace row ?? built-in default(1)
-- and snapshotted onto the cycle (§6).
-- (2b) per-account approval delegation [USER v2]
ALTER TABLE connections
ADD COLUMN approval_mode text NOT NULL DEFAULT 'system'
CHECK (approval_mode IN ('system','delegated'));
-- [AUDIT #9/#13] composite-FK target on connections: must exist before any FK that
-- references connections(id, workspace_id) (connection_approvers, post_versions)
ALTER TABLE connections ADD CONSTRAINT connections_id_ws_uk UNIQUE (id, workspace_id);
CREATE TABLE connection_approvers (
workspace_id uuid NOT NULL,
connection_id uuid NOT NULL,
user_id uuid NOT NULL REFERENCES users(id),
assigned_by uuid NOT NULL REFERENCES users(id),
assigned_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (connection_id, user_id),
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections(id, workspace_id) ON DELETE CASCADE
);
-- Assignments: assigner must be the connection controller or hold connection.manage;
-- approver must hold an active workspace membership. Changes bump the authz epoch
-- (trigger, mirroring group_members) and revoke the removed approver's live votes.
-- (2c) [AUDIT #5] composite-FK target on drafts: must exist before any FK that
-- references drafts(id, workspace_id) (post_versions, draft_contributors,
-- review_cycles, review_events below)
ALTER TABLE drafts ADD CONSTRAINT drafts_id_ws_uk UNIQUE (id, workspace_id);
-- (3) immutable versions [GLM hash mechanics + SOL target pinning]
CREATE TABLE post_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
draft_id uuid NOT NULL,
version int NOT NULL CHECK (version >= 1),
post jsonb NOT NULL,
connection_id uuid, -- sealed target; NOT NULL when draft is group-scoped [SOL]
content_hash text GENERATED ALWAYS AS (encode(digest(post::text, 'sha256'), 'hex')) STORED,
seal_kind text NOT NULL CHECK (seal_kind IN ('submit','edit','reopen','migrated')),
sealed_by uuid NOT NULL REFERENCES users(id),
sealed_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (draft_id, version),
UNIQUE (id, draft_id, workspace_id),
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE CASCADE,
FOREIGN KEY (connection_id, workspace_id) REFERENCES connections(id, workspace_id) ON DELETE RESTRICT -- [SECURITY #9, AUDIT #13] composite FK: same-workspace target, DB-enforced; RESTRICT so a connection referenced by a sealed version cannot be deleted out from under it (connections are soft-disabled instead)
);
-- [AUDIT #11] Single contract, stated once: versions are update-immutable and
-- cascade-deletable ONLY via `drafts` — the trigger rejects UPDATE outright and
-- rejects any DELETE whose root is not the drafts cascade (pg_trigger_depth() check);
-- BEFORE INSERT trigger requires connection_id when the draft has group_id [SOL].
-- [SECURITY #9] Seal-time guard (BEFORE INSERT - a database invariant; D5 stays as the
-- API-level guard): when connection_id IS NOT NULL the connection must belong to the
-- SAME workspace (composite FK above) AND be in scope for the draft's group:
-- drafts.group_id IS NULL OR EXISTS (SELECT 1 FROM group_connections gc
-- WHERE gc.connection_id = NEW.connection_id AND gc.group_id = drafts.group_id).
-- Any mismatch raises; a version is never sealed against a cross-tenant or
-- out-of-scope connection.
-- (4) contributors [OP]
CREATE TABLE draft_contributors (
workspace_id uuid NOT NULL,
draft_id uuid NOT NULL,
user_id uuid NOT NULL REFERENCES users(id),
first_at timestamptz NOT NULL DEFAULT now(),
last_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (draft_id, user_id),
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE CASCADE
);
-- [AUDIT #10] Append-only and immutable: a BEFORE INSERT/UPDATE/DELETE trigger
-- rejects UPDATE and DELETE outright (contributor rows are history; last_at is
-- never rewritten), with the sole exception of the drafts cascade
-- (pg_trigger_depth() check). INSERT is the only legal mutation and it is
-- mandatory: every seal (submit/edit/reopen) inserts rows for the sealer and any
-- editor recorded since the last seal, in the same transaction — a post_versions
-- seal trigger fails the tx if the sealer has no draft_contributors row.
-- (5) review cycles [SOL shape, GLM snapshot semantics]
CREATE TABLE review_cycles (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
draft_id uuid NOT NULL,
group_id uuid, -- [AUDIT #16] nullable: group delete SET NULL, mirroring drafts
group_name text, -- [AUDIT #16] group name snapshot at submit: audit trail survives group deletion
round int NOT NULL CHECK (round >= 1),
version_id uuid NOT NULL,
required_approvals smallint NOT NULL CHECK (required_approvals BETWEEN 1 AND 5), -- [AUDIT #23] snapshot inherits the same 1..5 floor
policy_source text NOT NULL CHECK (policy_source IN ('group','workspace','default')),
approval_source text NOT NULL DEFAULT 'group' CHECK (approval_source IN ('group','connection')), -- [USER v2]
blind_votes boolean NOT NULL,
publish_at timestamptz, -- declared schedule ("primed to go out")
deadline_at timestamptz NOT NULL, -- LEAST(publish_at, submitted_at + stale_after)
status text NOT NULL CHECK (status IN
('primed','in_review','approved','denied','superseded','expired','blocked','overridden')),
submitted_by uuid NOT NULL REFERENCES users(id),
submitted_at timestamptz NOT NULL DEFAULT now(),
closed_at timestamptz,
closed_reason text,
UNIQUE (draft_id, round),
UNIQUE (id, workspace_id), -- [AUDIT #5] composite-FK target for review_votes / review_overrides / review_events
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE CASCADE,
FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id)
ON DELETE SET NULL (group_id), -- [AUDIT #16] group deletion detaches the cycle; reference kept as history via group_name
FOREIGN KEY (version_id, draft_id, workspace_id)
REFERENCES post_versions(id, draft_id, workspace_id)
);
-- (6) votes [SOL constraints + OP hash binding]
CREATE TABLE review_votes (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
cycle_id uuid NOT NULL,
voter_user_id uuid NOT NULL REFERENCES users(id),
decision text NOT NULL CHECK (decision IN ('approve','deny')),
reason text, -- NOT NULL enforced for deny by CHECK
snapshot_sha256 text NOT NULL CHECK (snapshot_sha256 ~ '^[0-9a-f]{64}$'),
cast_at timestamptz NOT NULL DEFAULT now(),
revoked_at timestamptz, -- reviewer removed from group mid-review
revoked_reason text,
source text NOT NULL DEFAULT 'live' CHECK (source IN ('live','migrated')),
UNIQUE (cycle_id, voter_user_id),
CHECK (reason IS NULL OR btrim(reason) <> ''), -- [USER v2] deny reason optional (UI-suggested); never stored blank
CHECK (revoked_at IS NULL OR (revoked_reason IS NOT NULL AND btrim(revoked_reason) <> '')),
FOREIGN KEY (cycle_id, workspace_id) REFERENCES review_cycles(id, workspace_id)
ON DELETE CASCADE -- [AUDIT #1] cycle-scoped history, no independent lifecycle: draft→cycle cascade must not be blocked
);
-- [AUDIT #11] Same single contract as versions and events: update-immutable, cascade-deletable
-- only via `drafts` — the trigger rejects UPDATE of everything except the revoke fields
-- (set only by the revocation trigger) and rejects any DELETE whose root is not the
-- drafts cascade (pg_trigger_depth() check) [SOL].
-- (7) overrides [SOL: separate table, one per cycle]
CREATE TABLE review_overrides (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL,
cycle_id uuid NOT NULL,
admin_user_id uuid NOT NULL REFERENCES users(id),
reason text NOT NULL CHECK (btrim(reason) <> ''),
snapshot_sha256 text NOT NULL CHECK (snapshot_sha256 ~ '^[0-9a-f]{64}$'), -- [AUDIT #19] same format constraint as review_votes
at timestamptz NOT NULL DEFAULT now(),
UNIQUE (cycle_id),
FOREIGN KEY (cycle_id, workspace_id) REFERENCES review_cycles(id, workspace_id)
ON DELETE CASCADE -- [AUDIT #1] cycle-scoped history, no independent lifecycle: draft→cycle cascade must not be blocked
);
-- append-only; BEFORE INSERT trigger: admin is Owner/Admin, NOT a contributor, cycle is blocked/expired,
-- AND [AUDIT #19] NEW.snapshot_sha256 = (SELECT content_hash FROM post_versions WHERE id = cycle.version_id)
-- — an override can only ratify the exact content the cycle pinned. The transition trigger's
-- override-row requirement (4.2 enforce_review_transition) is the second layer.
-- (8) unified event log = chat + system events [SOL]
CREATE TABLE review_events (
workspace_id uuid NOT NULL,
draft_id uuid NOT NULL,
seq bigint NOT NULL CHECK (seq > 0), -- gapless per draft, allocated under draft row lock
cycle_id uuid,
kind text NOT NULL CHECK (kind IN
('message','submitted','edited','approved','denied','vote_revoked','quorum_met',
'quorum_lost','needs_changes','blocked','unblocked','scheduled','expired',
'published','hidden','unhidden','deleted','override')),
actor_user_id uuid REFERENCES users(id), -- NULL = system
body text, -- required for kind='message'
data jsonb NOT NULL DEFAULT '{}',
CHECK (kind <> 'message' OR (body IS NOT NULL AND btrim(body) <> '')), -- [AUDIT #18] message body must be non-empty
CHECK (octet_length(coalesce(body, '')) <= 8192), -- [AUDIT #18] bounded body: 8 KiB hard cap
CHECK (octet_length(data::text) <= 8192), -- [AUDIT #18] bounded event payload: 8 KiB hard cap
at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (draft_id, seq),
FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE CASCADE,
FOREIGN KEY (cycle_id, workspace_id) REFERENCES review_cycles(id, workspace_id) -- [AUDIT #20, #22] nullable composite FK to review_cycles(id, workspace_id), action consistent with #1/#2; system events carry the cycle they narrate (feasible via the UNIQUE (id, workspace_id) from #5); [AUDIT #22] the cycle-narrating kinds (denied/override/quorum_met/etc.) can never be orphaned from their cycle
);
-- [AUDIT #11] Same single contract as versions and votes: update-immutable, cascade-deletable
-- only via `drafts` — UPDATE rejected; DELETE rejected unless its root is the drafts
-- cascade (pg_trigger_depth() check); kind='message' requires non-empty body.
-- [AUDIT #18] Size caps are table CHECKs, not conventions: body ≤ 8192 bytes (non-empty when
-- kind='message') and data ≤ 8192 bytes — oversize writes are refused by the database.
-- [SECURITY #7] review_events policy (RLS IN ADDITION to tenant_isolation; also a
-- mandatory service-layer precondition, see 4.2):
-- SELECT ... USING: caller holds draft.view AND (drafts.group_id IS NULL OR EXISTS (
-- SELECT 1 FROM group_members gm WHERE gm.group_id = drafts.group_id
-- AND gm.user_id = current_setting('app.user_id')::uuid AND gm.status = 'active'))
-- INSERT of kind='message' WITH CHECK: that same predicate AND an active
-- group_members row with review_role IN ('member','editor','reviewer') for
-- drafts.group_id; system kinds are inserted only via the SECURITY DEFINER
-- service path. Fails closed: no membership => zero rows on read, refused write.
-- (9) drafts columns
ALTER TABLE drafts
ADD COLUMN group_id uuid,
ADD COLUMN revision int NOT NULL DEFAULT 0, -- optimistic concurrency, bumped on save
ADD COLUMN current_version_id uuid,
ADD COLUMN approved_version_id uuid,
ADD COLUMN event_seq bigint NOT NULL DEFAULT 0,
ADD COLUMN hidden_at timestamptz, ADD COLUMN hidden_by uuid REFERENCES users(id),
ADD COLUMN hidden_reason text,
ADD COLUMN deleted_at timestamptz, ADD COLUMN deleted_by uuid REFERENCES users(id),
ADD COLUMN deleted_reason text,
ADD CONSTRAINT drafts_group_fk FOREIGN KEY (group_id, workspace_id)
REFERENCES groups(id, workspace_id) ON DELETE SET NULL (group_id),
ADD CONSTRAINT drafts_curver_fk FOREIGN KEY (current_version_id, id, workspace_id)
REFERENCES post_versions(id, draft_id, workspace_id),
ADD CONSTRAINT drafts_appver_fk FOREIGN KEY (approved_version_id, id, workspace_id)
REFERENCES post_versions(id, draft_id, workspace_id),
ADD CONSTRAINT drafts_appver_state CHECK ( -- [AUDIT #21] a live approval pointer exists only in approval-or-later states
approved_version_id IS NULL OR state IN ('approved','scheduled','published')),
ADD CONSTRAINT drafts_hidden_consistent CHECK (
(hidden_at IS NULL AND hidden_by IS NULL AND hidden_reason IS NULL)
OR (hidden_at IS NOT NULL AND hidden_by IS NOT NULL AND btrim(coalesce(hidden_reason,'')) <> '')),
ADD CONSTRAINT drafts_deleted_consistent CHECK (
(deleted_at IS NULL AND deleted_by IS NULL AND deleted_reason IS NULL)
OR (deleted_at IS NOT NULL AND deleted_by IS NOT NULL AND btrim(coalesce(deleted_reason,'')) <> ''));
-- state set: drop 006 CHECK, install
CHECK (state IN ('editing','primed','in_review','needs_changes','blocked',
'approved','scheduled','published','expired'))
Every new table: denormalized workspace_id + composite FKs, reject_workspace_change trigger, ENABLE+FORCE ROW LEVEL SECURITY with the exact tenant_isolation NULLIF policy, GRANT … TO sp_app — shapes copied verbatim from 006/008 [ALL].
4.2 Key SQL functions (SECURITY DEFINER, search_path pinned, sp_app-only [OP conventions])
-
review_eventsaccess contract (binding): enforced as an RLS policy in addition totenant_isolationand re-checked as a mandatory service-layer precondition on every read/write of the table. Read requiresdraft.viewAND (drafts.group_id IS NULLOR an activegroup_membersrow fordrafts.group_id). Writes ofkind='message'additionally requirereview_role IN ('member','editor','reviewer')in that group. Fails closed: missing membership/permission yields no rows (read) or an exception (write). TheGET /publishing/drafts/:id/eventsendpoint inherits exactly this predicate - never a genericRbacGuard + RLSpassthrough. [AUDIT #18] The same endpoint serves reads only through the bounded-page contract of §7 (limitdefault 100 / max 500 withnext_cursor) — there is no unbounded read of the event log. -
Moderation visibility contract (binding) [AUDIT #15]: every draft read endpoint — list, detail, versions, diffs, events — applies
drafts.hidden_at IS NULL AND drafts.deleted_at IS NULLunless the caller holdsmoderation.hide(hidden rows) /moderation.delete(deleted rows). Enforced as an RLS policy ondrafts(and, via the parent draft, onpost_versions/review_events) plus a shared repository filter used by every query path — never only a UI/archive filter. Fails closed: a non-admin direct request for a hidden/deleted draft, its versions, diffs or events yields 404/no rows. -
review_eligible_reviewers(draft uuid, workspace uuid)→ set of user ids: [USER v2] if the draft's connection isdelegated, itsconnection_approvers; else group rolereviewer— always ∩ active membership ∩ NOT author ∩ NOT contributor ∩ NOT sealer of current version. One function used by the service, the vote trigger, and the quorum count [OP: single source of truth]. [SECURITY #8] The body starts with fail-closed tenant validation: ASSERT the target draft'sworkspace_idequals theworkspaceargument (and, when invoked for a session, thatcurrent_setting('app.workspace_id')IS NOT NULL and equals it); mismatch raises immediately - no rows, no cross-tenant leak. -
review_valid_approval_count(cycle uuid, workspace uuid)→ count of live (not revoked)approvevotes from eligible reviewers whosesnapshot_sha256matches the cycle's version hash [SOL]. [SECURITY #8] Same contract: the body first asserts the cycle'sworkspace_idequals theworkspaceargument (and matchescurrent_setting('app.workspace_id')when session-scoped). The single-UUID signatures are removed; every caller passes both arguments. -
review_vote_eligible()trigger: refuses votes unless cyclein_review, voter eligible (re-checked live), snapshot hash matches [SOL+OP]. [AUDIT #14] The cycle-state and live-eligibility checks apply only whenNEW.source = 'live'(refusal condition is... AND NEW.source = 'live'), sosource='migrated'backfill rows (§11) — inserted into already-approvedcycles, possibly by a legacy reviewer who is now author/contributor or lacks the reviewer role — bypass them; the snapshot-hash match and all CHECK constraints are still enforced for every row. Only the011migration role may insertsource='migrated'(sp_app inserts withsource <> 'live'are refused). -
enforce_review_transition()trigger on drafts: the legal-pairs table in §5; refusespost/group_idchanges outsideediting/needs_changes; refusesapprovedwithout a closed quorum or an override row [AUDIT #19] whosesnapshot_sha256equals the cycle version'scontent_hash— this override-row requirement is the second layer on top of the 4.1(7) override insert trigger; refusesscheduledunlessdrafts.posthash equals the approved version's [SOL] — [AUDIT #24] §6.6 full-reset is the primary defence (an edit after approval supersedes the cycle before scheduling can even be attempted); this hash check is the backstop for any gap in it, and its refusal surfaces as 409STALE_VERSIONdirecting the user to re-submit — no approved-but-unschedulable state without a recovery transition. [AUDIT #12]→ expiredis legal only from the pre-publication setprimed|in_review|blocked|approved— the trigger refusesscheduled → expiredandpublished → expired. [SECURITY #9] It also refusesscheduledunless the approved version'sconnection_idsatisfies the 4.1(3) seal-time guard (same workspace, in scope fordrafts.group_id) - re-validated at the transition, not only at seal time. -
assign_event_seq()trigger:event_seq := event_seq + 1under the draft row lock; gapless per draft [OP]. [AUDIT #26] Global lock order (binding for every path that touches review state): authz rows (group_members/connection_approvers) → draft row → cycle row → votes/events. The vote path takes the draft rowFOR UPDATE(seq + quorum + deadline check) before the cycle rowFOR UPDATEand only then inserts the vote/event; the membership-change path takes authz rows → draft → cycle → votes/events in the same order. No path may lock a cycle row before its draft row. -
review_due_expiries()sweeper discovery, mirroringpublishing_due_schedules()(006:79-85) [OP]. [SECURITY #8] It takes no row argument, so it runs strictly under the caller's tenant context: every candidate cycle must satisfyworkspace_id = current_setting('app.workspace_id')::uuid; a NULL/empty setting selects zero rows (fail-closed), never all tenants. -
Vote revocation on membership/role change: trigger on
group_membersUPDATE/DELETE revokes that user's live votes on open cycles in the group (setsrevoked_at/reason), then recount — quorum already met stays met [OP, fixed lock order: authz rows → draft → cycle → votes/events — the [AUDIT #26] global order above, identical to the vote path].
5. State machine (deliverable 3)
| from → to | fired by | guard |
|---|---|---|
| editing → primed | author/editor stages (choose group + connection + optional publish_at) | group has ≥ N eligible reviewers (preflight, 400 if not) — solo exception [AUDIT #17]: N=1 with 0 eligible reviewers still primes (workspace approval.grant holders are the fallback pool, and if that is empty too, submit lands blocked-at-submit) [GLM] |
| needs_changes → primed | author re-stages after edits | same preflight |
| expired → primed | author re-primes with new schedule | same preflight |
| primed → in_review | submit (seals version n, opens cycle) | version sealed; policy snapshotted; deadline = LEAST(publish_at, now+stale_after); same tx inserts draft_contributors rows for the sealer + any editor since the last seal [AUDIT #10, seal trigger enforces] |
| in_review → approved | quorum_met (Nth valid approve, same tx) | row lock + clock_timestamp() < deadline_at else → expired [ALL] |
| in_review → needs_changes | deny (veto) | reason optional, UI prompts for it [USER v2] |
| in_review → editing | author withdraws for edits | cycle superseded |
| in_review → blocked | recount after reviewer removal | eligibility < required [GLM] |
| approved → scheduled | quorum tx (auto, if publish_at future) | same tx inserts scheduled_publications, starts workflow with startDelay [ALL] |
| approved → editing | late edit | pending schedule cancelled in same tx; cycle superseded; loud dismissal event; [AUDIT #21] approved_version_id = NULL is set in the same tx as the supersede — between supersede and the next approval no reader (D1's claim check included) can dereference a stale approval |
| scheduled → approved | cancelSchedule (existing) | unchanged from 006 |
| scheduled → published | scheduler activity | D1 fix: claim requires state='scheduled' AND a pending scheduled_publications row, and publishes post_versions.post of approved_version_id |
| published → (none) | archive = filtered read of published | no state change [GLM] |
| primed, in_review, blocked, approved → expired | sweeper/vote-time check when deadline passes | pre-publication (deadline-bearing) states only — the explicit set `primed |
| blocked → in_review | reviewer added (auto-recount) | eligibility ≥ required again [GLM] |
| blocked/expired → approved | admin override | reason mandatory; not a contributor; cycle closed overridden [SOL+OP] |
Service returns 400/403/409 with precise codes (REVIEW_EXPIRED, QUORUM_UNREACHABLE, STALE_VERSION, VOTE_INELIGIBLE); the DB trigger is the backstop, not the error surface [SOL].
6. Quorum engine semantics (deliverable 5)
- Config:
groups.review_approvals_required ?? workspace_review_settings.approvals_required ?? 1, resolved once at submit, stored on the cycle withpolicy_source. Default1preserves today's behavior for solo workspaces as ablocked-at-submitcycle, never a self-approval [AUDIT #17]: with N=1 and 0 eligible reviewers the cycle is created inblockedand is resolved by a fallback reviewer (below) or the admin override.≥2turns on structural four-eyes [GLM]. Admin-only setters (review.manage). [AUDIT #23]requiredis bounded to 1–5 at every layer (workspace §4.1(2), group §4.1(2), cycle snapshot §4.1(5)):required=0has no closing rule — no transition firesquorum_metwithout a vote and submit-time auto-approve is deliberately not defined — so it is refused by CHECK at the door, exactly the deadlock-by-config class §6.8 exists to refuse. - Parallel any-N-of-eligible [ALL]. No tiers (noted as future). Eligibility pool [USER v2]: if the draft's target connection is
delegated, the pool is itsconnection_approvers(minus author/contributors); otherwise the group'sreviewers. Solo fallback [AUDIT #17]: when that pool is empty, workspaceapproval.grantholders (Owner/Admin/Approver, minus author/contributors) become eligible; if they are empty too, submit landsblocked-at-submitwith the admin override as the only exit. Resolved at submit and snapshotted on the cycle (approval_source); mode changes affect future cycles only. - Vote rules: one live vote per reviewer per cycle (
UNIQUE); decision immutable after cast — a reviewer may not flip; they may deny later if they first approved? No: one vote total, immutable. To change course, the deny path is a new cycle after withdrawal — keep it strict [MERGE, strictest of the three]. - Deny = veto [ALL]: cycle closes
denied, draftneeds_changes[USER v2: deny reason optional but suggested — the UI asks "why?" and records it in chat + audit when given; this supersedes the spec's mandatory-reason rule]. Sibling approvals remain as history; count for nothing (cycle-scoped tally). - Staleness: every vote copies the cycle's version hash; the vote trigger rejects a hash mismatch (409
STALE_VERSION— can't happen given full-reset, backstop only) [SOL]. - Edit = full reset [ALL]: any content/target change after seal supersedes the cycle, cancels any pending schedule in the same tx, seals version n+1 on resubmit, and emits a
editedevent listing dismissed approvers with a diff link (loud dismissal [OP]). [AUDIT #10] Same-tx contributor contract (binding): every seal (submit/edit/reopen) insertsdraft_contributorsrows for the sealer and for any editor recorded since the last seal, in the seal transaction itself; thepost_versionsseal trigger fails the transaction if the sealer is absent — eligibility can never miss a contributor, and existing rows are immutable (§4.1(4)). - Scheduled-time race [ALL]: quorum handler, holding the draft row lock, checks
clock_timestamp() < publish_at; if true → schedule in same tx; else →expired. Submissions requirepublish_at ≥ now() + 120 s(min lead). Correctness never depends on sweeper latency. - Deadlock [GLM]: preflight at priming (§5) +
blockedstate on mid-review shrinkage + override exit (§5).blocked ≠ needs_changes: "edit the post" is wrong advice when the problem is staffing.
7. Review chat & delivery (deliverable 6)
- One table, both kinds [SOL]:
review_events—messagerows are human chat; everything else is system events rendered inline (submitted, votes fully attributed — transparent ledger, reasons shown when given [USER v2] — state changes, overrides, moderation). - Transport = polling [GLM]:
GET /publishing/drafts/:id/events?after=<seq>&limit=<n>while a detail view is open (4 s); queue lists refresh on action or 15 s. Every poll rides the existing epoch-fencedRbacGuard+ RLS, i.e. the 4.2review_eventspolicy (read =draft.viewAND (ungrouped draft OR activegroup_membersrow fordrafts.group_id); posting amessageadditionally requiresreview_role IN ('member','editor','reviewer')in that group) — revocation latency = poll interval, no new credential surface (EventSource can't send our Bearer header). - Bounded pages [AUDIT #18]:
limitdefaults to 100 and is clamped to 1–500; each response returns at mostlimitevents plusnext_cursor= theseqof the last row returned (omitted when the page is not full). Clients loop?after=<next_cursor>until no cursor is present, so a 4 s poll — or a backlog catch-up — always returns a bounded page (≤ 500 events), never an unbounded response. - Ordering is structural: gapless
seqallocated under the draft row lock; the cursor is a total order; lost/duplicated polls are harmless (?after=). - Upgrade path: SSE doorbells +
LISTEN/NOTIFY[OP] later; identicalafter=<seq>query, no schema change. - Approve/Deny buttons live in the chat surface (R5): approve = one click; deny = reason modal (required).
8. UI plan (deliverable 7)
- Review queue (replaces the
/approvalsstub): virtualized column list (reusecomponents/streams/column.tsxpattern,@tanstack/react-virtual, j/k navigation) of drafts inprimed/in_review/needs_changes/blocked/expired; state chips (STATE_TONEextended); quorum progress ("2/3 ✓"); filters: needs-my-review, mine, needs-changes, expiring soon, blocked. - Post detail (builds on Compose patterns): rendered post preview + version diff (v n-1 → n), side chat/event stream with vote buttons, quorum tracker, schedule info, contributor list.
- Compose additions: "Prime for review" action (group + connection + optional schedule), replacing the current self-approve chain when quorum ≥ 2.
- Archive: filtered read of
published(chat history preserved, read-only), admin sees hidden posts with a badge + unhide/delete actions. - Team/group screens:
review_roleselector on group members; group settings (quorum, admin control) [USER v2]. - Connections screen additions [USER v2]: per-account approval settings — mode picker (
systemvsdelegated to named approvers), approver pool editor for delegated accounts, shown on the connection detail; the queue groups/filters by account so a delegated approver sees "awaiting my signoff" across the accounts they own.
9. Edge cases (deliverable 8) — each with its resolution
- Reviewer leaves mid-review → votes revoked by trigger (reason), recount; met quorums stay met; unmet →
blocked[OP lock order: authz rows → draft → cycle → votes/events — global order [AUDIT #26], shared with the vote path so a concurrent vote cannot invert draft/cycle locks]. - Deadline race (vote vs expiry) → both take
FOR UPDATEon the draft; vote-time expiry check; loser seesREVIEW_EXPIRED[ALL]. - Sole reviewer is the author → with quorum ≥ 2: preflight 400 at priming. With quorum 1: prime/submit succeeds — the pool falls back to workspace
approval.grantholders [AUDIT #17], and if that pool is empty too the cycle is createdblocked-at-submitawaiting admin override; never enters an unresolvable state [GLM]. - Admin who contributed tries to override → refused structurally (contributors table) [OP].
- Quorum met after publish_at →
expired, nothing scheduled, author notified, re-prime path [ALL]. - Concurrent Nth and (N+1)th vote → both under row lock; second lands as history after cycle closed (no double schedule:
UNIQUE(draft_id)on scheduled_publications backstop). - Epoch bump mid-vote (role change logs reviewer out) →
POST /auth/reissue+ client single retry (D7 fix) [OP]. - Group deleted while cycles open →
ON DELETE SET NULL (group_id)frees the draft and setsreview_cycles.group_idto NULL the same way [AUDIT #16] — each cycle keeps itsgroup_namesnapshot so the audit trail stays readable, and superseding a cycle preserves the reference as history rather than deleting it; open cycles superseded by group-delete guard trigger; audit event [OP]. - Workspace owner cleanup deletes drafts → cascades flow (versions, cycles, votes, events) under the single §4.1 contract — [AUDIT #1]
review_votesandreview_overridesreferencereview_cycleswithON DELETE CASCADE, so drafts → cycles → votes/overrides is never blocked by a NO ACTION FK: events/versions/votes are update-immutable and cascade-deletable only viadrafts; direct DELETEs are refused by the trigger [GLM, AUDIT #11]. - Hidden post with open cycle → hide is metadata only; cycle continues (admins act with full information) [MERGE].
- Connection approval mode switched mid-review [USER v2] → the cycle snapshotted
approval_sourceat submit; open cycles keep their pool, new cycles use the new mode. Mode change emits a thread event for visibility. - Delegated pool shrinks below N (approver removed/leaves) → same
blockedhandling as group reviewers: recount trigger revokes their live votes,blockedif quorum unreachable, admin override or pool restore resolves. - Preflight for delegated accounts counts the connection pool, not group reviewers: "Account X needs 2 approvals; pool has 1" at priming.
10. Audit events (deliverable 9) — all through audit_append, same-tx (D4 fixed)
review.primed, review.submitted (version hash, policy snapshot, approval_source), review.vote_cast (decision; reason when provided; snapshot hash), review.vote_revoked, review.quorum_met, review.denied, review.cycle_superseded, review.blocked, review.unblocked, review.overridden (admin, reason), review.expired, review.hidden/unhidden/deleted (moderation, reason), review.policy_changed, review.connection_delegation_changed (mode/pool changes [USER v2]).
Scheduler keeps publication.published/failed as sp_scheduler. Hash v2 (010) makes every diff value — snapshot hashes, reasons, policy before/after — tamper-evident [OP].
11. Migration & backfill (deliverable: Q9) — 011, per-tenant set_config before guard triggers
| legacy state | new state | cycle | votes |
|---|---|---|---|
| editing | editing | none | none |
| in_review | needs_changes | none (author re-primes) | none |
| rejected | needs_changes | none | none |
| approved/scheduled/published | unchanged | backfilled cycle required=1, status=approved, v1 from current post |
one synthetic approve vote (source='migrated') by legacy reviewer_user_id where present — exempt from the live cycle-state/eligibility checks of review_vote_eligible() via its NEW.source = 'live' guard (§4.2), hash still verified [AUDIT #14] |
| (none) | — | — | legacy deny reasons recovered from audit_log.diff->>'reason' on draft.rejected, else migration note |
reviewer_user_id becomes read-only/deprecated; dropped in a later 012 once web stops reading it. Legacy self-approved drafts are kept as history and counted in a review.migrated audit row [OP].
12. Phased implementation (deliverable 10)
- WP5.0 — Foundation fixes (010): D1–D8. Gate: existing suites green + new regression tests for claim-state-check and audit hash v2.
- WP5.1 — Schema (011): tables, triggers, functions, RLS, backfill. Gate: migration up/down on dev DB;
audit_verify_chain()true post-backfill. - WP5.2 — Service + API: priming/submit/vote/override/moderation endpoints, quorum engine, event log writes; every draft read endpoint (list, detail, versions, diffs, events) applies the §4.2 moderation visibility contract. Gate: integration tests (RLS, transitions, quorum, races) on live Postgres, including admin vs non-admin reads of hidden/deleted drafts across all five read endpoints.
- WP5.3 — Scheduler contract: claim eligibility + approved-version publishing + expiry sweeper. Gate:Temporal tests incl. deadline race.
- WP5.4 — Web: queue, detail+chat, compose changes, archive, role/quorum admin. Gate: E2E on real API; visual QA.
- WP5.5 — Hardening: lock-order verification, load test on event seq allocation, docs.
13. Test plan sketch (deliverable 11) — the 10 that catch the worst
- Claim refuses any draft not in
state='scheduled'with a pendingscheduled_publicationsrow, and publishespost_versions.postofapproved_version_id(D1). 2. Audit verify passes with v1+v2 rows. 3. Author/contributor vote → 403. 4. Deny without reason succeeds; deny with reason records it in thread + audit [USER v2]. 5. Nth vote under lock schedules exactly once. 6. Vote at deadline → expired, not scheduled. 7. Edit after approval cancels schedule + supersedes cycle same-tx. 8. Reviewer removal revokes votes; met quorum survives. 9. Preflight blocks solo-author quorum-2 priming. 10. Migration backfill: verify chain + counts + resubmit path from legacy rejected. 11. Delegated account: group reviewer's vote → 403; pool member's vote counts [USER v2]. 12. Mode switch mid-review: open cycle keeps its pool [USER v2]. 13. Delegated pool shrink → blocked + override path [USER v2]. 14. Transition-table test [AUDIT #12]:enforce_review_transition()refusesscheduled → expiredandpublished → expired;→ expiredsucceeds from each ofprimed,in_review,blocked,approved. 15. Seal/contributor test [AUDIT #10]: a seal transaction that does not insert adraft_contributorsrow for the sealer fails; UPDATE/DELETE of an existing contributor row fails outside the drafts cascade. 16. Edit-after-approval-then-schedule [AUDIT #24]: both paths — (a) primary: an edit after approval supersedes the cycle and cancels the pending schedule in the same tx (draft back toediting,approved_version_id = NULL); (b) backstop:approved → scheduledwhile thedrafts.posthash no longer equals the approved version's is refused with 409STALE_VERSIONand the UI directs the author to re-submit. 17. Draft-delete cascade [AUDIT #1]: under the real011migration, deleting a draft that has cast votes (live and revoked) and anoverriddencycle with areview_overridesrow succeeds and removes its cycles, votes, overrides, events and versions; a direct DELETE of a vote/override row is still refused.
14. Confidence & risks
- Highest confidence: schema shape, single-veto deny, full-reset staleness, cycle-snapshotted quorum (all three proposals converged independently).
- Least sure: (a) gapless
seqallocation under extreme concurrency — measure contention in WP5.5; (b) blind-vote redaction completeness (audit page leak accepted, documented); (c) D7 reissue flow client behavior across all screens. - Prototype first: WP5.0 + WP5.1 on a copy of dev data, run tests 1, 5, 6, 8 — everything whose failure would be silent.