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

@ -6,6 +6,7 @@ import { scrub } from '../audit/audit.service.js';
import { draftDestinationAccounts } from '../publishing/approval-gate.service.js';
import type { DraftPost } from '../publishing/publishing.service.js';
import { transitionPollDraft } from '../publishing/poll-approval.js';
import { releasePublishPoll } from '../publishing/publish-governance.js';
import { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js';
import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js';
@ -18,7 +19,10 @@ interface PollRow {
anonymity: string; opens_at: Date; closes_at: Date; created_by: string;
winner_option_id: string | null; eligible_user_ids: string[] | null;
parent_poll_id: string | null; closed_at: Date | null; close_reason: string | null; outbox_state: string;
/** 'draft_publish' = publish-phase gate (publish-governance.ts); else a review/chat poll. */
subject_kind: string; publish_connection_id: string | null; publish_request: unknown;
}
const isPublishPoll = (poll: PollRow): boolean => poll.subject_kind === 'draft_publish';
interface OptionRow { id: string; seq: number; text: string; submitted_by: string; submitted_at: Date; draft_revision_id: string | null; author: string }
interface Member { user_id: string; role: string; can_vote: boolean }
interface VoteRow { voter_kind: 'user' | 'chat_identity'; voter_user_id: string | null; voter_remote_id: string; voter_handle: string;
@ -301,7 +305,12 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
Object.assign(poll, claimed.rows[0]);
const reasons = evaluation.state === 'vetoed' ? ['VETOED'] : evaluation.state === 'quorum_unreachable' ? ['INSUFFICIENT_QUORUM']
: evaluation.reason === 'timebox' ? [evaluation.state === 'passed' ? 'TIMEBOX_EXPIRED_AUTO_APPROVE' : 'TIMEBOX_EXPIRED_AUTO_REJECT'] : [];
if (poll.draft_id && !poll.rule.dryRun) {
if (poll.draft_id && !poll.rule.dryRun && isPublishPoll(poll)) {
// Publish phase: the draft stays approved either way; a pass schedules
// the held publish (execute-on-vote), a fail/expiry/veto leaves it
// re-publishable and is recorded blocked below.
if (evaluation.state === 'passed') await releasePublishPoll(client, poll);
} else if (poll.draft_id && !poll.rule.dryRun) {
const winnerText = poll.winner_option_id ? (await options(client, poll.id)).find(o => o.id === poll.winner_option_id)?.text : undefined;
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
passed: evaluation.state === 'passed', ...(winnerText === undefined ? {} : { text: winnerText }),
@ -316,14 +325,15 @@ async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void>
async function recordE2(client: PoolClient, poll: PollRow, evaluation: PollEvaluation, reasons: string[]): Promise<void> {
if (!poll.draft_id) return;
const draft = (await client.query('SELECT post FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
for (const accountId of draftDestinationAccounts(draft.post as DraftPost)) {
const accounts = isPublishPoll(poll) && poll.publish_connection_id ? [poll.publish_connection_id] : draftDestinationAccounts(draft.post as DraftPost);
for (const accountId of accounts) {
const provider = (await client.query('SELECT provider FROM connections WHERE id = $1', [accountId])).rows[0]?.provider ?? 'unknown';
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,$5,$6,$7,$8,$9)`,
[poll.workspace_id, poll.draft_id, { platform: provider, accountId, ...(poll.topic_binding_id ? { topicBindingId: poll.topic_binding_id } : {}) },
poll.policy_version, poll.rule.dryRun ? 'pending' : poll.state === 'passed' ? 'fired' : 'blocked',
{ ...evaluation, pollState: poll.state }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
{ ...evaluation, pollState: poll.state, ...(isPublishPoll(poll) ? { phase: 'publish' } : {}) }, reasons, `system:chat-poll-gate:${poll.id}`, poll.id]);
}
}
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
@ -344,7 +354,9 @@ async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
Object.assign(poll, changed.rows[0]);
await audit(client, poll, 'poll.stale', null, { reason: 'RESET_ON_CHANGE' });
await recordE2(client, poll, evaluation, ['RESET_ON_CHANGE']);
if (poll.rule.dryRun || !poll.draft_id) return;
// A stale publish gate never re-arms or touches the draft: the approval it
// answered is gone, and a new approval opens a new publish gate.
if (poll.rule.dryRun || !poll.draft_id || isPublishPoll(poll)) return;
if (poll.rule.resetOnChange) {
await openPollInTransaction(client, poll.workspace_id, {
draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question,
@ -375,6 +387,6 @@ export async function autoOpenDraftPoll(client: PoolClient, workspaceId: string,
if (!draftDestinationAccounts(post).length) return;
const rule = await draftPollRule(client, { post });
if (!rule) return;
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open'", [draftId])).rowCount) return;
if ((await client.query("SELECT 1 FROM polls WHERE draft_id = $1 AND state = 'open' AND subject_kind <> 'draft_publish'", [draftId])).rowCount) return;
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
}