T-HUB/docs/design/proposal-sol.md
glass beadian aa5e262f3a Tactical Media Broadcast Hub — initial release
Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
2026-10-10 11:56:42 +00:00

61 KiB
Raw Permalink Blame History

Group Review & Signoff for Posts — design proposal

Status: proposal only. Migration names, endpoints, and types below are proposed, not present code. The current implementation has a single reviewer field and a direct in_review → approved transition, with no vote records [apps/api/migrations/006_publishing.sql:2-12; apps/api/src/publishing/publishing.service.ts:179-205].

1. Executive summary

  1. A post belongs to exactly one group and one target connection for a review cycle. The existing scheduler already binds one draft to one connection and enforces UNIQUE(draft_id), while Compose expands multiple targets into multiple drafts [apps/api/migrations/006_publishing.sql:14-25; apps/web/src/app/(main)/compose/page.tsx:114-139]. Preserve that unit of approval.
  2. Group members get one explicit editorial role: member, editor, or reviewer. Editors edit and submit; reviewers vote but cannot edit. Workspace Owner/Admin can manage policy, moderate, and use a reasoned override. A post's author can never cast an approval or override it.
  3. Every edit creates a full immutable version. Submission pins one version, target, deadline, and quorum. Editing an in-review or approved post starts a new cycle; old votes remain historical and count for no later submission. This replaces the mutable post plus reviewer_user_id as the authority [apps/api/migrations/006_publishing.sql:6-11].
  4. Any one denial with a trimmed nonempty reason sends the submission to needs_changes. Parallel distinct approvals automatically satisfy a frozen quorum of 2 or 3; the final vote schedules the already specified publication if its deadline is still in the future. A missed deadline expires, with no remote publish.
  5. The review thread is an ordered database event log delivered by SSE. HTTP handles writes. This uses the existing session/epoch gate pattern without pretending the current realtime gateway is a transport: it is an authorization function only [apps/api/src/realtime/gateway.ts:3-31].

The ten decisions requested in spec §5

Q1 — Store one role on group_members. Add review_role text with member/editor/reviewer values and default member. A single role makes editor and reviewer mutually exclusive for a group post, and reuses the existing unique group_id,user_id membership and epoch trigger [apps/api/migrations/008_groups.sql:27-38,75-80]. A separate roles table could support combined duties later but adds a second authorization surface and makes the stated separation easier to violate. Workspace roles remain intact as route admission and administrator authority, not substitutes for a group role; memberships currently has one role per workspace [apps/api/migrations/001_tenants.sql:20-32].

Q2 — Store full immutable content per version, with a hash. A hash alone cannot reproduce the exact rendered content or show a trustworthy diff after drafts.post changes. draft_versions therefore holds the complete normalized DraftPost and SHA-256 of canonical JSON plus target ID; review_submissions points to one version. This follows the current JSONB post model [apps/api/migrations/006_publishing.sql:2-11] and gives the renderer an immutable payload. Deduplicated blobs may be future storage work.

Q3 — Workspace default 2, group override 2 or 3, frozen on submit. Owner/Admin sets workspaces.review_quorum_default (2 or 3) and optional groups.review_quorum_override. Effective value is COALESCE(group override, workspace default), copied into each submission. This lets a workspace start safely with one setting while sensitive groups require three; later setting changes affect only future submissions. The current groups table has no review policy [apps/api/migrations/008_groups.sql:17-25]. Per-connection policy is future work because a reviewed draft already has one group and target.

Q4 — SSE for thread delivery, HTTP for mutations. Use one EventSource connection for the selected post, with replay from a per-draft sequence and a short database polling loop; queue counts refresh by HTTP. SSE is cheaper to wire than a bidirectional WS gateway for a read-heavy thread, while preserving order through a row-locked per-draft sequence. The existing gateway only authorizes stream.view subscriptions and has no transport or topic implementation [apps/api/src/realtime/gateway.ts:3-31]. WS fanout can replace SSE later without changing the event schema.

Q5 — One denial closes the round. Denial requires a reason and moves the draft to needs_changes immediately. Already cast approvals remain immutable historical votes, displayed in the thread, but do not count in a resubmission. This is a clear author-facing repair path and avoids publishing content one qualified reviewer explicitly objected to. Majority denial can be a future policy if product evidence calls for it; today's reject reason is optional and only stored in an audit diff [apps/api/src/publishing/publishing.controller.ts:30-32,172-177; apps/api/src/publishing/publishing.service.ts:197-204].

Q6 — Full reset on edit. Editing during review or after approval first supersedes the submission, then moves to editing, creates a version, and requires resubmit. Reviewers see a diff from the prior submitted version. Retaining votes for allegedly unchanged fields would need a defensible semantic diff of text, media, visibility, target, and provider metadata; the current post is one JSONB value [apps/api/src/publishing/publishing.service.ts:10-16]. Partial dismissal is future work.

Q7 — Auto-schedule if quorum lands before the deadline. The final vote uses database clock time while holding the draft row lock. If clock_timestamp() is before publish_at, create the pending scheduled_publications row in the same transaction; at or after the time, expire and require a new deadline plus fresh submission. A T−1 minute quorum therefore schedules automatically, with no manual reconfirmation. The current Temporal starter can accept startDelay and its pending row is durable [apps/api/src/publishing/publishing.service.ts:82-106,216-245].

Q8 — Deadlock requires another person. A sole reviewer who authored the draft cannot vote. A different Owner/Admin may use a reasoned override, which records a separate override action and does not masquerade as a reviewer vote; a sole human admin author also cannot override. The UI offers Invite reviewer / Ask another admin, never a self-approve button. This preserves the four-eyes rule in spec §3 while retaining administrator rescue for small groups.

Q9 — Grandfather completed old approvals; re-prime unfinished ones. Backfill version 1 for every existing draft, retain reviewer_user_id as historical metadata only, and record a migration provenance audit event per changed draft. Existing approved/scheduled/published rows remain valid legacy outcomes; do not fabricate multiple votes from one reviewer. Existing in_review and rejected rows become needs_changes and require group assignment, an editor, and a fresh submission. This is honest about the one-reviewer current model [apps/api/migrations/006_publishing.sql:7-10; apps/api/src/publishing/publishing.service.ts:188-205]. The old direct approve/reject routes become compatibility aliases to vote actions only for a valid group submission, never direct state setters.

Q10 — Show prior votes and identities. Reviewers see who has approved, denied, or had a vote revoked before voting. The thread already needs system approval events; hiding names there would be inconsistent and would obscure duplicate/reassignment handling. Mitigate peer pressure by making the rendered snapshot and diff the visual focus and requiring an explicit individual confirmation. Blind voting can be tested later as an organization policy, but the audit trail remains attributed.

2. Data model and migration 009 sketch

2.1 Relational shape

