T-HUB/docs/design/proposal-opus.md
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
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/.
2026-10-10 11:56:42 +00:00

131 KiB
Raw Permalink Blame History

Proposal (lane: opus) — Group Review & Signoff for Posts

Status: design proposal, no code changed Date: 2026-10-07 Answers: runs/wp5/spec-review-workflow.md §5 (Q1–Q10) and §6 (parts 1–11)

Numbering correction. The spec asks for migration 009_*, but apps/api/migrations/009_video.sql already exists. This proposal uses 010_audit_hash_v2.sql (a small prerequisite, see §9.2) and 011_review_workflow.sql (the feature).


0. Pre-existing defects this design depends on (found while reading)

These are real today. The review feature makes each one worse, so each is fixed in WP5.0 (§10):

# Where Defect Why it matters for review
D1 apps/scheduler/src/activities/publish.activity.ts:136-139 claim() joins drafts but never checks d.state. It publishes d.post (:141) for any pending schedule row. Quorum is only real if the publisher refuses anything that isn't approved, and publishes the approved bytes.
D2 publish.activity.ts:79 claim() runs outside the try, so a "not pending / not found" throw is retried by Temporal (the default retry policy has no limit). An edit or cancel that removes a pending schedule leaves a workflow retrying forever.
D3 001_tenants.sql:92-98,155 audit_hash(prev, actor, action, target_id, at) excludes diff. A "snapshot hash in the audit log" (R4) placed in diff is not tamper-evident. Only ordering and actor are chained.
D4 apps/api/src/publishing/publishing.service.ts:136,175,181,307-322 Audit is written after commit (appendTenantAudit opens its own tx, audit.service.ts:29-37). A crash between a state change and its audit loses the audit row. Votes need same-tx audit, as groups.service.ts:12-13,123-133 already does.
D5 apps/api/src/publishing/publishing.controller.ts:113-129 + rbac.guard.ts:64-74 schedule takes connectionId from the body. @RequireGroupAccess only reads :connectionId params, and schedule doesn't use it anyway. A non-admin can schedule through a connection outside their groups. Group-scoped review has to close this.
D6 apps/api/src/realtime/gateway.ts:20-21,24-29 It compares the epoch against the raw bigint (a string from node-postgres, so it is always "stale"). Its permission query omits m.status = 'active'. This is a reason not to build chat on this port (Q4).
D7 apps/web/src/lib/api-client.ts:90 + rbac.guard.ts:46-48 Any epoch bump (every group_members change, 008_groups.sql:79-80) gives every user in the workspace a 401, and the client drops the token. No re-issue path exists. Changing review roles mid-review would log reviewers out mid-vote (§8, E7).
D8 002_rbac_seed.sql:9,25,35 publication.delete is seeded to Owner, Admin, and Publisher, and nothing enforces it. R7 says delete is admin-only, so this permission can't be reused (§4.1).

Q. Answers to the 10 open design questions (spec §5)

Q1 — Group-scoped role storage

Decision: one review_role column on group_members, text NOT NULL DEFAULT 'member' CHECK (review_role IN ('member','editor','reviewer')).

Justification. A single value makes editor and reviewer mutually exclusive by construction, which is exactly the separation of duties in R2. A multi-role group_member_roles table would allow someone to be both "editor" and "reviewer", and every eligibility query would need to re-forbid that. The column also inherits everything 008 already built: the row is pinned to one tenant by composite FKs (008_groups.sql:35-37), RLS (:94-96), the workspace-immutability trigger (:68-69), cascade-on-leave (:37), and the epoch bump, because trg_group_member_bump_epoch already fires on UPDATE (:79-80). A role change is therefore fenced exactly like a membership change, with zero new trigger code. Reusing workspace roles alone is impossible, because memberships is UNIQUE(workspace_id, user_id) with one role (001_tenants.sql:20-27). Workspace roles stay the floor (you need draft.view to hold any review role, §4.2). Future: if multi-role is ever needed (e.g. "reviewer in tier 2 only"), move to a table and keep the column as a view.

Q2 — Snapshot model

Decision: immutable draft_versions rows that hold a full canonical content copy and a DB-computed SHA-256 content_hash. A version is created only at seal points: submit, edit-while-sealed, and admin restart. Saves in editing / changes_requested mutate drafts.post and do not create versions.

Justification. Post payloads are tiny: DraftPost is text, asset ids, visibility, scheduledAt, and metadata (publishing.service.ts:10-16). Full copies cost almost nothing, while hash-only versions would make the "diff since last review" view (R4) impossible after the working copy moves on. The hash is computed in SQL from jsonb::text, which is deterministic for jsonb, by a BEFORE INSERT trigger. The app can't supply a wrong hash, and a trigger keeps it from ever changing. Keeping the hash separate from the content lets an admin purge content (R7) while the hash, vote bindings, and audit stay verifiable. Every vote row copies the hash it approved, and the publisher sends draft_versions.content for approved_version_id, never drafts.post (fixes D1). What reviewers approved is byte-for-byte what ships.

Q3 — Quorum config location and precedence

Decision: a review_policies table with at most one workspace row (group_id IS NULL) and at most one row per group. Precedence is group row → workspace row → built-in default, and the first row found wins as a whole (no field merging). The built-in default is required_approvals = 1, exempt_roles = {Owner,Admin}, blind_votes = true, stale_after = 30 days, min_lead = 2 min. Only approval.configure (Owner and Admin, new) may write a policy. The effective policy is captured onto each version at seal time (required_approvals, policy_source, blind_votes, deadline_at).

Justification. "Group" is the unit that has reviewers, so it has to be able to override. The workspace row gives admins one knob for everything without a group. Whole-row precedence is easy to explain in the UI ("this group uses its own policy"), while field-level merging creates surprising combinations. Capture-at-seal makes a round's contract fixed: lowering N can't silently push a pending post over the line, and raising N can't strand one. Admins apply a new policy to a live round explicitly with restart review (§3). The built-in default reproduces today's behaviour exactly (Q9): Publisher drafts need one approver who isn't the author, and Owner/Admin drafts go straight through as they do today via the compose chain (compose/page.tsx:119-141). This works without seeding rows, which would fight FORCE RLS in a migration. Future: per-connection overrides (e.g. "brand account needs 3"), as a third precedence tier.

Q4 — Chat transport

Decision: SSE for invalidation ("doorbells") plus REST for data, fanned out with Postgres LISTEN/NOTIFY. The client's SSE reader is fetch-based so it can send Authorization: Bearer. Polling the same REST endpoints is the automatic fallback.

Justification.

  • Cost and complexity. SSE is a plain Nest HTTP route (@Sse() returning an Observable), so it goes through the existing RbacGuard with its correct epoch normalisation (rbac.guard.ts:42-48) and active-membership check. WS would need @nestjs/websockets, a new adapter, sticky routing, and the buggy gateway.ts port (D6). Every write (vote, comment) is already a guarded REST call, so a bidirectional channel adds nothing. The API is bearer-only with no cookies (apps/api/src/main.ts:15-20), so the browser's EventSource can't authenticate. A ~60-line fetch + ReadableStream SSE reader in apps/web/src/lib/ can.
  • Ordering. The stream carries no content, only {draftId, kind, seq}. Ordering lives in the DB: chat rows get a gapless, commit-ordered per-draft seq, allocated under the draft row lock (§6.1). Clients fetch ?after=<seq>, so a lost or duplicated doorbell is harmless and reconnects need no Last-Event-ID.
  • AuthZ. NOTIFY is sent on commit, so there are no phantom events. The payload holds only ids, so RLS is never bypassed. Each stream caches the user's visible group ids, which is safe because any group or role change bumps the epoch: the stream sees the bump on its next doorbell or heartbeat, sends event: reauth, and closes.
  • Polling is the same GET .../messages?after= and GET /publishing/queue. The client switches after 3 failed reconnects, with zero extra server code.

Q5 — Deny semantics

Decision: one deny vetoes the round. The draft goes in_review → changes_requested, and the version's outcome = 'changes_requested'. Other live approvals on that version are kept as history but can never count again, because they are bound to a version that is now closed. A resubmit always seals a new version (even with byte-identical content) and starts at 0/N. Deny needs a reason that isn't empty, enforced at three layers: DTO, service, and a DB CHECK.

Justification. Four-eyes exists to stop bad posts. Under majority-deny, a post with a known, reasoned objection would ship anyway. A single deny is cheap to resolve: the reason lands in the chat, and the author edits or replies, then resubmits. Starting a resubmit from zero, even with unchanged content, means a deny can't be "outvoted" by quietly resubmitting. The denying reviewer still has to approve or deny again, in the open. This mirrors Hootsuite and Sprout, where rejection returns the post to the author. Future: deny_threshold on review_policies, for very large reviewer pools where one hostile reviewer could stall everything.

Q6 — Re-review after edit

Decision: full reset. Any change to sealed content (text, media, visibility, scheduledAt, target connection) by an author, editor, or admin in in_review, approved, or scheduled (pending) seals version n+1 and opens a new round at 0/N. A pending schedule is cancelled in the same tx. The draft stays or returns to in_review, and a system chat event lists the dismissed approvers with a diff link.

Justification. "Approve a frozen snapshot, not a moving target" is the compliance core of R4. Scope-aware dismissal ("only a typo changed") needs a semantic diff policy that is hard to get right, and is precisely the loophole maker-checker rules close. Because dismissal is explicit and visible (chat event, audit event, notification hook to dismissed voters), the spec's worry about edits "silently" invalidating approvals is met: approvals are invalidated loudly. Future: dismiss_scope policy, for example keeping votes when only scheduledAt moves later.

Q7 — Scheduled-time race (quorum at T-1min)

Decision: auto-publish, no manual re-confirm. The approved version includes scheduledAt, so reviewers approved that content at that time. When quorum completes before the deadline, the same tx inserts the scheduled_publications row (publish_at = scheduledAt), and the workflow starts after commit with the remaining delay. The race is settled by a row lock plus clock_timestamp(). Vote, expiry, and edit all take SELECT … FOR UPDATE on the draft. A vote that finds clock_timestamp() >= deadline_at expires the draft itself and returns 409 REVIEW_EXPIRED. Submissions must have scheduledAt ≥ now() + min_lead (2 min by default).

Justification. A re-confirm step puts a human on the critical path at the worst moment and turns many valid approvals into expiries. Re-confirming adds no safety, since the content and time were already sealed and approved. Author and admin keep the existing cancel (publishing.service.ts:248-273) right up to claim. Correctness never depends on sweeper latency: the vote-time check and the claim gate (D1 fix) both enforce the deadline.

Q8 — Author is the only reviewer (deadlock)

Decision: submission is allowed and the deadlock is surfaced, not hidden.

  1. At seal, the service computes eligible (review_eligible_reviewers, §5.2). If eligible < required, the round is flagged reachable=false. The UI shows a red banner ("Quorum unreachable: 0 eligible / 2 required"), the chat gets a quorum_unreachable event, and the notifier hook fires to Owner/Admin.
  2. There are three resolution paths, all audited:
    • (a) An admin adds a reviewer to the group. Eligibility is computed live, so the round becomes reachable with no restart.
    • (b) An admin who did not contribute to the draft uses override approve (approval.override, reason mandatory, review.override_approved).
    • (c) An admin changes the group policy (required_approvals = 0, or adds an exempt role) and then restarts review, which recaptures the policy.
  3. A sole Owner who authored the draft can never override their own draft (contributors are excluded structurally). Their path is (c), an explicit, audited policy choice. Owner/Admin are exempt by default under the built-in policy, so solo workspaces work out of the box.

Justification. Refusing submission would block discussion in chat and hide the cause. Auto-lowering N to eligible would silently weaken four-eyes. Every way out is visible, attributable, and needs someone other than the author, except the policy change, which is itself a logged governance act.

Q9 — Migrating existing single-step approvals

Decision: backfill in 011_review_workflow.sql, per workspace under set_config('app.workspace_id', …, true), before the guard triggers are created.

legacy state new state version 1 vote rows
editing editing none none
in_review in_review content = current post; required=1; policy_source='default'; deadline_at = migration time + 30d (legacy scheduledAt was advisory) none
approved / scheduled / published unchanged outcome='approved'; approved_version_id = current_version_id approve by reviewer_user_id, source='migrated'
rejected changes_requested outcome='changes_requested' deny by reviewer_user_id; reason taken from the last audit_log row action='draft.rejected' for that draft (publishing.service.ts:199-203 put it only there), else the literal 'Migrated from single-step review; original reason unavailable.'

connection_id is backfilled from scheduled_publications.connection_id, else from post->'providerMetadata'->>'connectionId' (what compose writes, compose/page.tsx:126), but only if that id exists in the same workspace. draft_contributors gets the author. Legacy drafts where the author approved their own post (possible today through the compose chain) are kept as-is and counted in a per-workspace review.migrated audit row ({drafts, versions, votes, selfApproved}). They are history, not blocked. drafts.reviewer_user_id stays, read-only and deprecated (no longer written), and is dropped in a later migration once the web no longer reads it (api-types.ts:81).

Justification. Every published post gets a provable version and approval record. rejected stops being a dead end (spec §2.1). Doing it inside the migration, before the triggers exist, avoids a bypass flag that sp_app could also set.

Q10 — Blind voting vs peer visibility

Decision: blind until your own vote or until the round closes; configurable through review_policies.blind_votes (default true). Everyone sees the count ("1/3"). An eligible reviewer who hasn't voted sees approvals as "A reviewer approved" with no name. Identities appear once they vote, or for everyone once the round closes (approved, changes requested, expired, superseded). Authors, editors, and admins always see identities. Denies are never blind, because a deny closes the round.

Justification. The research names rubber-stamping as the failure mode of multi-approver flows, and seeing two senior names on a post is the strongest anchor there is. Blind-until-voted keeps the collaborative chat (reviewers can talk; nothing is secret after they commit) without anchoring the decision itself. Redaction happens server-side in the messages and detail serializers, never in the client. Stated limit: this is a UX property, not a security property. The workspace Approver role has audit.view (002_rbac_seed.sql:48-49), and the audit page shows review.vote_cast actors. That leak is accepted and documented.


1. Executive summary — the 5 sharpest decisions

  1. What ships is exactly what was approved, enforced in the database. Content is sealed into immutable, hash-stamped draft_versions. Votes bind to a versionId and its hash, and a stale versionId returns 409. The scheduler publishes draft_versions.content of approved_version_id and refuses anything else. A drafts trigger refuses state transitions not in the table, refuses approved without a closed quorum, and refuses content changes to sealed drafts that don't seal a new version.
  2. Separation of duties is structural, not by convention. The group role is a single exclusive column (member | editor | reviewer). Eligibility excludes the author, every content contributor, and every sealer of the draft, through one SQL function used by the service, the vote trigger, and the quorum count. Admin override is a separate, reason-required action that contributors can't use.
  3. Seven states, with rejected replaced by a resubmittable changes_requested and a new expired. archived is a derived view of published plus read-only, not a new state, so the scheduler stays the only writer of terminal publication state.
  4. Any-N-of-group-reviewers, a single-deny veto, and full reset on edit, with quorum policy captured at seal. One SQL function computes approvals, eligibility, and the requirement live. Removing a reviewer revokes their pending votes through a trigger, under a fixed lock order (authz rows → draft → votes/messages) that prevents a deadlock this design would otherwise create (§5.6).
  5. SSE doorbells, REST data, LISTEN/NOTIFY fan-out, and epoch-fenced streams. This needs no new deps and no WS port. Chat ordering comes from a per-draft gapless seq allocated under the draft row lock. Epoch churn is made survivable by a POST /auth/reissue that re-reads live authz (fixes D7), so a role change no longer logs reviewers out mid-vote.

2. Data model

2.1 Overview

