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/.
153 lines
13 KiB
Markdown
153 lines
13 KiB
Markdown
# 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.
|