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.
This commit is contained in:
glass beadian 2026-10-14 08:04:56 +00:00
parent f2eb14956e
commit b89dea5c93
26 changed files with 1637 additions and 31 deletions

View file

@ -79,6 +79,36 @@ describe("E3 pre-dispatch evaluation", () => {
});
});
describe("E3 publish-phase gate (connections.fields.publishGovernance)", () => {
for (const mode of ["admin1", "admin_majority", "admin_consensus"]) {
it(`${mode}: holds until the publish poll for the approved revision passed`, () => {
for (const pending of [null, "open", "failed", "vetoed", "quorum_unreachable", "stale"]) {
const outcome = evaluateE3(row({ publish_governance: mode, publish_poll_state: pending }));
expect(outcome.outcome).toBe("blocked");
expect(outcome.reasonCodes).toEqual(["INSUFFICIENT_QUORUM", "PENDING_POLL"]);
expect(outcome.pollState).toBe(pending ?? "none");
}
expect(evaluateE3(row({ publish_governance: mode, publish_poll_state: "passed" })).outcome).toBe("fired");
});
}
it("auto and unset connections need no publish poll", () => {
expect(evaluateE3(row({ publish_governance: "auto", publish_poll_state: null })).outcome).toBe("fired");
expect(evaluateE3(row({ publish_governance: null, publish_poll_state: null })).outcome).toBe("fired");
expect(evaluateE3(row({ publish_governance: "unknown-mode", publish_poll_state: null })).outcome).toBe("fired");
});
it("a revoked approval still wins over a passed publish poll", () => {
const outcome = evaluateE3(row({ state: "editing", publish_governance: "admin1", publish_poll_state: "passed" }));
expect(outcome.reasonCodes).toEqual(["RESET_ON_CHANGE"]);
});
it("a passed publish poll does not bypass the snapshot fail-closed posture", () => {
const outcome = evaluateE3(row({ effective: null, policy_version: null, publish_governance: "admin1", publish_poll_state: "passed" }));
expect(outcome.reasonCodes).toEqual(["OUTSIDE_POLICY"]);
});
});
describe("reason-code vocabulary mirror", () => {
it("matches the §4 closed vocabulary the gate_decisions CHECK enforces", () => {
expect(E3_REASON_CODES).toEqual([

View file

@ -49,6 +49,13 @@ export type ReasonCode = (typeof E3_REASON_CODES)[number];
export const E3_ACTOR = "system:gate-e3";
/**
* Publish-phase gate modes (connections.fields.publishGovernance), mirrored
* from apps/api/src/publishing/publish-governance.ts across the app package
* boundary. "auto" and unset need no publish poll.
*/
export const PUBLISH_GATE_MODES = ["admin1", "admin_majority", "admin_consensus"] as const;
/** DB column order for the re-check query. */
interface E3Row {
state: string | null;
@ -58,6 +65,10 @@ interface E3Row {
effective: { mode: "immediate" | "quorum" | "admin_only" } | null;
policy_version: string | null;
poll_state: string | null;
/** connections.fields->>'publishGovernance' of the attempt's destination. */
publish_governance?: string | null;
/** Latest publish poll for (draft, destination, approved revision). */
publish_poll_state?: string | null;
}
export interface E3Outcome {
@ -87,6 +98,22 @@ export function evaluateE3(row: E3Row | undefined): E3Outcome {
return { outcome: "blocked", reasonCodes: [reason("RESET_ON_CHANGE")], policyVersion: row.policy_version ?? "none", mode: row.effective?.mode ?? "none", approvedHash, pollState: null };
}
// 1b) Publish-phase gate: a gated destination dispatches only after the
// admins' publish poll for THIS approved revision passed. Attempts are
// normally created by that pass; this re-check also catches schedules
// made before the connection was switched to a gate mode.
if ((PUBLISH_GATE_MODES as readonly string[]).includes(row.publish_governance ?? "")
&& row.publish_poll_state !== "passed") {
return {
outcome: "blocked",
reasonCodes: [reason("INSUFFICIENT_QUORUM"), reason("PENDING_POLL")],
policyVersion: row.policy_version ?? "none",
mode: row.effective?.mode ?? "none",
approvedHash,
pollState: row.publish_poll_state ?? "none",
};
}
// 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) {
@ -159,7 +186,12 @@ export class DispatchGateService {
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
AND p.subject_kind <> 'draft_publish'
ORDER BY p.created_at DESC LIMIT 1) AS poll_state,
(SELECT c.fields->>'publishGovernance' FROM connections c WHERE c.id = $2::uuid) AS publish_governance,
(SELECT p.state FROM polls p WHERE p.draft_id = d.id AND p.subject_kind = 'draft_publish'
AND p.publish_connection_id = $2::uuid AND p.draft_revision = d.approved_revision
ORDER BY p.created_at DESC LIMIT 1) AS publish_poll_state
FROM drafts d
LEFT JOIN posting_policy_snapshots s
ON s.workspace_id = d.workspace_id
@ -168,7 +200,8 @@ export class DispatchGateService {
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`,
SELECT state, approved_hash, approved_revision, reviewer_user_id, effective, policy_version, poll_state,
publish_governance, publish_poll_state FROM target`,
[args.idempotencyKey, args.connectionId, args.topicBindingId ?? null],
);
const row = result.rows[0] as E3Row | undefined;

View file

@ -85,6 +85,14 @@ describe("E3 gate in the dispatch path", () => {
expect(h.publish).not.toHaveBeenCalled();
});
it("publish gate pending (admin poll not passed): known no-send with zero HTTP requests", async () => {
const gate = gateReturning({ outcome: "blocked", reasonCodes: ["INSUFFICIENT_QUORUM", "PENDING_POLL"], policyVersion: "pv1", mode: "immediate", approvedHash: "abc", pollState: "open" });
const h = harness({ kind: "claimed", job }, gate);
await expect(h.activity.publishScheduled(ref)).resolves.toEqual({ kind: "failed", code: "POLICY_INSUFFICIENT_QUORUM" });
expect(h.fetchImpl).not.toHaveBeenCalled();
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);