object kind purpose
group_members.review_role column Q1: member / editor / reviewer, mutually exclusive
drafts.group_id, connection_id, bundle_id, revision, current_version_id, approved_version_id, chat_seq, hidden_*, deleted_* columns group scoping, sealed target, optimistic concurrency, version pointers, chat ordering, moderation
draft_versions table, immutable Q2: sealed snapshots, DB-computed hash, captured policy, outcome
draft_contributors table everyone who ever changed content (self-approval exclusion beyond the author)
review_votes table, append-only except revoke/redact one live vote per reviewer per version
review_policies table Q3: workspace row plus per-group override
review_messages table, append-only except redaction R5: comments plus system events, gapless per-draft seq
review_* functions SQL/plpgsql eligibility, quorum, capabilities, policy; one source of truth for service, guard, and triggers
guard triggers plpgsql DB-enforced state machine, sealing, vote rules (defence in depth, spec §3)
review_due_expiries() SECURITY DEFINER sweeper discovery across tenants, mirroring publishing_due_schedules() (006_publishing.sql:79-85)

Conventions followed, each matching an existing file:

  • Every new table carries workspace_id and composite FKs (008_groups.sql:4-12).
  • reject_workspace_change() triggers (001_tenants.sql:164-171).
  • ENABLE + FORCE ROW LEVEL SECURITY with the exact tenant_isolation policy text (006_publishing.sql:51-66).
  • Explicit sp_app grants (006_publishing.sql:68).
  • SECURITY DEFINER functions with SET search_path = public, REVOKE ALL … FROM PUBLIC, GRANT EXECUTE … TO sp_app (006_publishing.sql:72-89).
  • Audit only through audit_append (001_tenants.sql:100-121).

2.2 010_audit_hash_v2.sql (prerequisite, fixes D3)

-- 010: audit chain v2 — the row hash now covers diff, so values recorded in
-- diff (content hashes, vote decisions, policy before/after) are tamper-evident.
-- v1 rows keep verifying with the v1 formula; every new row is v2.
ALTER TABLE audit_log ADD COLUMN hash_version smallint NOT NULL DEFAULT 1
  CHECK (hash_version IN (1, 2));

CREATE FUNCTION audit_hash_v2(p_prev text, p_actor uuid, p_action text, p_target_id text,
                              p_at timestamptz, p_diff jsonb)
RETURNS text LANGUAGE sql IMMUTABLE AS $$
  SELECT encode(digest(
    coalesce(p_prev, '') || '|' || coalesce(p_actor::text, '') || '|' ||
    p_action || '|' || p_target_id || '|' || extract(epoch FROM p_at)::text || '|' ||
    coalesce(p_diff::text, ''),            -- jsonb::text is canonical (sorted keys)
    'sha256'), 'hex')
$$;

-- Same signature, same SECURITY DEFINER + tenant assertion as 001; only the hash changes.
CREATE OR REPLACE FUNCTION audit_append(
  p_workspace_id uuid, p_actor_user_id uuid, p_actor_role text, p_action text,
  p_target_type text, p_target_id text, p_diff jsonb DEFAULT NULL
) RETURNS uuid
LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$
DECLARE
  v_id uuid; v_prev text; v_hash text; v_now timestamptz := clock_timestamp();
BEGIN
  PERFORM fn_assert_tenant(p_workspace_id);
  -- Serialise appends per workspace: two concurrent READ COMMITTED appends could
  -- otherwise read the same head and fork the chain (§9.2). Released at commit.
  PERFORM pg_advisory_xact_lock(hashtext('audit:' || p_workspace_id::text));
  SELECT hash INTO v_prev FROM audit_log
   WHERE workspace_id = p_workspace_id ORDER BY seq DESC LIMIT 1;
  v_hash := audit_hash_v2(v_prev, p_actor_user_id, p_action, p_target_id, v_now, p_diff);
  INSERT INTO audit_log (workspace_id, actor_user_id, actor_role, action, target_type, target_id,
                         diff, at, prev_hash, hash, hash_version)
  VALUES (p_workspace_id, p_actor_user_id, p_actor_role, p_action, p_target_type, p_target_id,
          p_diff, v_now, v_prev, v_hash, 2)
  RETURNING id INTO v_id;
  RETURN v_id;
END $$;

-- audit_append_system: identical change (v2 hash, hash_version 2, advisory lock on 'audit:system'),
-- body otherwise as 001_tenants.sql:123-144.

CREATE OR REPLACE FUNCTION audit_verify_chain(p_workspace_id uuid DEFAULT NULL) RETURNS boolean
LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$
DECLARE r record; v_prev text := NULL;
BEGIN
  FOR r IN SELECT * FROM audit_log
            WHERE workspace_id IS NOT DISTINCT FROM p_workspace_id
            ORDER BY seq ASC LOOP
    IF r.prev_hash IS DISTINCT FROM v_prev THEN RETURN false; END IF;
    IF CASE r.hash_version
         WHEN 1 THEN audit_hash(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at)
         ELSE audit_hash_v2(r.prev_hash, r.actor_user_id, r.action, r.target_id, r.at, r.diff)
       END <> r.hash THEN
      RETURN false;
    END IF;
    v_prev := r.hash;
  END LOOP;
  RETURN true;
END $$;

Callers are unchanged: appendTenantAudit (audit.service.ts:29-37), groups.service.ts:123-133, and the scheduler's direct audit_append calls (publish.activity.ts:186-188,200-203) all keep the same signature.

2.3 011_review_workflow.sql

-- 011: group review & signoff. Sections:
--   1 columns   2 tables   3 indexes   4 immutability   5 RLS/grants
--   6 pure functions   7 backfill (BEFORE guard triggers exist)
--   8 guard triggers   9 vote reconciliation   10 group-delete guard
--   11 notify   12 sweeper discovery   13 permissions

-- ── 1. columns ────────────────────────────────────────────────────────────
ALTER TABLE drafts ADD CONSTRAINT drafts_id_workspace_key UNIQUE (id, workspace_id);

ALTER TABLE group_members
  ADD COLUMN review_role text NOT NULL DEFAULT 'member'
    CHECK (review_role IN ('member', 'editor', 'reviewer'));
-- ADD COLUMN does not fire row triggers: no epoch bump at migration time.

ALTER TABLE drafts
  ADD COLUMN group_id uuid,
  ADD COLUMN connection_id uuid,
  ADD COLUMN bundle_id uuid,                       -- sibling drafts from one compose action (UI grouping only)
  ADD COLUMN revision integer NOT NULL DEFAULT 0,  -- bumped on every content save (optimistic concurrency)
  ADD COLUMN current_version_id uuid,
  ADD COLUMN approved_version_id uuid,
  ADD COLUMN chat_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,
  -- PG15+ column-list SET NULL: only group_id/connection_id are nulled, workspace_id stays.
  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_connection_fk FOREIGN KEY (connection_id, workspace_id)
    REFERENCES connections(id, workspace_id) ON DELETE SET NULL (connection_id),   -- 008_groups.sql:15
  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: 'rejected' -> 'changes_requested', + 'expired'. Data is remapped per
-- tenant in section 7 (FORCE RLS: an owner-level UPDATE without app.workspace_id
-- would silently match nothing for a non-superuser owner).
ALTER TABLE drafts DROP CONSTRAINT drafts_state_check;   -- 006_publishing.sql:7-8 (auto-named)

-- ── 2. tables ─────────────────────────────────────────────────────────────
CREATE TABLE draft_versions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL,
  draft_id uuid NOT NULL,
  version_no integer NOT NULL CHECK (version_no >= 1),
  content jsonb,                               -- review_content(post, connectionId); NULL only after purge
  content_hash text NOT NULL,                  -- set by trigger from content; never changes
  sealed_by uuid NOT NULL REFERENCES users(id),
  sealed_at timestamptz NOT NULL DEFAULT now(),
  seal_kind text NOT NULL CHECK (seal_kind IN ('submit', 'edit', 'reopen', 'migrated')),
  review_mode text NOT NULL CHECK (review_mode IN ('quorum', 'exempt', 'none')),
  required_approvals smallint NOT NULL CHECK (required_approvals BETWEEN 0 AND 5),
  policy_source text NOT NULL CHECK (policy_source IN ('group', 'workspace', 'default')),
  blind_votes boolean NOT NULL,
  deadline_at timestamptz NOT NULL,            -- LEAST(post.scheduledAt, sealed_at + stale_after)
  outcome text CHECK (outcome IN ('approved', 'changes_requested', 'superseded', 'withdrawn',
                                  'expired', 'overridden', 'exempt')),
  outcome_at timestamptz,
  outcome_by uuid REFERENCES users(id),        -- NULL = system
  purged_at timestamptz,
  UNIQUE (draft_id, version_no),
  UNIQUE (id, draft_id, workspace_id),
  FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE CASCADE,
  CHECK ((content IS NULL) = (purged_at IS NOT NULL)),
  CHECK ((outcome IS NULL) = (outcome_at IS NULL))
);

ALTER TABLE drafts
  ADD CONSTRAINT drafts_current_version_fk FOREIGN KEY (current_version_id, id, workspace_id)
    REFERENCES draft_versions(id, draft_id, workspace_id),
  ADD CONSTRAINT drafts_approved_version_fk FOREIGN KEY (approved_version_id, id, workspace_id)
    REFERENCES draft_versions(id, draft_id, workspace_id);

CREATE TABLE draft_contributors (
  workspace_id uuid NOT NULL,
  draft_id uuid NOT NULL,
  user_id uuid NOT NULL REFERENCES users(id),
  first_contributed_at timestamptz NOT NULL DEFAULT now(),
  last_contributed_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
);

CREATE TABLE review_votes (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL,
  draft_id uuid NOT NULL,
  version_id uuid NOT NULL,
  reviewer_user_id uuid NOT NULL REFERENCES users(id),
  decision text NOT NULL CHECK (decision IN ('approve', 'deny')),
  reason text CHECK (reason IS NULL OR char_length(reason) <= 2000),
  content_hash text NOT NULL,                  -- copied from the version by trigger: what was approved
  source text NOT NULL DEFAULT 'cast' CHECK (source IN ('cast', 'migrated')),
  cast_at timestamptz NOT NULL DEFAULT now(),
  revoked_at timestamptz,
  revoke_cause text CHECK (revoke_cause IN ('retracted', 'membership_changed', 'role_changed')),
  reason_redacted_at timestamptz,
  FOREIGN KEY (version_id, draft_id, workspace_id)
    REFERENCES draft_versions(id, draft_id, workspace_id) ON DELETE CASCADE,
  -- R3: deny requires a reason (unless purged afterwards).
  CHECK (decision <> 'deny' OR reason_redacted_at IS NOT NULL OR btrim(coalesce(reason, '')) <> ''),
  CHECK ((revoked_at IS NULL) = (revoke_cause IS NULL)),
  CHECK (revoke_cause IS DISTINCT FROM 'retracted' OR decision = 'approve')  -- only approvals are retractable
);
CREATE UNIQUE INDEX review_votes_one_live ON review_votes (version_id, reviewer_user_id)
  WHERE revoked_at IS NULL;

CREATE TABLE review_policies (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  group_id uuid,                                -- NULL = workspace default row
  required_approvals smallint NOT NULL CHECK (required_approvals BETWEEN 0 AND 5),
  exempt_roles text[] NOT NULL DEFAULT '{}',    -- validated against role_permissions by the service
  blind_votes boolean NOT NULL DEFAULT true,
  stale_after interval NOT NULL DEFAULT '30 days'
    CHECK (stale_after BETWEEN interval '1 hour' AND interval '90 days'),
  min_lead interval NOT NULL DEFAULT '2 minutes'
    CHECK (min_lead BETWEEN interval '0' AND interval '1 day'),
  updated_by uuid REFERENCES users(id) ON DELETE SET NULL,
  updated_at timestamptz NOT NULL DEFAULT now(),
  FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE CASCADE
);
CREATE UNIQUE INDEX review_policies_workspace ON review_policies (workspace_id) WHERE group_id IS NULL;
CREATE UNIQUE INDEX review_policies_group ON review_policies (group_id) WHERE group_id IS NOT NULL;

CREATE TABLE review_messages (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL,
  draft_id uuid NOT NULL,
  seq bigint NOT NULL,                          -- assigned by trigger under the draft row lock
  kind text NOT NULL CHECK (kind IN ('comment', 'event')),
  author_user_id uuid REFERENCES users(id),     -- NULL for system events
  client_message_id uuid,                       -- idempotent retries after re-auth (§8 E7)
  version_id uuid,                              -- version the message refers to ("on v3")
  body text CHECK (body IS NULL OR char_length(body) BETWEEN 1 AND 4000),
  event_type text,
  event jsonb,
  created_at timestamptz NOT NULL DEFAULT now(),
  redacted_at timestamptz,
  redacted_by uuid REFERENCES users(id),
  UNIQUE (draft_id, seq),
  UNIQUE (draft_id, client_message_id),
  FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE CASCADE,
  FOREIGN KEY (version_id, draft_id, workspace_id)
    REFERENCES draft_versions(id, draft_id, workspace_id),     -- MATCH SIMPLE: NULL version allowed
  CHECK (
    (kind = 'comment' AND author_user_id IS NOT NULL AND event_type IS NULL AND event IS NULL
       AND (body IS NOT NULL OR redacted_at IS NOT NULL))
    OR (kind = 'event' AND author_user_id IS NULL AND event_type IS NOT NULL AND event IS NOT NULL
       AND body IS NULL))
);

-- ── 3. indexes ────────────────────────────────────────────────────────────
CREATE INDEX drafts_queue ON drafts (workspace_id, state, updated_at DESC, id DESC)
  WHERE deleted_at IS NULL;
CREATE INDEX drafts_group_state ON drafts (group_id, state) WHERE group_id IS NOT NULL;
CREATE INDEX drafts_bundle ON drafts (bundle_id) WHERE bundle_id IS NOT NULL;
CREATE INDEX draft_versions_open_deadline ON draft_versions (deadline_at) WHERE outcome IS NULL;
CREATE INDEX review_votes_live_version ON review_votes (version_id) WHERE revoked_at IS NULL;
CREATE INDEX review_votes_reviewer ON review_votes (workspace_id, reviewer_user_id, cast_at DESC);
CREATE INDEX group_members_reviewers ON group_members (group_id) WHERE review_role = 'reviewer';

-- ── 4. workspace immutability (001_tenants.sql:164-171) ──────────────────────
CREATE TRIGGER trg_draft_version_ws_immutable BEFORE UPDATE ON draft_versions
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_draft_contributor_ws_immutable BEFORE UPDATE ON draft_contributors
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_review_vote_ws_immutable BEFORE UPDATE ON review_votes
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_review_policy_ws_immutable BEFORE UPDATE ON review_policies
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_review_message_ws_immutable BEFORE UPDATE ON review_messages
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();

-- ── 5. RLS + grants ───────────────────────────────────────────────────────
ALTER TABLE draft_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE draft_contributors ENABLE ROW LEVEL SECURITY;
ALTER TABLE review_votes ENABLE ROW LEVEL SECURITY;
ALTER TABLE review_policies ENABLE ROW LEVEL SECURITY;
ALTER TABLE review_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE draft_versions FORCE ROW LEVEL SECURITY;
ALTER TABLE draft_contributors FORCE ROW LEVEL SECURITY;
ALTER TABLE review_votes FORCE ROW LEVEL SECURITY;
ALTER TABLE review_policies FORCE ROW LEVEL SECURITY;
ALTER TABLE review_messages FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON draft_versions
  USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
  WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON draft_contributors
  USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
  WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON review_votes
  USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
  WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON review_policies
  USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
  WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);
CREATE POLICY tenant_isolation ON review_messages
  USING (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid)
  WITH CHECK (workspace_id = NULLIF(current_setting('app.workspace_id', true), '')::uuid);

