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:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import { evaluateE3, E3_REASON_CODES, type E3Outcome } from "./dispatch-gate.js";
/**
* E3 pre-dispatch re-check (chatgov-design.md §4 step 5) — pure decision table.
* Authorization fences are deliberately NOT re-tested here: the repository's
* claim + withDispatchGate own them; E3 only adds the governance side.
*/
type Row = Parameters<typeof evaluateE3>[0];
function row(overrides: Partial<NonNullable<Row>> = {}): NonNullable<Row> {
return {
state: "approved",
approved_hash: "abc123",
approved_revision: "1",
reviewer_user_id: "20000000-0000-4000-8000-000000000002",
effective: { mode: "immediate" },
policy_version: "pv1",
poll_state: null,
...overrides,
};
}
describe("E3 pre-dispatch evaluation", () => {
it("fires an approved draft under a live snapshot", () => {
expect(evaluateE3(row())).toEqual({
outcome: "fired",
reasonCodes: [],
policyVersion: "pv1",
mode: "immediate",
approvedHash: "abc123",
pollState: null,
} satisfies E3Outcome);
});
it("approval revoked post-approve blocks with RESET_ON_CHANGE", () => {
const outcome = evaluateE3(row({ state: "rejected", approved_hash: null, approved_revision: null, reviewer_user_id: null }));
expect(outcome.outcome).toBe("blocked");
expect(outcome.reasonCodes).toEqual(["RESET_ON_CHANGE"]);
});
it("a draft reopened to editing blocks with RESET_ON_CHANGE", () => {
expect(evaluateE3(row({ state: "editing", approved_hash: null })).reasonCodes).toEqual(["RESET_ON_CHANGE"]);
});
it("no snapshot row fails closed with OUTSIDE_POLICY (P0 hand-off posture)", () => {
const outcome = evaluateE3(row({ effective: null, policy_version: null }));
expect(outcome.outcome).toBe("blocked");
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
expect(outcome.policyVersion).toBe("none");
});
it("no draft/attempt row at all fails closed with OUTSIDE_POLICY", () => {
const outcome = evaluateE3(undefined);
expect(outcome.outcome).toBe("blocked");
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
});
it("quorum mode blocks until the linked poll passed (P2 seam)", () => {
const blocked = evaluateE3(row({ effective: { mode: "quorum" }, poll_state: "open" }));
expect(blocked.outcome).toBe("blocked");
expect(blocked.reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
const fired = evaluateE3(row({ effective: { mode: "quorum" }, poll_state: "passed" }));
expect(fired.outcome).toBe("fired");
});
it("quorum mode with no poll row is pending, never silently fired", () => {
expect(evaluateE3(row({ effective: { mode: "quorum" }, poll_state: null })).reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
});
it("admin_only does not re-gate at E3 (its E1 verdict already fired)", () => {
expect(evaluateE3(row({ effective: { mode: "admin_only" } })).outcome).toBe("fired");
});
it("policy_version is read from the LIVE snapshot, so a recompiled (changed) policy is what E3 records", () => {
const outcome = evaluateE3(row({ policy_version: "pv2" }));
expect(outcome.policyVersion).toBe("pv2");
});
});
describe("reason-code vocabulary mirror", () => {
it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => {
expect(E3_REASON_CODES).toEqual([
"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",
]);
});
});

View 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;
}
}

View file