The SQL below is the intended shape for apps/api/migrations/009_group_review.sql. It follows direct workspace_id predicates, FORCE RLS, immutability triggers, composite group FKs, and sp_app grants from 006 and 008 [apps/api/migrations/006_publishing.sql:44-68; apps/api/migrations/008_groups.sql:14-15,66-104]. Run as a migration transaction; backfill existing rows before enabling the final state guard. Values for content_sha256 are calculated from jsonb::text and the target UUID by the database function shown below, rather than trusting client bytes.

BEGIN;
ALTER TABLE workspaces ADD COLUMN review_quorum_default smallint NOT NULL DEFAULT 2
  CHECK (review_quorum_default IN (2, 3));
ALTER TABLE groups ADD COLUMN review_quorum_override smallint
  CHECK (review_quorum_override IN (2, 3));
ALTER TABLE group_members ADD COLUMN review_role text NOT NULL DEFAULT 'member'
  CHECK (review_role IN ('member', 'editor', 'reviewer'));
-- The existing group_members AFTER UPDATE trigger bumps authz_epoch for role changes.
ALTER TABLE drafts ADD COLUMN group_id uuid;
ALTER TABLE drafts ADD COLUMN review_round integer NOT NULL DEFAULT 0 CHECK (review_round >= 0);
ALTER TABLE drafts ADD COLUMN review_event_seq bigint NOT NULL DEFAULT 0 CHECK (review_event_seq >= 0);
ALTER TABLE drafts ADD COLUMN hidden_at timestamptz;
ALTER TABLE drafts ADD COLUMN hidden_by uuid REFERENCES users(id);
ALTER TABLE drafts ADD COLUMN deleted_at timestamptz;
ALTER TABLE drafts ADD COLUMN deleted_by uuid REFERENCES users(id);
ALTER TABLE drafts ADD CONSTRAINT drafts_id_workspace_key UNIQUE (id, workspace_id);
ALTER TABLE drafts ADD CONSTRAINT drafts_group_workspace_fk
  FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE RESTRICT;
ALTER TABLE drafts DROP CONSTRAINT drafts_state_check; -- resolve actual generated name in migration
ALTER TABLE drafts ADD CONSTRAINT drafts_state_check CHECK (state IN
  ('editing','primed','in_review','needs_changes','approved','scheduled',
   'published','archived','expired'));
ALTER TABLE drafts ADD CONSTRAINT drafts_deleted_hidden_check
  CHECK (deleted_at IS NULL OR hidden_at IS NOT NULL);
ALTER TABLE scheduled_publications ADD COLUMN claim_eligibility_checked_at timestamptz;

CREATE TABLE draft_versions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  draft_id uuid NOT NULL,
  version_no integer NOT NULL CHECK (version_no > 0),
  post jsonb NOT NULL,
  connection_id uuid,
  content_sha256 text NOT NULL CHECK (content_sha256 ~ '^[0-9a-f]{64}$'),
  made_by uuid REFERENCES users(id),
  created_at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (draft_id, version_no), UNIQUE (id, draft_id, workspace_id),
  FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE RESTRICT,
  FOREIGN KEY (connection_id, workspace_id) REFERENCES connections(id, workspace_id) ON DELETE RESTRICT
);
CREATE TABLE review_submissions (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  draft_id uuid NOT NULL,
  group_id uuid NOT NULL,
  round integer NOT NULL CHECK (round > 0),
  version_id uuid NOT NULL,
  required_approvals smallint NOT NULL CHECK (required_approvals IN (2, 3)),
  publish_at timestamptz,
  status text NOT NULL CHECK (status IN
    ('primed','in_review','approved','denied','superseded','expired')),
  submitted_by uuid NOT NULL REFERENCES users(id),
  submitted_at timestamptz NOT NULL DEFAULT now(),
  closed_at timestamptz,
  UNIQUE (draft_id, round), UNIQUE (id, workspace_id),
  FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE RESTRICT,
  FOREIGN KEY (group_id, workspace_id) REFERENCES groups(id, workspace_id) ON DELETE RESTRICT,
  FOREIGN KEY (version_id, draft_id, workspace_id)
    REFERENCES draft_versions(id, draft_id, workspace_id) ON DELETE RESTRICT
);
CREATE TABLE review_votes (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  submission_id uuid NOT NULL,
  voter_user_id uuid NOT NULL REFERENCES users(id),
  decision text NOT NULL CHECK (decision IN ('approve','deny')),
  reason text,
  snapshot_sha256 text NOT NULL CHECK (snapshot_sha256 ~ '^[0-9a-f]{64}$'),
  cast_at timestamptz NOT NULL DEFAULT now(),
  revoked_at timestamptz,
  revoked_reason text,
  UNIQUE (submission_id, voter_user_id),
  CHECK (decision <> 'deny' OR (reason IS NOT NULL AND btrim(reason) <> '')),
  CHECK (revoked_at IS NULL OR (revoked_reason IS NOT NULL AND btrim(revoked_reason) <> '')),
  FOREIGN KEY (submission_id, workspace_id)
    REFERENCES review_submissions(id, workspace_id) ON DELETE RESTRICT
);
CREATE TABLE review_overrides (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  submission_id uuid NOT NULL,
  admin_user_id uuid NOT NULL REFERENCES users(id),
  reason text NOT NULL CHECK (btrim(reason) <> ''),
  snapshot_sha256 text NOT NULL CHECK (snapshot_sha256 ~ '^[0-9a-f]{64}$'),
  at timestamptz NOT NULL DEFAULT now(),
  UNIQUE (submission_id),
  FOREIGN KEY (submission_id, workspace_id)
    REFERENCES review_submissions(id, workspace_id) ON DELETE RESTRICT
);
CREATE TABLE review_events (
  workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
  draft_id uuid NOT NULL,
  seq bigint NOT NULL CHECK (seq > 0),
  submission_id uuid,
  kind text NOT NULL CHECK (kind IN
    ('message','submitted','edited','approved','denied','vote_revoked',
     'quorum_met','quorum_lost','needs_changes','scheduled','expired','published','archived',
     'hidden','unhidden','deleted','override')),
  actor_user_id uuid REFERENCES users(id),
  body text,
  data jsonb NOT NULL DEFAULT '{}',
  at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (draft_id, seq),
  FOREIGN KEY (draft_id, workspace_id) REFERENCES drafts(id, workspace_id) ON DELETE RESTRICT,
  FOREIGN KEY (submission_id, workspace_id)
    REFERENCES review_submissions(id, workspace_id) ON DELETE RESTRICT,
  CHECK (kind <> 'message' OR (body IS NOT NULL AND btrim(body) <> ''))
);
CREATE INDEX drafts_review_queue ON drafts (workspace_id, group_id, state, updated_at DESC, id)
  WHERE deleted_at IS NULL;
CREATE INDEX review_submissions_due ON review_submissions (publish_at, id)
  WHERE status = 'in_review' AND publish_at IS NOT NULL;
CREATE INDEX review_votes_live ON review_votes (submission_id, voter_user_id)
  WHERE revoked_at IS NULL AND decision = 'approve';
CREATE INDEX review_events_replay ON review_events (workspace_id, draft_id, seq);