GRANT SELECT, INSERT, UPDATE ON draft_versions, draft_contributors, review_votes,
  review_policies, review_messages TO sp_app;
GRANT DELETE ON review_policies TO sp_app;     -- deleting a group row = "inherit workspace policy"
-- 003_app_role.sql:16-17 default privileges grant DELETE on every new table;
-- history tables are append-only for the app. FK cascades (workspace deletion)
-- run as the table owner and are unaffected.
REVOKE DELETE ON draft_versions, draft_contributors, review_votes, review_messages FROM sp_app;
REVOKE DELETE ON drafts FROM sp_app;          -- drafts are tombstoned, never hard-deleted (§7.4)

-- ── 6. pure functions (SECURITY INVOKER: run under the caller's RLS) ─────────
CREATE FUNCTION review_content(p_post jsonb, p_connection_id uuid) RETURNS jsonb
LANGUAGE sql IMMUTABLE AS $$
  SELECT jsonb_build_object('post', p_post, 'connectionId', p_connection_id)
$$;

CREATE FUNCTION review_content_hash(p_content jsonb) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
  SELECT encode(digest(p_content::text, 'sha256'), 'hex')
$$;

CREATE FUNCTION review_effective_policy(p_workspace_id uuid, p_group_id uuid)
RETURNS TABLE(required_approvals smallint, exempt_roles text[], blind_votes boolean,
              stale_after interval, min_lead interval, source text)
LANGUAGE sql STABLE AS $$
  SELECT p.required_approvals, p.exempt_roles, p.blind_votes, p.stale_after, p.min_lead, p.source
  FROM (
    SELECT rp.required_approvals, rp.exempt_roles, rp.blind_votes, rp.stale_after, rp.min_lead,
           'group'::text AS source, 1 AS rank
      FROM review_policies rp WHERE p_group_id IS NOT NULL AND rp.group_id = p_group_id
    UNION ALL
    SELECT rp.required_approvals, rp.exempt_roles, rp.blind_votes, rp.stale_after, rp.min_lead,
           'workspace', 2
      FROM review_policies rp WHERE rp.workspace_id = p_workspace_id AND rp.group_id IS NULL
    UNION ALL
    SELECT 1::smallint, ARRAY['Owner', 'Admin']::text[], true, interval '30 days', interval '2 minutes',
           'default', 3
  ) p
  ORDER BY p.rank
  LIMIT 1
$$;

-- Who may vote on this draft's CURRENT round. Single source of truth for the
-- service, review_vote_guard, review_quorum and review_capabilities.
CREATE FUNCTION review_eligible_reviewers(p_draft_id uuid) RETURNS TABLE(user_id uuid)
LANGUAGE sql STABLE AS $$
  SELECT m.user_id
  FROM drafts d
  JOIN memberships m ON m.workspace_id = d.workspace_id AND m.status = 'active'
  WHERE d.id = p_draft_id
    AND m.user_id <> d.author_user_id
    AND NOT EXISTS (SELECT 1 FROM draft_contributors c
                    WHERE c.draft_id = d.id AND c.user_id = m.user_id)
    AND NOT EXISTS (SELECT 1 FROM draft_versions v
                    WHERE v.draft_id = d.id AND v.sealed_by = m.user_id
                      AND v.seal_kind IN ('submit', 'edit'))
    AND CASE
      WHEN d.group_id IS NULL THEN EXISTS (           -- legacy workspace draft: approval.grant holders
        SELECT 1 FROM role_permissions rp
        WHERE rp.role = m.role AND rp.permission = 'approval.grant')
      ELSE EXISTS (                                   -- group draft: group reviewers with the draft.view floor
        SELECT 1 FROM group_members gm
        WHERE gm.group_id = d.group_id AND gm.user_id = m.user_id AND gm.review_role = 'reviewer')
        AND EXISTS (SELECT 1 FROM role_permissions rp
                    WHERE rp.role = m.role AND rp.permission = 'draft.view')
    END
$$;

-- Live count: votes from users who are no longer eligible never count, even
-- before reconciliation materialises their revocation (§5.4).
CREATE FUNCTION review_quorum(p_version_id uuid)
RETURNS TABLE(approvals integer, required integer, eligible integer)
LANGUAGE sql STABLE AS $$
  WITH v AS (SELECT id, draft_id, required_approvals FROM draft_versions WHERE id = p_version_id),
       e AS (SELECT r.user_id FROM v, LATERAL review_eligible_reviewers(v.draft_id) r)
  SELECT
    (SELECT count(*)::int FROM review_votes rv
      WHERE rv.version_id = p_version_id AND rv.decision = 'approve' AND rv.revoked_at IS NULL
        AND rv.reviewer_user_id IN (SELECT user_id FROM e)),
    (SELECT required_approvals::int FROM v),
    (SELECT count(*)::int FROM e)
$$;

CREATE FUNCTION review_capabilities(p_draft_id uuid, p_user_id uuid) RETURNS text[]
LANGUAGE plpgsql STABLE AS $$
DECLARE
  d drafts%ROWTYPE;
  v_role text; v_perms text[]; v_admin boolean; v_grole text;
  v_author boolean; v_content boolean; v_contrib boolean; v_publisher boolean;
  v_caps text[] := ARRAY[]::text[];
BEGIN
  SELECT * INTO d FROM drafts WHERE id = p_draft_id;
  IF NOT FOUND OR d.deleted_at IS NOT NULL THEN RETURN v_caps; END IF;
  SELECT m.role INTO v_role FROM memberships m
   WHERE m.workspace_id = d.workspace_id AND m.user_id = p_user_id AND m.status = 'active';
  IF v_role IS NULL THEN RETURN v_caps; END IF;
  SELECT coalesce(array_agg(rp.permission), ARRAY[]::text[]) INTO v_perms
    FROM role_permissions rp WHERE rp.role = v_role;
  IF NOT ('draft.view' = ANY (v_perms)) THEN RETURN v_caps; END IF;
  IF d.hidden_at IS NOT NULL AND NOT ('draft.hide' = ANY (v_perms)) THEN RETURN v_caps; END IF;
  v_admin := v_role IN ('Owner', 'Admin');           -- WORKSPACE_WIDE_ROLES, groups.service.ts:21
  IF d.group_id IS NOT NULL THEN
    SELECT gm.review_role INTO v_grole FROM group_members gm
     WHERE gm.group_id = d.group_id AND gm.user_id = p_user_id;
    IF v_grole IS NULL AND NOT v_admin THEN RETURN v_caps; END IF;   -- not your group: invisible
  END IF;

  v_author := d.author_user_id = p_user_id;
  v_content := v_admin OR ('draft.edit' = ANY (v_perms) AND CASE
                 WHEN d.group_id IS NULL THEN true
                 ELSE v_grole = 'editor' OR (v_author AND v_grole = 'member') END);
  v_publisher := v_admin OR CASE
                 WHEN d.group_id IS NULL THEN 'publication.schedule' = ANY (v_perms)
                 ELSE v_author AND v_grole = 'member' END;
  v_contrib := v_author
    OR EXISTS (SELECT 1 FROM draft_contributors c WHERE c.draft_id = d.id AND c.user_id = p_user_id)
    OR EXISTS (SELECT 1 FROM draft_versions v WHERE v.draft_id = d.id AND v.sealed_by = p_user_id
                 AND v.seal_kind IN ('submit', 'edit'));

  v_caps := ARRAY['view'];
  IF d.hidden_at IS NOT NULL THEN                    -- frozen: moderators may only view / unhide / purge
    v_caps := v_caps || ARRAY['hide'];
    IF 'draft.purge' = ANY (v_perms) THEN v_caps := v_caps || ARRAY['purge']; END IF;
    RETURN v_caps;
  END IF;
  IF d.state <> 'published' THEN v_caps := v_caps || ARRAY['comment']; END IF;
  IF v_content AND d.state IN ('editing', 'changes_requested', 'in_review', 'approved', 'scheduled') THEN
    v_caps := v_caps || ARRAY['edit'];
  END IF;
  IF v_content AND d.state IN ('editing', 'changes_requested')
     AND (d.group_id IS NOT NULL OR v_admin OR 'approval.request' = ANY (v_perms)) THEN
    v_caps := v_caps || ARRAY['submit'];
  END IF;
  IF (v_author OR v_admin) AND d.state IN ('in_review', 'approved', 'scheduled') THEN
    v_caps := v_caps || ARRAY['withdraw'];
  END IF;
  IF d.state = 'in_review'
     AND EXISTS (SELECT 1 FROM review_eligible_reviewers(d.id) e WHERE e.user_id = p_user_id) THEN
    v_caps := v_caps || ARRAY['vote'];
  END IF;
  IF v_publisher AND d.state = 'approved' THEN v_caps := v_caps || ARRAY['release']; END IF;
  IF v_publisher AND d.state = 'scheduled' THEN v_caps := v_caps || ARRAY['cancel_schedule', 'retry_publish']; END IF;
  IF v_content AND d.state = 'expired' THEN v_caps := v_caps || ARRAY['reopen']; END IF;
  IF 'approval.override' = ANY (v_perms) THEN
    IF d.state = 'in_review' AND NOT v_contrib THEN v_caps := v_caps || ARRAY['override']; END IF;
    IF d.state IN ('in_review', 'approved', 'scheduled') THEN v_caps := v_caps || ARRAY['restart_review']; END IF;
  END IF;
  IF 'draft.hide' = ANY (v_perms) THEN v_caps := v_caps || ARRAY['hide']; END IF;
  IF 'draft.purge' = ANY (v_perms) THEN v_caps := v_caps || ARRAY['purge']; END IF;
  RETURN v_caps;
END $$;

-- ── 7. backfill (Q9) — per tenant, before any guard trigger exists ───────────
DO $$
DECLARE
  ws record; d record; v_version uuid; v_hash text; v_reason text;
  n_versions integer; n_votes integer; n_self integer;
BEGIN
  FOR ws IN SELECT id FROM workspaces LOOP            -- workspaces has no RLS (001_tenants.sql:4-10)
    PERFORM set_config('app.workspace_id', ws.id::text, true);
    n_versions := 0; n_votes := 0; n_self := 0;

    UPDATE drafts SET state = 'changes_requested' WHERE state = 'rejected';

    UPDATE drafts dr SET connection_id = coalesce(
        (SELECT sp.connection_id FROM scheduled_publications sp WHERE sp.draft_id = dr.id),
        (SELECT c.id FROM connections c
          WHERE c.id::text = dr.post -> 'providerMetadata' ->> 'connectionId'))
      WHERE dr.connection_id IS NULL;

    INSERT INTO draft_contributors (workspace_id, draft_id, user_id, first_contributed_at, last_contributed_at)
      SELECT workspace_id, id, author_user_id, created_at, updated_at FROM drafts
      ON CONFLICT DO NOTHING;

    FOR d IN SELECT * FROM drafts WHERE state <> 'editing' LOOP
      v_reason := NULL;
      v_hash := review_content_hash(review_content(d.post, d.connection_id));
      INSERT INTO draft_versions (workspace_id, draft_id, version_no, content, content_hash,
          sealed_by, sealed_at, seal_kind, review_mode, required_approvals, policy_source,
          blind_votes, deadline_at, outcome, outcome_at, outcome_by)
      VALUES (ws.id, d.id, 1, review_content(d.post, d.connection_id), v_hash,
          d.author_user_id, d.updated_at, 'migrated', 'quorum', 1, 'default', false,
          CASE WHEN d.state = 'in_review' THEN now() + interval '30 days' ELSE d.updated_at END,
          CASE d.state WHEN 'in_review' THEN NULL
                       WHEN 'changes_requested' THEN 'changes_requested' ELSE 'approved' END,
          CASE WHEN d.state = 'in_review' THEN NULL ELSE d.updated_at END,
          CASE WHEN d.state = 'in_review' THEN NULL ELSE d.reviewer_user_id END)
      RETURNING id INTO v_version;
      n_versions := n_versions + 1;

      IF d.state <> 'in_review' AND d.reviewer_user_id IS NOT NULL THEN
        IF d.state = 'changes_requested' THEN            -- reason lived only in audit (publishing.service.ts:199-203)
          SELECT a.diff ->> 'reason' INTO v_reason FROM audit_log a
           WHERE a.workspace_id = ws.id AND a.action = 'draft.rejected' AND a.target_id = d.id::text
           ORDER BY a.seq DESC LIMIT 1;
        END IF;
        INSERT INTO review_votes (workspace_id, draft_id, version_id, reviewer_user_id, decision,
            reason, content_hash, source, cast_at)
        VALUES (ws.id, d.id, v_version, d.reviewer_user_id,
            CASE WHEN d.state = 'changes_requested' THEN 'deny' ELSE 'approve' END,
            CASE WHEN d.state = 'changes_requested' THEN coalesce(nullif(btrim(v_reason), ''),
                 'Migrated from single-step review; original reason unavailable.') END,
            v_hash, 'migrated', d.updated_at);
        n_votes := n_votes + 1;
        IF d.reviewer_user_id = d.author_user_id THEN n_self := n_self + 1; END IF;
      END IF;

      UPDATE drafts SET current_version_id = v_version,
             approved_version_id = CASE WHEN d.state IN ('approved', 'scheduled', 'published')
                                        THEN v_version END
       WHERE id = d.id;
    END LOOP;

    IF n_versions > 0 THEN
      PERFORM audit_append(ws.id, NULL, 'sp_migration', 'review.migrated', 'workspace', ws.id::text,
        jsonb_build_object('versions', n_versions, 'votes', n_votes, 'selfApproved', n_self));
    END IF;
  END LOOP;
  PERFORM set_config('app.workspace_id', '', true);
END $$;

-- CHECK validation scans the heap directly (not subject to RLS).
ALTER TABLE drafts ADD CONSTRAINT drafts_state_check CHECK (state IN
  ('editing', 'in_review', 'changes_requested', 'approved', 'scheduled', 'published', 'expired'));

-- ── 8. guard triggers (defence in depth; the service checks first for nice errors) ──
-- SQLSTATEs: 23514 = invariant (as reject_workspace_change), RV001..RV004 = review rules
-- the service maps to API codes (§5.7).
CREATE FUNCTION review_draft_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE v record; q record;
BEGIN
  IF OLD.deleted_at IS NOT NULL THEN                 -- tombstones are final (FK SET NULL still allowed)
    IF (NEW.state, NEW.post, NEW.current_version_id, NEW.approved_version_id, NEW.hidden_at, NEW.deleted_at)
       IS DISTINCT FROM
       (OLD.state, OLD.post, OLD.current_version_id, OLD.approved_version_id, OLD.hidden_at, OLD.deleted_at) THEN
      RAISE EXCEPTION 'draft % is deleted', OLD.id USING ERRCODE = '23514';
    END IF;
    RETURN NEW;
  END IF;

  IF OLD.hidden_at IS NOT NULL AND NEW.hidden_at IS NOT NULL AND NEW.state IS DISTINCT FROM OLD.state THEN
    RAISE EXCEPTION 'draft % is hidden; unhide before changing state', OLD.id USING ERRCODE = '23514';
  END IF;

  IF NEW.state IS DISTINCT FROM OLD.state AND (OLD.state, NEW.state) NOT IN (VALUES
      ('editing', 'in_review'), ('editing', 'approved'),
      ('changes_requested', 'in_review'), ('changes_requested', 'approved'),
      ('in_review', 'approved'), ('in_review', 'changes_requested'),
      ('in_review', 'editing'), ('in_review', 'expired'),
      ('approved', 'scheduled'), ('approved', 'in_review'), ('approved', 'editing'),
      ('scheduled', 'approved'), ('scheduled', 'published'),
      ('scheduled', 'in_review'), ('scheduled', 'editing'),
      ('expired', 'editing')) THEN
    RAISE EXCEPTION 'illegal draft transition % -> %', OLD.state, NEW.state USING ERRCODE = '23514';
  END IF;

  IF NEW.group_id IS DISTINCT FROM OLD.group_id AND NEW.group_id IS NOT NULL AND OLD.state <> 'editing' THEN
    RAISE EXCEPTION 'group can only change while editing' USING ERRCODE = '23514';
  END IF;

  -- Sealed content changes only together with a new version whose hash matches.
  IF NEW.current_version_id IS DISTINCT FROM OLD.current_version_id THEN
    SELECT dv.draft_id, dv.content_hash INTO v FROM draft_versions dv WHERE dv.id = NEW.current_version_id;
    IF v.draft_id IS DISTINCT FROM NEW.id
       OR v.content_hash <> review_content_hash(review_content(NEW.post, NEW.connection_id)) THEN
      RAISE EXCEPTION 'current version does not match draft content' USING ERRCODE = '23514';
    END IF;
  ELSIF (NEW.post IS DISTINCT FROM OLD.post
         OR (NEW.connection_id IS DISTINCT FROM OLD.connection_id AND NEW.connection_id IS NOT NULL))
        AND OLD.state NOT IN ('editing', 'changes_requested')
        AND NEW.deleted_at IS NULL THEN                -- purge redaction is the only exception
    RAISE EXCEPTION 'draft content is sealed in state %', OLD.state USING ERRCODE = '23514';
  END IF;

  -- Entering approved needs a closed, qualifying round.
  IF NEW.state = 'approved' AND OLD.state IN ('editing', 'changes_requested', 'in_review') THEN
    SELECT dv.outcome, dv.review_mode INTO v FROM draft_versions dv WHERE dv.id = NEW.current_version_id;
    IF OLD.state = 'in_review' AND v.outcome IS DISTINCT FROM 'approved' AND v.outcome IS DISTINCT FROM 'overridden' THEN
      RAISE EXCEPTION 'approval requires a closed quorum or override' USING ERRCODE = '23514';
    END IF;
    IF OLD.state <> 'in_review' AND v.outcome IS DISTINCT FROM 'exempt' THEN
      RAISE EXCEPTION 'direct approval requires an exempt version' USING ERRCODE = '23514';
    END IF;
    IF v.outcome = 'approved' THEN
      SELECT * INTO q FROM review_quorum(NEW.current_version_id);
      IF q.approvals < q.required THEN
        RAISE EXCEPTION 'quorum not met (% of %)', q.approvals, q.required USING ERRCODE = '23514';
      END IF;
    END IF;
  END IF;

  IF NEW.state IN ('approved', 'scheduled', 'published')
     AND NEW.approved_version_id IS DISTINCT FROM NEW.current_version_id THEN
    RAISE EXCEPTION 'approved version must be the current version' USING ERRCODE = '23514';
  END IF;
  IF NEW.state NOT IN ('approved', 'scheduled', 'published') AND NEW.approved_version_id IS NOT NULL THEN
    RAISE EXCEPTION 'approved_version_id must be cleared when leaving approval' USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_draft_review_guard BEFORE UPDATE ON drafts
  FOR EACH ROW EXECUTE FUNCTION review_draft_guard();

CREATE FUNCTION review_version_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    IF NEW.content IS NULL THEN RAISE EXCEPTION 'version content required' USING ERRCODE = '23514'; END IF;
    NEW.content_hash := review_content_hash(NEW.content);      -- never trust a caller-supplied hash
    NEW.purged_at := NULL;
    NEW.sealed_at := clock_timestamp();
    IF NEW.outcome IS NOT NULL AND NOT (NEW.outcome = 'exempt' AND NEW.review_mode IN ('exempt', 'none')) THEN
      RAISE EXCEPTION 'only exempt versions are born closed' USING ERRCODE = '23514';
    END IF;
    RETURN NEW;
  END IF;
  IF (NEW.id, NEW.draft_id, NEW.version_no, NEW.content_hash, NEW.sealed_by, NEW.sealed_at, NEW.seal_kind,
      NEW.review_mode, NEW.required_approvals, NEW.policy_source, NEW.blind_votes, NEW.deadline_at)
     IS DISTINCT FROM
     (OLD.id, OLD.draft_id, OLD.version_no, OLD.content_hash, OLD.sealed_by, OLD.sealed_at, OLD.seal_kind,
      OLD.review_mode, OLD.required_approvals, OLD.policy_source, OLD.blind_votes, OLD.deadline_at) THEN
    RAISE EXCEPTION 'draft versions are immutable' USING ERRCODE = '23514';
  END IF;
  IF OLD.outcome IS NOT NULL AND (NEW.outcome, NEW.outcome_at, NEW.outcome_by)
     IS DISTINCT FROM (OLD.outcome, OLD.outcome_at, OLD.outcome_by) THEN
    RAISE EXCEPTION 'version outcome is final' USING ERRCODE = '23514';
  END IF;
  IF NEW.content IS DISTINCT FROM OLD.content AND NOT (NEW.content IS NULL AND NEW.purged_at IS NOT NULL) THEN
    RAISE EXCEPTION 'version content can only be purged' USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_draft_version_guard BEFORE INSERT OR UPDATE ON draft_versions
  FOR EACH ROW EXECUTE FUNCTION review_version_guard();

CREATE FUNCTION review_vote_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
DECLARE d record; v record;
BEGIN
  IF TG_OP = 'UPDATE' THEN
    IF (NEW.id, NEW.draft_id, NEW.version_id, NEW.reviewer_user_id, NEW.decision, NEW.content_hash,
        NEW.source, NEW.cast_at)
       IS DISTINCT FROM
       (OLD.id, OLD.draft_id, OLD.version_id, OLD.reviewer_user_id, OLD.decision, OLD.content_hash,
        OLD.source, OLD.cast_at) THEN
      RAISE EXCEPTION 'votes are immutable' USING ERRCODE = '23514';
    END IF;
    IF OLD.revoked_at IS NOT NULL
       AND (NEW.revoked_at, NEW.revoke_cause) IS DISTINCT FROM (OLD.revoked_at, OLD.revoke_cause) THEN
      RAISE EXCEPTION 'vote already revoked' USING ERRCODE = '23514';
    END IF;
    IF NEW.reason IS DISTINCT FROM OLD.reason AND NOT (NEW.reason IS NULL AND NEW.reason_redacted_at IS NOT NULL) THEN
      RAISE EXCEPTION 'vote reason can only be redacted' USING ERRCODE = '23514';
    END IF;
    RETURN NEW;
  END IF;

  IF NEW.source <> 'cast' THEN RAISE EXCEPTION 'only cast votes may be inserted' USING ERRCODE = '23514'; END IF;
  SELECT dr.state, dr.current_version_id, dr.hidden_at, dr.deleted_at, dr.author_user_id
    INTO d FROM drafts dr WHERE dr.id = NEW.draft_id;
  IF d.state IS DISTINCT FROM 'in_review' OR d.hidden_at IS NOT NULL OR d.deleted_at IS NOT NULL THEN
    RAISE EXCEPTION 'round_closed' USING ERRCODE = 'RV004';
  END IF;
  IF NEW.version_id IS DISTINCT FROM d.current_version_id THEN
    RAISE EXCEPTION 'version_stale' USING ERRCODE = 'RV003';
  END IF;
  SELECT dv.content_hash, dv.outcome INTO v FROM draft_versions dv WHERE dv.id = NEW.version_id;
  IF v.outcome IS NOT NULL THEN RAISE EXCEPTION 'round_closed' USING ERRCODE = 'RV004'; END IF;
  IF NEW.reviewer_user_id = d.author_user_id
     OR EXISTS (SELECT 1 FROM draft_contributors c
                WHERE c.draft_id = NEW.draft_id AND c.user_id = NEW.reviewer_user_id)
     OR EXISTS (SELECT 1 FROM draft_versions dv
                WHERE dv.draft_id = NEW.draft_id AND dv.sealed_by = NEW.reviewer_user_id
                  AND dv.seal_kind IN ('submit', 'edit')) THEN
    RAISE EXCEPTION 'self_approval_forbidden' USING ERRCODE = 'RV001';
  END IF;
  IF NOT EXISTS (SELECT 1 FROM review_eligible_reviewers(NEW.draft_id) e
                 WHERE e.user_id = NEW.reviewer_user_id) THEN
    RAISE EXCEPTION 'reviewer_not_eligible' USING ERRCODE = 'RV002';
  END IF;
  NEW.content_hash := v.content_hash;              -- bind the vote to exactly what was sealed
  NEW.cast_at := clock_timestamp();
  NEW.revoked_at := NULL; NEW.revoke_cause := NULL; NEW.reason_redacted_at := NULL;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_review_vote_guard BEFORE INSERT OR UPDATE ON review_votes
  FOR EACH ROW EXECUTE FUNCTION review_vote_guard();

-- Chat ordering: allocating seq takes the draft row lock, so concurrent writers
-- on one draft serialise and seq order == commit order (gapless; rollbacks
-- roll the counter back too). Readers can page with ?after=seq safely.
CREATE FUNCTION review_message_guard() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'INSERT' THEN
    UPDATE drafts SET chat_seq = chat_seq + 1 WHERE id = NEW.draft_id RETURNING chat_seq INTO NEW.seq;
    IF NEW.seq IS NULL THEN RAISE EXCEPTION 'draft % not found', NEW.draft_id USING ERRCODE = '23503'; END IF;
    NEW.created_at := clock_timestamp();
    NEW.redacted_at := NULL; NEW.redacted_by := NULL;
    RETURN NEW;
  END IF;
  IF (NEW.id, NEW.draft_id, NEW.seq, NEW.kind, NEW.author_user_id, NEW.version_id, NEW.event_type,
      NEW.created_at, NEW.client_message_id)
     IS DISTINCT FROM
     (OLD.id, OLD.draft_id, OLD.seq, OLD.kind, OLD.author_user_id, OLD.version_id, OLD.event_type,
      OLD.created_at, OLD.client_message_id)
     OR OLD.redacted_at IS NOT NULL
     OR (NEW.body IS DISTINCT FROM OLD.body AND NOT (NEW.body IS NULL AND NEW.redacted_at IS NOT NULL))
     OR (NEW.event IS DISTINCT FROM OLD.event AND NEW.redacted_at IS NULL) THEN
    RAISE EXCEPTION 'review messages are append-only (redaction excepted)' USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_review_message_guard BEFORE INSERT OR UPDATE ON review_messages
  FOR EACH ROW EXECUTE FUNCTION review_message_guard();

-- ── 9. vote reconciliation on authz change (R8) ──────────────────────────────
-- Materialises what review_quorum already enforces live: votes from users who
-- stopped being eligible are revoked, with a chat event and an audit row, in
-- the SAME transaction as the membership/role change.
CREATE FUNCTION review_reconcile_votes(p_workspace_id uuid, p_user_id uuid, p_cause text) RETURNS void
LANGUAGE plpgsql AS $$
DECLARE r record;
BEGIN
  FOR r IN
    UPDATE review_votes rv SET revoked_at = clock_timestamp(), revoke_cause = p_cause
      FROM drafts d
     WHERE rv.workspace_id = p_workspace_id AND rv.reviewer_user_id = p_user_id
       AND rv.revoked_at IS NULL
       AND d.id = rv.draft_id AND d.state = 'in_review' AND rv.version_id = d.current_version_id
       AND NOT EXISTS (SELECT 1 FROM review_eligible_reviewers(d.id) e WHERE e.user_id = p_user_id)
    RETURNING rv.id, rv.draft_id, rv.version_id
  LOOP
    INSERT INTO review_messages (workspace_id, draft_id, kind, version_id, event_type, event)
    VALUES (p_workspace_id, r.draft_id, 'event', r.version_id, 'vote_revoked',
            jsonb_build_object('reviewerUserId', p_user_id, 'cause', p_cause));
    PERFORM audit_append(p_workspace_id, NULL, 'sp_review', 'review.vote_revoked', 'draft', r.draft_id::text,
            jsonb_build_object('voteId', r.id, 'versionId', r.version_id,
                               'reviewerUserId', p_user_id, 'cause', p_cause));
  END LOOP;
END $$;

CREATE FUNCTION review_group_member_changed() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'DELETE' THEN
    PERFORM review_reconcile_votes(OLD.workspace_id, OLD.user_id, 'membership_changed');
  ELSIF OLD.review_role = 'reviewer' AND NEW.review_role <> 'reviewer' THEN
    PERFORM review_reconcile_votes(NEW.workspace_id, NEW.user_id, 'role_changed');
  END IF;
  RETURN NULL;
END $$;
CREATE TRIGGER trg_group_member_review_reconcile AFTER UPDATE OF review_role OR DELETE ON group_members
  FOR EACH ROW EXECUTE FUNCTION review_group_member_changed();

CREATE FUNCTION review_membership_changed() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'DELETE' THEN
    PERFORM review_reconcile_votes(OLD.workspace_id, OLD.user_id, 'membership_changed');
  ELSIF NEW.role IS DISTINCT FROM OLD.role OR NEW.status IS DISTINCT FROM OLD.status THEN
    PERFORM review_reconcile_votes(NEW.workspace_id, NEW.user_id, 'role_changed');
  END IF;
  RETURN NULL;
END $$;
CREATE TRIGGER trg_membership_review_reconcile AFTER UPDATE OF role, status OR DELETE ON memberships
  FOR EACH ROW EXECUTE FUNCTION review_membership_changed();
-- Without a tenant context (e.g. system-level workspace deletion) RLS hides
-- every draft, the UPDATE matches nothing, and audit_append is never reached.

-- ── 10. a group with live reviews cannot be deleted ─────────────────────────
CREATE FUNCTION review_block_group_delete() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  IF EXISTS (SELECT 1 FROM drafts d
             WHERE d.group_id = OLD.id AND d.deleted_at IS NULL
               AND d.state NOT IN ('editing', 'published', 'expired')) THEN
    RAISE EXCEPTION 'group % has posts in review or scheduled', OLD.id USING ERRCODE = '55006';
  END IF;
  RETURN OLD;
END $$;
CREATE TRIGGER trg_group_review_block_delete BEFORE DELETE ON groups
  FOR EACH ROW EXECUTE FUNCTION review_block_group_delete();
-- Remaining editing/published/expired drafts get group_id SET NULL by the FK.
-- Workspace deletion: runs without tenant context, so the EXISTS sees no drafts.

-- ── 11. realtime doorbells (ids only; delivered at COMMIT) ─────────────────────
CREATE FUNCTION review_notify() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
  PERFORM pg_notify('rv_' || replace(NEW.workspace_id::text, '-', ''),
    json_build_object(
      'd', CASE WHEN TG_TABLE_NAME = 'drafts' THEN NEW.id ELSE NEW.draft_id END,
      'k', CASE WHEN TG_TABLE_NAME = 'drafts' THEN 'draft' ELSE 'message' END,
      's', CASE WHEN TG_TABLE_NAME = 'drafts' THEN NULL ELSE NEW.seq END)::text);
  RETURN NULL;
END $$;
-- UPDATE OF: the chat_seq bump does not list these columns, so no double doorbell.
CREATE TRIGGER trg_draft_review_notify
  AFTER INSERT OR UPDATE OF state, hidden_at, deleted_at, current_version_id ON drafts
  FOR EACH ROW EXECUTE FUNCTION review_notify();
CREATE TRIGGER trg_review_message_notify AFTER INSERT OR UPDATE ON review_messages
  FOR EACH ROW EXECUTE FUNCTION review_notify();

-- ── 12. sweeper discovery (same pattern/owner assumption as 006:79-85) ─────────
CREATE FUNCTION review_due_expiries() RETURNS TABLE(workspace_id uuid, draft_id uuid, deadline_at timestamptz)
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
  SELECT d.workspace_id, d.id, v.deadline_at
  FROM drafts d JOIN draft_versions v ON v.id = d.current_version_id
  WHERE d.state = 'in_review' AND d.hidden_at IS NULL AND d.deleted_at IS NULL
    AND v.outcome IS NULL AND v.deadline_at <= now()
  ORDER BY v.deadline_at
  LIMIT 500
$$;
REVOKE ALL ON FUNCTION review_due_expiries() FROM PUBLIC;
GRANT EXECUTE ON FUNCTION review_due_expiries() TO sp_app;

-- ── 13. permissions (additive; 002 idiom) ─────────────────────────────────
INSERT INTO role_permissions (role, permission)
SELECT r, p
FROM unnest(ARRAY['Owner', 'Admin']) AS r
CROSS JOIN unnest(ARRAY['approval.override', 'approval.configure', 'draft.hide', 'draft.purge']) AS p
ON CONFLICT DO NOTHING;

2.4 TypeScript shapes (API publishing/review modules and apps/web/src/lib/api-types.ts)

// replaces publishing.service.ts:8 and api-types.ts:64
export type DraftState =
  | "editing" | "in_review" | "changes_requested" | "approved" | "scheduled" | "published" | "expired";

export type GroupReviewRole = "member" | "editor" | "reviewer";
export type ReviewMode = "quorum" | "exempt" | "none";
export type VersionOutcome =
  | "approved" | "changes_requested" | "superseded" | "withdrawn" | "expired" | "overridden" | "exempt";

export interface QuorumStatus {
  versionId: string;
  versionNo: number;
  mode: ReviewMode;
  approvals: number;
  required: number;
  eligible: number;
  reachable: boolean;               // eligible >= required (or mode !== "quorum")
  deadlineAt: string;
}

export interface DraftVersionSummary {
  id: string;
  versionNo: number;
  contentHash: string;
  sealedBy: string;
  sealedAt: string;
  sealKind: "submit" | "edit" | "reopen" | "migrated";
  outcome: VersionOutcome | null;
  purged: boolean;
}

export interface ReviewVoteView {
  id: string;
  versionNo: number;
  reviewerUserId: string | null;    // null = blind-redacted for this viewer (Q10)
  decision: "approve" | "deny";
  reason: string | null;
  castAt: string;
  revoked: null | { at: string; cause: "retracted" | "membership_changed" | "role_changed" };
}

// DraftRecord gains (all optional on the wire for legacy rows):
export interface DraftReviewFields {
  groupId: string | null;
  connectionId: string | null;
  bundleId: string | null;
  revision: number;
  currentVersion: DraftVersionSummary | null;
  approvedVersionId: string | null;
  quorum: QuorumStatus | null;
  capabilities: DraftCapability[];  // from review_capabilities(); the UI never recomputes
  hidden: null | { at: string; by: string; reason: string };   // only present for draft.hide holders
}

export type ReviewEvent =
  | { type: "submitted"; versionNo: number; contentHash: string; required: number; eligible: number }
  | { type: "version_sealed"; versionNo: number; contentHash: string; prevVersionNo: number; dismissed: Array<string | null> }
  | { type: "vote_approved"; versionNo: number; reviewerUserId: string | null }
  | { type: "vote_denied"; versionNo: number; reviewerUserId: string; reason: string }
  | { type: "vote_retracted"; versionNo: number; reviewerUserId: string | null }
  | { type: "vote_revoked"; reviewerUserId: string | null; cause: "membership_changed" | "role_changed" }
  | { type: "quorum_reached"; versionNo: number; approvals: number; required: number }
  | { type: "quorum_unreachable"; required: number; eligible: number }
  | { type: "exempt_approved"; versionNo: number; sealerRole: string }
  | { type: "override_approved"; versionNo: number; byUserId: string; reason: string; approvalsAtOverride: number }
  | { type: "review_restarted"; versionNo: number; byUserId: string; reason: string }
  | { type: "withdrawn"; byUserId: string }
  | { type: "reopened"; byUserId: string }
  | { type: "expired"; reason: "schedule_passed" | "stale" }
  | { type: "scheduled"; publishAt: string; auto: boolean }
  | { type: "schedule_cancelled"; byUserId: string | null; cause: "manual" | "edit" | "hide" | "restart" | "withdraw" }
  | { type: "publish_blocked"; reason: string }
  | { type: "published"; remoteUrl: string | null; publicationId: string }
  | { type: "publish_failed"; error: string };

export type ReviewMessage =
  | { kind: "comment"; id: string; seq: number; authorUserId: string; versionNo: number | null;
      body: string | null; redacted: boolean; createdAt: string }
  | { kind: "event"; id: string; seq: number; versionNo: number | null; event: ReviewEvent; createdAt: string };

export interface ReviewPolicy {
  scope: "workspace" | "group";
  groupId: string | null;
  source: "group" | "workspace" | "default";       // where the effective values come from
  requiredApprovals: number;                       // 0..5
  exemptRoles: string[];
  blindVotes: boolean;
  staleAfterHours: number;
  minLeadMinutes: number;
}

export interface QueueItem {
  id: string;
  bundleId: string | null;
  state: DraftState;
  groupId: string | null;
  groupName: string | null;
  authorUserId: string;
  excerpt: string;                                  // first 140 chars of post.text
  connection: { id: string; provider: string; label: string } | null;
  scheduledAt: string | null;
  quorum: QuorumStatus | null;
  myVote: "approve" | "deny" | null;
  capabilities: DraftCapability[];
  hidden: boolean;
  lastMessageSeq: number;
  updatedAt: string;
}

3. State machine

3.1 States

state meaning content editable? in queue in archive
editing working copy, not sealed yes (drafts.post) "Drafts" (own/group) –
in_review sealed version n under vote ("primed") yes, but any save seals n+1 (Q6) yes –
changes_requested round closed by a deny yes "Needs changes" –
approved quorum met (or exempt/override); not yet scheduled yes, but a save seals n+1 → in_review yes –
scheduled pending scheduled_publications row yes while the schedule is pending: the schedule is cancelled and n+1 sealed. 409 if in_flight yes –
published scheduler wrote the outcome never (only purge redaction) – yes
expired deadline passed without a closed round no; needs reopen → editing "Expired" –

hidden_at and deleted_at are flags, not states (§7.4). A hidden draft is frozen (no state change), and a deleted draft is a terminal tombstone.

3.2 Transition table

Abbreviations:

  • A = author.
  • E = group editor (for workspace-scoped drafts: any holder of draft.edit).
  • R = an eligible reviewer (§5.2).
  • OA = workspace Owner/Admin (WORKSPACE_WIDE_ROLES, groups.service.ts:21).
  • sys = system actor, written to audit as actor_user_id = NULL with actor_role = 'sp_review' (API) or 'sp_scheduler' (worker), the same convention as publish.activity.ts:186-188.

Every transition: (1) takes the lock sequence in §5.6, (2) runs a CAS UPDATE … WHERE id=$1 AND state=$from in the existing transition() style (publishing.service.ts:275-292), (3) appends audit in the same tx (§9), (4) inserts a chat event row in the same tx, and (5) is re-validated by review_draft_guard (§2.3, section 8).

# from → to action / endpoint who guards (all checked inside the tx)
T1 ∅ → editing POST /publishing/drafts ws draft.create. For group drafts: group role member/editor, or OA the group_id group exists in the tenant (composite FK). connection_id ∈ group_connections for that group, or any connection for workspace drafts (OA: userCanUseConnection, groups.service.ts:447-467)
T2 editing → editing PATCH /publishing/drafts/:id A, E, OA baseRevision = drafts.revision, else 409 REVISION_CONFLICT. The actor is upserted into draft_contributors
T3 editing/changes_requested → in_review POST …/:id/submit ("prime") A, E, OA. Legacy workspace drafts additionally need approval.request (unchanged contract, publishing.controller.ts:81-89) content valid. scheduledAt is null or ≥ clock_timestamp() + min_lead. Connection is allowed for the group. Seals version n+1 with the captured policy. If eligible < required, flag quorum_unreachable
T3x editing/changes_requested → approved (→ scheduled) same submit call the sealer's ws role ∈ exempt_roles, or policy required_approvals = 0 version review_mode IN ('exempt','none'), outcome='exempt'. Auto-schedule follows the T5 rules
T4 in_review → in_review PATCH …/:id (edit while sealed) A, E, OA baseVersionNo = current version_no, else 409. Seals n+1 with seal_kind='edit'. The old version gets outcome='superseded'. Event version_sealed{dismissed:[…]}
T5 in_review → approved [→ scheduled] the N-th approving POST …/:id/votes sys (caused by R) review_quorum(v).approvals ≥ required. clock_timestamp() < deadline_at. Not hidden. Then: if post.scheduledAt is set and (group draft, or the legacy author holds publication.schedule) and the connection is still allowed → insert scheduled_publications, state scheduled. Otherwise stay approved
T6 in_review → changes_requested POST …/:id/votes {decision:'deny', reason} R non-empty reason ≤ 2000 chars. Current versionId
T7 in_review/approved/scheduled → editing POST …/:id/withdraw A, OA from scheduled, only if the schedule row is pending (it is deleted). An open version gets outcome='withdrawn'; an already-approved version keeps approved (history) and approved_version_id is cleared
T8 in_review → expired sweeper, or a vote/unhide that finds the deadline passed sys clock_timestamp() ≥ deadline_at. outcome='expired'. expiredReason: 'schedule_passed' | 'stale'
T9 in_review → approved [→ scheduled] POST …/:id/override {reason} OA with approval.override, not in the contributor/sealer set reason non-empty. outcome='overridden'. Records approvalsAtOverride and required
T10 in_review/approved/scheduled → in_review POST …/:id/restart-review {reason} OA with approval.override seals n+1 with identical content (seal_kind='reopen', not counted as a contribution) and recaptures policy. Cancels a pending schedule. An open old version gets outcome='superseded'; an approved one keeps approved (outcomes are final) and approved_version_id is cleared; audit review.restarted
T11 approved/scheduled → in_review PATCH …/:id (edit after approval) A, E, OA as T4. From scheduled: the schedule must be pending and is deleted in the same tx, else 409 PUBLISH_IN_FLIGHT
T12 approved → scheduled POST …/:id/release {publishAt?} group: A or OA. Legacy: holders of publication.schedule (unchanged, publishing.controller.ts:113-114) approved_version_id = current_version_id. publishAt defaults to now. Connection still allowed. Allowed only when the sealed scheduledAt is null (otherwise T5 already scheduled it; moving the time is an edit → T11)
T13 scheduled → approved DELETE …/:id/schedule (existing) as T12 the schedule is pending (existing logic, publishing.service.ts:248-273)
T14 scheduled → published scheduler complete() sys claim gate (§5.5)
T15 expired → editing POST …/:id/reopen A, E, OA none. The next submit needs a fresh scheduledAt
T16 scheduled (failed) → scheduled POST …/:id/retry-publish as T12 scheduled_publications.state='failed': delete it and insert a new pending row with a new idempotency key for the same approved version (no re-review, content unchanged)
M1 any except in-flight → same state, hidden_at set POST …/:id/hide {reason} draft.hide (OA) if scheduled+pending: delete the schedule and set state to approved in the same tx, so unhide can never cause a surprise publish
M2 hidden → visible POST …/:id/unhide draft.hide if in_review and the deadline has passed, apply T8 in the same tx
M3 any except in-flight → tombstone DELETE /publishing/drafts/:id {reason} draft.purge (OA) §7.4 redaction. Pending schedule deleted

3.3 Allowed (from,to) pairs enforced by the DB trigger

editing→in_review, editing→approved,
changes_requested→in_review, changes_requested→approved,
in_review→approved, in_review→changes_requested, in_review→editing, in_review→expired,
approved→scheduled, approved→in_review, approved→editing,
scheduled→approved, scheduled→published, scheduled→in_review, scheduled→editing,
expired→editing

Self-transitions (T4, T10 from in_review, T16) change current_version_id/revision, not state, and are covered by the content rules in review_draft_guard.


4. RBAC

4.1 New workspace permissions (additive; the 10-role seed contract is untouched)

permission Owner Admin others gates
approval.override ✓ ✓ – T9, T10
approval.configure ✓ ✓ – review_policies writes
draft.hide ✓ ✓ – M1, M2; seeing hidden drafts
draft.purge ✓ ✓ – M3

publication.delete is not reused (D8: Publisher holds it). It stays reserved for future remote retraction. Seeding follows the 002 idiom (INSERT … SELECT … unnest … ON CONFLICT DO NOTHING, 002_rbac_seed.sql:4-18). role_permissions has no epoch trigger, and none is needed, because the guard reads permissions live (rbac.guard.ts:50-60).

Existing permissions keep their meaning for workspace-scoped drafts (group_id IS NULL):

  • approval.request → submit.
  • approval.grant → eligible to vote.
  • publication.schedule → release and cancel.
  • draft.edit → content.

For group drafts, group roles decide, with the workspace permission as a floor (§4.2).

4.2 Group roles: capability matrix for group drafts

capability member editor reviewer OA (no group role needed) floor (ws permission)
view draft and chat ✓ ✓ ✓ ✓ draft.view
comment ✓ ✓ ✓ ✓ draft.view
create in group ✓ ✓ – ✓ draft.create
edit content (own) ✓ ✓ – ✓ draft.edit
edit content (others') – ✓ – ✓ draft.edit
submit / resubmit own ✓ – ✓ draft.edit
withdraw own – – ✓ –
vote approve/deny – – ✓ (if eligible) – (use override) draft.view
release / cancel schedule / retry own – – ✓ – (quorum is the authority, §4.4)
override, restart review – – – ✓ approval.override
hide/unhide, purge – – – ✓ draft.hide / draft.purge

The spec's rules map onto this matrix:

  • "Editors can edit, cannot post/approve": editors can't vote, release, cancel, or retry.
  • "Reviewers cannot edit": no content capability at all, even on a draft they authored before being made reviewer.
  • Assigning reviewer/editor is refused (400) if the target's workspace role lacks the floor permission (Analyst, Moderator, and the stream operator roles lack draft.view, 002_rbac_seed.sql:53-85).

Role assignment: PATCH /groups/:groupId/members/:userId {reviewRole}, @RequirePermission("workspace.members.manage"), the same gate as add/remove (groups.controller.ts:82-112). It returns {token} because the UPDATE bumps the epoch (008_groups.sql:79-80; contract in groups.service.ts:15-18). Audit group.member_review_role_changed {userId, reviewRole:[before, after]} is written in the same tx.

4.3 One capability function, used by the guard, service, and UI

-- returns e.g. {view,comment,edit,submit,vote}; empty array = 404 to the caller
CREATE FUNCTION review_capabilities(p_draft_id uuid, p_user_id uuid) RETURNS text[]
LANGUAGE plpgsql STABLE AS $$ … $$;   -- full body in §2.3, section 6

The guard extension mirrors RequireGroupAccess (rbac.guard.ts:8-19,64-74):

// apps/api/src/rbac/rbac.guard.ts (additions)
export const DRAFT_CAPABILITY_KEY = "required_draft_capability";
export type DraftCapability =
  | "view" | "comment" | "edit" | "submit" | "withdraw" | "vote" | "release"
  | "cancel_schedule" | "retry_publish" | "reopen" | "override" | "restart_review" | "hide" | "purge";

/** Gate on the caller's live capability for the route's :draftId. Fails closed without :draftId. */
export const RequireDraftCapability = (capability: DraftCapability): MethodDecorator =>
  SetMetadata(DRAFT_CAPABILITY_KEY, capability) as MethodDecorator;

// in canActivate, after the permission check:
//   const draftId = req.params?.draftId; isUuid(draftId) else 400
//   const caps = await withTenant(ws, c => c.query<{ caps: string[] }>(
//     "SELECT review_capabilities($1, $2) AS caps", [draftId, session.userId]));
//   caps.length === 0 → NotFoundException (don't leak hidden/foreign-group drafts as 403)
//   !caps.includes(required) → ForbiddenException(`missing draft capability: ${required}`)

The guard runs in its own tx, so its check is an early 403 only. The service re-evaluates the precise guard inside the mutation tx, under locks (§5.6). That is the same TOCTOU stance as today's guard and service split. GET /publishing/drafts/:id returns capabilities: DraftCapability[] from the same function, and the UI renders buttons from it without recomputing anything client-side.

4.4 Self-approval exclusion (structural)

There are three layers:

  1. SQL eligibility (review_eligible_reviewers, §5.2) excludes author_user_id, all draft_contributors, and all sealers of seal_kind IN ('submit','edit').
  2. The review_vote_guard trigger refuses any vote insert where the reviewer isn't eligible. Self or contributor cases raise SQLSTATE 'P0001' with message self_approval_forbidden, which the service maps to 403 {code:"SELF_APPROVAL_FORBIDDEN"}. Other ineligibility raises reviewer_not_eligible.
  3. Override checks the same contributor set: an admin who edited the post can't force-approve it.

Quorum as the publish authority. For group drafts, the group's approval is the authorization to publish through a connection assigned to that group (group_connections is the admin's grant). So a workspace Editor-role author with no publication.schedule can get a post out through quorum, which is the point of the feature. For legacy workspace drafts, auto-schedule at quorum still needs the author to hold publication.schedule (T5). Otherwise the draft waits in approved for a release by someone who holds it, exactly as today.

4.5 Exemption is not self-approval

exempt_roles (default {Owner,Admin} via the built-in policy) means review is not required when a user with that workspace role seals the version. No vote row is written. The version records review_mode='exempt', the audit records draft.approved {reviewMode:'exempt', sealerRole}, and the queue shows an "exempt" chip. This preserves today's one-click Owner/Admin publish (compose/page.tsx:121-138) without breaking R2's rule that nobody approves their own post. Groups that want strict four-eyes set exempt_roles = '{}', which is what the group-policy UI pre-fills.


5. Quorum engine

5.1 Vote lifecycle

                cast(approve, versionId)                 retract (own approve, while in_review)
  (no vote) ──────────────────────────────▶ LIVE APPROVE ───────────────────────────▶ REVOKED(retracted)
      │                                         │  version superseded / round closed
      │ cast(deny, versionId, reason)            ▼
      └──────────────▶ LIVE DENY ──▶ round closes immediately (T6)   HISTORICAL (counts nowhere)
                                                 ▲
         reviewer leaves group / role changes ───┘ REVOKED(membership_changed | role_changed)  (in_review only)
  • One live vote per reviewer per version (review_votes_one_live). After a retraction a reviewer may cast again on the same version, for example approve → retract → deny with a reason. A vote that has been cast can't be edited. Change happens by retract-then-recast, so the history reads honestly in chat and audit.
  • Only approvals can be retracted (CHECK in §2.3). A deny ends the round, so there's nothing left to retract.
  • Idempotency. POST …/votes with the same (versionId, decision) as the caller's live vote returns 200 with the existing vote. A different decision while a live vote exists returns 409 VOTE_EXISTS ("retract first"). This makes the re-auth retry in §8 E7 safe.

5.2 Eligibility (who counts)

review_eligible_reviewers(draft) (§2.3, section 6) is evaluated live at vote time, at count time, and in the capability check:

  • the user has an active workspace membership (007_auth.sql:12-13)
  • and they aren't the author, aren't in draft_contributors, and never sealed a submit/edit version of this draft
  • and either
    • for group drafts: their group review_role = 'reviewer' and their workspace role has draft.view
    • for workspace drafts: their workspace role has approval.grant (the existing contract, 002_rbac_seed.sql:8,24,49).

review_quorum(version) counts live approvals from currently eligible users only. That makes the count correct even before reconciliation triggers fire, for example in a race (§5.6, R5).

5.3 Snapshot and staleness semantics (R4)

  • Seal points. T3 submit, T4/T11 edit-while-sealed, and T10 restart. Each one, in one tx:
    1. INSERT draft_versions (hash computed by trigger, policy captured, deadline_at = LEAST(post.scheduledAt, now + stale_after)).
    2. Close the previous open version (outcome='superseded').
    3. UPDATE drafts SET post, connection_id, current_version_id, revision = revision+1, approved_version_id = NULL, state. The guard trigger checks that the hash matches.
    4. If a pending schedule exists, DELETE it (T11), else 409 when in_flight.
    5. Upsert draft_contributors for the sealer (edit and submit; not reopen).
    6. Chat event and audit.
  • Votes carry versionId. The UI sends the id of the version it rendered. If an editor sealed n+1 in between, the vote gets 409 VERSION_STALE {currentVersionNo}. The reviewer never approves bytes they didn't see.
  • What reviewers see. GET …/versions/:n returns the content and hash. GET …/versions/:n/diff?against=:m (default: the last version the caller voted on, otherwise n-1) returns a word-level diff computed server-side, so every client sees the same diff. That's a small Myers diff in apps/api/src/review/review.diff.ts with no new deps. Field changes to visibility, scheduledAt, connectionId, and mediaAssetIds are listed as structured key changes, the way the audit DiffViewer already renders [before, after] pairs (apps/web/src/app/(main)/audit/page.tsx:24-40).

5.4 Deny handling

A deny with a reason runs T6 in one tx:

  1. Vote insert (trigger-validated).
  2. Version outcome='changes_requested', outcome_by=reviewer.
  3. Draft → changes_requested.
  4. Chat vote_denied (reason inline), audit review.vote_cast + draft.changes_requested.
  5. Notifier changes_requested → author + group editors.

Other live approvals on that version stay in the table as history. They're bound to a closed version, so they never count again (Q5).

5.5 Quorum completion, auto-schedule, and the claim gate

When the vote that makes approvals ≥ required is cast:

// apps/api/src/review/review.service.ts — inside castVote's tenant transaction, after the vote insert
const q = await quorum(client, version.id);              // SELECT * FROM review_quorum($1)
if (decision === "approve" && q.approvals >= q.required) {
  await closeVersion(client, version.id, "approved", null);   // outcome_by NULL = system
  await setState(client, draft.id, "in_review", "approved", { approvedVersionId: version.id });
  await event(client, draft, { type: "quorum_reached", versionNo, approvals: q.approvals, required: q.required });
  await audit(client, ws, SYSTEM_REVIEW_ACTOR, "draft.approved", draft.id, {
    versionNo, contentHash: version.contentHash, approvals: q.approvals, required: q.required,
    voterIds: await liveApproverIds(client, version.id), reviewMode: "quorum",
  });
  const schedule = await maybeAutoSchedule(client, draft, version);   // T5 rules (§3.2)
  if (schedule !== null) afterCommit.push(() => workflowStarter.startScheduledPublish(schedule.idempotencyKey, schedule.publishAt));
}

maybeAutoSchedule reuses the existing schedule insert (publishing.service.ts:229-234) and runs the existing RLS-scoped connection check (:217-220). It adds the group check EXISTS group_connections(group_id, connection_id), which fixes D5 for this path. startScheduledPublish runs after commit, the same way and with the same failure tolerance as today (publishing.service.ts:242-244, sweeper apps/scheduler/src/worker.ts:56-73).

Claim gate (replaces publish.activity.ts:136-145, fixes D1). It publishes the approved version's content, not the working copy:

UPDATE scheduled_publications sp SET state = 'in_flight', error = NULL
FROM drafts d
JOIN draft_versions v ON v.id = d.approved_version_id AND v.draft_id = d.id
JOIN connections c ON c.id = sp.connection_id
WHERE sp.idempotency_key = $1 AND sp.state = 'pending'
  AND d.id = sp.draft_id
  AND d.state = 'scheduled'
  AND d.approved_version_id = d.current_version_id
  AND d.hidden_at IS NULL AND d.deleted_at IS NULL
  AND v.content IS NOT NULL
  AND (v.content ->> 'connectionId')::uuid = sp.connection_id
  AND (d.group_id IS NULL OR EXISTS (
        SELECT 1 FROM group_connections gc
        WHERE gc.group_id = d.group_id AND gc.connection_id = sp.connection_id))
RETURNING sp.id, sp.workspace_id, sp.draft_id, sp.idempotency_key,
          v.content -> 'post' AS post, v.content_hash,
          c.id AS connection_id, c.provider, c.channel_id, c.fields, c.created_at,
          (SELECT cr.id FROM credentials cr WHERE cr.connection_id = c.id
            ORDER BY cr.created_at DESC LIMIT 1) AS credential_id

If no row is returned:

  • Row missing or not pending: return { skipped: "not_pending" }, a non-retryable outcome (fixes D2).
  • Row pending but the gate failed (e.g. the connection was unassigned from the group): UPDATE … SET state='failed', error='review gate: <reason>', write chat publish_blocked, audit publication.blocked, and return { skipped }.

PublishActivity.publishScheduled moves claim inside its error handling, and its return type becomes StoredPublication | { skipped: string }. complete() (publish.activity.ts:158-191) also inserts the chat published event and records contentHash in the publication.published diff.

Expiry sweeper (new, in apps/scheduler/src/worker.ts beside sweepDue, every 30 s):

  1. SELECT * FROM review_due_expiries().
  2. For each row, open a tenant tx (inTenant, publish.activity.ts:234-248).
  3. SELECT … FOR UPDATE the draft and re-check state='in_review' AND deadline_at <= clock_timestamp().
  4. Apply T8: version outcome='expired', chat expired, audit draft.expired with actor role sp_scheduler, notifier expired → author.

A second query, with the same shape and deadline_at BETWEEN now() AND now() + 2h, drives the expiring_soon notifier hook. Hook delivery is idempotent per version, keyed in memory on (versionId, "expiring_soon"), and best-effort, since notifications are a non-goal (R9).

5.6 Race conditions and the lock order

Lock order (mandatory for every review path): memberships row → group_members row → drafts row → review_votes / review_messages / scheduled_publications.

Why it matters: removing a reviewer locks their group_members row (DELETE), and the reconciliation trigger then inserts a chat event, which takes the draft row lock (chat_seq, §2.3, section 8). A vote that locked the draft first and the reviewer's group_members row second would deadlock against it. So castVote:

  1. reads drafts.group_id without a lock (immutable once sealed, enforced by the guard)
  2. SELECT 1 FROM memberships WHERE workspace_id=$1 AND user_id=$2 FOR SHARE
  3. SELECT 1 FROM group_members WHERE group_id=$1 AND user_id=$2 FOR SHARE (group drafts)
  4. SELECT … FROM drafts WHERE id=$1 FOR UPDATE, then re-validates state, version, deadline (clock_timestamp()), and hidden/deleted
  5. inserts the vote, evaluates quorum, transitions.

FOR SHARE (not FOR KEY SHARE) is deliberate: a review_role UPDATE is a non-key update, which FOR KEY SHARE would let through.

# race resolution
R1 two "final" approvals at 2/3 both serialise on the draft lock. The second sees state='approved' and gets 409 ROUND_CLOSED; the UI shows "already approved". The vote isn't recorded, because it would change nothing and would mislead metrics.
R2 approve vs editor's save both lock the draft. If the seal wins, the vote's versionId is stale → 409 VERSION_STALE. If the vote wins, the seal supersedes the version and the approval becomes historical.
R3 approve vs deny serialised. Whichever commits second sees a closed round → 409 ROUND_CLOSED.
R4 final approval vs deadline (Q7) the vote tx compares clock_timestamp() after acquiring the lock (not now(), which is tx start). If the deadline has passed, it applies T8 and returns 409 REVIEW_EXPIRED. The sweeper takes the same lock.
R5 vote vs reviewer removal removal holds group_members exclusively, so the vote's FOR SHARE waits. Then the vote re-validates eligibility (trigger) → RV002 reviewer_not_eligible → 403. If the vote got its lock first, the removal waits for the vote to commit, and reconciliation then sees and revokes the new vote. Neither order leaves an orphaned live vote.
R6 quorum vs author withdraw draft lock. If withdraw wins, the vote gets ROUND_CLOSED. If the vote wins, withdraw from approved/scheduled is still allowed (T7).
R7 edit vs scheduler claim the edit DELETEs the schedule WHERE state='pending', and the claim UPDATEs WHERE state='pending'. The row lock serialises them. If the claim wins, the edit gets 409 PUBLISH_IN_FLIGHT. If the edit wins, the claim finds no row and skips without retrying.
R8 policy change mid-round no race: the policy is captured at seal (Q3).
R9 two editors saving baseRevision / baseVersionNo compare-and-set → 409 REVISION_CONFLICT with the server copy.
R10 chat writes vs transitions both go through the draft lock, so seq is commit-ordered (§6.1).

5.7 Error contract

The service pre-checks and returns these. DB SQLSTATEs map to the same codes, so a missed pre-check still produces a correct response.

HTTP code source
403 SELF_APPROVAL_FORBIDDEN service / RV001
403 REVIEWER_NOT_ELIGIBLE service / RV002
409 VERSION_STALE service / RV003
409 ROUND_CLOSED service / RV004
409 REVIEW_EXPIRED, PUBLISH_IN_FLIGHT, REVISION_CONFLICT, VOTE_EXISTS, GROUP_HAS_LIVE_REVIEWS (55006) service
400 REASON_REQUIRED, SCHEDULE_TOO_SOON, CONNECTION_NOT_IN_GROUP DTO / service
500 + alert 23514 from a guard trigger always a service bug. Logged with the draft id.

5.8 API surface

All routes are @UseGuards(RbacGuard). The coarse permission comes first, then @RequireDraftCapability where a :draftId exists. Verbs stay within the CORS allow-list GET, POST, PATCH, DELETE (main.ts:18), which has no PUT.

method & path permission / capability notes
GET /publishing/queue?filter=&groupId=&cursor=&limit= draft.view filters: needs_my_review, mine, needs_changes, in_review, approved, scheduled, expiring (deadline < 24h), expired, hidden (draft.hide only). Keyset on (updated_at, id). Returns QueueItem[] + nextCursor
GET /publishing/archive?groupId=&connectionId=&q=&from=&to=&cursor= draft.view state='published', visibility-filtered like the queue
POST /publishing/drafts draft.create body gains groupId?, connectionId, bundleId?
GET /publishing/drafts/:draftId draft.view + view DraftRecord & DraftReviewFields + versions + votes (blind-redacted)
PATCH /publishing/drafts/:draftId draft.edit + edit {post, connectionId?, baseRevision} or {…, baseVersionNo} when sealed
POST …/:draftId/submit draft.view + submit legacy drafts keep approval.request inside the capability
POST …/:draftId/withdraw · /reopen draft.view + withdraw / reopen
GET …/:draftId/versions/:n · /versions/:n/diff?against= draft.view + view purged → 410
POST …/:draftId/votes draft.view + vote {versionId, decision, reason?}
DELETE …/:draftId/votes/mine draft.view + vote retract own live approval
POST …/:draftId/approve · /reject (existing) unchanged decorators compat shims: approve → vote approve on current version. Reject → vote deny, and reason is now required (publishing.controller.ts:172-177 made it optional). Logged as deprecated
POST …/:draftId/override · /restart-review approval.override + override / restart_review {reason} required
POST …/:draftId/release · /retry-publish; DELETE …/:draftId/schedule draft.view + release / retry_publish / cancel_schedule schedule (existing, body connectionId) is kept for legacy drafts but now rejects connectionId ≠ drafts.connection_id and group-unassigned connections (D5)
POST …/:draftId/hide · /unhide; DELETE /publishing/drafts/:draftId draft.hide / draft.purge + capability {reason} required
GET …/:draftId/messages?after=&limit= · POST …/:draftId/messages draft.view + view / comment POST {body, clientMessageId} is idempotent
DELETE …/:draftId/messages/:messageId draft.hide redaction, audited
GET /review/events (SSE) draft.view §6.3
GET /review/policies · PATCH /review/policies workspace.view / approval.configure workspace row. PATCH {requiredApprovals,…} upserts
GET /groups/:groupId/review-policy · PATCH · DELETE workspace.view / approval.configure DELETE = inherit
PATCH /groups/:groupId/members/:userId workspace.members.manage {reviewRole} → {token}
POST /auth/reissue valid HMAC + unexpired + active membership §8 E7. Keeps the original expiresAt

6. Review chat

6.1 Data model

review_messages (§2.3) holds two kinds of row:

  • comments: human-written, ≤ 4000 chars, immutable, admin-redactable.
  • events: system-written in the same transaction as the transition they describe, so chat can never disagree with state.

seq is allocated by review_message_guard with UPDATE drafts SET chat_seq = chat_seq + 1 … RETURNING. That row lock serialises every writer on one draft, so seq order is commit order and has no gaps (a rolled-back insert also rolls back its increment). A reader paging ?after=41 therefore can't miss a row 42 that commits later. That would be the classic bug with identity columns or created_at cursors.

Comments carry version_id (the version current when posted), so the thread can show "commented on v2" dividers. Votes are not a separate chat surface: each vote writes a vote_approved/vote_denied event, and the deny reason appears inline as a reviewer bubble in a danger tone.

Mentions. @displayName tokens are parsed server-side against the draft's visible audience (group members + OA). Matches are stored in the event-free comment body as plain text and passed to the notifier port as {type:"mention", userIds}. Nothing is rendered as a link to users outside the audience.

6.2 Event types

The ReviewEvent union is in §2.4. Every state transition in §3.2 emits exactly one event, plus version_sealed, vote_*, quorum_reached/quorum_unreachable, publish_blocked, publish_failed, and published (from the scheduler). Server-side blind redaction (Q10) applies only to vote_approved, vote_retracted, and version_sealed.dismissed, and only for an eligible reviewer who hasn't voted on an open, blind version. In that case reviewerUserId is set to null.

6.3 Transport (Q4) — SSE doorbells

Server (apps/api/src/review/review-events.controller.ts + review-event-hub.ts):

  • GET /review/events is an @Sse() route behind RbacGuard + @RequirePermission("draft.view"). That means signature, epoch (normalised, rbac.guard.ts:42-48), and active membership are checked by the existing code (unlike D6).
  • ReviewEventHub (singleton per API instance) holds one dedicated pg client (outside the pool, db/client.ts:6-9) that LISTENs on rv_<workspace-uuid-hex> while that workspace has at least one subscriber, and UNLISTENs when the last one leaves.
  • On connect, a stream loads a StreamScope with {workspaceId, userId, epoch, visibleGroupIds, canSeeHidden, isWorkspaceWide} in one tenant tx. This cache is sound only while the epoch is unchanged, and every input to it (group membership, review role, workspace role, status) bumps the epoch (001_tenants.sql:191-192, 008_groups.sql:79-80).
  • On each doorbell {d, k, s}, the hub checks the workspace epoch once per workspace (debounced to 1 query per 250 ms) and, if it changed, sends event: reauth to every stream with the old epoch and closes them. Otherwise it fetches group_id, hidden_at, deleted_at for d (one indexed query, shared across the workspace's streams) and forwards {draftId, kind, seq} only to streams whose scope can see it.
  • Heartbeat: a : ping comment every 25 s, plus an epoch check at the same cadence. Each user is capped at 5 concurrent streams; the oldest is closed.
  • Multi-instance: NOTIFY reaches every API instance listening on the channel, so no sticky sessions are needed.

Client (apps/web/src/lib/review-events.ts, ~60 lines): fetch("/review/events", {headers:{Authorization}}), a ReadableStream line parser, and exponential backoff (1 s → 30 s). On reauth it calls POST /auth/reissue (§8 E7), then reconnects. After 3 consecutive failures it falls back to polling: the queue every 15 s, and the open draft's messages every 5 s. useReviewDoorbell(draftId?) triggers reload() on useApiResource (apps/web/src/lib/use-api.ts:65) for the queue, and fetchAfter(seq) for the open thread.

Rejected alternatives:

  • WS: new deps, a new auth path (D6), sticky sessions, and two-way messaging nobody needs.
  • Pure polling: fine as the fallback, but 5 s chat latency feels broken in a "vote together" flow, and the load grows with open tabs, not with activity.

6.4 Authz integration summary

check where
connect RbacGuard (draft.view, epoch, active)
per doorbell StreamScope (epoch-bounded cache)
per data fetch review_capabilities() through @RequireDraftCapability("view"), plus RLS
on epoch change stream closes; client reissues token or logs in
post comment @RequireDraftCapability("comment") (not hidden, not published, can view)

6.5 Notifier port (R9: designed, not built)

// apps/api/src/review/review.notifier.ts
export type ReviewNotification =
  | { type: "review_requested"; draftId: string; userIds: string[] }        // eligible reviewers at seal
  | { type: "changes_requested"; draftId: string; userIds: string[] }       // author + group editors
  | { type: "votes_dismissed"; draftId: string; userIds: string[] }         // previous approvers on reseal
  | { type: "quorum_unreachable"; draftId: string; userIds: string[] }      // Owner/Admin
  | { type: "expiring_soon" | "expired"; draftId: string; userIds: string[] }
  | { type: "mention"; draftId: string; messageId: string; userIds: string[] };

export interface ReviewNotifier { notify(n: ReviewNotification): Promise<void>; }
export const REVIEW_NOTIFIER = Symbol("REVIEW_NOTIFIER");
export class NoopReviewNotifier implements ReviewNotifier { async notify(): Promise<void> {} }

It's called after commit (fire-and-forget, the same stance as workflowStarter). An email or Slack adapter plugs in later without touching the review logic.


7. UI plan

7.1 Screens

/approvals → "Review" (replaces the stub apps/web/src/app/(main)/approvals/page.tsx; sidebar entry at components/shell/sidebar.tsx:13), three panes:

pane contents reuse
Queue (w-96) filter bar (Needs my review · Mine · Needs changes · In review · Approved/Scheduled · Expiring · Expired · Hidden for admins), group selector, virtualised list of QueueRows. A row shows the state Badge, a QuorumChip ("2/3 ✓", warn tone if unreachable, "exempt" chip), group, platform icon, excerpt, a scheduled-time countdown (red < 1 h), the myVote mark, and an unread dot. Bundle siblings render as one row with ×N useVirtualizer exactly as components/streams/column.tsx:27-33,57-79 (estimateSize 96, overscan 8). Badge/Button from components/ui/primitives.tsx. PlatformIcon brand component. useApiResource + OfflineBadge for sample mode
Detail (flex-1) header: state, QuorumChip, deadline, group, connection, author. Version strip v1…vn with outcome dots. Preview card per platform (char limit badge). Diff view (toggle v): word diff and structured field changes. Edit mode (editors/authors only) mounts PostEditor. A banner for quorum_unreachable, hidden, or expired preview card markup from compose/page.tsx:230-245. The tiptap setup moves from compose/page.tsx:65-79 into a shared components/compose/post-editor.tsx. The key/value diff from audit/page.tsx:15-40 handles field changes
Review panel (w-96) chat thread (bubbles, system events as centred timeline rows), composer, and a vote bar pinned at the bottom: Approve vN (primary) / Deny… (danger; expands a reason textarea; submit disabled while reason.trim()===""). It shows "You approved v3 · Retract" after voting, and nothing at all when capabilities lacks vote two-pane conversation and bubble layout from inbox/page.tsx:23-60. Avatar primitive

/approvals/[draftId] opens the same page preselected (deep links from the notifier and the audit page).

/archive (new sidebar item under Review): a virtualised list of published posts with group/platform/date filters, text search (q → ILIKE on post->>'text', plus a pg_trgm index as future work), and the remote URL. Detail is read-only: the final version, the version history, and the full chat transcript (composer hidden; the "Archived" banner shows the published_at). Admins get a … menu with Hide/Unhide and Delete. Delete opens a confirm dialog that requires a reason and typing the first 8 characters of the content hash, with the copy "Removes the content from Tactical Media Broadcast Hub. It does not delete the post from ."

Compose changes (compose/page.tsx):

  • Add a Group select (the user's groups; "Workspace (no group)" for legacy). Target chips are filtered to the group's connections.
  • The Approval select (:193-204) is removed. The primary button reads from a new GET /review/policies/effective?groupId= → "Submit for review (needs 2)" or "Publish" (exempt).
  • The client-side approve chain (:132) is deleted. The server decides through policy, so the client can no longer self-approve.
  • STATE_TONE (:35-42) gains changes_requested: "danger" and expired: "neutral"; rejected is removed.
  • "Recent drafts" links to /approvals/:id.

Groups and settings. No group UI exists in web yet (only API, groups.controller.ts). WP5.5 adds a Groups tab on /team: members with a Review role select (member/editor/reviewer), connections, and a Review policy card ("Inherit workspace" toggle, N stepper 0–5, exempt roles multiselect pre-filled empty, blind toggle, stale-after, min-lead). /settings gains the workspace review policy card. The role select applies the returned {token} via adoptToken (auth-store.ts:31-32).

7.2 Keyboard model (queue-first, same conventions as Streams)

The handler is copied from streams/page.tsx:68-101, including its input/textarea/contentEditable guard (:74-75), so typing a deny reason never fires shortcuts.

key action gated by
j / ↓, k / ↑ next/prev queue row (scrollToIndex, column.tsx:35-40) –
Enter / o focus detail –
a approve the rendered version (confirm popover shows vN · hash[0:8]; Enter confirms) vote
d open the deny reason box (focus); ⌘/Ctrl+Enter submits; Esc cancels vote
r retract my approval live own approval
c focus chat composer comment
e edit mode edit
v toggle diff –
[ / ] previous/next version –
f / F cycle filter forward/back –
? shortcut sheet –

Shortcuts render from capabilities only. A key whose capability is missing shows a toast with the reason ("You authored this post — someone else must approve it"), mirroring the Streams capability toasts (streams/page.tsx:83-95).

7.3 Empty, offline, and error states

  • Sample mode: useApiResource falls back to fixtures (use-api.ts:53-57), so the queue shows fixture rows and an OfflineBadge, and vote buttons are disabled with "Offline — sample mode".
  • 409 VERSION_STALE: an inline banner "v4 was sealed while you were reading — review the changes", with the diff opened against v3.
  • 401 epoch stale: the client reissues and retries once, and the pending reason text is preserved in component state (§8 E7).

7.4 Archive and moderation semantics (R7)

  • Archive is derived: state='published' and not deleted. It isn't a separate state, because the scheduler is the only writer of terminal publication state (publish.activity.ts:181-184). A second "archive" hop would add a failure point and no information. Published drafts are already immutable (guard trigger), and comments are refused on them, so "read-only, chat preserved" holds by construction.

  • Scope: both actions apply to all states (working posts and archive). Hiding a pending scheduled post cancels the schedule first (M1). Purging an in_flight post is refused (409) because the platform write can't be undone.

  • Hide (draft.hide): sets hidden_at/by/reason. Members get 404 everywhere (list, detail, SSE, archive) through review_capabilities returning {}. Admins see a "Hidden" badge and the hidden queue filter. A hidden draft is frozen (trigger), and the sweeper skips it. Unhide re-applies any due expiry.

  • Delete = tombstone + content purge, not hard delete:

    • drafts.post := '{}', deleted_at/by/reason set.
    • every draft_versions.content := NULL (purged_at; hashes kept).
    • every comment body := NULL (redacted).
    • every vote reason := NULL (reason_redacted_at).
    • every chat event carrying free text (vote_denied, override_approved, review_restarted) gets its event replaced by {type, redacted: true} (the message guard allows this only together with redacted_at).
    • any pending schedule deleted.
    • audit draft.purged {versionHashes:[…], messagesRedacted, votesRedacted}.

    Why not hard delete:

    • publications.draft_id … ON DELETE CASCADE (006_publishing.sql:30) would erase the record that something was published to a platform.
    • Audit target_ids would dangle, and the vote and approval record would vanish.

    The tombstone keeps "who approved which hash and when" (compliance) while removing the content (the user's intent). Remote retraction is future work, through the existing reserved publication.delete.

  • Audit never holds content. New events store hashes, ids, and decisions. Deny reasons are stored in audit only as reasonSha256 + reasonLength, so a purge really does remove the words, while the chain (v2, §2.2) still proves a reasoned deny happened.


8. Edge cases (each with a concrete resolution)

# case resolution
E1 Reviewer removed from the group (or workspace) mid-review trg_group_member_review_reconcile / trg_membership_review_reconcile revoke their live votes on in_review current versions in the same tx (cause = membership_changed/role_changed), with a chat vote_revoked and audit review.vote_revoked (actor sp_review). The quorum count already excludes them live (review_quorum). If eligible < required now holds, a quorum_unreachable event fires. Approvals on already-approved or scheduled posts stand: they were valid when cast. Admins use restart review (T10) if the removal was for cause.
E2 Reviewer's workspace role changes to one without draft.view/approval.grant same as E1 through the memberships trigger, cause='role_changed'.
E3 Deadline race: quorum at T-1 min, T+0.5 s, or exactly T Q7 and R4: the lock + clock_timestamp() check in the vote tx. Before T → auto-scheduled with the remaining delay. At or after T → the vote tx expires the draft and returns REVIEW_EXPIRED. The claim gate makes publishing an unapproved post impossible even if the sweeper is down. min_lead (2 min) stops submissions that would expire on arrival (SCHEDULE_TOO_SOON).
E4 Deadlock: author is the only reviewer / too few reviewers Q8: submission is allowed and flagged quorum_unreachable (banner, chat, notifier to OA). Exits: add a reviewer (live), override by a non-contributing admin, or change policy + restart. A solo Owner is exempt by default.
E5 Admin authors a post and tries to override it refused: override excludes contributors (review_capabilities, §4.4). They must use the exemption policy or another admin.
E6 Editor becomes reviewer after editing draft_contributors keeps them; they're ineligible on that draft forever (RV001). They can review other drafts.
E7 Epoch churn mid-vote (someone else's group change bumps the workspace epoch while a reviewer types a deny reason) Today this logs the reviewer out (D7). New: POST /auth/reissue accepts a token whose HMAC is valid and which hasn't expired, re-reads live role and status, and mints a token with the same expiresAt (no TTL extension). A removed user gets 401 and goes to login. api-client on a 401 with "authorization epoch stale" calls reissue once, then retries the original request. Votes are idempotent (§5.1) and comments carry clientMessageId, so the retry can't double-post. The reason text lives in React state and survives. If the user's eligibility genuinely changed, the retry returns 403 REVIEWER_NOT_ELIGIBLE with a clear message. Open SSE streams get reauth and reconnect after reissue.
E8 Policy changes mid-round captured at seal (Q3). The UI shows "This round requires 2 (policy now 3)" to admins with a Restart review button.
E9 Edit during scheduled, in_flight 409 PUBLISH_IN_FLIGHT; nothing changes (R7).
E10 Concurrent edits by two editors baseRevision/baseVersionNo CAS → 409 REVISION_CONFLICT with the server copy. The UI offers "Load theirs / Overwrite (re-applies my text on top of theirs as a new save)".
E11 Connection unassigned from the group or deleted mid-review Deleted → FK SET NULL (connection_id); submit/release/auto-schedule then fail with CONNECTION_NOT_IN_GROUP. Unassigned → the same checks at quorum and release. At claim time the gate fails the schedule with publish_blocked. Either way the draft stays approved/scheduled and the author re-targets through an edit (new round).
E12 Group deleted with live reviews review_block_group_delete → 55006 → 409 GROUP_HAS_LIVE_REVIEWS from GroupsService.deleteGroup (groups.service.ts:205-224). Editing, published, and expired drafts fall back to group_id NULL (still visible to OA; their audit keeps the group id).
E13 Author leaves the workspace drafts stay (author_user_id has no cascade, 006_publishing.sql:5). Editors and OA can still edit, submit, and withdraw (v_admin). Group-draft release then needs OA. The author stays excluded from voting (they aren't a member anyway).
E14 Deny, then resubmit with no changes allowed (a new version with the same hash, 0/N). The chat shows "resubmitted unchanged" (the hash equals the previous one), so the denier sees it plainly.
E15 Publish fails after approval scheduled_publications.state='failed' (publish.activity.ts:194-205); the draft stays scheduled. The scheduler writes a chat publish_failed event. T16 retry-publish replaces the failed row for the same approved version. No re-review, since the content is unchanged.
E16 Hidden draft whose deadline passes frozen: the sweeper skips it. Unhide applies T8 in the same tx if it's due.
E17 Multi-target compose (3 platforms) one draft per target stays (UNIQUE(draft_id) on schedules, 006_publishing.sql:24; compose/page.tsx:114-118), linked by bundle_id. The queue groups them visually. Reviewers vote per platform, because per-platform text and limits differ. "Approve all in bundle" is future work.
E18 Blind-vote leakage counts are visible by design. The audit page shows actors to audit.view holders, including the Approver role (002:48-49). Documented as a UX property (Q10).
E19 Legacy self-approved posts kept as history, counted in review.migrated.selfApproved (Q9). New self-approval is impossible.
E20 Very long-lived review stale_after (default 30 d, max 90 d) → expired(stale). This matches the research's "90-day auto-reject" but is configurable lower.

9. Audit & security

9.1 Event catalogue

All of these are written with audit_append on the same tenant client as the change (groups.service.ts:123-133 style), which fixes D4 for review paths. targetType is "draft" unless noted. Diff keys must never contain password|secret|token|key|credential|stream_key|refresh, because scrub() would redact them (audit.service.ts:9-18). For example, it's idempotency, not idempotencyKey.

action actor diff
draft.created (exists) user {state, groupId, connectionId}
draft.updated (exists) user {state, revision}. No content
draft.submitted (exists) user {from, to, versionNo, contentHash, required, eligible, policySource, reviewMode}
draft.version_sealed user {versionNo, contentHash, prevVersionNo, prevContentHash, sealKind, dismissedVoteIds}
review.vote_cast reviewer {versionNo, contentHash, decision, reasonSha256?, reasonLength?}
review.vote_retracted reviewer {voteId, versionNo}
review.vote_revoked sp_review (NULL user) {voteId, versionId, reviewerUserId, cause}
draft.approved (exists) sp_review {from, to, versionNo, contentHash, approvals, required, voterIds, reviewMode}; exempt adds sealerRole
draft.changes_requested reviewer {versionNo, contentHash, deniedBy}
draft.withdrawn · draft.reopened user {from, to, versionNo}
draft.expired sp_scheduler / sp_review {versionNo, reason, deadlineAt}
review.override_approved admin {versionNo, contentHash, approvalsAtOverride, required, reasonSha256, reasonLength}
review.restarted admin {fromVersionNo, toVersionNo, contentHash, reasonSha256}
review.quorum_unreachable sp_review {versionNo, required, eligible}
publication.scheduled (exists) user / sp_review adds {versionNo, contentHash, auto}
publication.retried · publication.blocked user / sp_scheduler {idempotency, reason?}
publication.published (exists) sp_scheduler adds {contentHash}
review.policy_changed (target group or workspace) admin {scope, groupId, before, after}
group.member_review_role_changed (target group) admin {userId, reviewRole:[before, after]}
draft.hidden · draft.unhidden admin {reasonSha256, cancelledSchedule}
draft.purged admin {versionHashes, messagesRedacted, votesRedacted, reasonSha256}
review.message_redacted (target review_message) admin {draftId, seq}
review.migrated (target workspace) sp_migration {versions, votes, selfApproved}

Comments aren't individually audited (volume). They're append-only by trigger, and only their redaction is audited.

9.2 Chain integration

  • 010 makes the chain cover diff (D3). After it, contentHash, decision, and voterIds are tamper-evident through audit_verify_chain. Without 010, R4's "snapshot hash in audit log" would be decorative.
  • The same-tx audit_append means a vote and its audit row commit or roll back together.
  • The chain head is a per-workspace hotspot: audit_append reads the last hash (001:114-115) under READ COMMITTED, so two concurrent appends can fork the chain (both read the same prev). This is pre-existing, but review adds high-concurrency appends (parallel votes). Mitigation proposed in 010: audit_append takes pg_advisory_xact_lock(hashtext('audit:' || p_workspace_id)) before reading the head. The lock is transaction-scoped and released at commit, so appends to one workspace serialise. That's cheap at this volume.

9.3 RLS and tenancy notes

  • All 5 new tables: ENABLE + FORCE RLS, tenant_isolation USING + WITH CHECK, workspace-immutability trigger, explicit grants, and DELETE revoked on history tables.
  • Composite FKs ((x_id, workspace_id)) make cross-tenant references impossible even through FK checks, which bypass RLS (the comment at publishing.service.ts:217-218 notes the same hazard).
  • The review functions are SECURITY INVOKER, so they see only the caller's tenant. The only DEFINER is review_due_expiries(), which returns ids only and is then followed by tenant-scoped work. It shares publishing_due_schedules's assumption that the function owner bypasses RLS (superuser/owner in migrations, 003_app_role.sql:1-4). A production owner that isn't a superuser would see zero rows under FORCE RLS, which is pre-existing for 006 as well. That's flagged in Risks.
  • Hidden and foreign-group drafts return 404, not 403 (no existence oracle).
  • review_capabilities reads the role from memberships, never from the token (same principle as userCanUseConnection, groups.service.ts:443-446).
  • SSE scope caching is sound only because every input to it bumps the epoch (§6.3).

10. Phased implementation plan

Each WP lands green on typecheck + vitest + integration before the next one starts. These are the same gates the repo used for WP3 and Level 2.

WP scope test gate (see §11)
5.0 Prereqs (S) D1 claim gate + {skipped} non-retryable (D2). 010_audit_hash_v2.sql + advisory lock. Publishing transitions → same-tx audit (D4). POST /auth/reissue + api-client retry (D7). schedule rejects connections outside the user's groups (D5) T7, chain-verify test, reissue test (removed user → 401; TTL not extended)
5.1 Schema (M) 011_review_workflow.sql complete (§2.3). TS types (§2.4). PublishingService keeps working on the new columns (legacy path) T9, T10, trigger unit tests via direct SQL as sp_app
5.2 Review service (L) apps/api/src/review/: seal / vote / retract / deny / withdraw / reopen / override / restart / release / retry. RequireDraftCapability. Policies API. PATCH group member reviewRole. Error contract §5.7. Compat shims for /approve, /reject T1–T5
5.3 Scheduler integration (M) auto-schedule at quorum, claim from version, expiry sweeper + expiring-soon hook, published/publish_failed chat events T6, T7 (extended)
5.4 Chat + realtime (M) messages API, ReviewEventHub (LISTEN), SSE route, client reader + polling fallback, blind redaction, notifier port (noop) ordering test (100 concurrent comments → gapless seq), SSE reauth-on-epoch test, blind-redaction test
5.5 UI (L) /approvals 3-pane, keyboard model, compose changes, PostEditor extraction, Groups tab with review roles and policy, workspace policy settings Playwright smoke: j/k nav, deny button disabled without reason, quorum chip updates on a second user's vote (two browser contexts), sample mode renders
5.6 Archive + moderation (M) archive API/UI, hide/unhide, purge, message redaction T8
5.7 Cleanup (S) stop writing reviewer_user_id; migration 012 drops it after web stops reading it; metrics queries (cycle time, approvals per reviewer) documented for analytics full regression

Critical path: 5.0 → 5.1 → 5.2 → (5.3 ∥ 5.4) → 5.5 → 5.6. WP5.6's backend can run in parallel with 5.5.


11. Test plan — the 10 tests that catch the worst regressions

All are integration tests against Postgres as sp_app (pattern: apps/api/src/groups/groups.integration.test.ts, db/tenancy.integration.test.ts) unless marked unit.

  1. Self-approval is structurally impossible. Author, an editor who saved content, and a former editor promoted to reviewer each POST …/votes → 403 SELF_APPROVAL_FORBIDDEN. Then, bypassing the service, a raw INSERT INTO review_votes as sp_app for the author raises RV001. An admin who edited the draft gets no override capability, and a direct override call returns 403.
  2. Quorum N=2 happy path with auto-schedule. Group policy N=2, scheduledAt = now+10 min. Reviewer A approves → 1/2 and still in_review. Reviewer B approves → approved → scheduled in the same tx. scheduled_publications.publish_at = scheduledAt. The chat has submitted, vote_approved×2, quorum_reached, scheduled with seq 1..5. audit_verify_chain(ws) is true, and the draft.approved diff has both voter ids and the content hash.
  3. Stale-version vote. A reviewer GETs v1; an editor PATCHes (seals v2); the reviewer approves versionId=v1 → 409 VERSION_STALE. Quorum for v2 is 0/N, and the v1 approval cast earlier doesn't count toward v2. The version_sealed event lists the dismissed voter.
  4. Deny requires a reason and resets the round. Empty or whitespace reason → 400. A raw insert of a deny with a NULL reason fails the CHECK. A valid deny → changes_requested, and the reason is visible in chat to the author. Resubmit → v2 at 0/N. The legacy /reject without a reason → 400.
  5. Reviewer removal mid-review, plus no deadlock. A 1/2 approval, then removeMember(reviewer) → the vote is revoked (membership_changed), the quorum is 0/2, and a vote_revoked chat row and audit row exist in the same tx as group.member_removed. Concurrency: 50 iterations of Promise.all([castVote(R), removeMember(R)]) → no 40P01 deadlock, and each run ends either with the vote revoked or the vote refused (never a live vote from a non-member).
  6. Deadline race. scheduledAt = now + min_lead + 1 s (test policy min_lead = 0). A 1/2 approval, then wait past the deadline, then the final approve → 409 REVIEW_EXPIRED, state expired, no scheduled_publications row. Concurrency: run the sweeper's expire and the final vote in parallel 50× → exactly one of {approved, expired} per run, never both, never published.
  7. The claim gate publishes only the approved bytes. (a) A raw UPDATE drafts SET post = … on a scheduled draft is rejected by the trigger. (b) A test-only owner connection forces drafts.state='approved' while a pending schedule exists; claim returns {skipped} and the connector's publish is never called. (c) The happy path: the connector receives draft_versions.content->'post' of the approved version, and the published diff hash equals the vote's content_hash. (d) Unassign the connection from the group after scheduling → the claim marks the schedule failed with publish_blocked.
  8. Hide and purge. A hidden draft is 404 for a group member on detail, queue, archive, and messages, and the SSE stream gets no doorbell for it. Admins see it. Hiding a scheduled draft deletes the pending schedule. Purge: the version content is NULL but the hashes remain, comment bodies and vote reasons are NULL, the publications row still exists, audit_verify_chain is true, and a non-admin DELETE gets 403. A raw DELETE FROM drafts as sp_app fails with a permission error.
  9. Tenancy and RLS on every new table. With tenant B's context, SELECT from each of the 5 tables returns 0 rows of tenant A. An insert with A's workspace_id fails WITH CHECK. A drafts.group_id from another tenant fails the composite FK. Changing workspace_id on any new row raises 23514. review_due_expiries() returns ids only, and review_capabilities for a user from another tenant returns {}.
  10. Migration backfill fidelity. Seed a fixture DB at migration 009 with drafts in every legacy state: rejected with a reason in audit, rejected without one, an approved self-approved draft, a published draft with a publication, and an in_review draft with a past scheduledAt. Run 010+011, then assert:
    • states are mapped and versions/votes exist with matching hashes
    • review_quorum is consistent
    • the review.migrated counts are right (selfApproved=1)
    • the in_review draft's deadline is now+30 d, not the past scheduledAt
    • the triggers are active afterwards (a raw illegal transition fails)
    • legacy flows still work: an Owner composes and publishes in one click (exempt), and a Publisher's draft needs an Approver (1/1), with the Publisher unable to approve it.

Additional fast unit tests: the word diff (review.diff.ts), blind redaction in the serializer, policy precedence (review_effective_policy group → workspace → default), and the SSE line parser.


Confidence & risks

Most confident: the sealed-version + claim-gate core (§5.5). It's small, it's enforced in SQL, and it closes a hole (D1) that exists today regardless of this feature. Same for the lock-order analysis (§5.6), which is mechanical once you see that the chat seq bump touches the draft row.

Least sure, in order. A prototype should validate these first:

  1. Trigger weight on hot paths. review_eligible_reviewers runs inside review_vote_guard, review_quorum, review_capabilities (on every guarded request), and review_reconcile_votes (once per in-review draft on every membership change). It's fine for groups of tens; untested for workspaces with hundreds of in-review drafts. Prototype: seed 500 in-review drafts and 50 reviewers, then measure removeMember latency and GET /publishing/queue?filter=needs_my_review p95. Fallback: materialise eligibility into a per-version review_eligibility table refreshed by the same triggers.
  2. The epoch is workspace-wide (D7). Every review-role change still invalidates every session in the workspace. /auth/reissue makes that survivable, but it's a security-relevant endpoint, and it changes the "re-authenticate" contract stated in session.ts:3-8. It needs an explicit security review: reissue must re-read the live role and status, must not extend expiresAt, and should be rate-limited. The longer-term fix (per-user epochs) is out of scope.
  3. Audit hash v2 + advisory lock (010) touches 001's core functions. If any external tool recomputes hashes with the v1 formula, it breaks for new rows. The fork-on-concurrent-append issue (§9.2) should be confirmed with a stress test before adding the lock, so the change is justified by evidence.
  4. SECURITY DEFINER owner assumption. review_due_expiries (like publishing_due_schedules) only works if the function owner bypasses FORCE RLS. Confirm the production migration role. If it isn't a superuser, both sweepers silently return nothing, and the fix is an owner with BYPASSRLS used only for these functions.
  5. Exempt-by-default for Owner/Admin. This preserves today's behaviour and solo workspaces, but a reader of R2 might see it as a loophole. It's a product call. The alternative is a strict default (exempt_roles = {}) plus a migration banner. I chose compatibility. Confirm with the user.
  6. Group drafts publish on quorum even if the author lacks publication.* (§4.4). That's intended ("the group's approval is the authority"), but it's a real change in who can cause publication. Confirm with the user.
  7. REVOKE DELETE ON drafts may break existing tests that clean up as sp_app. Check publishing.service.test.ts and the integration fixtures. They should clean up as the owner or through workspace deletion.
  8. ON DELETE SET NULL (col) needs PG ≥ 15. The repo says Postgres 16; confirm in docker-compose or CI before relying on it.
  9. SSE through the deployment's proxy. Buffering (nginx proxy_buffering) and idle timeouts can silently break streams. The 25 s heartbeat plus polling fallback cover correctness, but latency should be checked in the real ingress.

Prototype first (1–2 days): migration 011 on a copy of dev data + tests 1, 5, 6, and 7 against it. That exercises triggers, eligibility, lock order, the deadline race, and the claim gate: everything whose failure would be silent. The UI and SSE are conventional by comparison.