T-HUB/docs/design/spec-review-workflow.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

153 lines
13 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Spec — Group Review & Signoff for Posts ("Editorial Workflow")
Status: DRAFT for proposals
Owner: product owner
Date: 2026-10-07
Inputs: user requirements, codebase inventory (explore agent), background research (Hootsuite/Sprout approval features, four-eyes/maker-checker compliance patterns)
## 1. What the user asked for (verbatim intent)
1. Posts can be **primed to go out**, then reviewed and edited by others in the group.
2. Roles: **editors** (can edit, can NOT post/approve) and **reviewers** (give the OK).
3. System configurable so **2–3 people sign off** on a post before it publishes.
4. Reviewers can **approve, or deny with reasoning** (reason mandatory).
5. A **parallel chat** per post with voting/approval buttons.
6. Easy **scrolling through existing working posts** (the in-review queue).
7. When posts publish, they are **archived**.
8. Ability to make our own posts **invisible or delete** them — **admin-only** features.
9. Think through the whole system; spec the needs.
## 2. Current system (facts from codebase inventory)
### 2.1 Publishing state machine
- States in DB CHECK (`migrations/006_publishing.sql:7-8`): `editing, in_review, approved, rejected, scheduled, published`. TS mirrors: `publishing.service.ts:8`, web `api-types.ts:64`.
- Transitions are guarded CAS UPDATEs (`WHERE id=$1 AND state=$4`) via `transition()` helper (`publishing.service.ts:275-292`):
- `editing→in_review` submitForReview (perm `approval.request`)
- `in_review→approved` approve (perm `approval.grant`, stamps `reviewer_user_id`)
- `in_review→rejected` reject (perm `approval.grant`; reason goes ONLY into audit diff, not the draft row)
- `approved→scheduled` schedule (inserts `scheduled_publications`, perm `publication.schedule`)
- `scheduled→approved` cancelSchedule
- `scheduled→published` by scheduler activity (`publish.activity.ts:181-184`)
- **Approval record: NONE.** Only `drafts.reviewer_user_id` (single reviewer, last-writer-wins) + audit events. No quorum structure exists.
- **`rejected` is terminal** — there is NO resubmit path today. This feature must add one.
- Temporal: one workflow `scheduledPublish(idempotencyKey)` proxying one activity, **no signals/queries**. API starts it fire-and-forget with `startDelay`; durable `scheduled_publications` row (pending/in_flight/done/failed) is source of truth; sweeper `publishing_due_schedules()` re-starts missed workflows.
### 2.2 Data model
- `drafts(id, workspace_id, author_user_id, post jsonb, state, reviewer_user_id, created_at, updated_at)` — post payload = `{text, mediaAssetIds[], visibility, scheduledAt?, providerMetadata}`. **No soft-delete, no hidden flag, no delete endpoint at all.**
- `scheduled_publications` — `UNIQUE(draft_id)` (one schedule per draft). `publications` — outcome rows (remote_id, idempotency_key).
- All tables: FORCE RLS tenant_isolation on `app.workspace_id` + workspace-immutability triggers + `sp_app` grants. **Drafts are workspace-scoped only — no group column.**
### 2.3 RBAC
- 10 roles seeded (`002_rbac_seed.sql`): Owner, Admin, Publisher, Editor, Approver, Analyst, Moderator, VideoPublisher, StreamOperator, BroadcastOperator.
- `approval.grant`: only Owner/Admin/Approver. `approval.request`: Owner/Admin/Publisher.
- Enforcement: `RbacGuard` + `@RequirePermission()` decorator; guard verifies HMAC session → workspace `authz_epoch` (fail-closed 401 on mismatch) → permission via `role_permissions JOIN memberships(status='active')` inside `withTenant`.
- **Roles are per-workspace only** (`memberships.role`, UNIQUE(workspace_id,user_id) — one role per user per workspace). **`group_members` has NO role column** — no per-group roles exist.
### 2.4 Groups
- Tables (`008_groups.sql`): groups, group_members (no role), group_connections, group_assets. Denormalized workspace_id + composite FKs prevent cross-tenant groups.
- Epoch: any group_members change fires `bump_authz_epoch()`; sessions carry authzEpoch; guard compares live. Membership mutations mint a replacement token in the same tx (`{token}` response pattern).
- Connection scoping: `getVisibleConnections` — Owner/Admin see all; others see union of group connections via their memberships. Guard-side `userCanUseConnection` reads role from DB.
### 2.5 Realtime & chat
- **No WS transport exists.** `realtime/gateway.ts` is an authz port only (`authorizeSubscription` checks session, workspace, epoch, `stream.view`). No topics, no client code.
- Web Inbox is a fixtures-only two-pane chat UI (conversation list + thread bubbles + reply box). Reusable as visual pattern, no backend.
### 2.6 Frontend
- Compose (`compose/page.tsx`): tiptap editor, target chips, visibility, approval select, schedule; `publish()` chains `create→submit→[approve]→[schedule]` gated on `can()`; "Recent drafts" sidebar with STATE_TONE badges is the only post list today.
- `/approvals` is a 10-line stub; `/calendar` stub.
- Streams columns (`components/streams/column.tsx`): `@tanstack/react-virtual` useVirtualizer, j/k keyboard nav — reusable pattern for the working-posts queue.
- Audit page (236 lines) with diff viewer exists.
### 2.7 Audit
- `audit_log` hash-chained per workspace (`audit_hash = sha256(prev|actor|action|target_id|epoch)`), SECURITY DEFINER append fns, `audit_verify_chain`. Services write via `appendTenantAudit` (after-commit) or same-tx `SELECT audit_append(...)`. Scheduler writes with `actor_role='sp_scheduler'`.
## 3. Background research — patterns to draw from
### Social tools (Hootsuite, Sprout)
- Workflow shapes: **linear, tiered (multi-level), parallel, conditional**. Sprout steps support **Any vs All** sign-off ("any 1 of step's approvers" / "all must approve").
- **Post-locking after approval**: any post-approval edit re-triggers the chain. Hootsuite: "Once approved, the post is locked; changes send it back through approval."
- **Expired queue**: if not approved by scheduledAt, publish FAILS, post moves to Expired (author re-schedules + resubmits). 90-day auto-reject of stale pending items.
- Rejected → author edits → resubmits. Rejection notes surface to author.
- **Approval Activity** panel: every edit (who/when) + threaded internal comments + current step; approvers can edit-before-approve; tag members; notifications (digest + hourly reminders).
- Approvals inbox filters: Assigned-to-me, Created-by-me, Rejected, Expired.
- Teams of approvers (not individuals) to avoid bottlenecks; duplicate-reviewer skip.
### Four-eyes / maker-checker (compliance-grade)
- **Self-approval must be structurally impossible** — server-side check `approver ≠ author`, dedicated error, not policy.
- **Approve a frozen snapshot, not a moving target**: submit seals content (hash); edits after submit = new submission + new approval cycle; prior snapshots stay in audit trail. Reviewer sees the real diff.
- **Deny requires non-empty reason** (empty reason refused); approvals/denials recorded with actor+timestamp+snapshot hash.
- Eligibility re-checked at every transition (not just submit).
- Parallel double-approval (Signavio): parallel split/join, either order, automatic gateway; enforce distinct reviewers at assignment.
- Edge cases to model explicitly: reviewer leaves mid-review (votes revoked? reassigned?), vacation delegation, emergency admin override (logged as override), conflict discovered late.
- State machine enforced in BOTH app logic and DB triggers (defense in depth).
- Metrics: approval cycle time, approvals per reviewer (rubber-stamp detection).
## 4. Requirements (normative for proposals)
### R1 Lifecycle
- New/extended states covering: draft/editing → **primed/submitted for review** → in-review → approved (quorum met) → scheduled → published → **archived**; deny → **needs-changes** (back to author/editor with reasons); **resubmit** path (currently missing — `rejected` is terminal today).
- If a scheduled time passes without quorum → **expired** state (post does NOT publish; author notified; re-prime after re-approval).
### R2 Roles & permissions
- **Editor**: can edit group posts (content), cannot approve, cannot publish.
- **Reviewer**: can approve/deny with mandatory reason, cannot edit content.
- Roles must be assignable **per group** (group-scoped), coexisting with workspace roles. Author can never approve their own post (structural, server-side). Admin/Owner retain override power (logged as override).
- Existing `approval.grant`/`approval.request` permissions are the workspace-level seeds; extend, don't break, current 10-role model.
### R3 Quorum signoff
- Configurable **N approvals required** (2–3 typical) — decide where config lives (workspace default + per-group override? per-connection?) and who sets it (admin-only).
- Parallel (any-N-of-eligible) semantics by default; justify if you add tiered/sequential mode.
- Vote rules: one live vote per reviewer per submission; vote change allowed while in-review (with new reason) or immutable — pick and justify. Deny = single vote sends to needs-changes. Quorum met → auto-transition approved (→ scheduling continues if schedule set).
### R4 Staleness & integrity
- Content edits during review must NOT silently invalidate approvals: pick a model (frozen snapshot + new cycle on edit, vs edit-with-approval-dismissal + diff shown) and justify. Snapshot hash in audit log.
- Reviewer must always see exactly what they're approving (rendered post + diff since last review).
### R5 Review chat
- Per-post thread: messages from group members + **system events** (submitted, edits, approvals/denials with reasons, state changes) rendered inline.
- **Approve / Deny+reason buttons in the chat surface** for eligible reviewers.
- Realtime delivery: no WS transport exists today — propose transport (WS gateway wiring, SSE, or polling) with epoch/authz integration; justify choice.
### R6 Working-posts queue (UI)
- Scrollable list of in-flight posts: state chips, quorum progress (e.g. "2/3 ✓"), filters (needs-my-review, needs-changes, expiring soon), keyboard-friendly (j/k pattern from Streams).
- Post detail: rendered post preview + version diff + chat/vote panel + schedule info.
### R7 Archive & moderation
- Published posts land in an **archive view** (read-only, chat history preserved for record).
- **Hide** (invisible to members; badge for admins) and **delete** (hard or soft — justify) of posts — **space-admin-only**, new permissions, hash-chained audit events, epoch-safe.
- Decide moderation scope: archive-only or also drafts/working posts.
### R8 Security & audit (mandatory repo patterns)
- Everything through existing RbacGuard/`@RequirePermission`, FORCE RLS + tenant isolation on new tables, sequential migration `009_*`, hash-chained audit events for every state transition and vote, epoch re-token where membership/role changes affect pending reviews (reviewer removed from group → their pending votes must be revoked/re-counted).
### R9 Non-goals (this phase)
- Email/external notifications (design the hook, don't build), mobile-specific UI, per-platform conditional routing, AI-assisted review.
## 5. Open design questions (proposals must answer explicitly)
1. Group-scoped role storage: column on `group_members` vs separate `group_member_roles` table vs reusing workspace roles — tradeoffs?
2. Snapshot model: full content copies per version vs content-hash + immutable version rows?
3. Where does quorum config live and what's the precedence (workspace default → group override)?
4. Chat transport: WS (wire the existing authz port) vs SSE vs polling — cost/complexity/ordering guarantees?
5. Deny semantics: 1-deny-returns-to-author vs majority-deny vs configurable; what happens to other cast approvals on deny?
6. Re-review after edit: full reset vs dismiss-only-changed-scope (recommend one, note the other as future)?
7. Scheduled-time race: quorum completes at T-1min to scheduledAt — auto-publish or require manual re-confirm?
8. Author is also the only reviewer in a tiny group — deadlock handling (admin override path)?
9. Migration path for existing single-step approvals (`reviewer_user_id` backfill)?
10. Should reviewers see who else approved before them (peer pressure) or vote blind until quorum?
## 6. Proposal deliverable format (each lane produces `runs/wp5/proposal-<lane>.md`)
1. Executive summary — the 5 sharpest decisions you make.
2. Data model — migration SQL sketch (009+, following repo conventions: FORCE RLS, tenant isolation, immutability triggers, sp_app grants, audit fns reuse).
3. State machine — full state/transition table with guards and who can fire each.
4. RBAC — new permissions, group-role mechanism, self-approval exclusion, admin override.
5. Quorum engine — vote lifecycle, staleness/snapshot semantics, deny handling, race conditions.
6. Review chat — data model, event types, transport decision, authz integration.
7. UI plan — screens (queue, detail, archive), reuse of existing components, keyboard model.
8. Edge cases — the ugly ones (reviewer leaves, deadline race, deadlock, epoch churn mid-vote).
9. Audit & security — event list, chain integration, RLS notes.
10. Phased implementation plan — WP-sized chunks with test gates.
11. Test plan sketch — the 10 tests that would catch the worst regressions.
Constraints: NestJS + Postgres 16 + Temporal patterns as in repo; TS strict, no `as any`; follow existing naming/style; do not break the 10-role seed contract; proposals are designs — do NOT implement.