CREATE FUNCTION review_content_hash(p_post jsonb, p_connection_id uuid) RETURNS text
LANGUAGE sql IMMUTABLE AS $$
  SELECT encode(digest(p_post::text || '|' || coalesce(p_connection_id::text, 'unassigned'), 'sha256'), 'hex')
$$;
REVOKE ALL ON FUNCTION review_content_hash(jsonb, uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION review_content_hash(jsonb, uuid) TO sp_app;

CREATE FUNCTION review_version_sealed() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP <> 'INSERT' THEN
    RAISE EXCEPTION 'draft versions are immutable' USING ERRCODE = '23514';
  END IF;
  IF NEW.content_sha256 <> review_content_hash(NEW.post, NEW.connection_id) THEN
    RAISE EXCEPTION 'version hash mismatch' USING ERRCODE = '23514';
  END IF;
  IF NEW.connection_id IS NULL AND EXISTS
    (SELECT 1 FROM drafts WHERE id = NEW.draft_id AND group_id IS NOT NULL) THEN
    RAISE EXCEPTION 'review version needs a target connection' USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_draft_version_sealed BEFORE INSERT OR UPDATE OR DELETE ON draft_versions
  FOR EACH ROW EXECUTE FUNCTION review_version_sealed();
CREATE FUNCTION review_vote_frozen() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF TG_OP = 'DELETE' OR
     (TG_OP = 'UPDATE' AND ((NEW.id, NEW.workspace_id, NEW.cast_at,
       NEW.decision, NEW.reason, NEW.snapshot_sha256,
       NEW.voter_user_id, NEW.submission_id) IS DISTINCT FROM
       (OLD.id, OLD.workspace_id, OLD.cast_at,
       OLD.decision, OLD.reason, OLD.snapshot_sha256,
       OLD.voter_user_id, OLD.submission_id) OR
       OLD.revoked_at IS NOT NULL OR NEW.revoked_at IS NULL)) THEN
    RAISE EXCEPTION 'vote decision is immutable' USING ERRCODE = '23514';
  END IF;
  RETURN COALESCE(NEW, OLD);
END $$;
CREATE TRIGGER trg_review_vote_frozen BEFORE UPDATE OR DELETE ON review_votes
  FOR EACH ROW EXECUTE FUNCTION review_vote_frozen();
CREATE FUNCTION review_reject_update_delete() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  RAISE EXCEPTION 'review evidence is append-only' USING ERRCODE = '23514';
END $$;
CREATE TRIGGER trg_review_override_sealed BEFORE UPDATE OR DELETE ON review_overrides
  FOR EACH ROW EXECUTE FUNCTION review_reject_update_delete();
CREATE TRIGGER trg_review_event_sealed BEFORE UPDATE OR DELETE ON review_events
  FOR EACH ROW EXECUTE FUNCTION review_reject_update_delete();
CREATE TRIGGER trg_draft_version_ws_immutable BEFORE UPDATE ON draft_versions
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_review_submission_ws_immutable BEFORE UPDATE ON review_submissions
  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_override_ws_immutable BEFORE UPDATE ON review_overrides
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
CREATE TRIGGER trg_review_event_ws_immutable BEFORE UPDATE ON review_events
  FOR EACH ROW EXECUTE FUNCTION reject_workspace_change();
ALTER TABLE draft_versions ENABLE ROW LEVEL SECURITY;
ALTER TABLE review_submissions ENABLE ROW LEVEL SECURITY;
ALTER TABLE review_votes ENABLE ROW LEVEL SECURITY;
ALTER TABLE review_overrides ENABLE ROW LEVEL SECURITY;
ALTER TABLE review_events ENABLE ROW LEVEL SECURITY;
ALTER TABLE draft_versions FORCE ROW LEVEL SECURITY;
ALTER TABLE review_submissions FORCE ROW LEVEL SECURITY;
ALTER TABLE review_votes FORCE ROW LEVEL SECURITY;
ALTER TABLE review_overrides FORCE ROW LEVEL SECURITY;
ALTER TABLE review_events 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 review_submissions
  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_overrides
  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_events
  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, DELETE ON draft_versions, review_submissions,
  review_votes, review_overrides, review_events TO sp_app;
COMMIT;

The ALTER that drops the old state check must discover its generated PostgreSQL name during migration, or 006 should first be inspected with pg_constraint. All new mutable tenant tables have the same trigger, FORCE RLS policy, and grants as 006. draft_versions is append-only despite UPDATE/DELETE grants because its trigger rejects those operations. A later migration may narrow grants after existing runtime role assumptions are checked. Current audit_append is SECURITY DEFINER and asserts app.workspace_id, so new services invoke it on their withTenant client [apps/api/migrations/001_tenants.sql:72-86,100-121; apps/api/src/db/client.ts:11-33].

2.2 Additional database guards and backfill

009 also installs a drafts BEFORE UPDATE trigger named enforce_review_transition. It rejects any state pair outside the table in §3; rejects post/group changes except while entering or in editing; rejects approved unless the latest active submission has valid distinct approvals at its frozen required_approvals or a non-author admin override row; rejects scheduled unless the row's version hash still matches drafts.post; and rejects published unless a same-draft schedule is done. Its SQL predicate for a valid approval is a count of review_votes joined to active memberships and group_members(review_role='reviewer'), excluding revoked_at, the draft author, and any vote whose snapshot_sha256 differs from the submission version. A legacy row with group_id IS NULL is exempt only for approved/scheduled/published → later legacy states, and cannot enter a new review without group assignment. This is the database half of the state machine; service-side checks return useful 400/403/409 errors. The scheduler currently updates a draft to published without checking the affected row count, so that call must assert one row and roll back on failure [apps/scheduler/src/activities/publish.activity.ts:158-190].

The security-critical predicate and trigger shape are:

CREATE FUNCTION review_valid_approval_count(p_submission uuid) RETURNS integer
LANGUAGE sql STABLE AS $$
  SELECT count(DISTINCT v.voter_user_id)::integer
  FROM review_votes v
  JOIN review_submissions s ON s.id = v.submission_id
  JOIN draft_versions ver ON ver.id = s.version_id
  JOIN drafts d ON d.id = s.draft_id
  JOIN memberships m ON m.workspace_id = s.workspace_id
    AND m.user_id = v.voter_user_id AND m.status = 'active'
  JOIN group_members gm ON gm.group_id = s.group_id
    AND gm.user_id = v.voter_user_id AND gm.review_role = 'reviewer'
  WHERE s.id = p_submission AND v.decision = 'approve'
    AND v.revoked_at IS NULL AND v.voter_user_id <> d.author_user_id
    AND v.snapshot_sha256 = ver.content_sha256
$$;
REVOKE ALL ON FUNCTION review_valid_approval_count(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION review_valid_approval_count(uuid) TO sp_app;
CREATE FUNCTION review_vote_eligible() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v_author uuid; v_hash text; v_state text; v_group uuid;
BEGIN
  SELECT d.author_user_id, ver.content_sha256, d.state, s.group_id
    INTO v_author, v_hash, v_state, v_group
  FROM review_submissions s JOIN drafts d ON d.id = s.draft_id
  JOIN draft_versions ver ON ver.id = s.version_id
  WHERE s.id = NEW.submission_id AND s.workspace_id = NEW.workspace_id;
  IF v_state <> 'in_review' OR v_author = NEW.voter_user_id OR
     v_hash IS DISTINCT FROM NEW.snapshot_sha256 OR
     NOT EXISTS (SELECT 1 FROM memberships m JOIN group_members gm
       ON gm.workspace_id = m.workspace_id AND gm.user_id = m.user_id
       WHERE m.workspace_id = NEW.workspace_id AND m.user_id = NEW.voter_user_id
         AND m.status = 'active' AND gm.group_id = v_group
         AND gm.review_role = 'reviewer') THEN
    RAISE EXCEPTION 'ineligible or stale review vote' USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_review_vote_eligible BEFORE INSERT ON review_votes
  FOR EACH ROW EXECUTE FUNCTION review_vote_eligible();
CREATE FUNCTION review_override_eligible() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v_author uuid; v_hash text; v_state text;
BEGIN
  SELECT d.author_user_id, ver.content_sha256, d.state
    INTO v_author, v_hash, v_state
  FROM review_submissions s JOIN drafts d ON d.id = s.draft_id
  JOIN draft_versions ver ON ver.id = s.version_id
  WHERE s.id = NEW.submission_id AND s.workspace_id = NEW.workspace_id;
  IF v_state <> 'in_review' OR v_author = NEW.admin_user_id OR
     v_hash IS DISTINCT FROM NEW.snapshot_sha256 OR
     NOT EXISTS (SELECT 1 FROM memberships m WHERE m.workspace_id = NEW.workspace_id
       AND m.user_id = NEW.admin_user_id AND m.status = 'active'
       AND m.role IN ('Owner','Admin')) THEN
    RAISE EXCEPTION 'ineligible or stale review override' USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_review_override_eligible BEFORE INSERT ON review_overrides
  FOR EACH ROW EXECUTE FUNCTION review_override_eligible();

The final transition trigger uses the exact allowed (OLD.state,NEW.state) pairs in §3. Its critical branch is:

CREATE FUNCTION enforce_review_transition() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE v_round review_submissions%ROWTYPE; v_hash text; v_override boolean;
BEGIN
  IF OLD.review_round > 0 AND NEW.group_id IS DISTINCT FROM OLD.group_id THEN
    RAISE EXCEPTION 'review group is sealed' USING ERRCODE = '23514';
  END IF;
  IF NEW.post IS DISTINCT FROM OLD.post AND NEW.state <> 'editing' THEN
    RAISE EXCEPTION 'post content is sealed outside editing' USING ERRCODE = '23514';
  END IF;
  IF NEW.state IS DISTINCT FROM OLD.state AND NOT (
    (OLD.state, NEW.state) IN (
      ('editing','primed'), ('needs_changes','primed'), ('expired','primed'),
      ('primed','in_review'), ('in_review','needs_changes'),
      ('in_review','approved'), ('in_review','expired'),
      ('in_review','editing'), ('approved','editing'),
      ('approved','scheduled'), ('approved','expired'),
      ('approved','in_review'), ('scheduled','in_review'),
      ('approved','needs_changes'), ('scheduled','needs_changes'),
      ('scheduled','approved'), ('scheduled','editing'),
      ('scheduled','published'), ('published','archived'))
  ) THEN
    RAISE EXCEPTION 'illegal review transition' USING ERRCODE = '23514';
  END IF;
  IF NEW.group_id IS NOT NULL AND NEW.state IN ('approved','scheduled') THEN
    SELECT s, ver.content_sha256 INTO v_round, v_hash
      FROM review_submissions s JOIN draft_versions ver ON ver.id = s.version_id
      WHERE s.draft_id = NEW.id AND s.round = NEW.review_round;
    IF NOT FOUND OR v_round.status <> 'approved' OR
       review_content_hash(NEW.post, (SELECT connection_id FROM draft_versions
         WHERE id = v_round.version_id)) IS DISTINCT FROM v_hash THEN
      RAISE EXCEPTION 'approved snapshot missing or changed' USING ERRCODE = '23514';
    END IF;
    SELECT EXISTS (SELECT 1 FROM review_overrides o
      JOIN memberships m ON m.workspace_id = o.workspace_id
        AND m.user_id = o.admin_user_id AND m.status = 'active'
        AND m.role IN ('Owner','Admin')
      WHERE o.submission_id = v_round.id AND o.snapshot_sha256 = v_hash
        AND o.admin_user_id <> OLD.author_user_id) INTO v_override;
    IF review_valid_approval_count(v_round.id) < v_round.required_approvals
       AND NOT v_override THEN
      RAISE EXCEPTION 'review quorum missing' USING ERRCODE = '23514';
    END IF;
  END IF;
  IF NEW.state = 'published' AND OLD.state = 'scheduled' AND NOT EXISTS
    (SELECT 1 FROM scheduled_publications sp WHERE sp.draft_id = NEW.id
      AND sp.state = 'done' AND
      (NEW.group_id IS NULL OR sp.claim_eligibility_checked_at IS NOT NULL)) THEN
    RAISE EXCEPTION 'publication not complete' USING ERRCODE = '23514';
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_enforce_review_transition BEFORE UPDATE ON drafts
  FOR EACH ROW EXECUTE FUNCTION enforce_review_transition();

This branch is supplemented by a priming guard that requires a current group/version/connection and by a submission trigger that rejects UPDATE of draft_id, group_id, round, version_id, required_approvals, publish_at, submitted_by, or submitted_at; only status and closed_at may change along the status sequence in §3. Legacy group_id NULL rows are allowed only for their grandfathered approved/scheduled/published transitions. The service writes submission status approved before updating draft state. At claim, the scheduler sets claim_eligibility_checked_at only after its current quorum/hash/target check, then enters in_flight before calling the provider. A later reviewer removal cannot undo that external call; completion validates the claim proof rather than recounting live membership. The scheduler writes schedule state done before publishing, matching its current update order [apps/scheduler/src/activities/publish.activity.ts:177-184].

Backfill one draft_versions row for each draft, taking its current post and its target from providerMetadata.connectionId when present. Existing drafts with no unambiguous target keep NULL connection_id in that legacy version and must choose a target before a new submission; review_version_sealed refuses a NULL target for group drafts. This avoids inventing a target for old multi-target drafts; Compose's saved draft path currently stores targetConnectionIds while the publish path stores connectionId [apps/web/src/app/(main)/compose/page.tsx:108-110,125-127]. On rollout, convert rejected/in_review to needs_changes with a migration audit event; leave approved/scheduled/published grandfathered. Background migration archives already published rows with a distinct publication.archived audit entry. Keep reviewer_user_id read-only historical metadata; never derive quorum from it.

The scheduler's existing publishing_due_schedules() returns pending rows [apps/api/migrations/006_publishing.sql:79-89]. Add narrow SECURITY DEFINER functions, matching publishing_schedule_workspace's restricted grants:

CREATE FUNCTION review_due_deadlines() RETURNS TABLE(submission_id uuid)
LANGUAGE sql VOLATILE SECURITY DEFINER SET search_path = public AS $$
  SELECT id FROM review_submissions
  WHERE status = 'in_review' AND publish_at <= clock_timestamp()
  ORDER BY publish_at, id
$$;
CREATE FUNCTION review_submission_workspace(p_submission_id uuid) RETURNS uuid
LANGUAGE sql STABLE SECURITY DEFINER SET search_path = public AS $$
  SELECT workspace_id FROM review_submissions WHERE id = p_submission_id
$$;
REVOKE ALL ON FUNCTION review_due_deadlines() FROM PUBLIC;
REVOKE ALL ON FUNCTION review_submission_workspace(uuid) FROM PUBLIC;
GRANT EXECUTE ON FUNCTION review_due_deadlines(),
  review_submission_workspace(uuid) TO sp_app;

A periodic worker uses those functions, enters withTenant, locks the draft, and expires an in_review round whose deadline is at or before DB clock. No pending scheduled_publications row exists until quorum. This avoids making an unapproved post visible to the existing sweeper.

3. State machine

Primed is a short, durable state after an editor seals the version and before the server opens review; the same request normally advances primed → in_review. Keeping it visible allows recovery of a transaction interrupted between an external workflow start and the durable submission (Temporal start remains after commit). Published is a short successful result, then archived in the same completion transaction; both transitions get events. The current state CHECK and TS union must be extended together [apps/api/migrations/006_publishing.sql:7-8; apps/api/src/publishing/publishing.service.ts:8; apps/web/src/lib/api-types.ts:64].

From → to Initiator Required guard and action
create → editing group editor or Owner/Admin Group and target connection exist in same workspace; group owns connection; create version 1.
editing/needs_changes/expired → primed editor or Owner/Admin Lock draft; valid target; nonempty content; at least N eligible non-author reviewers or a declared admin escalation; future deadline if supplied; increment round, snapshot post/target/quorum/deadline.
primed → in_review service Same transaction or idempotent recovery; event and audit; no votes yet.
in_review → needs_changes eligible reviewer Deny with nonempty reason; close submission denied.
in_review → approved service Distinct currently eligible non-author approvals reach frozen quorum before deadline, or non-author admin override with reason.
in_review → expired deadline worker or final vote DB clock >= publish_at without quorum; close round, no schedule row.
in_review/approved → editing editor or Owner/Admin Supersede round, retain history, clear schedule intent; approved has no active schedule.
approved → scheduled service or Publisher/Admin Recount current votes/override; version/target unchanged; connection still assigned; pending schedule insert. If no desired time, authorized Publisher/Admin chooses publish time after approval.
approved → expired service Stored desired publish_at passed before scheduling.
scheduled → approved authorized scheduler-canceller Only pending schedule, cancel/delete row as current service does [apps/api/src/publishing/publishing.service.ts:248-272].
approved/scheduled → in_review membership reconciliation Quorum lost before remote claim; cancel pending schedule, reopen same sealed round, and preserve remaining votes.
in_review/approved/scheduled → needs_changes connection/group reconciliation Target is no longer valid; cancel pending schedule and require editor to choose a valid target and resubmit.
scheduled → editing editor or Owner/Admin First cancel pending schedule, then supersede; reject if in_flight/done.
scheduled → published scheduler Claim pending only when due, current quorum and hash still valid; remote success; idempotency key matches.
published → archived scheduler Same completion transaction, preserving publication row and review history.
any nondeleted → same state, hidden/unhidden Owner/Admin Admin moderation; hide applies to working and archive views.
any except in_flight scheduled → tombstoned Owner/Admin Admin soft delete with mandatory reason; pending schedule cancelled; no physical cascade.

No direct editing → approved, needs_changes → approved, or expired → scheduled path exists. A denied post re-enters review only via a new version/submission, even if text is unchanged. A schedule request at a timestamp already passed is rejected; immediate publication is represented by publish_at=clock_timestamp() only after approval. Existing schedule is CAS on approved and inserts one scheduled_publications row [apps/api/src/publishing/publishing.service.ts:216-235].

4. RBAC and API contract

The guard currently checks signed session, live workspace authz_epoch, then workspace role_permissions inside withTenant [apps/api/src/rbac/rbac.guard.ts:33-62]. Keep that order. Add RequireReviewGroupAccess(action) as a second decorator, analogous to RequireGroupAccess for connectionId [apps/api/src/rbac/rbac.guard.ts:8-19,64-74]. It reads :draftId or :groupId and performs a tenant-scoped draft/group/active membership check, returning 404 for invisible group drafts. Every write service repeats the check after SELECT ... FOR UPDATE because epoch or group membership may change between guard and mutation.

Exact permission additions to role_permissions (no new workspace role names):

INSERT INTO role_permissions(role, permission)
SELECT r, p FROM unnest(ARRAY[
  'Owner','Admin','Publisher','Editor','Approver','Analyst','Moderator',
  'VideoPublisher','StreamOperator','BroadcastOperator']) AS r
CROSS JOIN unnest(ARRAY[
  'review.view','review.comment','review.submit','review.edit','review.vote']) AS p
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions(role, permission)
SELECT r, p FROM unnest(ARRAY['Owner','Admin']) AS r
CROSS JOIN unnest(ARRAY[
  'review.config.manage','review.override','post.hide','post.delete']) AS p
ON CONFLICT DO NOTHING;

These route permissions admit an active workspace member to the group-role decision; they do not independently grant editorial actions. This is necessary because group reviewer may currently have any one of the ten workspace roles, while approval.grant today belongs only to Owner/Admin/Approver and approval.request to Owner/Admin/Publisher [apps/api/migrations/002_rbac_seed.sql:4-50]. Keep existing approval.grant/approval.request for legacy callers, but new group review routes require the review.* permission plus exact group role. Group member may view/comment; editor may view/comment/create/edit/submit but cannot vote or schedule/publish; reviewer may view/comment/vote but cannot change content; Owner/Admin may manage policy, hide/delete, and override with a reason. Publisher workspace role alone cannot bypass group role or quorum. The existing publication.schedule permission is still required for manual scheduling after approval; automatic scheduling is a server action caused by the authorized final vote.

Proposed Nest controller shape (each handler also validates DTOs and uses the service transaction):

@Get('groups/:groupId/review') @RequirePermission('review.view') @RequireReviewGroupAccess('view')
listReviewQueue(...): Promise<ReviewQueuePage>;
@Post('publishing/drafts/:draftId/prime') @RequirePermission('review.submit') @RequireReviewGroupAccess('submit')
prime(...): Promise<ReviewDetail>;
@Post('publishing/drafts/:draftId/votes') @RequirePermission('review.vote') @RequireReviewGroupAccess('vote')
vote(@Body() dto: { submissionId: string; versionId: string;
  snapshotSha256: string; decision: 'approve' | 'deny'; reason?: string }): Promise<ReviewDetail>;
@Post('publishing/drafts/:draftId/override') @RequirePermission('review.override')
override(@Body() dto: { submissionId: string; reason: string }): Promise<ReviewDetail>;
@Post('publishing/drafts/:draftId/messages') @RequirePermission('review.comment') @RequireReviewGroupAccess('comment')
comment(@Body() dto: { body: string }): Promise<ReviewEvent>;
@Patch('publishing/drafts/:draftId/moderation') @RequirePermission('post.hide')
setHidden(@Body() dto: { hidden: boolean; reason: string }): Promise<ReviewDetail>;
@Delete('publishing/drafts/:draftId') @RequirePermission('post.delete')
tombstone(@Body() dto: { reason: string }): Promise<void>;

The new vote endpoint accepts submission/version/hash as optimistic preconditions and returns 409 on mismatch; the server derives actor and role from its session, never request JSON. Self approval is checked in the service AND in a DB vote guard: NEW.voter_user_id <> drafts.author_user_id; the override table/record uses the same exclusion. Reusing the direct current approve endpoint without this change would bypass quorum [apps/api/src/publishing/publishing.controller.ts:91-110]. Group role/policy mutations already bump the workspace epoch through group_members AFTER UPDATE; group settings and connection assignment changes need these additional triggers:

CREATE TRIGGER trg_group_review_policy_epoch
  AFTER UPDATE OF review_quorum_override ON groups
  FOR EACH ROW WHEN (OLD.review_quorum_override IS DISTINCT FROM NEW.review_quorum_override)
  EXECUTE FUNCTION bump_authz_epoch();
CREATE TRIGGER trg_group_connection_review_epoch
  AFTER INSERT OR DELETE ON group_connections
  FOR EACH ROW EXECUTE FUNCTION bump_authz_epoch();
CREATE FUNCTION bump_review_default_epoch() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
  IF NEW.review_quorum_default IS DISTINCT FROM OLD.review_quorum_default THEN
    NEW.authz_epoch := OLD.authz_epoch + 1;
  END IF;
  RETURN NEW;
END $$;
CREATE TRIGGER trg_workspace_review_default_epoch
  BEFORE UPDATE OF review_quorum_default ON workspaces
  FOR EACH ROW EXECUTE FUNCTION bump_review_default_epoch();

These mutations mint the caller a replacement token inside the transaction, matching GroupsService [apps/api/src/groups/groups.service.ts:12-18,226-267].

5. Quorum engine and integrity

One submission has one vote per reviewer enforced by UNIQUE(submission_id,voter_user_id). Votes cannot change within that submission; a mistaken decision is handled by an editor resubmission, with a reason and clear history. A denial closes the round immediately. Quorum is COUNT(DISTINCT voter_user_id) among approvals that are unrevoked, from active workspace and reviewer group memberships, excluding the author and matching the sealed hash. The round's required_approvals never changes. Config edits affect the next round. The repository already uses CAS transitions, so the quorum transaction locks the draft row and then writes vote, count, state, review event, and audit entry atomically [apps/api/src/publishing/publishing.service.ts:275-292]. All review votes, role changes, and scheduler claims lock the workspace row first, then draft, then schedule, so an epoch-changing membership mutation and a remote claim have a defined serialization point. A claim that wins the lock may publish; a removal that wins first forces the claim to recheck and fail.

type VoteInput = {
  submissionId: string; versionId: string; snapshotSha256: string;
  decision: 'approve' | 'deny'; reason?: string;
};
async function castVote(client: PoolClient, draftId: string, actor: Actor, input: VoteInput) {
  const draft = await lockDraft(client, draftId); // SELECT ... FOR UPDATE
  assertLiveEpochAndGroupReviewer(client, draft, actor);
  assert(draft.authorUserId !== actor.userId, 'SELF_APPROVAL_FORBIDDEN');
  assert(draft.state === 'in_review', 'REVIEW_CLOSED');
  const round = await activeSubmission(client, draft);
  assert(round.id === input.submissionId && round.versionId === input.versionId &&
    round.contentSha256 === input.snapshotSha256, 'STALE_SNAPSHOT');
  if (round.publishAt !== null && await dbNow(client) >= round.publishAt) return expire(client, draft, round);
  if (input.decision === 'deny') assert(input.reason?.trim(), 'DENIAL_REASON_REQUIRED');
  await insertVoteAndEventAndAudit(client, draft, round, actor, input);
  if (input.decision === 'deny') return closeNeedsChanges(client, draft, round);
  if (await validApprovalCount(client, round) >= round.requiredApprovals) {
    await markApprovedAndAudit(client, draft, round);
    if (round.publishAt !== null) await scheduleIfFuture(client, draft, round);
  }
  return readReviewDetail(client, draft.id);
}

The hash covers all JSONB post fields and the target connection. The reviewer detail response uses draft_versions.post, never drafts.post, to render the proposed publication; it includes previous submitted version and a structured diff for text, media IDs, visibility, target, and provider metadata. The current scheduler reads d.post at claim time [apps/scheduler/src/activities/publish.activity.ts:133-145]; change that query to join the approved submission/version and use its post. Also require d.state='scheduled', sp.publish_at <= clock_timestamp(), a current quorum/override, and the approved version hash at claim. This is the final guard against edits, role removals, and early Temporal wakeups. Claims must be idempotent and use the pending→in_flight state as today [apps/scheduler/src/activities/publish.activity.ts:133-155].

The review_overrides table in §2 carries a nonempty reason and immutable snapshot hash. It is valid only while the admin retains active Owner/Admin status, is not the author, and the hash matches. It satisfies the approval gate but is labeled Override, never 2/3. One valid denial closes the round before an override can be applied; admin must return the post to an editor for a new submission. A second different admin can rescue a one-reviewer group; no override can waive the self-approval exclusion.

6. Review chat and delivery

review_events is the durable, per-post thread. Message bodies are plain text (escape on render), length limited by API to 4,000 characters, and not editable or deletable by ordinary members. System events carry structured IDs, version numbers, hashes, old/new state, reason, and quorum counts in data; do not put secrets or access tokens in it. The current audit scrubber redacts keys matching secret/token/credential, so event-to-audit diff construction must use the same scrub helper [apps/api/src/audit/audit.service.ts:9-18]. Events are written with the state mutation in the same tenant transaction. Increment drafts.review_event_seq under the draft row lock, then insert (draft_id,seq); this serializes commit order per draft and makes Last-Event-ID safe.

SSE endpoint GET /publishing/drafts/:draftId/events uses @RequirePermission('review.view') and group access. It sends rows after a validated integer cursor, then polls for new rows every 2 seconds, heartbeat every 15 seconds, and rechecks live epoch/group access at least each heartbeat; on change it sends auth_revoked and closes. Reconnect fetches the gap with Last-Event-ID. For native EventSource bearer-token limits, use fetch streaming with Authorization: Bearer rather than cookie or token in URL. The server caps replay pages and asks the client to resync detail when cursor is too old. SSE can lag but cannot grant action rights: every vote/comment still crosses RbacGuard and the transaction check. The existing realtime authz port compares epoch but assumes a numeric DB epoch; the new SSE path should normalize bigint as RbacGuard does [apps/api/src/realtime/gateway.ts:19-23; apps/api/src/rbac/rbac.guard.ts:42-47].

System event type shape:

type ReviewEventKind = 'message' | 'submitted' | 'edited' | 'approved' |
  'denied' | 'vote_revoked' | 'quorum_met' | 'scheduled' | 'expired' |
  'published' | 'archived' | 'hidden' | 'unhidden' | 'deleted' | 'override';
interface ReviewEvent { draftId: string; seq: number; kind: ReviewEventKind;
  actorUserId: string | null; submissionId: string | null;
  body: string | null; data: Record<string, unknown>; at: string; }

7. UI plan

Replace the ten-line Approvals stub with a queue/detail surface; Compose remains the editor entry point. Compose already has tiptap, target chips, visibility, a schedule field, and state badges [apps/web/src/app/(main)/compose/page.tsx:65-88,151-220,247-265]. Remove its current create→submit→self-approve→schedule loop for group posts, since it permits an author with approval.grant to approve the post they just created [apps/web/src/app/(main)/compose/page.tsx:114-139]. Creation requires a group and exactly one target per draft; multiple targets produce linked sibling drafts, each independently reviewed. Submit opens the sealed review detail. The schedule field becomes a deadline/desired publish time included in the submission, explicitly labeled; if empty, approved posts await an authorized scheduler.

The queue uses cursor pagination (updated_at DESC,id DESC) and virtual rows via the existing StreamColumn useVirtualizer pattern [apps/web/src/components/streams/column.tsx:28-39,56-78]. Each row shows group, target, preview, state, required/current count, author, due time, and an expired/needs_changes label. Filters: needs my review (eligible, not author, no vote), created by me, needs changes, expiring soon, approved awaiting schedule, and archive. Use j/k and arrow navigation, Enter to open, and preserve focus/scroll on return, following Streams' existing keyboard scheme [apps/web/src/app/(main)/streams/page.tsx:77-80]. Do not bind shortcuts while focus is in Tiptap, an input, or a chat composer.

Detail has the immutable rendered post preview, target/visibility/schedule, version selector and diff, quorum progress with named votes, then the thread with Approve and Deny+reason buttons for eligible reviewers. The buttons submit the displayed submission/version/hash preconditions, disable while pending, and handle 409 by refreshing detail. An editor sees Edit and Resubmit; a reviewer sees no content edit control. Admin controls Hide/Unhide and Delete appear on working and archive posts. Hidden posts are omitted from member queue/archive/search, but admins see a Hidden badge and full record. Archive is read-only for content and comments, with preserved review history and publication link; admin moderation remains available. The current Published state is already a draft state but has no archive UI [apps/api/migrations/006_publishing.sql:7-8; apps/web/src/app/(main)/compose/page.tsx:35-42].

8. Edge cases and exact resolutions

Case Resolution
Reviewer leaves group or loses reviewer role before quorum Existing group_members change bumps epoch; a same-transaction reconciliation marks their votes revoked_at/reason, emits vote_revoked and audit entries, then recalculates count. New tokens are minted for the mutator; stale sessions get 401 [apps/api/migrations/008_groups.sql:75-80; apps/api/src/groups/groups.service.ts:226-267].
Workspace membership is removed/suspended Membership epoch trigger invalidates sessions [apps/api/migrations/001_tenants.sql:180-192]. Team mutation must call the same reconciliation; claim and every vote independently join active membership, so a missed callback cannot publish.
Quorum had been met, but reviewer leaves before pending schedule claims Reconciliation cancels a pending schedule, returns scheduled/approved to in_review, emits quorum_lost and audit, and alerts the queue. If claim already set in_flight, its pre-call gate wins; once remote call began, treat completion as published and log the exact eligibility change timestamp. No attempt to pretend a remote publish can be rolled back.
Final vote and deadline worker race Both lock drafts row. DB clock is checked after lock. Vote first before deadline may schedule; worker later sees approved/scheduled and does nothing. Worker first at/after deadline expires; vote gets REVIEW_CLOSED.
Final vote at T−1 minute Auto-schedule pending row for T, start Temporal after commit. If Temporal wakes early, claim rejects until publish_at; sweeper picks it up when due.
Quorum arrives at/after deadline Expire instead of approve. Editor chooses a new future time, creates a new round, and all reviewers vote anew. No auto-publish of stale content.
Author is sole reviewer Prime is blocked with insufficient independent reviewers, unless a different Owner/Admin is available for reasoned override. The author, even as Owner/Admin, cannot vote/override.
Reviewer goes on vacation Admin changes group reviewer assignment or adds another group member with reviewer role; epoch invalidates old sessions, pending votes from removed reviewers revoke, and the current round can continue if enough eligible people remain. Delegation is an explicit role change, not token sharing.
Editor changes text/media/visibility/target during review Action supersedes round and its votes; new immutable version and diff are required. Existing vote requests fail 409.
Editor changes approved post Reopen to editing; if pending scheduled first cancel it atomically. In_flight/done cannot be reopened; create a new draft instead.
Duplicate vote or concurrent final votes Unique submission/reviewer plus draft row lock yields one live count and one schedule. Duplicate returns 409 with current detail; retries with an idempotency request key may return prior result.
Epoch changes after guard but before vote Transaction reads epoch again while holding draft lock and checks active group/role; mismatch fails 401 with no vote.
Group/connection unassigned mid-review Prime/vote/schedule recheck assignment. On unassignment, active round becomes needs_changes and pending schedule cancels; editor chooses a valid target and resubmits. Existing group_connections mutations currently audit assignment but do not bump epoch, so 009 adds an epoch trigger on that table too [apps/api/src/groups/groups.service.ts:271-314].
Group deleted with working drafts New draft group FK uses ON DELETE RESTRICT, so admin must tombstone/reassign drafts first; this deliberately prevents review evidence cascading with group deletion, unlike current group children [apps/api/migrations/008_groups.sql:27-60].
Hidden or deleted post has a pending schedule Hide is internal only and does not cancel publication by itself; UI explicitly warns that scheduling continues. Delete cancels pending schedule and tombstones; reject delete if in_flight, and on published archive tombstone only the internal record. Remote provider deletion is a separate future integration.
Scheduler failure after remote call Preserve current idempotency_key and publication lookup, reconcile by remote result, and never automatically retry with a new key; audit publication.failed and show retry state. Current activity first checks publication by key [apps/scheduler/src/activities/publish.activity.ts:75-90,123-130].
Old rejected/in_review row Migration moves to needs_changes and demands group/target selection before prime; never count reviewer_user_id as a vote.

The same-transaction reconciliation is a change to GroupsService, TeamService, and group connection mutation paths, not a background-only cleanup. DB transition and claim guards remain fail closed even if an application path forgets to call it. For direct SQL/cascades, add an AFTER DELETE/UPDATE trigger on group_members and memberships that invokes a narrow review_reconcile_member(workspace_id,user_id,group_id) function under tenant context; it updates affected vote revocations and pending draft states and appends audit events. Because a cascade can delete the parent before the child trigger runs, the function uses group_members' denormalized OLD.workspace_id and OLD.group_id, following the existing epoch design [apps/api/migrations/008_groups.sql:4-12].

9. Audit, moderation, and security

Every state transition, vote, revocation, override, config/role change, message, hide/unhide, and tombstone gets audit_append in the SAME tenant transaction as the corresponding rows, as GroupsService already does [apps/api/src/groups/groups.service.ts:12-13,123-133]. Current PublishingService appends audit after the mutation commits, which leaves a gap; migrate its review paths to same-tx audit [apps/api/src/publishing/publishing.service.ts:127-138,307-322]. Scheduler already appends publication.published in its completion transaction with actor_role sp_scheduler [apps/scheduler/src/activities/publish.activity.ts:158-190]. Never call audit_append_system from a tenant workflow; the DB distinguishes tenant/system by app.workspace_id [apps/api/migrations/001_tenants.sql:88-121,123-144].

Event names: review.primed, review.submitted, review.version_created, review.vote_approved, review.vote_denied, review.vote_revoked, review.quorum_met, review.quorum_lost, review.expired, review.needs_changes, review.overridden, publication.scheduled, publication.schedule_cancelled, publication.published, publication.archived, post.hidden, post.unhidden, post.deleted, review.message_added, review.policy_changed, review.role_changed. Audit diff includes draftId, groupId, round, versionId, contentSha256, target connection ID, frozen quorum, deadline, old/new state, actor and decision/reason when relevant. audit_hash currently chains actor/action/target/time, while diff is stored but not part of the hash input [apps/api/migrations/001_tenants.sql:55-67,92-121]. Prototype should verify whether regulatory expectations require diff integrity; if so, use a separately versioned audit hash migration and verifier, not a silent change to existing hashes.

Moderation is workspace Owner/Admin only, irrespective of group role; new post.hide/post.delete permissions are seeded only to those two roles. Hide toggles hidden_at/hidden_by, keeps all rows and remote content intact, and excludes the post from non-admin list/detail/event endpoints; an admin sees a badge. Soft delete sets deleted_at/deleted_by, hidden_at, reason in audit, cancels pending work, and retains versions, votes, thread, and publication rows for verification. The DELETE HTTP verb therefore means tombstone, not a SQL DELETE. A published post may remain remote, so the UI says Remove from this workspace and does not promise deletion at the provider. Archive remains read-only for normal group members. RLS isolates workspaces; group visibility is an explicit guard/service condition because workspace RLS alone cannot hide another group's draft [apps/api/migrations/006_publishing.sql:58-66; apps/api/migrations/008_groups.sql:91-104].

No API accepts caller-supplied actor role, vote tally, or audit hash. FK checks can bypass RLS, so draft/group/connection lookups must be RLS-scoped before insert, as the current schedule method explains [apps/api/src/publishing/publishing.service.ts:216-220]. The hash and preview are bound to the version; responses use optimistic preconditions. Read endpoints must filter hidden/deleted before loading events. Admin reads are still tenant-scoped.

10. Phased implementation plan and gates

  1. WP1 — Migration and compatibility. Add 009 schema, constraints, RLS, role seed additions, backfill/version hash, and transition guard. Gate: migrate a fixture DB containing all six old states, confirm no fabricated votes, FORCE RLS isolation, valid legacy scheduling, and audit chain verification.
  2. WP2 — Group policy and authz. Group role/config endpoints, epoch triggers and replacement tokens, draft group/connection scope, guard decorator. Gate: role matrix, self-approval DB rejection, cross-group 404, stale token 401, and cascade reconciliation.
  3. WP3 — Snapshot/quorum services. Prime, edit/resubmit, votes, denial, override, transactional event+audit, version diff API. Gate: concurrent last votes create one schedule; denial reason and stale version fail; no author vote.
  4. WP4 — Scheduler/deadlines. Due deadline worker, claim eligibility/hash/time check, automatic schedule, archive on completion, failure/idempotency behavior. Gate: deadline/claim race and remote call exactly once under retries.
  5. WP5 — Queue/detail/archive UI and SSE. Compose handoff, virtualized queue, keyboard model, thread replay, buttons, moderation. Gate: keyboard and focus checks, 409 refresh, hidden visibility, SSE reconnect/epoch revocation. Email/external notification hook can emit an outbox row for future workers, without sending in this phase (spec R9).

11. Ten regression tests

  1. Self approval: author with group reviewer role and Owner permission cannot vote or override; another eligible reviewer can.
  2. Two final votes race: both requests serialize; one state transition, one scheduled_publications row, two votes, valid audit chain.
  3. Denial reason: blank/whitespace denied at DTO and DB; valid denial closes round and previous approvals cannot count on resubmit.
  4. Edit staleness: after a reviewer loads version V, editor edits/resubmits V+1; vote with V/hash fails 409, and V remains viewable.
  5. Role removal and epoch: group reviewer removal revokes pending vote, reduces count, invalidates old token, and blocks schedule/claim.
  6. Deadline serialization: vote at T−1s schedules; vote at/after T expires; worker and final vote in parallel never publish an unapproved post.
  7. Scheduler claim: early Temporal wake, stale hash, missing quorum, and unassigned connection all fail before connector.publish; retry by same key does not duplicate a successful remote post.
  8. Tenant and group isolation: FORCE RLS with missing/foreign app.workspace_id returns zero rows; cross-group member gets 404 on detail/SSE and cannot vote via guessed IDs.
  9. Migration: old approved/scheduled/published remain grandfathered; old in_review/rejected become needs_changes; reviewer_user_id creates no fake vote; audit_verify_chain stays true.
  10. Moderation/archive: ordinary group member cannot hide/delete; admin tombstone cancels pending schedule, preserves audit/thread; published completion creates archived state and hides internal posts from member lists.

The live-DB tests should follow the existing groups integration pattern for RLS/FKs/epoch and the scheduler repository's idempotency behavior, rather than only mock SQL calls [apps/api/src/groups/groups.integration.test.ts:217-245; apps/scheduler/src/activities/publish.activity.ts:75-90].

Confidence & risks

The strongest risk is the boundary between a pending schedule, an in_flight remote call, and a reviewer role removal: once a provider call starts, local revocation cannot retract it. Prototype that race first with a controllable connector and assert the claim gate's exact lock order. The second risk is migration of old drafts without a unique target; inspect real providerMetadata shapes before finalizing the nullable backfill window. The third is throughput of per-post SSE polling and audit append contention; measure with many open review tabs before choosing polling intervals or a broker. The existing audit chain does not hash diff content [apps/api/migrations/001_tenants.sql:92-121], so a separate audit-hardening decision is needed if signoff evidence must be tamper-evident down to the snapshot hash and denial reason.