@ -0,0 +1,100 @@
import { describe, expect, it, vi } from "vitest";
import { RateLimitedHttpClient, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
import type { AttemptRef, ClaimedJob, ClaimOutcome, GateOutcome, Lease, StoredPublication } from "@tactical-media-broadcast-hub/publishing-execution";
import { PublishActivity, type ActivityRepository } from "./publish.activity.js";
import type { DispatchGateService, E3Outcome } from "./dispatch-gate.js";
/**
* E3 integration into the dispatch path (chatgov-design.md §4 step 5): the
* gate runs before any provider call, settles a blocked destination as a
* known no-send, and is skipped (ungated, dispatch as today) when absent.
*/
const ref: AttemptRef = { workspaceId: "workspace", scheduleId: "schedule", generation: "1", idempotencyKey: "key" };
const job: ClaimedJob = {
lease: { ...ref, attemptId: "attempt", draftId: "draft", ownerToken: "owner", claimEpoch: "1", leaseUntil: new Date(Date.now() + 90_000) },
attemptNo: "1", retryCount: 0, post: { text: "hello", mediaAssetIds: [], visibility: "public", providerMetadata: {} },
connection: { id: "connection", provider: "telegram", channelId: "channel", fields: {}, createdAt: new Date() },
credential: { id: "credential-uuid", generation: "3" }, priorDispatch: undefined,
};
const publication: StoredPublication = { id: "publication", workspaceId: ref.workspaceId, draftId: "draft", connectionId: "connection", idempotencyKey: "key", remoteId: "remote", publishedAt: new Date(), providerMetadata: {} };
function harness(claim: ClaimOutcome = { kind: "claimed", job }, gate?: DispatchGateService | null) {
let stored = false;
const repository: ActivityRepository = {
claim: vi.fn(async () => stored ? { kind: "done" as const, publication } : claim),
heartbeat: vi.fn(async () => true),
withDispatchGate: vi.fn(async (_lease: Lease, _policy: { allowRedispatch?: boolean; dedupeWindowMs?: number }, send: () => Promise<Response>): Promise<GateOutcome<Response>> => ({ kind: "sent", value: await send() })),
recordReceipt: vi.fn(async () => ({ inserted: true, receipt: { id: "receipt", workspaceId: ref.workspaceId, attemptId: "attempt", remoteId: "remote", receivedAt: new Date(), providerMetadata: {} } })),
finalize: vi.fn(async () => { stored = true; return { kind: "done" as const, publication }; }),
retryLater: vi.fn(async () => ({ kind: "settled" as const })), retryDeduplicated: vi.fn(async () => ({ kind: "settled" as const })),
pauseForReauthorization: vi.fn(async () => ({ kind: "settled" as const })), fail: vi.fn(async () => ({ kind: "settled" as const })), markUnknown: vi.fn(async () => ({ kind: "settled" as const })),
};
const fetchImpl = vi.fn(async () => new Response("{}"));
const http = new RateLimitedHttpClient({ fetchImpl });
const publish = vi.fn(async () => { await http.request("telegram", "https://api.telegram.org/sendMessage", { method: "POST" }, { retry: false }); return publication; });
const connector = { publish } as unknown as PlatformConnector;
const activity = new PublishActivity(repository, { get: () => connector }, () => ({ heartbeat: vi.fn() }), gate);
return { activity, repository, publish, fetchImpl };
}
function gateReturning(outcome: E3Outcome): DispatchGateService {
return {
check: vi.fn(async () => outcome),
} as unknown as DispatchGateService;
}
describe("E3 gate in the dispatch path", () => {
it("no gate configured: dispatches exactly as today (existing drafts unchanged)", async () => {
const h = harness();
await expect(h.activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "done", publicationId: "publication" });
expect(h.publish).toHaveBeenCalledTimes(1);
expect(h.repository.fail).not.toHaveBeenCalled();
});
it("gate fires: send proceeds and no policy settlement occurs", async () => {
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
const h = harness({ kind: "claimed", job }, gate);
await expect(h.activity.publishScheduled(ref)).resolves.toMatchObject({ kind: "done" });
expect(h.publish).toHaveBeenCalledTimes(1);
expect(h.repository.fail).not.toHaveBeenCalled();
});
it("approval revoked post-approve: blocks with RESET_ON_CHANGE before any send", async () => {
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["RESET_ON_CHANGE"], policyVersion: "pv1", mode: "immediate", approvedHash: null, pollState: null });
const h = harness({ kind: "claimed", job }, gate);
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_RESET_ON_CHANGE" });
expect(h.fetchImpl).not.toHaveBeenCalled();
expect(h.publish).not.toHaveBeenCalled();
expect(h.repository.fail).toHaveBeenCalledWith(job.lease, "POLICY_RESET_ON_CHANGE");
});
it("no snapshot: fails closed with POLICY_OUTSIDE_POLICY and zero HTTP requests", async () => {
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["OUTSIDE_POLICY"], policyVersion: "none", mode: "none", approvedHash: null, pollState: null });
const h = harness({ kind: "claimed", job }, gate);
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_OUTSIDE_POLICY" });
expect(h.fetchImpl).not.toHaveBeenCalled();
});
it("stale settle (lease lost) yields wait, never a second send", async () => {
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["RESET_ON_CHANGE"], policyVersion: "pv1", mode: "immediate", approvedHash: null, pollState: null });
const h = harness({ kind: "claimed", job }, gate);
vi.mocked(h.repository.fail).mockResolvedValue({ kind: "stale" });
const outcome = await h.activity.publishScheduled(ref);
expect(outcome).toEqual({ kind: "wait", until: expect.any(String) });
expect(h.publish).not.toHaveBeenCalled();
});
it("gate evaluated per destination with the lease's idempotency key", async () => {
const gate = gateReturning({ outcome: "fired", reasonCodes: [], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: null });
const h = harness({ kind: "claimed", job }, gate);
await h.activity.publishScheduled(ref);
expect(gate.check).toHaveBeenCalledWith(expect.objectContaining({
workspaceId: job.lease.workspaceId,
draftId: job.lease.draftId,
idempotencyKey: job.lease.idempotencyKey,
connectionId: job.connection.id,
platform: job.connection.provider,
}));
});
});

