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