# 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=`, 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) ```sql -- 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` ```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`) ```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 } | { 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 ```sql -- 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`): ```ts // 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: ```ts // 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: ```sql 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: '`, 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 `DELETE`s the schedule `WHERE state='pending'`, and the claim `UPDATE`s `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 `LISTEN`s on `rv_` while that workspace has at least one subscriber, and `UNLISTEN`s 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) ```ts // 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; } export const REVIEW_NOTIFIER = Symbol("REVIEW_NOTIFIER"); export class NoopReviewNotifier implements ReviewNotifier { async notify(): Promise {} } ``` 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 `QueueRow`s. 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_id`s 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.