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:
parent
f2eb14956e
commit
b89dea5c93
26 changed files with 1637 additions and 31 deletions
|
|
@ -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([
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue