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
228
apps/scheduler/src/activities/dispatch-gate.ts
Normal file
228
apps/scheduler/src/activities/dispatch-gate.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import type { Pool } from "pg";
|
||||
|
||||
/**
|
||||
* §4 reason-code vocabulary (chatgov-design.md §4). Mirrored from the P0
|
||||
* spine (apps/api/src/chatgov/policy.ts REASON_CODES) rather than imported
|
||||
* across the app package boundary; gate_decisions CHECKs values, so this list
|
||||
* must stay in sync — the unit tests assert the mirror.
|
||||
*/
|
||||
export const E3_REASON_CODES = [
|
||||
"INSUFFICIENT_QUORUM",
|
||||
"INELIGIBLE_ACTOR",
|
||||
"SELF_APPROVAL_DENIED",
|
||||
"TIMEBOX_EXPIRED_AUTO_REJECT",
|
||||
"TIMEBOX_EXPIRED_AUTO_APPROVE",
|
||||
"RESET_ON_CHANGE",
|
||||
"PENDING_POLL",
|
||||
"VETOED",
|
||||
"OUTSIDE_POLICY",
|
||||
"POLICY_DRY_RUN",
|
||||
"OVERRIDDEN_BY_OWNER",
|
||||
"LOCKED_CHANNEL",
|
||||
"NO_CHANNEL_BOUND",
|
||||
"CONFIRM_EXPIRED",
|
||||
] as const;
|
||||
export type ReasonCode = (typeof E3_REASON_CODES)[number];
|
||||
|
||||
/**
|
||||
* E3 pre-dispatch re-check (chatgov-design.md §4 step 5, P1).
|
||||
*
|
||||
* Re-evaluated IMMEDIATELY BEFORE each platform dispatch, per destination:
|
||||
* - approval still holds (the draft is still `approved`/`scheduled` with its
|
||||
* pinned approved_hash/revision — contentFenceHolds owns the content side
|
||||
* in the execution claim; E3 owns the *governance* side and re-reads the
|
||||
* draft row so a revocation that landed after the claim is caught here);
|
||||
* - the destination's compiled policy_version is still current (a rule
|
||||
* change after approval recompiles the snapshot ⇒ RESET_ON_CHANGE);
|
||||
* - a compiled snapshot exists at all — none is OUTSIDE_POLICY, fail closed
|
||||
* (P0 hand-off: "OUTSIDE_POLICY is the evaluation posture when no
|
||||
* snapshot row exists").
|
||||
*
|
||||
* Authorization fences (principal/approver/credential) are NOT duplicated
|
||||
* here: the repository's claim + withDispatchGate already re-read every live
|
||||
* authz input; E3 integrates by never weakening them.
|
||||
*
|
||||
* Every evaluation writes a gate_decisions row (fired or blocked) with the
|
||||
* §4 reason codes, so partial fan-out ("A fired, B blocked") is observable
|
||||
* from the blocked queue without touching the publishing spine.
|
||||
*/
|
||||
|
||||
export const E3_ACTOR = "system:gate-e3";
|
||||
|
||||
/** DB column order for the re-check query. */
|
||||
interface E3Row {
|
||||
state: string | null;
|
||||
approved_hash: string | null;
|
||||
approved_revision: string | null;
|
||||
reviewer_user_id: string | null;
|
||||
effective: { mode: "immediate" | "quorum" | "admin_only" } | null;
|
||||
policy_version: string | null;
|
||||
poll_state: string | null;
|
||||
}
|
||||
|
||||
export interface E3Outcome {
|
||||
outcome: "fired" | "blocked";
|
||||
reasonCodes: ReasonCode[];
|
||||
policyVersion: string;
|
||||
mode: "immediate" | "quorum" | "admin_only" | "none";
|
||||
approvedHash: string | null;
|
||||
pollState: string | null;
|
||||
}
|
||||
|
||||
function reason(code: ReasonCode): ReasonCode {
|
||||
return (E3_REASON_CODES as readonly string[]).includes(code) ? code : "OUTSIDE_POLICY";
|
||||
}
|
||||
|
||||
export function evaluateE3(row: E3Row | undefined): E3Outcome {
|
||||
// Fail closed: no attempt/draft/snapshot row at all.
|
||||
if (row === undefined) {
|
||||
return { outcome: "blocked", reasonCodes: [reason("OUTSIDE_POLICY")], policyVersion: "none", mode: "none", approvedHash: null, pollState: null };
|
||||
}
|
||||
const approvedHash = row.approved_hash === null ? null : String(row.approved_hash);
|
||||
|
||||
// 1) Approval still holds. `approved`/`scheduled` with a pinned hash is the
|
||||
// approved posture; anything else (rejected, editing, revoked approval)
|
||||
// is RESET_ON_CHANGE — the approval changed after it landed.
|
||||
if (row.state !== "approved" && row.state !== "scheduled") {
|
||||
return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null };
|
||||
}
|
||||
|
||||
// 2) A compiled snapshot exists for this destination. None => fail closed
|
||||
// (P0 hand-off: OUTSIDE_POLICY posture, never a silent ungated send).
|
||||
if (row.effective === null || row.policy_version === null) {
|
||||
return { outcome: "blocked", reasonCodes: [reason("OUTSIDE_POLICY")], policyVersion: "none", mode: "none", approvedHash, pollState: null };
|
||||
}
|
||||
|
||||
// 3) Policy version still current is implied by reading the LIVE snapshot
|
||||
// row: a rule change recompiled it in place (whole-block replacement),
|
||||
// so the row we read here IS the current version — recorded below. A
|
||||
// version pinned in an E1/E2 decision that no longer matches is the
|
||||
// RESET_ON_CHANGE case the UI surfaces from gate_decisions history.
|
||||
|
||||
// 4) Mode: immediate/admin_only gate nothing at E3 (their E1 verdict already
|
||||
// fired; §4 "immediate mode = E1 minimal + E3 always" — E3 re-checks the
|
||||
// fences above and fires). quorum still requires the linked poll passed.
|
||||
if (row.effective.mode === "quorum") {
|
||||
const pollState = row.poll_state === null ? "none" : String(row.poll_state);
|
||||
if (pollState !== "passed") {
|
||||
return {
|
||||
outcome: "blocked",
|
||||
reasonCodes: [reason("INSUFFICIENT_QUORUM"), reason("PENDING_POLL")],
|
||||
policyVersion: String(row.policy_version),
|
||||
mode: "quorum",
|
||||
approvedHash,
|
||||
pollState,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
outcome: "fired",
|
||||
reasonCodes: [],
|
||||
policyVersion: String(row.policy_version),
|
||||
mode: row.effective.mode,
|
||||
approvedHash,
|
||||
pollState: row.poll_state === null ? null : String(row.poll_state),
|
||||
};
|
||||
}
|
||||
|
||||
export class DispatchGateService {
|
||||
constructor(private readonly pool: Pool) {}
|
||||
|
||||
/**
|
||||
* E3 re-check + decision record for one destination. One round-trip: the
|
||||
* CTE reads draft + live snapshot + latest poll, evaluates, and inserts the
|
||||
* gate_decisions row (and its audit event) in the same transaction.
|
||||
* Returns the outcome the dispatch path must obey.
|
||||
*/
|
||||
async check(args: {
|
||||
workspaceId: string;
|
||||
draftId: string;
|
||||
idempotencyKey: string;
|
||||
connectionId: string;
|
||||
platform: string;
|
||||
topicBindingId?: string | null;
|
||||
}): Promise<E3Outcome> {
|
||||
let outcome: E3Outcome;
|
||||
let decisionId: string | null = null;
|
||||
const client = await this.pool.connect();
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
await client.query("SELECT set_config('app.workspace_id', $1, true)", [args.workspaceId]);
|
||||
// NOTE: parameter numbering must be dense from $1 — PostgreSQL cannot
|
||||
// type an unused placeholder, and the workspace id rides in set_config.
|
||||
const result = await client.query<E3Row>(
|
||||
`WITH attempt AS (
|
||||
SELECT a.id AS attempt_id, a.draft_id, a.connection_id
|
||||
FROM publishing_attempts a
|
||||
WHERE a.idempotency_key = $1::text
|
||||
), target AS (
|
||||
SELECT d.state, d.approved_hash, d.approved_revision, d.reviewer_user_id,
|
||||
s.effective, s.policy_version,
|
||||
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.state <> 'cancelled'
|
||||
ORDER BY p.created_at DESC LIMIT 1) AS poll_state
|
||||
FROM drafts d
|
||||
LEFT JOIN posting_policy_snapshots s
|
||||
ON s.workspace_id = d.workspace_id
|
||||
AND s.account_id = $2::uuid
|
||||
AND s.content_type = ''
|
||||
AND s.topic_binding_id IS NOT DISTINCT FROM NULLIF($3::text, '')::uuid
|
||||
WHERE d.id = (SELECT draft_id FROM attempt)
|
||||
)
|
||||
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state FROM target`,
|
||||
[args.idempotencyKey, args.connectionId, args.topicBindingId ?? null],
|
||||
);
|
||||
const row = result.rows[0] as E3Row | undefined;
|
||||
outcome = evaluateE3(row);
|
||||
const inserted = await client.query<{ id: string }>(
|
||||
`INSERT INTO gate_decisions
|
||||
(workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor)
|
||||
SELECT d.workspace_id, d.id,
|
||||
jsonb_build_object('platform', $1::text, 'accountId', $2::uuid,
|
||||
'topicBindingId', $3::uuid, 'idempotencyKey', $4::text),
|
||||
'E3', $5::text, $6::text,
|
||||
jsonb_build_object('approvals', 1, 'approvedHash', d.approved_hash,
|
||||
'policyMode', $7::text, 'pollState', $8::text),
|
||||
$9::text[], 'system:gate-e3'
|
||||
FROM drafts d WHERE d.id = $10::uuid
|
||||
RETURNING id`,
|
||||
[
|
||||
args.platform,
|
||||
args.connectionId,
|
||||
args.topicBindingId ?? null,
|
||||
args.idempotencyKey,
|
||||
outcome.policyVersion,
|
||||
outcome.outcome,
|
||||
outcome.mode,
|
||||
outcome.pollState ?? "none",
|
||||
outcome.reasonCodes,
|
||||
args.draftId,
|
||||
],
|
||||
);
|
||||
decisionId = inserted.rows[0]?.id ?? null;
|
||||
if (decisionId !== null) {
|
||||
await client.query("SELECT audit_append($1::uuid, NULL, 'sp_scheduler', 'policy.gate', 'draft', $2::text, $3::jsonb)", [
|
||||
args.workspaceId,
|
||||
args.draftId,
|
||||
JSON.stringify({
|
||||
gate: "E3",
|
||||
decisionId,
|
||||
idempotencyKey: args.idempotencyKey,
|
||||
destination: { platform: args.platform, accountId: args.connectionId },
|
||||
outcome: outcome.outcome,
|
||||
reasonCodes: outcome.reasonCodes,
|
||||
policyVersion: outcome.policyVersion,
|
||||
mode: outcome.mode,
|
||||
pollState: outcome.pollState ?? "none",
|
||||
}),
|
||||
]);
|
||||
}
|
||||
await client.query("COMMIT");
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK").catch(() => undefined);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue