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
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue