T-HUB/apps/api/src/publishing/publish-governance.ts
glass beadian b89dea5c93 feat: per-connection publish governance — auto, 1-admin, majority, consensus
Per the owner's spec: each social connection gets publishGovernance in its
fields — unset = manual publish (today), auto = dispatch on approval,
admin1/admin_majority/admin_consensus = a publish-phase chatgov poll
(subject draft_publish, its own open-poll slot, no collision with review
gates). Dispatch holds until the publish poll passes (E3 pre-dispatch
check); auto dispatches on approval. Includes migration 033, the PATCH
/connections/:id/publish-governance route (chatgov.manage), and 19+31 new
tests. Implementation by the branding-panel process (claude/opus), lint
and integration fixes here.
2026-10-14 08:04:56 +00:00

345 lines
20 KiB
TypeScript

import { ConflictException } from "@nestjs/common";
import type { PoolClient } from "pg";
import { isPublishingProvider } from "@tactical-media-broadcast-hub/connector-runtime";
import { PublishingExecutionRepository, PublishingStateError, type ScheduledAttempt } from "@tactical-media-broadcast-hub/publishing-execution";
import { scrub } from "../audit/audit.service.js";
import { parsePolicyRule, policyHash, type PolicyRule } from "../chatgov/policy.js";
import { durationMs } from "../chatgov/poll-evaluator.js";
import { pool } from "../db/client.js";
/**
* Per-connection publish governance (the publish-phase gate).
*
* `connections.fields.publishGovernance` decides what happens once a draft
* targeting that connection (providerMetadata.connectionId) is `approved`:
*
* unset manual — today's behavior: a publication.publish holder
* calls POST /publishing/drafts/:id/publish.
* auto the approval itself schedules the publish (now). No
* second gate: the proposal vote was the decision.
* admin1 a SECOND chatgov poll (subject_kind 'draft_publish',
* admin_majority kind yes_no) opens on approval / on the publish route;
* admin_consensus the publish is scheduled only when it passes
* (execute-on-vote). Same rule shapes as proposal polls.
*
* The scheduling itself is always PublishingExecutionRepository.scheduleAttempt
* — the durable pending row the scheduler sweep turns into the unchanged
* `scheduledPublish` Temporal workflow — so every authorization fence the
* claim re-reads (principal permission, approver, credential generation)
* still applies to governed and automatic publishes. E3 additionally refuses
* to dispatch to a gated connection unless the publish poll for the approved
* revision passed (apps/scheduler dispatch-gate.ts).
*/
export const PUBLISH_GOVERNANCE_MODES = ["auto", "admin1", "admin_majority", "admin_consensus"] as const;
export type PublishGovernance = (typeof PUBLISH_GOVERNANCE_MODES)[number];
export type PublishGateMode = Exclude<PublishGovernance, "auto">;
/** Publish polls close after a day; an unanswered gate rejects (re-publishable). */
export const PUBLISH_GATE_TIMEBOX = { duration: "P1D", onExpire: "reject" } as const;
const ADMIN_ROLES = ["Owner", "Admin"];
/** Lenient read of stored fields: anything unrecognised is manual (null). */
export function readPublishGovernance(fields: unknown): PublishGovernance | null {
if (typeof fields !== "object" || fields === null) return null;
const value = (fields as Record<string, unknown>).publishGovernance;
return PUBLISH_GOVERNANCE_MODES.find((mode) => mode === value) ?? null;
}
export function isPublishGateMode(value: PublishGovernance | null): value is PublishGateMode {
return value === "admin1" || value === "admin_majority" || value === "admin_consensus";
}
/**
* Compile a gate mode into a §1a rule against the admin roster AT OPEN.
* The count is snapshotted into the rule (and its policy_version): admins
* added later do not raise the bar; admins removed later can make the arm
* unreachable, which closes the poll `quorum_unreachable` (blocked).
*
* admin1 admin_only (the evaluator's implied {admins, x:1} arm)
* admin_majority quorum, [{admins, x: floor(n/2)+1}], join any
* admin_consensus quorum, [{admins, x: n}], join all, veto allowed
*
* Self-approval is allowed: the content was already approved under the
* proposal policy; this gate is the admins' sign-off on the dispatch, and
* excluding an author-admin would make consensus unreachable by design.
*/
export function compilePublishRule(mode: PublishGateMode, adminCount: number): PolicyRule {
if (!Number.isInteger(adminCount) || adminCount < 1) {
throw new ConflictException({ code: "PUBLISH_GATE_NO_ADMINS", message: "publish gate needs at least one active admin" });
}
const common = { version: 1, anonymity: "named", allowSelfApproval: true, resetOnChange: false, timebox: PUBLISH_GATE_TIMEBOX };
switch (mode) {
case "admin1":
return parsePolicyRule({ ...common, mode: "admin_only" });
case "admin_majority":
return parsePolicyRule({ ...common, mode: "quorum", join: "any", arms: [{ kind: "admins", x: Math.floor(adminCount / 2) + 1 }] });
case "admin_consensus":
return parsePolicyRule({ ...common, mode: "quorum", join: "all", allowVeto: true, arms: [{ kind: "admins", x: adminCount }] });
}
}
/**
* Admins whose ballot counts toward an `admins` arm — the same definition the
* poll tally uses (poll.service resolveTally): active Owner/Admin members
* holding chatgov.vote, plus active members with an approved Admin/Owner
* chat-identity role override.
*/
export async function governanceAdminIds(client: PoolClient, workspaceId: string): Promise<string[]> {
const rows = await client.query<{ user_id: string }>(
`SELECT m.user_id FROM memberships m
WHERE m.workspace_id = $1 AND m.status = 'active'
AND ((m.role = ANY($2::text[]) AND EXISTS (SELECT 1 FROM role_permissions rp WHERE rp.role = m.role AND rp.permission = 'chatgov.vote'))
OR EXISTS (SELECT 1 FROM chat_identities ci WHERE ci.user_id = m.user_id AND ci.status = 'approved' AND ci.role_override = ANY($2::text[])))
ORDER BY m.user_id`,
[workspaceId, ADMIN_ROLES],
);
return rows.rows.map((row) => String(row.user_id));
}
/** What the publish route answers when the publish is held for admins. */
export interface PublishHold {
state: "awaiting_publish_approval";
draftId: string;
connectionId: string;
publishGovernance: PublishGateMode;
pollId: string;
closesAt: Date;
rule: PolicyRule;
}
interface PublishRequest {
publishAt: string;
requiredPermission: "publication.schedule" | "publication.publish";
}
interface PublishPollRow {
id: string;
state: string;
closes_at: Date;
rule: PolicyRule;
}
function holdFrom(draftId: string, connectionId: string, mode: PublishGateMode, poll: PublishPollRow): PublishHold {
return { state: "awaiting_publish_approval", draftId, connectionId, publishGovernance: mode, pollId: poll.id, closesAt: new Date(poll.closes_at), rule: poll.rule };
}
/**
* Publish-route gate (runs inside PublishingService.schedule's transaction,
* after its tenancy/provider/group checks). `proceed` = the publish poll for
* the CURRENT approved revision passed, or the draft is not currently
* approved (scheduleAttempt then refuses with its usual error). Otherwise
* the open poll is reused, or a fresh one is opened (first request, or a
* re-publish after a failed/expired/vetoed gate).
*/
export async function checkPublishGate(client: PoolClient, input: {
workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode;
requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"];
}): Promise<{ kind: "proceed" } | { kind: "held"; hold: PublishHold; opened: boolean }> {
const draft = (await client.query(
"SELECT state, revision, approved_revision, approved_hash FROM drafts WHERE id = $1 FOR UPDATE", [input.draftId])).rows[0];
if (draft === undefined || draft.state !== "approved" || draft.approved_hash === null
|| draft.approved_revision === null || String(draft.approved_revision) !== String(draft.revision)) {
return { kind: "proceed" };
}
const latest = await latestPublishPoll(client, input.draftId, input.connectionId, String(draft.approved_revision));
if (latest?.state === "passed") return { kind: "proceed" };
if (latest?.state === "open") return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, latest), opened: false };
const poll = await openPublishPoll(client, { ...input, revision: String(draft.approved_revision) });
return { kind: "held", hold: holdFrom(input.draftId, input.connectionId, input.mode, poll), opened: true };
}
async function latestPublishPoll(client: PoolClient, draftId: string, connectionId: string, revision: string): Promise<PublishPollRow | undefined> {
return (await client.query<PublishPollRow>(
`SELECT id, state, closes_at, rule FROM polls
WHERE draft_id = $1 AND subject_kind = 'draft_publish' AND publish_connection_id = $2 AND draft_revision = $3
ORDER BY created_at DESC LIMIT 1`,
[draftId, connectionId, revision],
)).rows[0];
}
/**
* Open the publish-phase poll + its pending E2 gate row + audit, in the
* caller's transaction (the caller holds the draft row lock; same-transaction
* FK KEY SHARE on gate_decisions/polls is safe). The requester is recorded as
* created_by and becomes the scheduling principal on release.
*/
export async function openPublishPoll(client: PoolClient, input: {
workspaceId: string; draftId: string; connectionId: string; mode: PublishGateMode; revision: string;
requestedBy: string; publishAt: Date; requiredPermission: PublishRequest["requiredPermission"];
}): Promise<PublishPollRow> {
const admins = await governanceAdminIds(client, input.workspaceId);
const rule = compilePublishRule(input.mode, admins.length);
const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1", [input.connectionId])).rows[0];
const fields = (connection?.fields ?? {}) as Record<string, unknown>;
const label = typeof fields.handle === "string" && fields.handle !== "" ? fields.handle : String(connection?.provider ?? "connection");
const question = `Publish the approved draft to ${label}?`.slice(0, 300);
const now = new Date();
const request: PublishRequest = { publishAt: input.publishAt.toISOString(), requiredPermission: input.requiredPermission };
const policyVersion = policyHash(rule);
const poll = (await client.query<PublishPollRow>(
`INSERT INTO polls (workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at,
created_by, draft_revision, publish_connection_id, publish_request)
VALUES ($1, $2, 'draft_publish', $3, 'yes_no', $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING id, state, closes_at, rule`,
[input.workspaceId, input.draftId, question, rule, policyVersion, now, new Date(now.getTime() + durationMs(rule.timebox?.duration ?? "P1D")),
input.requestedBy, input.revision, input.connectionId, request],
)).rows[0]!;
const needed = rule.mode === "admin_only" ? 1 : rule.arms?.[0]?.x ?? 1;
await client.query(
`INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor, poll_id)
VALUES ($1, $2, $3, 'E2', $4, 'pending', $5, $6, $7, $8)`,
[input.workspaceId, input.draftId, { platform: String(connection?.provider ?? "unknown"), accountId: input.connectionId }, policyVersion,
{ phase: "publish", publishGovernance: input.mode, adminCount: admins.length, arms: [{ kind: "admins", got: 0, needed }], pollState: "open" },
["INSUFFICIENT_QUORUM", "PENDING_POLL"], `user:${input.requestedBy}`, poll.id],
);
await client.query("SELECT audit_append($1, $2, 'Member', 'poll.opened', 'poll', $3, $4)", [input.workspaceId, input.requestedBy, poll.id,
scrub({ draftId: input.draftId, kind: "yes_no", phase: "publish", connectionId: input.connectionId, publishGovernance: input.mode,
adminCount: admins.length, policyVersion, actor: `user:${input.requestedBy}` })]);
return poll;
}
type ScheduleRepository = Pick<PublishingExecutionRepository, "scheduleAttempt">;
let defaultRepository: ScheduleRepository | undefined;
function repositoryOrDefault(repository?: ScheduleRepository): ScheduleRepository {
if (repository !== undefined) return repository;
defaultRepository ??= new PublishingExecutionRepository(pool);
return defaultRepository;
}
/**
* Schedule inside a SAVEPOINT so a refused publish (credential gone, approval
* voided, schedule already active, principal revoked) never rolls back the
* vote / approval that triggered it. The refusal is audited instead.
*/
async function scheduleGuarded(client: PoolClient, repository: ScheduleRepository, input: Parameters<ScheduleRepository["scheduleAttempt"]>[1]):
Promise<{ ok: true; attempt: ScheduledAttempt } | { ok: false; code: string }> {
await client.query("SAVEPOINT publish_governance");
try {
const attempt = await repository.scheduleAttempt(client, input);
await client.query("RELEASE SAVEPOINT publish_governance");
return { ok: true, attempt };
} catch (error) {
await client.query("ROLLBACK TO SAVEPOINT publish_governance");
if (error instanceof PublishingStateError) return { ok: false, code: error.code };
throw error;
}
}
async function auditDraft(client: PoolClient, workspaceId: string, actor: { userId: string | null; role: string }, action: string, draftId: string, diff: Record<string, unknown>): Promise<void> {
await client.query("SELECT audit_append($1, $2, $3, $4, 'draft', $5, $6)", [workspaceId, actor.userId, actor.role, action, draftId, scrub(diff)]);
}
/**
* E2 close seam for publish polls (called by poll.service evaluateLocked
* under the workspace → draft → poll locks, after the exactly-once claim).
* A passed poll schedules the publish the requester asked for; the scheduler
* sweep starts the workflow. A refused schedule is audited, the poll stays
* passed, and the holder can re-publish once the cause is fixed.
*/
export async function releasePublishPoll(client: PoolClient, poll: {
id: string; workspace_id: string; draft_id: string | null; created_by: string;
publish_connection_id: string | null; publish_request: unknown;
}, repository?: ScheduleRepository): Promise<{ released: true; scheduleId: string } | { released: false; code: string }> {
if (poll.draft_id === null || poll.publish_connection_id === null) return { released: false, code: "NOT_A_PUBLISH_POLL" };
const request = (poll.publish_request ?? {}) as Partial<PublishRequest>;
const requested = typeof request.publishAt === "string" ? new Date(request.publishAt) : new Date();
const publishAt = Number.isFinite(requested.getTime()) && requested.getTime() > Date.now() ? requested : new Date();
const requiredPermission = request.requiredPermission === "publication.schedule" ? "publication.schedule" : "publication.publish";
const result = await scheduleGuarded(client, repositoryOrDefault(repository), {
workspaceId: poll.workspace_id, draftId: poll.draft_id, connectionId: poll.publish_connection_id,
scheduledByUserId: poll.created_by, requiredPermission, publishAt,
});
const system = { userId: null, role: "System" };
if (!result.ok) {
await auditDraft(client, poll.workspace_id, system, "publication.publish_release_failed", poll.draft_id,
{ pollId: poll.id, connectionId: poll.publish_connection_id, code: result.code, actor: `system:chat-poll-gate:${poll.id}` });
return { released: false, code: result.code };
}
await auditDraft(client, poll.workspace_id, system, "publication.scheduled", poll.draft_id, {
connectionId: poll.publish_connection_id, publishAt: publishAt.toISOString(), trigger: "publish_poll_passed",
pollId: poll.id, scheduledByUserId: poll.created_by, actor: `system:chat-poll-gate:${poll.id}`,
});
return { released: true, scheduleId: result.attempt.scheduleId };
}
/**
* The principal a system-initiated publish runs as: the approving user when
* they could publish to this connection themselves, else a live Owner/Admin
* (the poll-approval delegation pattern). Mirrors the repository's authorize
* predicate, which re-checks it at schedule and at every claim.
*/
async function publishPrincipal(client: PoolClient, workspaceId: string, connectionId: string, preferredUserId: string | null): Promise<string | null> {
const row = (await client.query<{ user_id: string }>(
`SELECT m.user_id FROM memberships m
JOIN role_permissions rp ON rp.role = m.role AND rp.permission = 'publication.publish'
WHERE m.workspace_id = $1 AND m.status = 'active'
AND (m.role = ANY($4::text[]) OR EXISTS (SELECT 1 FROM group_connections gc JOIN group_members gm ON gm.group_id = gc.group_id
WHERE gc.connection_id = $2 AND gm.user_id = m.user_id))
ORDER BY COALESCE(m.user_id = $3::uuid, false) DESC, CASE m.role WHEN 'Owner' THEN 0 WHEN 'Admin' THEN 1 ELSE 2 END, m.user_id
LIMIT 1`,
[workspaceId, connectionId, preferredUserId, ADMIN_ROLES],
)).rows[0];
return row === undefined ? null : String(row.user_id);
}
export type ApprovalFollowUp =
| { kind: "dispatched"; connectionId: string; attempt: ScheduledAttempt; publishAt: Date }
| { kind: "held"; connectionId: string; pollId: string }
| { kind: "skipped"; connectionId: string; code: string };
/**
* Approval seam: runs in the SAME transaction that moved the draft to
* `approved` (console approve, or the E2 proposal-poll pass), after the
* UPDATE. Manual connections (no setting) return null — today's behavior.
* Never throws for a refused follow-up: the approval must stand on its own.
*/
export async function onDraftApproved(client: PoolClient, input: {
workspaceId: string; draftId: string; providerMetadata: Record<string, unknown>;
preferredUserId: string | null; actor: { userId: string | null; role: string };
}, repository?: ScheduleRepository): Promise<ApprovalFollowUp | null> {
const connectionId = input.providerMetadata.connectionId;
if (typeof connectionId !== "string" || connectionId === "") return null;
const connection = (await client.query("SELECT provider, fields FROM connections WHERE id = $1 AND disconnected_at IS NULL", [connectionId])).rows[0];
if (connection === undefined) return null;
const mode = readPublishGovernance(connection.fields);
if (mode === null) return null;
const skip = async (code: string): Promise<ApprovalFollowUp> => {
await auditDraft(client, input.workspaceId, input.actor, "publication.auto_publish_skipped", input.draftId, { connectionId, publishGovernance: mode, code });
return { kind: "skipped", connectionId, code };
};
if (!isPublishingProvider(String(connection.provider))) return skip("PROVIDER_NOT_PUBLISHING");
const principal = await publishPrincipal(client, input.workspaceId, connectionId, input.preferredUserId);
if (principal === null) return skip("NO_AUTHORIZED_PRINCIPAL");
if (mode === "auto") {
const publishAt = new Date();
const result = await scheduleGuarded(client, repositoryOrDefault(repository), {
workspaceId: input.workspaceId, draftId: input.draftId, connectionId,
scheduledByUserId: principal, requiredPermission: "publication.publish", publishAt,
});
if (!result.ok) return skip(result.code);
await auditDraft(client, input.workspaceId, input.actor, "publication.scheduled", input.draftId, {
connectionId, publishAt: publishAt.toISOString(), trigger: "auto_on_approval", scheduledByUserId: principal,
});
return { kind: "dispatched", connectionId, attempt: result.attempt, publishAt };
}
// Gate modes: the second gate starts running as soon as the draft is
// approved; the publish route reuses this poll instead of opening another.
await client.query("SAVEPOINT publish_governance_open");
try {
const gate = await checkPublishGate(client, {
workspaceId: input.workspaceId, draftId: input.draftId, connectionId, mode,
requestedBy: principal, publishAt: new Date(), requiredPermission: "publication.publish",
});
await client.query("RELEASE SAVEPOINT publish_governance_open");
if (gate.kind === "held") return { kind: "held", connectionId, pollId: gate.hold.pollId };
return null;
} catch (error) {
await client.query("ROLLBACK TO SAVEPOINT publish_governance_open");
if (error instanceof ConflictException) return skip("PUBLISH_GATE_UNAVAILABLE");
throw error;
}
}