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