View file

@ -0,0 +1,85 @@
import { ApplicationFailure } from "@temporalio/common";
import { describe, expect, it, vi } from "vitest";
import { RateLimitedHttpClient, type PlatformConnector } from "@tactical-media-broadcast-hub/connector-sdk";
import type { AttemptRef, ClaimedJob, ClaimOutcome, StoredPublication, Lease, GateOutcome } from "@tactical-media-broadcast-hub/publishing-execution";
import { PublishActivity, type ActivityRepository } from "./publish.activity.js";
const ref: AttemptRef = { workspaceId: "workspace", scheduleId: "schedule", generation: "1", idempotencyKey: "key" };
const job: ClaimedJob = {
lease: { ...ref, attemptId: "attempt", draftId: "draft", ownerToken: "owner", claimEpoch: "1", leaseUntil: new Date(Date.now() + 90_000) },
attemptNo: "1", retryCount: 0, post: { text: "hello", mediaAssetIds: [], visibility: "public", providerMetadata: {} },
connection: { id: "connection", provider: "telegram", channelId: "channel", fields: {}, createdAt: new Date() },
credential: { id: "credential-uuid", generation: "3" }, priorDispatch: undefined,
};
const publication: StoredPublication = { id: "publication", workspaceId: ref.workspaceId, draftId: "draft", connectionId: "connection", idempotencyKey: "key", remoteId: "remote", publishedAt: new Date(), providerMetadata: {} };
function harness(claim: ClaimOutcome = { kind: "claimed", job }) {
let stored = false;
const repository: ActivityRepository = {
claim: vi.fn(async () => stored ? { kind: "done" as const, publication } : claim),
heartbeat: vi.fn(async () => true),
withDispatchGate: vi.fn(async (_lease: Lease, _policy: { allowRedispatch?: boolean; dedupeWindowMs?: number }, send: () => Promise<Response>): Promise<GateOutcome<Response>> => ({ kind: "sent", value: await send() })),
recordReceipt: vi.fn(async () => ({ inserted: true, receipt: { id: "receipt", workspaceId: ref.workspaceId, attemptId: "attempt", remoteId: "remote", receivedAt: new Date(), providerMetadata: {} } })),
finalize: vi.fn(async () => { stored = true; return { kind: "done" as const, publication }; }),
retryLater: vi.fn(async () => ({ kind: "settled" as const })), retryDeduplicated: vi.fn(async () => ({ kind: "settled" as const })),
pauseForReauthorization: vi.fn(async () => ({ kind: "settled" as const })), fail: vi.fn(async () => ({ kind: "settled" as const })), markUnknown: vi.fn(async () => ({ kind: "settled" as const })),
};
const fetchImpl = vi.fn(async () => new Response("{}"));
const http = new RateLimitedHttpClient({ fetchImpl });
const publish = vi.fn(async () => { await http.request("telegram", "https://api.telegram.org/sendMessage", { method: "POST" }, { retry: false }); return publication; });
// Only publish is used by this test composition; production registers complete connectors.
const connector = { publish } as unknown as PlatformConnector;
const activity = new PublishActivity(repository, { get: () => connector }, () => ({ heartbeat: vi.fn() }));
return { activity, repository, publish, fetchImpl };
}
describe("publishing execution wiring", () => {
it("propagates a non-retryable claim refusal without publishing or failing a job", async () => {
const h = harness();
const claimError = ApplicationFailure.nonRetryable("draft is not approved");
vi.mocked(h.repository.claim).mockRejectedValue(claimError);
await expect(h.activity.publishScheduled(ref)).rejects.toBe(claimError);
expect(claimError.nonRetryable).toBe(true);
expect(h.publish).not.toHaveBeenCalled(); expect(h.repository.fail).not.toHaveBeenCalled();
});
it("returns stored publication without publishing twice", async () => {
const h = harness();
const first = await h.activity.publishScheduled(ref); const second = await h.activity.publishScheduled(ref);
expect(second).toEqual(first); expect(h.publish).toHaveBeenCalledTimes(1);
expect(h.repository.recordReceipt).toHaveBeenCalledTimes(1); expect(h.repository.finalize).toHaveBeenCalledTimes(1);
expect(h.repository.withDispatchGate).toHaveBeenCalledTimes(1); expect(h.fetchImpl).toHaveBeenCalledTimes(1);
});
it("finalizes durable evidence with zero provider calls", async () => {
const h = harness({ kind: "finalize", job, receipt: { id: "receipt", workspaceId: ref.workspaceId, attemptId: "attempt", remoteId: "remote", providerMetadata: {}, receivedAt: new Date() } });
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "done", publicationId: "publication" });
expect(h.publish).not.toHaveBeenCalled(); expect(h.repository.recordReceipt).not.toHaveBeenCalled();
});
it("never routes a receipt failure into a fresh send or safe retry", async () => {
const h = harness(); const error = new Error("database unavailable"); vi.mocked(h.repository.recordReceipt).mockRejectedValue(error);
await expect(h.activity.publishScheduled(ref)).rejects.toBe(error);
expect(h.publish).toHaveBeenCalledTimes(1); expect(h.repository.retryLater).not.toHaveBeenCalled(); expect(h.repository.fail).not.toHaveBeenCalled(); expect(h.repository.finalize).not.toHaveBeenCalled();
});
it("refuses a fenced send with zero HTTP requests", async () => {
const h = harness(); vi.mocked(h.repository.withDispatchGate).mockResolvedValue({ kind: "paused", code: "AUTHZ_REVOKED" });
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "paused", code: "AUTHZ_REVOKED" });
expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.repository.retryLater).not.toHaveBeenCalled();
});
it("does not resend ambiguous Telegram work", async () => {
const h = harness({ kind: "claimed", job: { ...job, priorDispatch: { startedAt: new Date(), count: 1 } } });
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" });
expect(h.fetchImpl).not.toHaveBeenCalled(); expect(h.repository.markUnknown).toHaveBeenCalledTimes(1);
});
it("pauses an authentication rejection", async () => {
const h = harness(); const { AuthError } = await import("@tactical-media-broadcast-hub/connector-sdk"); h.publish.mockRejectedValue(new AuthError("rejected", "telegram", false));
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "paused", code: "CREDENTIAL_REJECTED" });
expect(h.repository.pauseForReauthorization).toHaveBeenCalledTimes(1); expect(h.repository.retryLater).not.toHaveBeenCalled();
});
it("retains unexpected provider scheduling evidence as unknown without a fabricated Publication", async () => {
const h = harness();
const http = new RateLimitedHttpClient({ fetchImpl: h.fetchImpl });
h.publish.mockImplementation(async () => {
await http.request("telegram", "https://api.telegram.org/sendMessage", { method: "POST" }, { retry: false });
return { ...publication, remoteId: "scheduled:42", providerMetadata: { outcome: "provider_scheduled", scheduledAt: "2030-01-01T00:00:00.000Z" } };
});
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "unknown", code: "UNEXPECTED_PROVIDER_SCHEDULED" });
expect(h.repository.markUnknown).toHaveBeenCalledWith(job.lease, { remoteId: "scheduled:42", scheduledAt: "2030-01-01T00:00:00.000Z" });
expect(h.repository.recordReceipt).not.toHaveBeenCalled(); expect(h.repository.finalize).not.toHaveBeenCalled(); expect(h.repository.retryLater).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,164 @@
import { Context } from "@temporalio/activity";
import type { ConnectorConnection, NormalizedPost } from "@tactical-media-broadcast-hub/core";
import type { ConnectorRegistry } from "@tactical-media-broadcast-hub/connector-sdk";
import { DispatchFenceError, publishWithPolicy, publishingDispatchPolicy, runWithPublishingContext } from "@tactical-media-broadcast-hub/connector-runtime";
import { DispatchGateService, type E3Outcome } from "./dispatch-gate.js";
import type { AttemptRef, ClaimedJob, GateOutcome, PublishingExecutionRepository, SettleOutcome, Lease } from "@tactical-media-broadcast-hub/publishing-execution";
export type ActivityOutcome =
| { kind: "done"; publicationId: string }
| { kind: "wait"; until: string }
| { kind: "paused" | "failed" | "superseded" | "unknown"; code: string };
export type ActivityRepository = Pick<PublishingExecutionRepository, "claim" | "heartbeat" | "recordReceipt" | "finalize" | "retryLater" | "retryDeduplicated" | "pauseForReauthorization" | "markUnknown" | "fail"> & { withDispatchGate(lease: Lease, policy: { allowRedispatch?: boolean; dedupeWindowMs?: number }, send: () => Promise<Response>): Promise<GateOutcome<Response>> };
export interface ActivityLifecycle { heartbeat(): void; signal?: AbortSignal }
function temporalLifecycle(): ActivityLifecycle {
const context = Context.current();
return { heartbeat: () => context.heartbeat(), signal: context.cancellationSignal };
}
function connectionOf(job: ClaimedJob): ConnectorConnection {
const fields: Record<string, string> = {};
for (const [key, value] of Object.entries(job.connection.fields)) if (typeof value === "string") fields[key] = value;
return { ...job.connection, fields, workspaceId: job.lease.workspaceId, credentialId: { kind: "credential-ref", id: job.connection.id } };
}
function postOf(job: ClaimedJob): NormalizedPost {
const visibility = job.post.visibility;
if (!["public", "unlisted", "private", "followers", "provider_default"].includes(visibility)) throw new Error("INVALID_POST_VISIBILITY");
return { ...job.post, visibility: visibility as NormalizedPost["visibility"] };
}
/** Persist only documented remote evidence; unknown fields never enter DB/history. */
export function safeMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const key of ["messageId", "chatId", "channelId", "outcome", "scheduledAt"]) {
const value = metadata[key];
if ((typeof value === "string" && value.length <= 512) || (typeof value === "number" && Number.isSafeInteger(value))) result[key] = value;
}
return result;
}
class GateRefusal extends Error {
constructor(readonly outcome: Exclude<GateOutcome<Response>, { kind: "sent" }>) { super("DISPATCH_FENCE_REFUSED"); }
}
export class PublishActivity {
/**
* E3 chatgov pre-dispatch re-check (chatgov-design.md §4 step 5). Optional:
* `undefined` preserves today's ungated dispatch (used by suites whose DB
* predates the chatgov spine); `null` explicitly disables it.
*/
constructor(
private readonly repository: ActivityRepository,
private readonly registry: Pick<ConnectorRegistry, "get">,
private readonly lifecycle: () => ActivityLifecycle = temporalLifecycle,
private readonly dispatchGate?: DispatchGateService | null,
) {}
/** E3 re-check before any send. Authorization fences are NOT duplicated:
* the repository's claim + withDispatchGate already re-read every live
* authz input; this gate adds the governance side (approval + policy
* currency) and blocks closed when it cannot prove a destination is
* inside policy. Absent gate or absent chatgov tables => dispatch as today. */
private async e3Check(lease: Lease, job: ClaimedJob): Promise<E3Outcome | undefined> {
const gate = this.dispatchGate ?? null;
if (gate === null) return undefined;
try {
return await gate.check({
workspaceId: lease.workspaceId,
draftId: lease.draftId,
idempotencyKey: lease.idempotencyKey,
connectionId: job.connection.id,
platform: job.connection.provider,
});
} catch (error) {
if (error instanceof Error && error.message.includes("does not exist")) return undefined; // chatgov tables absent: ungated
throw error;
}
}
async publishScheduled(ref: AttemptRef): Promise<ActivityOutcome> {
const claim = await this.repository.claim(ref);
if (claim.kind === "done") return { kind: "done", publicationId: claim.publication.id };
if (claim.kind === "wait") return { kind: "wait", until: claim.until.toISOString() };
if (claim.kind !== "claimed" && claim.kind !== "finalize") return claim;
const job = claim.job;
const lease = job.lease;
const finalize = async (): Promise<ActivityOutcome> => {
const result = await this.repository.finalize(lease);
return result.kind === "done" ? { kind: "done", publicationId: result.publication.id } : { kind: "wait", until: new Date(Date.now() + 1000).toISOString() };
};
// Evidence/finalization errors propagate to infrastructure retries. They
// never pass through provider failure classification or trigger a send.
if (claim.kind === "finalize") return finalize();
const lifecycle = this.lifecycle();
const abort = new AbortController();
const signal = AbortSignal.any([abort.signal, ...(lifecycle.signal ? [lifecycle.signal] : [])]);
const heartbeatTimer = setInterval(() => { try { lifecycle.heartbeat(); } catch { abort.abort(); } }, 5000);
let renewal: Promise<void> | undefined;
const timer = setInterval(() => {
if (renewal) return;
renewal = this.repository.heartbeat(lease).then((owned) => { if (!owned) abort.abort(); else lifecycle.heartbeat(); }).catch(() => abort.abort()).finally(() => { renewal = undefined; });
}, 10_000);
const settle = (result: SettleOutcome, outcome: ActivityOutcome): ActivityOutcome => result.kind === "stale" ? { kind: "wait", until: new Date(Date.now() + 1000).toISOString() } : outcome;
try {
lifecycle.heartbeat();
const connection = connectionOf(job);
// E3: chatgov pre-dispatch re-check, per destination, before any send.
const gate = await this.e3Check(lease, job);
if (gate !== undefined && gate.outcome === "blocked") {
// Known no-send: settle the attempt failed (governance refusal) so the
// blocked destination surfaces in the failed schedule + gate_decisions.
const settled = await this.repository.fail(lease, `POLICY_${gate.reasonCodes[0] ?? "OUTSIDE_POLICY"}`);
return settled.kind === "stale"
? { kind: "wait", until: new Date(Date.now() + 1000).toISOString() }
: { kind: "failed", code: `POLICY_${gate.reasonCodes[0] ?? "OUTSIDE_POLICY"}` };
}
const connector = this.registry.get(connection.provider);
const { policy, origin, dedupeVerified } = publishingDispatchPolicy(connection);
let outcome;
try {
outcome = await runWithPublishingContext({ workspaceId: lease.workspaceId, connectionId: connection.id, credentialId: job.credential.id, credentialGeneration: job.credential.generation, signal }, () => publishWithPolicy(connector, connection, postOf(job), {
idempotencyKey: lease.idempotencyKey, policy, dedupeVerified,
firstDispatchAt: job.firstDispatchStartedAt,
unsettledPriorDispatch: job.priorDispatch !== undefined,
aroundMutation: async (event, send) => {
signal.throwIfAborted();
const gate = await this.repository.withDispatchGate(lease, { allowRedispatch: dedupeVerified && event.origin === origin, dedupeWindowMs: dedupeVerified && event.origin === origin ? policy.dedupeWindowMs : 0 }, send);
if (gate.kind === "sent") return gate.value;
throw new DispatchFenceError(job.priorDispatch !== undefined, new GateRefusal(gate));
},
}));
} catch (error) {
if (error instanceof DispatchFenceError && error.cause instanceof GateRefusal) {
const gate = error.cause.outcome;
if (gate.kind === "finalize") return finalize();
if (gate.kind === "lost") return { kind: "wait", until: new Date(Date.now() + 1000).toISOString() };
return gate;
}
// Infrastructure faults are retried by Temporal and re-claim the same journal.
throw error;
}
if (outcome.kind === "published") {
const receipt = outcome.receipt;
await this.repository.recordReceipt(lease, { remoteId: receipt.remoteId, remoteUrl: receipt.remoteUrl, publishedAt: receipt.publishedAt, providerMetadata: safeMetadata(receipt.providerMetadata) });
return finalize();
}
if (outcome.kind === "provider_scheduled") {
// This is remote evidence, not a published receipt. Keep it in the
// unknown journal via a safe audit; never manufacture a Publication.
return settle(await this.repository.markUnknown(lease, { remoteId: outcome.scheduledRemoteId, scheduledAt: outcome.scheduledAt.toISOString() }), { kind: "unknown", code: "UNEXPECTED_PROVIDER_SCHEDULED" });
}
if (outcome.kind === "unknown") return settle(await this.repository.markUnknown(lease), { kind: "unknown", code: outcome.code });
if (outcome.kind === "rejected_permanent") {
// An earlier ambiguous send cannot be disproved by this rejection.
if (job.priorDispatch) return settle(await this.repository.markUnknown(lease), { kind: "unknown", code: "PUBLISH_OUTCOME_UNKNOWN" });
return outcome.needsReauthorization
? settle(await this.repository.pauseForReauthorization(lease, outcome.code), { kind: "paused", code: outcome.code })
: settle(await this.repository.fail(lease, outcome.code), { kind: "failed", code: outcome.code });
}
const delay = Math.max(outcome.retryAfterMs ?? 0, Math.min(300_000, 1000 * 2 ** job.retryCount));
const until = new Date(Date.now() + delay);
const result = outcome.resend === "deduplicated"
? await this.repository.retryDeduplicated(lease, outcome.code, until)
: await this.repository.retryLater(lease, outcome.code, until);
// Settlement may exhaust the budget: re-claim reads the authoritative terminal state.
return settle(result, { kind: "wait", until: (result.kind === "settled" ? result.until ?? until : until).toISOString() });
} finally { clearInterval(timer); clearInterval(heartbeatTimer); await renewal; }
}
}