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/.
13 KiB
13 KiB
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)
- Posts can be primed to go out, then reviewed and edited by others in the group.
- Roles: editors (can edit, can NOT post/approve) and reviewers (give the OK).
- System configurable so 2–3 people sign off on a post before it publishes.
- Reviewers can approve, or deny with reasoning (reason mandatory).
- A parallel chat per post with voting/approval buttons.
- Easy scrolling through existing working posts (the in-review queue).
- When posts publish, they are archived.
- Ability to make our own posts invisible or delete them — admin-only features.
- 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, webapi-types.ts:64. - Transitions are guarded CAS UPDATEs (
WHERE id=$1 AND state=$4) viatransition()helper (publishing.service.ts:275-292):editing→in_reviewsubmitForReview (permapproval.request)in_review→approvedapprove (permapproval.grant, stampsreviewer_user_id)in_review→rejectedreject (permapproval.grant; reason goes ONLY into audit diff, not the draft row)approved→scheduledschedule (insertsscheduled_publications, permpublication.schedule)scheduled→approvedcancelSchedulescheduled→publishedby 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. rejectedis 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 withstartDelay; durablescheduled_publicationsrow (pending/in_flight/done/failed) is source of truth; sweeperpublishing_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_appgrants. 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 → workspaceauthz_epoch(fail-closed 401 on mismatch) → permission viarole_permissions JOIN memberships(status='active')insidewithTenant. - Roles are per-workspace only (
memberships.role, UNIQUE(workspace_id,user_id) — one role per user per workspace).group_membershas 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-sideuserCanUseConnectionreads role from DB.
2.5 Realtime & chat
- No WS transport exists.
realtime/gateway.tsis an authz port only (authorizeSubscriptionchecks 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()chainscreate→submit→[approve]→[schedule]gated oncan(); "Recent drafts" sidebar with STATE_TONE badges is the only post list today. /approvalsis a 10-line stub;/calendarstub.- Streams columns (
components/streams/column.tsx):@tanstack/react-virtualuseVirtualizer, j/k keyboard nav — reusable pattern for the working-posts queue. - Audit page (236 lines) with diff viewer exists.
2.7 Audit
audit_loghash-chained per workspace (audit_hash = sha256(prev|actor|action|target_id|epoch)), SECURITY DEFINER append fns,audit_verify_chain. Services write viaappendTenantAudit(after-commit) or same-txSELECT audit_append(...). Scheduler writes withactor_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 —
rejectedis 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.requestpermissions 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 migration009_*, 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)
- Group-scoped role storage: column on
group_membersvs separategroup_member_rolestable vs reusing workspace roles — tradeoffs? - Snapshot model: full content copies per version vs content-hash + immutable version rows?
- Where does quorum config live and what's the precedence (workspace default → group override)?
- Chat transport: WS (wire the existing authz port) vs SSE vs polling — cost/complexity/ordering guarantees?
- Deny semantics: 1-deny-returns-to-author vs majority-deny vs configurable; what happens to other cast approvals on deny?
- Re-review after edit: full reset vs dismiss-only-changed-scope (recommend one, note the other as future)?
- Scheduled-time race: quorum completes at T-1min to scheduledAt — auto-publish or require manual re-confirm?
- Author is also the only reviewer in a tiny group — deadlock handling (admin override path)?
- Migration path for existing single-step approvals (
reviewer_user_idbackfill)? - 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)
- Executive summary — the 5 sharpest decisions you make.
- Data model — migration SQL sketch (009+, following repo conventions: FORCE RLS, tenant isolation, immutability triggers, sp_app grants, audit fns reuse).
- State machine — full state/transition table with guards and who can fire each.
- RBAC — new permissions, group-role mechanism, self-approval exclusion, admin override.
- Quorum engine — vote lifecycle, staleness/snapshot semantics, deny handling, race conditions.
- Review chat — data model, event types, transport decision, authz integration.
- UI plan — screens (queue, detail, archive), reuse of existing components, keyboard model.
- Edge cases — the ugly ones (reviewer leaves, deadline race, deadlock, epoch churn mid-vote).
- Audit & security — event list, chain integration, RLS notes.
- Phased implementation plan — WP-sized chunks with test gates.
- 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.