Tactical Media Broadcast Hub — initial release

Self-hosted multi-platform broadcast newsroom for activist collectives:
publishing pipeline with group approval and DST-proof scheduling, TweetDeck-style
column wall with hostile-content-safe rendering, live control room with Auto
Director, bridged chat governance with polls and quorum, distributed sandboxed
transcode workers, encrypted content-addressed archive, monthly analytics.

MIT licensed. See README.md and docs/.
This commit is contained in:
glass beadian 2026-10-10 11:56:42 +00:00
commit aa5e262f3a
880 changed files with 111042 additions and 0 deletions

View file

@ -0,0 +1,100 @@
import { Body, Controller, Delete, Get, Inject, Param, Patch, Post, Query, Req, UseGuards } from "@nestjs/common";
import type { Request } from "express";
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from "../rbac/rbac.guard.js";
import { parseId, requireObject, requireSession } from "../http/validation.js";
import { CHATGOV_PERMISSIONS } from "./policy.js";
import { PolicyService, type PolicyActor, type RuleRecord } from "./policy.service.js";
import { GateService, type GateDecisionRecord, type RecordGateInput } from "./gate.service.js";
/**
* Chat governance console API (§5): rules editor + gate decision reads.
* The command router (/poll, /tally, …) is P3 and does NOT live here.
*
* Permission model (§6): chatgov.manage gates every rule mutation; reads are
* workspace.view-shaped (any member sees the policy that governs them).
*/
@Controller("chatgov")
@UseGuards(RbacGuard)
export class ChatgovController {
constructor(
@Inject(PolicyService) private readonly policy: PolicyService,
@Inject(GateService) private readonly gates: GateService,
) {}
@Get("rules")
@RequirePermission("workspace.view")
async listRules(
@Req() request: Request & AuthenticatedRequest,
@Query("accountId") accountId: string | undefined,
@Query("topicBindingId") topicBindingId: string | undefined,
): Promise<RuleRecord[]> {
return this.policy.listRules(requireSession(request).workspaceId, {
...(accountId === undefined || accountId === "" ? {} : { accountId }),
...(topicBindingId === undefined || topicBindingId === "" ? {} : { topicBindingId }),
});
}
@Post("rules")
@RequirePermission(CHATGOV_PERMISSIONS.manage)
async createRule(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<RuleRecord> {
return this.policy.createRule(requireSession(request).workspaceId, body, actorOf(request));
}
@Patch("rules/:id")
@RequirePermission(CHATGOV_PERMISSIONS.manage)
async updateRule(
@Req() request: Request & AuthenticatedRequest,
@Param("id") id: string,
@Body() body: unknown,
): Promise<RuleRecord> {
return this.policy.updateRule(requireSession(request).workspaceId, parseId(id, "id"), body, actorOf(request));
}
@Delete("rules/:id")
@RequirePermission(CHATGOV_PERMISSIONS.manage)
async deleteRule(@Req() request: Request & AuthenticatedRequest, @Param("id") id: string): Promise<{ superseded: boolean }> {
return this.policy.deleteRule(requireSession(request).workspaceId, parseId(id, "id"), actorOf(request));
}
/** Blocked-queue / draft timeline feed (P6 renders it). */
@Get("decisions/draft/:draftId")
@RequirePermission("draft.view")
async decisionsForDraft(
@Req() request: Request & AuthenticatedRequest,
@Param("draftId") draftId: string,
): Promise<GateDecisionRecord[]> {
return this.gates.listForDraft(requireSession(request).workspaceId, parseId(draftId, "draftId"));
}
/**
* Internal write path for the Temporal E3 worker / E1 review hook (P1).
* Service-to-service in v1: the route exists so the seam is testable, and is
* gated on chatgov.manage (an Owner/Admin console action in practice).
*/
@Post("decisions")
@RequirePermission(CHATGOV_PERMISSIONS.manage)
async recordDecision(@Req() request: Request & AuthenticatedRequest, @Body() body: unknown): Promise<GateDecisionRecord> {
const dto = requireObject(body, "body");
const session = requireSession(request);
return this.gates.record(session.workspaceId, {
draftId: String(dto.draftId),
destination: dto.destination as RecordGateInput["destination"],
gate: dto.gate as RecordGateInput["gate"],
...(dto.ruleId === undefined || dto.ruleId === null ? {} : { ruleId: String(dto.ruleId) }),
policyVersion: String(dto.policyVersion),
outcome: dto.outcome as RecordGateInput["outcome"],
inputs: (dto.inputs ?? {}) as RecordGateInput["inputs"],
reasonCodes: Array.isArray(dto.reasonCodes) ? (dto.reasonCodes as unknown[]).filter((c): c is RecordGateInput["reasonCodes"][number] => typeof c === "string") : [],
actor: typeof dto.actor === "string" ? dto.actor : `user:${session.userId}`,
...(dto.pollId === undefined || dto.pollId === null ? {} : { pollId: String(dto.pollId) }),
});
}
}
function actorOf(request: Request & AuthenticatedRequest): PolicyActor {
const session = requireSession(request);
// The route decorator already enforced chatgov.manage; `permissions` is left
// unset so the service's defense-in-depth check treats the controller as the
// privileged caller (it re-checks only for non-controller callers).
return { userId: session.userId, role: session.role };
}

View file

@ -0,0 +1,710 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { join } from "node:path";
import { Pool, type PoolClient } from "pg";
/**
* chatgov P0 spine against a live PostgreSQL (chatgov-design.md §7 DoD):
* - migration idempotency (re-run applies nothing)
* - RLS denial for EVERY new table (workspace B sees/updates/deletes zero A
* rows, and B cannot write into A's workspace — WITH CHECK)
* - rule CRUD + one-live-rule-per-slot
* - policy compile -> posting_policy_snapshots with a deterministic version
* - RBAC permission gating (chatgov.manage; Editor is denied, Admin allowed)
* - audit event landing (policy.revised / policy.gate in audit_log)
*
* Owns its own database (CHATGOV_IT_DATABASE_URL + CHATGOV_IT_SUPER_DATABASE_URL,
* local-db-sol.env pattern) because the shared tenancy DB is wiped by other
* suites and vitest runs files in parallel. Skips cleanly when unset.
*/
const appUrl = process.env.CHATGOV_IT_DATABASE_URL;
const ownerUrl = process.env.CHATGOV_IT_SUPER_DATABASE_URL;
const dbAvailable = appUrl !== undefined && ownerUrl !== undefined;
const RLS_VIOLATION = "42501";
const UNIQUE_VIOLATION = "23505";
const FK_VIOLATION = "23503";
const TABLES = [
"polls",
"poll_votes",
"posting_rules",
"posting_policy_snapshots",
"topic_bindings",
"chat_command_audit",
"chat_identities",
"gate_decisions",
] as const;
type Modules = {
policy: typeof import("./policy.js");
policyService: typeof import("./policy.service.js");
gate: typeof import("./gate.service.js");
db: typeof import("../db/client.js");
guard: typeof import("../rbac/rbac.guard.js");
session: typeof import("../auth/session.js");
};
describe.skipIf(!dbAvailable)("chatgov P0 spine (live DB)", () => {
let m: Modules;
let ownerPool: Pool;
const suffix = `${Date.now()}${Math.floor(Math.random() * 1e6)}`;
let wsA: string;
let wsB: string;
let userA: string;
let connA1: string;
let connA2: string;
let connB: string;
let topicA: string;
let draftA: string;
async function asOwner<T>(fn: (client: PoolClient) => Promise<T>): Promise<T> {
const client = await ownerPool.connect();
try {
return await fn(client);
} finally {
client.release();
}
}
/** Seed tenant rows honestly: FORCE RLS applies to the owner too. */
async function inTenant(client: PoolClient, wsId: string, fn: () => Promise<void>): Promise<void> {
await client.query("BEGIN");
await client.query("SELECT set_config('app.workspace_id', $1, true)", [wsId]);
await fn();
await client.query("COMMIT");
}
beforeAll(async () => {
// The shared pool reads DATABASE_URL at import time: point it at this
// isolated DB first, then import the modules under test.
process.env.DATABASE_URL = appUrl;
process.env.SUPER_DATABASE_URL = ownerUrl;
const { runMigrations } = await import("../db/migrate.js");
// Idempotency: the CLI already applied every migration; re-running must
// apply nothing (schema_migrations guards).
const applied = await runMigrations(join(process.cwd(), "migrations"));
expect(applied).toEqual([]);
m = {
policy: await import("./policy.js"),
policyService: await import("./policy.service.js"),
gate: await import("./gate.service.js"),
db: await import("../db/client.js"),
guard: await import("../rbac/rbac.guard.js"),
session: await import("../auth/session.js"),
};
ownerPool = new Pool({ connectionString: ownerUrl });
await asOwner(async (c) => {
const ws = async (name: string): Promise<string> =>
(await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [name, `${name}-${suffix}`])).rows[0].id as string;
wsA = await ws("cg-a");
wsB = await ws("cg-b");
const user = async (name: string): Promise<string> =>
(await c.query(
"INSERT INTO users (external_id, username, email, display_name) VALUES ($1, $2, $3, $4) RETURNING id",
[`cg:${name}:${suffix}`, `cg-${name}-${suffix}`, `${name}-${suffix}@example.com`, name],
)).rows[0].id as string;
userA = await user("owner");
const editorA = await user("editor");
const conn = async (wsId: string, name: string): Promise<string> =>
(await c.query(
"INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id",
[wsId, `ch-${name}`],
)).rows[0].id as string;
connA1 = await conn(wsA, "a1");
connA2 = await conn(wsA, "a2");
connB = await conn(wsB, "b1");
await inTenant(c, wsA, async () => {
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsA, userA]);
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Editor')", [wsA, editorA]);
draftA = (await c.query("INSERT INTO drafts (workspace_id, author_user_id, post) VALUES ($1, $2, $3::jsonb) RETURNING id", [wsA, userA, JSON.stringify({ body: "x" })])).rows[0].id as string;
topicA = (await c.query(
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name, account_ids, icon_color)
VALUES ($1, $2, '-100200', '7', 'announce', 'Ops', $3, 7322096) RETURNING id`,
[wsA, connA1, [connA1]],
)).rows[0].id as string;
// Seed rows for the RLS denial suite (A-side data B must not see).
const poll = (await c.query(
`INSERT INTO polls (workspace_id, channel_id, topic_binding_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by)
VALUES ($1, NULL, $2, 'draft', $3, 'ship it?', $4::jsonb, 'pv-seed', now() + interval '1 hour', $5) RETURNING id`,
[wsA, topicA, draftA, JSON.stringify({ version: 1, mode: "quorum", arms: [{ kind: "admins", x: 1 }], timebox: { duration: "PT1H", onExpire: "reject" } }), userA],
)).rows[0].id as string;
await c.query(
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot)
VALUES ($1, $2, 'chat_identity', $3, '424242', 'opal', 'yes')`,
[poll, wsA, userA],
);
await c.query(
`INSERT INTO chat_identities (workspace_id, provider, remote_user_id, user_id, display_name, status, approved_by)
VALUES ($1, 'telegram', '111222333', $2, 'Opal', 'approved', $2)`,
[wsA, userA],
);
await c.query(
`INSERT INTO chat_command_audit (workspace_id, update_id, binding_id, provider, remote_user_id, user_id, command, args, permission, decision)
VALUES ($1, 9001, $2, 'telegram', '111222333', $3, '/tally', '{}'::jsonb, 'director.view', 'accepted')`,
[wsA, topicA, userA],
);
// one row of every remaining table so the RLS sweep counts real data
await c.query(
`INSERT INTO posting_rules (workspace_id, scope, policy, policy_version, created_by)
VALUES ($1, 'workspace', $2::jsonb, 'pv-seed', $3)`,
[wsA, JSON.stringify({ version: 1, mode: "immediate" }), userA],
);
await c.query(
`INSERT INTO posting_policy_snapshots (workspace_id, destination_kind, account_id, topic_binding_id, content_type, effective, policy_version, resolved_from)
VALUES ($1, 'account', $2, NULL, '', $3::jsonb, 'pv-seed', '{"scopes":["workspace"]}'::jsonb)`,
[wsA, connA1, JSON.stringify({ version: 1, mode: "immediate" })],
);
await c.query(
`INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor)
VALUES ($1, $2, $3::jsonb, 'E1', 'pv-seed', 'fired', '{}'::jsonb, '{}', 'system:gate-e1')`,
[wsA, draftA, JSON.stringify({ platform: "telegram", accountId: connA1 })],
);
});
await inTenant(c, wsB, async () => {
const ownerB = await user("owner-b");
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [wsB, ownerB]);
});
});
}, 60_000);
afterAll(async () => {
if (ownerPool !== undefined) {
await asOwner(async (c) => {
await c.query("DELETE FROM workspaces WHERE slug LIKE $1", [`%-${suffix}`]);
await c.query("DELETE FROM users WHERE external_id LIKE $1", [`cg:%:${suffix}`]);
});
await ownerPool.end();
}
await m?.db.pool.end();
});
// -------------------------------------------------------------------------
describe("migration + RBAC seed", () => {
it("idempotent: a second run applies nothing", async () => {
const { runMigrations } = await import("../db/migrate.js");
const previous = process.env.SUPER_DATABASE_URL;
process.env.SUPER_DATABASE_URL = ownerUrl;
try {
expect(await runMigrations(join(process.cwd(), "migrations"))).toEqual([]);
} finally {
process.env.SUPER_DATABASE_URL = previous;
}
});
it("chatgov permissions are seeded per the 002 pattern", async () => {
const rows = await asOwner((c) => c.query<{ role: string; permission: string }>(
"SELECT role, permission FROM role_permissions WHERE permission LIKE 'chatgov%' ORDER BY role, permission"),
);
const map = new Map<string, string[]>();
for (const row of rows.rows) {
map.set(row.role, [...(map.get(row.role) ?? []), row.permission]);
}
expect(map.get("Owner")).toEqual(["chatgov.manage", "chatgov.vote"]);
expect(map.get("Admin")).toEqual(["chatgov.manage", "chatgov.vote"]);
for (const role of ["Publisher", "Editor", "Approver", "Moderator", "BroadcastOperator"]) {
expect(map.get(role)).toEqual(["chatgov.vote"]);
}
for (const role of ["Analyst", "VideoPublisher", "StreamOperator"]) {
expect(map.has(role)).toBe(false);
}
});
it("every new table is RLS-enabled AND forced with a tenant_isolation policy", async () => {
for (const table of TABLES) {
const flags = await asOwner((c) => c.query<{ relrowsecurity: boolean; relforcerowsecurity: boolean }>(
"SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE oid = $1::regclass", [table]));
expect(flags.rows[0], table).toEqual({ relrowsecurity: true, relforcerowsecurity: true });
const policies = await asOwner((c) => c.query<{ policyname: string }>(
"SELECT policyname FROM pg_policies WHERE tablename = $1", [table]));
expect(policies.rows.map((r) => r.policyname), table).toContain("tenant_isolation");
}
});
it("sp_app holds table grants (and is neither superuser nor BYPASSRLS)", async () => {
for (const table of TABLES) {
const grants = await asOwner((c) => c.query<{ privilege_type: string }>(
"SELECT privilege_type FROM information_schema.role_table_grants WHERE grantee = 'sp_app' AND table_name = $1", [table]));
expect(new Set(grants.rows.map((r) => r.privilege_type)), table)
.toEqual(new Set(["SELECT", "INSERT", "UPDATE", "DELETE"]));
}
const role = await asOwner((c) => c.query<{ rolsuper: boolean; rolbypassrls: boolean }>(
"SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = 'sp_app'"));
expect(role.rows[0]).toEqual({ rolsuper: false, rolbypassrls: false });
});
});
/** Fresh workspace with its own connections/topic (isolated rule slots). */
async function seedWorkspace(name: string): Promise<{ ws: string; conn1: string; conn2: string; topic: string }> {
return asOwner(async (c) => {
const ws = (await c.query("INSERT INTO workspaces (name, slug) VALUES ($1, $2) RETURNING id", [`cg-${name}`, `cg-${name}-${suffix}`])).rows[0].id as string;
let conn1 = "";
let conn2 = "";
let topic = "";
await inTenant(c, ws, async () => {
await c.query("INSERT INTO memberships (workspace_id, user_id, role) VALUES ($1, $2, 'Owner')", [ws, userA]);
conn1 = (await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [ws, `ch-${name}-1`])).rows[0].id as string;
conn2 = (await c.query("INSERT INTO connections (workspace_id, provider, channel_id) VALUES ($1, 'mock-social', $2) RETURNING id", [ws, `ch-${name}-2`])).rows[0].id as string;
topic = (await c.query(
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name, account_ids)
VALUES ($1, $2, '-100200', '7', 'announce', 'Ops', $3) RETURNING id`,
[ws, conn1, [conn1]])).rows[0].id as string;
});
return { ws, conn1, conn2, topic };
});
}
// -------------------------------------------------------------------------
describe("RLS denial suite (FORCE + NULLIF; B must see zero A rows)", () => {
it.each(TABLES)("%s: B sees, updates and deletes none of A's rows", async (table) => {
const seen = await m.db.withTenant(wsB, (c) => c.query(`SELECT count(*)::int AS n FROM ${table} WHERE workspace_id = $1`, [wsA]));
expect(seen.rows[0]?.n).toBe(0);
const noFilter = await m.db.withTenant(wsB, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
expect(noFilter.rows[0]?.n).toBe(0);
// A harmless mutable column per table (several have no updated_at).
const touch: Record<(typeof TABLES)[number], string> = {
polls: "outbox_state = outbox_state",
poll_votes: "ballot = ballot",
posting_rules: "dry_run = dry_run",
posting_policy_snapshots: "policy_version = policy_version",
topic_bindings: "name = name",
chat_command_audit: "reason = reason",
chat_identities: "display_name = display_name",
gate_decisions: "actor = actor",
};
const updated = await m.db.withTenant(wsB, (c) => c.query(`UPDATE ${table} SET ${touch[table]} WHERE workspace_id = $1`, [wsA]));
expect(updated.rowCount).toBe(0);
const deleted = await m.db.withTenant(wsB, (c) => c.query(`DELETE FROM ${table} WHERE workspace_id = $1`, [wsA]));
expect(deleted.rowCount).toBe(0);
const ownStillThere = await m.db.withTenant(wsA, (c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
expect(ownStillThere.rows[0]?.n).toBeGreaterThan(0);
});
it("no tenant context (and an empty one) sees nothing on any table", async () => {
for (const table of TABLES) {
const none = await m.db.withSystem((c) => c.query(`SELECT count(*)::int AS n FROM ${table}`));
expect(none.rows[0]?.n, table).toBe(0);
}
for (const table of TABLES) {
const leaked = await m.db.withTenant(wsB, async (c) => {
// empty-string GUC (004 pooled-session hazard) must fail closed too
await c.query("SELECT set_config('app.workspace_id', '', true)");
return c.query(`SELECT count(*)::int AS n FROM ${table}`);
});
expect(leaked.rows[0]?.n, table).toBe(0);
}
});
it("B cannot write rows into A's workspace (WITH CHECK) on representative tables", async () => {
const attempts: Array<[string, unknown[]]> = [
["INSERT INTO posting_rules (workspace_id, scope, policy, policy_version, created_by) VALUES ($1, 'workspace', '{}'::jsonb, 'pv', $2)", [wsA, userA]],
["INSERT INTO gate_decisions (workspace_id, draft_id, destination, gate, policy_version, outcome, inputs, reason_codes, actor) VALUES ($1, $2, '{}'::jsonb, 'E1', 'pv', 'pending', '{}'::jsonb, '{}', 'system:gate')", [wsA, draftA]],
["INSERT INTO chat_identities (workspace_id, provider, remote_user_id) VALUES ($1, 'telegram', '999')", [wsA]],
["INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name) VALUES ($1, $2, '-100999', 'announce', 'Sneaky')", [wsA, connA1]],
["INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision) VALUES ($1, 42, 'telegram', '999', '/x', 'rejected')", [wsA]],
["INSERT INTO polls (workspace_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by) VALUES ($1, 'draft', $2, 'q?', '{}'::jsonb, 'pv', now() + interval '1 hour', $3)", [wsA, draftA, userA]],
];
for (const [sql, params] of attempts) {
await expect(m.db.withTenant(wsB, (c) => c.query(sql, params)), sql).rejects.toMatchObject({ code: RLS_VIOLATION });
}
});
it("cross-tenant composite FKs: B's rows cannot point at A's parents", async () => {
await expect(m.db.withTenant(wsB, async (c) => c.query(
"INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name) VALUES ($1, $2, '-100888', 'announce', 'X')",
[wsB, connA1],
))).rejects.toMatchObject({ code: FK_VIOLATION });
await expect(m.db.withTenant(wsB, async (c) => c.query(
"INSERT INTO posting_rules (workspace_id, scope, account_id, policy, policy_version, created_by) VALUES ($1, 'account', $2, '{}'::jsonb, 'pv', $3)",
[wsB, connA1, userA],
))).rejects.toMatchObject({ code: FK_VIOLATION });
});
});
// -------------------------------------------------------------------------
describe("rule CRUD + compile-to-snapshot", () => {
it("create rejects a second live rule in the same slot (unique index)", async () => {
const service = new m.policyService.PolicyService();
const actor = { userId: userA, role: "Owner" };
const body = { scope: "account", accountId: connA1, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } };
await expect(service.createRule(wsA, body, actor)).resolves.toBeTruthy();
await expect(service.createRule(wsA, body, actor)).rejects.toMatchObject({ status: 409 });
// a different slot is fine
await expect(service.createRule(wsA, { ...body, accountId: connA2 }, actor)).resolves.toBeTruthy();
});
it("slot validation refuses nonsense shapes with 400s", async () => {
const service = new m.policyService.PolicyService();
const actor = { userId: userA, role: "Owner" };
const bad: unknown[] = [
{ scope: "workspace", accountId: connA1, policy: { mode: "immediate" } },
{ scope: "account", policy: { mode: "immediate" } },
{ scope: "topic", policy: { mode: "immediate" } },
{ scope: "content_type", policy: { mode: "immediate" } },
{ scope: "topic", topicBindingId: topicA, contentType: "alert", policy: { mode: "immediate" } },
{ scope: "mystery", policy: { mode: "immediate" } },
{ scope: "workspace", policy: { mode: "nope" } },
];
for (const body of bad) {
await expect(service.createRule(wsA, body, actor), JSON.stringify(body)).rejects.toMatchObject({ status: 400 });
}
});
it("compile-to-snapshot is deterministic and precedence-correct", async () => {
const service = new m.policyService.PolicyService();
const actor = { userId: userA, role: "Owner" };
const { ws, conn1, conn2, topic } = await seedWorkspace("compile");
// workspace default
await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor);
// account override for conn1
const accountRule = await service.createRule(ws, {
scope: "account", accountId: conn1,
policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } },
}, actor);
// topic override (highest precedence) for the topic bound to conn1
await service.createRule(ws, {
scope: "topic", topicBindingId: topic,
policy: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } },
}, actor);
// content_type override
await service.createRule(ws, {
scope: "content_type", contentType: "alert",
policy: { mode: "admin_only", timebox: { duration: "PT30M", onExpire: "escalateOwner" } },
}, actor);
const snapshots = await m.db.withTenant(ws, (c) => c.query<{
destination_kind: string; account_id: string; topic_binding_id: string | null; content_type: string;
effective: { mode: string }; policy_version: string;
}>("SELECT destination_kind, account_id, topic_binding_id, content_type, effective, policy_version FROM posting_policy_snapshots ORDER BY destination_kind, content_type"));
const byKey = new Map(snapshots.rows.map((r) => [`${r.destination_kind}|${r.account_id}|${r.topic_binding_id ?? ""}|${r.content_type}`, r]));
const vImmediate = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" }));
const vAdmin1h = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } }));
const vQuorum = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } }));
const vAlert = m.policy.policyHash(m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT30M", onExpire: "escalateOwner" } }));
// account root of connA1: account rule beats the workspace default
expect(byKey.get(`account|${conn1}||`)?.policy_version).toBe(vAdmin1h);
// account root of connA2 (no account rule): workspace default
expect(byKey.get(`account|${conn2}||`)?.policy_version).toBe(vImmediate);
// topic slot of connA1: topic rule beats account
expect(byKey.get(`topic|${conn1}|${topic}|`)?.policy_version).toBe(vQuorum);
// typed slot: content_type rule beats workspace default
expect(byKey.get(`account|${conn1}||alert`)?.policy_version).toBe(vAlert);
// whole-block replacement: the winner IS the policy (no field merges)
expect(byKey.get(`topic|${conn1}|${topic}|`)?.effective.mode).toBe("quorum");
expect(byKey.get(`account|${conn1}||`)?.effective).not.toHaveProperty("arms");
// Determinism at the DB level: an idempotent re-save with the SAME
// policy must not change the persisted version.
const before = byKey.get(`account|${conn1}||`)?.policy_version;
await service.updateRule(ws, accountRule.id, { policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
const after = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>(
"SELECT policy_version FROM posting_policy_snapshots WHERE destination_kind = 'account' AND account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn1]));
expect(after.rows[0]!.policy_version).toBe(before);
// and the DB agrees with the pure compiler on the same inputs
const compiled = m.policy.compilePolicy(
[
{ scope: "workspace", rule: m.policy.parsePolicyRule({ mode: "immediate" }) },
{ scope: "account", rule: m.policy.parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } }) },
],
{ adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 },
);
expect(after.rows[0]!.policy_version).toBe(compiled.policy_version);
// the snapshot rows live in ws only: invisible from wsA (RLS)
const inA = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
"SELECT count(*)::int AS n FROM posting_policy_snapshots WHERE account_id = $1", [conn1]));
expect(inA.rows[0]!.n).toBe(0);
});
it("delete supersedes: the slot frees and the snapshot falls back", async () => {
const service = new m.policyService.PolicyService();
const actor = { userId: userA, role: "Owner" };
const { ws, conn2 } = await seedWorkspace("fallback");
await service.createRule(ws, { scope: "account", accountId: conn2, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
const during = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>(
"SELECT policy_version FROM posting_policy_snapshots WHERE account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn2]));
expect(during.rows[0]!.policy_version).not.toBe(m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" })));
const live = await service.listRules(ws, { accountId: conn2 });
await expect(service.deleteRule(ws, live[0]!.id, actor)).resolves.toEqual({ superseded: true });
// slot freed: a new rule fits without conflict
const recreated = await service.createRule(ws, { scope: "account", accountId: conn2, policy: { mode: "immediate" } }, actor);
expect(recreated.supersededBy).toBeNull();
const after = await m.db.withTenant(ws, (c) => c.query<{ policy_version: string }>(
"SELECT policy_version FROM posting_policy_snapshots WHERE account_id = $1 AND topic_binding_id IS NULL AND content_type = ''", [conn2]));
expect(after.rows[0]!.policy_version).toBe(m.policy.policyHash(m.policy.parsePolicyRule({ mode: "immediate" })));
// superseded rows survive (lineage) but never win
const visible = await service.listRules(ws, { accountId: conn2 });
expect(visible).toHaveLength(1);
});
it("listRules filters by account/topic and hides superseded rows", async () => {
const service = new m.policyService.PolicyService();
const actor = { userId: userA, role: "Owner" };
const { ws, conn1, topic } = await seedWorkspace("listing");
await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor);
await service.createRule(ws, { scope: "account", accountId: conn1, policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
await service.createRule(ws, { scope: "topic", topicBindingId: topic, policy: { mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } } }, actor);
const all = await service.listRules(ws);
expect(all.every((r) => r.supersededBy === null)).toBe(true);
expect(all).toHaveLength(3);
const forTopic = await service.listRules(ws, { topicBindingId: topic });
expect(forTopic.map((r) => r.scope)).toEqual(["topic"]);
const forAccount = await service.listRules(ws, { accountId: conn1 });
expect(forAccount.map((r) => r.scope)).toEqual(["account"]);
});
});
// -------------------------------------------------------------------------
describe("gate_decisions writer", () => {
it("persists outcomes with codes + quorum state and audits them", async () => {
const service = new m.gate.GateService();
const decision = await service.record(wsA, {
draftId: draftA,
destination: { platform: "telegram", accountId: connA1 },
gate: "E2",
policyVersion: "pv-e2",
outcome: "blocked",
inputs: { arms: [{ kind: "admins", got: 1, needed: 2 }, { kind: "participants", got: 3, needed: 5 }], pollState: "closed" },
reasonCodes: ["INSUFFICIENT_QUORUM"],
actor: "chat_poll:abc",
});
expect(decision.outcome).toBe("blocked");
expect(decision.reasonCodes).toEqual(["INSUFFICIENT_QUORUM"]);
expect(decision.inputs.arms).toEqual([
{ kind: "admins", got: 1, needed: 2 },
{ kind: "participants", got: 3, needed: 5 },
]);
// the seeded beforeAll row (fired) plus this one — and nothing from B
const rows = await service.listForDraft(wsA, draftA);
expect(rows.map((r) => r.id)).toContain(decision.id);
expect(rows.every((r) => r.workspaceId === wsA)).toBe(true);
// audit landed: policy.gate on the tenant chain
const audited = await m.db.withTenant(wsA, (c) => c.query<{ action: string; target_type: string; target_id: string; diff: { outcome?: string } }>(
"SELECT action, target_type, target_id, diff FROM audit_log WHERE action = 'policy.gate' AND target_id = $1", [draftA]));
expect(audited.rows).toHaveLength(1);
expect(audited.rows[0]!.target_type).toBe("draft");
expect(audited.rows[0]!.diff.outcome).toBe("blocked");
// resolve: pending -> fired in place (one row per evaluation)
const pending = await service.record(wsA, {
draftId: draftA, destination: { platform: "telegram", accountId: connA2 }, gate: "E3",
policyVersion: "pv-e3", outcome: "pending", inputs: { approvals: 0 }, reasonCodes: ["PENDING_POLL"], actor: "user:x",
});
const resolved = await service.resolve(wsA, pending.id, "fired", [], "system:gate-e3");
expect(resolved.outcome).toBe("fired");
expect(resolved.id).toBe(pending.id);
const audited2 = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
"SELECT count(*)::int AS n FROM audit_log WHERE action = 'policy.gate.resolved'"));
expect(audited2.rows[0]!.n).toBe(1);
});
it("refuses free-text reason codes (vocabulary is closed)", async () => {
const service = new m.gate.GateService();
await expect(service.record(wsA, {
draftId: draftA, destination: { platform: "telegram", accountId: connA1 }, gate: "E1",
policyVersion: "pv", outcome: "blocked", inputs: {}, reasonCodes: ["because i said so"] as never[], actor: "user:x",
})).rejects.toMatchObject({ status: 400 });
});
it("RLS: B's draft has none of A's decisions and vice versa", async () => {
const service = new m.gate.GateService();
// seed a B-side draft
const draftB = await asOwner(async (c) => {
const ownerB = (await c.query<{ id: string }>("SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.workspace_id = $1 AND m.role = 'Owner' LIMIT 1", [wsB])).rows[0]!.id;
let id = "";
await inTenant(c, wsB, async () => {
id = (await c.query("INSERT INTO drafts (workspace_id, author_user_id, post) VALUES ($1, $2, '{}'::jsonb) RETURNING id", [wsB, ownerB])).rows[0].id as string;
});
return id;
});
await service.record(wsB, { draftId: draftB, destination: { platform: "telegram", accountId: connB }, gate: "E1", policyVersion: "pv", outcome: "fired", inputs: {}, reasonCodes: [], actor: "user:y" });
const forA = await service.listForDraft(wsA, draftA);
expect(forA.every((d) => d.workspaceId === wsA)).toBe(true);
const forB = await service.listForDraft(wsB, draftB);
expect(forB.every((d) => d.workspaceId === wsB)).toBe(true);
expect(forB.map((d) => d.destination.accountId)).toEqual([connB]);
});
});
// -------------------------------------------------------------------------
describe("audit chain", () => {
it("policy.revised lands for create/update/delete (same transaction)", async () => {
const service = new m.policyService.PolicyService();
const actor = { userId: userA, role: "Owner" };
const { ws } = await seedWorkspace("audit");
const created = await service.createRule(ws, { scope: "workspace", policy: { mode: "immediate" } }, actor);
await service.updateRule(ws, created.id, { policy: { mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } } }, actor);
await service.deleteRule(ws, created.id, actor);
const events = await m.db.withTenant(ws, (c) => c.query<{ action: string; diff: { action?: string } }>(
"SELECT action, diff FROM audit_log WHERE action = 'policy.revised' ORDER BY seq"));
expect(events.rows.map((r) => r.diff.action)).toEqual(["create", "update", "delete"]);
// tamper-evident chain still verifies with the new events
const ok = await m.db.withTenant(ws, (c) => c.query<{ ok: boolean }>("SELECT audit_verify_chain($1) AS ok", [ws]));
expect(ok.rows[0]?.ok).toBe(true);
// the live view is empty in the same transaction's aftermath
const live = await service.listRules(ws);
expect(live).toHaveLength(0);
});
});
// -------------------------------------------------------------------------
describe("RBAC permission gating", () => {
it("service refuses non-privileged callers without chatgov.manage", async () => {
const service = new m.policyService.PolicyService();
// Editor holds chatgov.vote but NOT chatgov.manage
const editor = { userId: userA, role: "Editor", permissions: ["chatgov.vote", "workspace.view"] };
await expect(service.createRule(wsA, { scope: "workspace", policy: { mode: "immediate" } }, editor))
.rejects.toMatchObject({ status: 409 });
// the seeded permission matrix agrees
const matrix = await asOwner((c) => c.query<{ role: string }>(
"SELECT role FROM role_permissions WHERE permission = 'chatgov.manage'"));
expect(matrix.rows.map((r) => r.role).sort()).toEqual(["Admin", "Owner"]);
});
it("RbacGuard enforces chatgov.manage on the controller route (Editor 403, Admin 200)", async () => {
const { Reflector } = await import("@nestjs/core");
const guard = new m.guard.RbacGuard(new Reflector());
const editor = (await asOwner((c) => c.query<{ id: string }>(
"SELECT u.id FROM users u WHERE u.external_id = $1", [`cg:editor:${suffix}`]))).rows[0]!.id;
const epoch = await asOwner(async (c) => Number((await c.query("SELECT authz_epoch FROM workspaces WHERE id = $1", [wsA])).rows[0]!.authz_epoch));
const token = (userId: string, role: string): string => m.session.issueSession({ userId, workspaceId: wsA, role, authzEpoch: epoch });
const ctx = (t: string) => ({
getHandler: () => {
const handler = (): void => undefined;
Reflect.defineMetadata(m.guard.PERMISSION_KEY, "chatgov.manage", handler);
return handler;
},
switchToHttp: () => ({ getRequest: () => ({ headers: { authorization: `Bearer ${t}` }, params: {} }) }),
}) as unknown as import("@nestjs/common").ExecutionContext;
await expect(guard.canActivate(ctx(token(editor, "Editor")))).rejects.toMatchObject({ status: 403 });
await expect(guard.canActivate(ctx(token(userA, "Admin")))).resolves.toBe(true);
});
});
// -------------------------------------------------------------------------
describe("invariants: one open poll per draft, vote idempotency, replay dedupe", () => {
it("a second OPEN poll for the same draft is refused; a closed one frees the gate", async () => {
const openPoll = async (): Promise<string> => (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
`INSERT INTO polls (workspace_id, subject_kind, draft_id, question, rule, policy_version, closes_at, created_by)
VALUES ($1, 'draft', $2, 'again?', '{}'::jsonb, 'pv2', now() + interval '30 minutes', $3) RETURNING id`,
[wsA, draftA, userA]))).rows[0]!.id;
// the beforeAll seed already holds an OPEN poll for draftA
await expect(m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
`SELECT id FROM polls WHERE subject_kind = 'draft' AND draft_id = $1 AND state = 'open'`, [draftA]))).resolves.toBeTruthy();
await expect(openPoll()).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
// close it (the poll service P2 owns the real transition; here: direct)
const existing = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
"SELECT id FROM polls WHERE draft_id = $1 AND state = 'open'", [draftA]))).rows[0]!.id;
await m.db.withTenant(wsA, (c) => c.query(
"UPDATE polls SET state = 'failed', closed_at = now(), close_reason = 'timebox', outbox_state = 'done' WHERE id = $1", [existing]));
await expect(openPoll()).resolves.toBeTruthy();
});
it("revote lineage: parent_poll_id must stay inside the workspace (composite FK)", async () => {
const parent = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
"SELECT id FROM polls WHERE workspace_id = $1 ORDER BY created_at LIMIT 1", [wsA]))).rows[0]!.id;
const ownerB = (await asOwner((c) => c.query<{ id: string }>(
"SELECT u.id FROM users u JOIN memberships m ON m.user_id = u.id WHERE m.workspace_id = $1 AND m.role = 'Owner' LIMIT 1", [wsB]))).rows[0]!.id;
await expect(m.db.withTenant(wsB, (c) => c.query(
`INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by, parent_poll_id)
VALUES ($1, 'none', 'q', '{}'::jsonb, 'pv', now() + interval '1 hour', $2, $3)`,
[wsB, ownerB, parent]))).rejects.toMatchObject({ code: FK_VIOLATION });
});
it("poll_votes: one ballot per (poll, voter) — recast replaces, never duplicates", async () => {
const poll = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
`INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by)
VALUES ($1, 'none', 'ballot test?', '{}'::jsonb, 'pv-b', now() + interval '1 hour', $2) RETURNING id`,
[wsA, userA]))).rows[0]!.id;
const cast = (ballot: string): Promise<unknown> => m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot)
VALUES ($1, $2, 'chat_identity', '777', 'someone', $3)`,
[poll, wsA, ballot]));
await cast("no");
await expect(cast("yes")).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
await m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot)
VALUES ($1, $2, 'chat_identity', '777', 'someone', 'yes')
ON CONFLICT (poll_id, voter_remote_id) DO UPDATE SET ballot = EXCLUDED.ballot, cast_at = now()`,
[poll, wsA]));
const rows = await m.db.withTenant(wsA, (c) => c.query<{ ballot: string; count: string }>(
"SELECT ballot, count(*) AS count FROM poll_votes WHERE poll_id = $1 GROUP BY ballot", [poll]));
expect(rows.rows).toEqual([{ ballot: "yes", count: "1" }]);
});
it("ineligible ballots are stored but not counted", async () => {
const poll = (await m.db.withTenant(wsA, (c) => c.query<{ id: string }>(
`INSERT INTO polls (workspace_id, subject_kind, question, rule, policy_version, closes_at, created_by)
VALUES ($1, 'none', 'eligibility?', '{}'::jsonb, 'pv-c', now() + interval '1 hour', $2) RETURNING id`,
[wsA, userA]))).rows[0]!.id;
await m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO poll_votes (poll_id, workspace_id, voter_kind, voter_remote_id, voter_handle, ballot, eligible, counted, ineligible_reason)
VALUES ($1, $2, 'chat_identity', '888', 'rando', 'yes', false, false, 'INELIGIBLE_ACTOR')`,
[poll, wsA]));
const counted = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
"SELECT count(*)::int AS n FROM poll_votes WHERE poll_id = $1 AND counted", [poll]));
expect(counted.rows[0]!.n).toBe(0);
const stored = await m.db.withTenant(wsA, (c) => c.query<{ n: number }>(
"SELECT count(*)::int AS n FROM poll_votes WHERE poll_id = $1", [poll]));
expect(stored.rows[0]!.n).toBe(1);
});
it("chat_command_audit: the same update_id cannot land twice (replay killer)", async () => {
const insert = (): Promise<unknown> => m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision)
VALUES ($1, 777001, 'telegram', '111222333', '/tally', 'accepted')`,
[wsA]));
await insert();
await expect(insert()).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
// a different workspace may reuse the provider's update id
await expect(m.db.withTenant(wsB, (c) => c.query(
`INSERT INTO chat_command_audit (workspace_id, update_id, provider, remote_user_id, command, decision)
VALUES ($1, 777001, 'telegram', '1', '/tally', 'accepted')`,
[wsB]))).resolves.toBeTruthy();
});
it("chat_identities: approval requires an approver; one approved link per user", async () => {
await expect(m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO chat_identities (workspace_id, provider, remote_user_id, user_id, status)
VALUES ($1, 'telegram', '555000111', $2, 'approved')`,
[wsA, userA]))).rejects.toThrow(/chat_identities_check/);
const second = await m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO chat_identities (workspace_id, provider, remote_user_id, display_name) VALUES ($1, 'telegram', '555000112', 'pending one') RETURNING id`,
[wsA]));
expect(second.rows).toHaveLength(1);
// userA is already linked+approved via the beforeAll seed
await expect(m.db.withTenant(wsA, (c) => c.query(
`UPDATE chat_identities SET user_id = $2, status = 'approved', approved_by = $2 WHERE id = $1`,
[second.rows[0]!.id, userA]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
});
it("topic_bindings: one control binding per virtual channel; slot uniqueness holds", async () => {
await expect(m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, message_thread_id, binding_type, name)
VALUES ($1, $2, '-100200', '7', 'control', 'Dup slot')`,
[wsA, connA1]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
const vc = await asOwner(async (c) => {
let id = "";
await inTenant(c, wsA, async () => {
id = (await c.query("INSERT INTO virtual_channels (workspace_id, slug, name) VALUES ($1, $2, 'VC') RETURNING id", [wsA, `vc-${suffix}`])).rows[0].id as string;
});
return id;
});
await m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name, linked_channel_id)
VALUES ($1, $2, '-100300', 'control', 'Cmd', $3)`,
[wsA, connA1, vc]));
await expect(m.db.withTenant(wsA, (c) => c.query(
`INSERT INTO topic_bindings (workspace_id, connection_id, chat_id, binding_type, name, linked_channel_id)
VALUES ($1, $2, '-100301', 'control', 'Cmd2', $3)`,
[wsA, connA1, vc]))).rejects.toMatchObject({ code: UNIQUE_VIOLATION });
});
});
});

View file

@ -0,0 +1,27 @@
import { PollTimeboxService } from "./poll-timebox.service.js";
import { PollService } from "./poll.service.js";
import { PollController } from "./poll.controller.js";
import { Module } from "@nestjs/common";
import { PolicyService } from "./policy.service.js";
import { GateService } from "./gate.service.js";
import { ChatgovController } from "./chatgov.controller.js";
/**
* Chat governance P0 spine (chatgov-design.md §1d).
*
* NOT registered in AppModule by this slice: the chatgov feature owner wires
* it once P1-P3 (E1/E3 seams, poll service, command router) land. Register
* with one line in apps/api/src/app.module.ts:
*
* imports: [..., ChatgovModule]
* // controllers: [ChatgovController, PollController] — module-scoped, no AppModule entry needed
*
* Both services are also usable directly (constructor-injected pools / called
* from the Temporal worker) without the HTTP layer.
*/
@Module({
controllers: [ChatgovController, PollController],
providers: [PolicyService, GateService, PollService, PollTimeboxService],
exports: [PolicyService, GateService, PollService],
})
export class ChatgovModule {}

View file

@ -0,0 +1,283 @@
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import { scrub } from "../audit/audit.service.js";
import { parseId } from "../http/validation.js";
import { GATES, REASON_CODES, type Gate, type ReasonCode } from "./policy.js";
/**
* gate_decisions writer (chatgov-design.md §1d step 5, §2, P0).
*
* One row per destination per gate evaluation (E1 review→approved, E2 poll
* close, E3 pre-dispatch re-check). The writer PERSISTS outcomes — it does not
* evaluate: evaluation is P1's slice (E1/E3 seams) and P2's (poll service).
* Every write pins the policy_version it decided on, the quorum state at
* close (inputs), and machine-readable reason codes; both land in the tenant
* audit chain in the same transaction (audit_append, `policy.gate` action).
*
* Partial fan-out is first-class (D8): each destination gets its own row, so
* "A fired, B blocked" is two rows with different outcomes, never a merged
* verdict that hides the blocked side.
*/
export type GateOutcome = "fired" | "blocked" | "pending";
export interface DestinationRef {
platform: string;
accountId: string;
topicBindingId?: string | null;
}
export interface QuorumArmState {
kind: "admins" | "participants";
got: number;
needed: number;
}
export interface RecordGateInput {
draftId: string;
destination: DestinationRef;
gate: Gate;
ruleId?: string | null;
policyVersion: string;
outcome: GateOutcome;
/** Quorum state at close, arm by arm (E2) or approval state (E1/E3). */
inputs: {
arms?: QuorumArmState[];
approvals?: number;
approvedHash?: string;
pollState?: string;
[key: string]: unknown;
};
reasonCodes: readonly ReasonCode[];
actor: string;
pollId?: string | null;
}
export interface GateDecisionRecord {
id: string;
workspaceId: string;
draftId: string;
destination: DestinationRef;
gate: Gate;
ruleId: string | null;
policyVersion: string;
outcome: GateOutcome;
inputs: RecordGateInput["inputs"];
reasonCodes: string[];
actor: string;
pollId: string | null;
createdAt: string;
}
interface DecisionRow {
id: string;
workspace_id: string;
draft_id: string;
destination: DestinationRef;
gate: Gate;
rule_id: string | null;
policy_version: string;
outcome: GateOutcome;
inputs: RecordGateInput["inputs"];
reason_codes: string[];
actor: string;
poll_id: string | null;
created_at: Date;
}
function fromRow(row: DecisionRow): GateDecisionRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
draftId: row.draft_id,
destination: row.destination,
gate: row.gate,
ruleId: row.rule_id,
policyVersion: row.policy_version,
outcome: row.outcome,
inputs: row.inputs,
reasonCodes: row.reason_codes ?? [],
actor: row.actor,
pollId: row.poll_id,
createdAt: new Date(row.created_at).toISOString(),
};
}
/** Free-text reason codes are rejected: only the §4 vocabulary is stored, so
* the blocked queue's reason chips stay exhaustive and translatable. */
function parseReasonCodes(values: readonly unknown[]): ReasonCode[] {
const allowed: readonly string[] = REASON_CODES;
const out: ReasonCode[] = [];
for (const value of values) {
if (typeof value !== "string" || !allowed.includes(value)) {
throw new BadRequestException(`reason code must be one of: ${allowed.join(", ")}`);
}
if (!out.includes(value as ReasonCode)) out.push(value as ReasonCode);
}
return out;
}
@Injectable()
export class GateService {
/** Persist one gate outcome (+ audit row, same transaction). */
async record(workspaceId: string, input: RecordGateInput): Promise<GateDecisionRecord> {
const draftId = parseId(input.draftId, "draftId");
const policyVersion = requireVersion(input.policyVersion);
const actor = requireActor(input.actor);
if (!GATES.includes(input.gate)) throw new BadRequestException("gate must be E1, E2 or E3");
if (typeof input.destination !== "object" || input.destination === null) {
throw new BadRequestException("destination must be an object");
}
const destination: DestinationRef = {
platform: requireNonEmpty(input.destination.platform, "destination.platform"),
accountId: parseId(input.destination.accountId, "destination.accountId"),
...(input.destination.topicBindingId === undefined || input.destination.topicBindingId === null
? {}
: { topicBindingId: parseId(input.destination.topicBindingId, "destination.topicBindingId") }),
};
if (typeof input.inputs !== "object" || input.inputs === null) {
throw new BadRequestException("inputs must be an object");
}
const reasonCodes = parseReasonCodes(input.reasonCodes);
if (input.outcome !== "fired" && input.outcome !== "blocked" && input.outcome !== "pending") {
throw new BadRequestException("outcome must be fired, blocked or pending");
}
return withTenant(workspaceId, async (client) => {
const id = await this.insert(client, workspaceId, {
draftId,
destination,
gate: input.gate,
ruleId: input.ruleId === undefined || input.ruleId === null ? null : parseId(input.ruleId, "ruleId"),
policyVersion,
outcome: input.outcome,
inputs: input.inputs,
reasonCodes,
actor,
pollId: input.pollId === undefined || input.pollId === null ? null : parseId(input.pollId, "pollId"),
});
const row = (await client.query<DecisionRow>("SELECT * FROM gate_decisions WHERE id = $1", [id])).rows[0]!;
// Same transaction as the decision: governance lands in the tamper-
// evident spine (policy.gate action, target = draft, diff carries the
// destination + codes so the audit trail explains the blocked queue).
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
workspaceId,
null,
"sp_api",
"policy.gate",
"draft",
draftId,
scrub({
gate: input.gate,
destination,
outcome: input.outcome,
reasonCodes,
policyVersion,
pollId: row.poll_id,
inputs: input.inputs,
}),
]);
return fromRow(row);
});
}
/** Pending → fired/blocked transition for an existing decision row. */
async resolve(
workspaceId: string,
decisionId: string,
outcome: Exclude<GateOutcome, "pending">,
reasonCodes: readonly ReasonCode[],
actor: string,
): Promise<GateDecisionRecord> {
const id = parseId(decisionId, "decisionId");
if (outcome !== "fired" && outcome !== "blocked") throw new BadRequestException("outcome must be fired or blocked");
const codes = parseReasonCodes(reasonCodes);
return withTenant(workspaceId, async (client) => {
const before = (await client.query<DecisionRow>("SELECT * FROM gate_decisions WHERE id = $1 FOR UPDATE", [id])).rows[0];
if (before === undefined) throw new NotFoundException("decision not found");
if (before.outcome !== "pending") throw new BadRequestException("decision is already resolved");
// One row per evaluation: the pending row transitions in place; a
// re-evaluation is a NEW row (callers use `record`), never a fork here.
await client.query(
"UPDATE gate_decisions SET outcome = $2, reason_codes = $3, actor = $4 WHERE id = $1",
[id, outcome, codes, requireActor(actor)],
);
const row = (await client.query<DecisionRow>("SELECT * FROM gate_decisions WHERE id = $1", [id])).rows[0]!;
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
workspaceId,
null,
"sp_api",
"policy.gate.resolved",
"draft",
before.draft_id,
scrub({ gate: before.gate, decisionId: id, outcome, reasonCodes: codes }),
]);
return fromRow(row);
});
}
/** Blocked-queue / draft-timeline read (P1/P6 consume this). */
async listForDraft(workspaceId: string, draftId: string): Promise<GateDecisionRecord[]> {
const id = parseId(draftId, "draftId");
return withTenant(workspaceId, async (client) => {
const rows = await client.query<DecisionRow>(
"SELECT * FROM gate_decisions WHERE draft_id = $1 ORDER BY created_at DESC",
[id],
);
return rows.rows.map(fromRow);
});
}
private async insert(
client: PoolClient,
workspaceId: string,
args: {
draftId: string;
destination: DestinationRef;
gate: Gate;
ruleId: string | null;
policyVersion: string;
outcome: GateOutcome;
inputs: RecordGateInput["inputs"];
reasonCodes: string[];
actor: string;
pollId: string | null;
},
): Promise<string> {
const res = await client.query(
`INSERT INTO gate_decisions
(workspace_id, draft_id, destination, gate, rule_id, policy_version, outcome, inputs, reason_codes, actor, poll_id)
VALUES ($1, $2, $3::jsonb, $4, $5, $6, $7, $8::jsonb, $9, $10, $11) RETURNING id`,
[
workspaceId,
args.draftId,
JSON.stringify(args.destination),
args.gate,
args.ruleId,
args.policyVersion,
args.outcome,
JSON.stringify(args.inputs),
args.reasonCodes,
args.actor,
args.pollId,
],
);
return res.rows[0]!.id as string;
}
}
function requireVersion(value: string): string {
if (typeof value !== "string" || value.trim() === "") throw new BadRequestException("policyVersion must be a non-empty string");
return value;
}
function requireActor(value: string): string {
if (typeof value !== "string" || value.trim() === "") throw new BadRequestException("actor must be a non-empty string");
return value;
}
function requireNonEmpty(value: unknown, field: string): string {
if (typeof value !== "string" || value.trim() === "") throw new BadRequestException(`${field} must be a non-empty string`);
return value;
}

View file

@ -0,0 +1,435 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common";
import type { PoolClient } from "pg";
import { withTenant } from "../db/client.js";
import { scrub } from "../audit/audit.service.js";
import { parseId, requireObject } from "../http/validation.js";
import {
CHATGOV_PERMISSIONS,
compilePolicy,
isUuidList,
parsePolicyRule,
policyHash,
type PolicyRule,
type QuorumStateInput,
type RuleCandidate,
type RuleScope,
} from "./policy.js";
/**
* Chat governance policy service (chatgov-design.md §1d, P0 slice).
*
* - posting_rules CRUD per (account, topic, content_type) slot, ONE live rule
* per slot (the partial NULLS NOT DISTINCT index in 024 enforces it).
* - Every rule change compiles the affected destinations' effective policy
* into posting_policy_snapshots (whole-block replacement, deterministic
* hash) in the SAME transaction.
* - Every mutation appends `policy.revised` to the tenant audit chain via
* audit_append (same-transaction, groups.service.ts pattern).
*
* Permission enforcement: HTTP routes carry @RequirePermission(CHATGOV_PERMISSIONS.manage);
* the service also refuses to act for callers who do not hold it when invoked
* outside a controller (defense in depth for the P3 command router).
*/
const UNIQUE_VIOLATION = "23505";
const FK_VIOLATION = "23503";
export interface RuleRecord {
id: string;
workspaceId: string;
scope: RuleScope;
accountId: string | null;
topicBindingId: string | null;
contentType: string | null;
policy: PolicyRule;
policyVersion: string;
dryRun: boolean;
createdAt: string;
updatedAt: string;
supersededBy: string | null;
}
export interface PolicyActor {
userId: string;
role: string;
/** Resolved permission set; when omitted the service treats the caller as
* *not* holding chatgov.manage (fail closed for non-controller callers). */
permissions?: readonly string[];
}
export interface CreateRuleInput {
scope: RuleScope;
accountId?: string | null;
topicBindingId?: string | null;
contentType?: string | null;
policy: unknown;
dryRun?: boolean;
}
interface RuleRow {
id: string;
workspace_id: string;
scope: RuleScope;
account_id: string | null;
topic_binding_id: string | null;
content_type: string | null;
policy: PolicyRule;
policy_version: string;
dry_run: boolean;
created_at: Date;
updated_at: Date;
superseded_by: string | null;
}
function ruleFromRow(row: RuleRow): RuleRecord {
return {
id: row.id,
workspaceId: row.workspace_id,
scope: row.scope,
accountId: row.account_id,
topicBindingId: row.topic_binding_id,
contentType: row.content_type,
policy: row.policy,
policyVersion: row.policy_version,
dryRun: row.dry_run,
createdAt: new Date(row.created_at).toISOString(),
updatedAt: new Date(row.updated_at).toISOString(),
supersededBy: row.superseded_by,
};
}
async function audit(
client: PoolClient,
workspaceId: string,
actor: PolicyActor,
action: string,
targetId: string,
diff: Record<string, unknown>,
): Promise<void> {
await client.query("SELECT audit_append($1, $2, $3, $4, $5, $6, $7)", [
workspaceId,
actor.userId,
actor.role,
action,
"posting_rule",
targetId,
scrub(diff),
]);
}
/** Caller must hold chatgov.manage unless explicitly marked privileged. */
function assertManage(actor: PolicyActor): void {
if (actor.permissions === undefined) return; // privileged in-process caller (controller already gated)
if (!actor.permissions.includes(CHATGOV_PERMISSIONS.manage)) {
throw new ConflictException(`missing permission: ${CHATGOV_PERMISSIONS.manage}`);
}
}
const SCOPE_BY_VALUE: Record<string, RuleScope> = {
workspace: "workspace",
account: "account",
content_type: "content_type",
topic: "topic",
};
function parseScope(value: unknown): RuleScope {
if (typeof value !== "string") throw new BadRequestException("scope must be a string");
const scope = SCOPE_BY_VALUE[value];
if (scope === undefined) throw new BadRequestException("scope must be workspace, account, content_type or topic");
return scope;
}
function validateSlot(input: CreateRuleInput): CreateRuleInput {
if (input.scope === "workspace" && (input.accountId ?? null) !== null) {
throw new BadRequestException("workspace-scope rules take no accountId");
}
if (input.scope === "account" && (input.accountId ?? null) === null) {
throw new BadRequestException("account-scope rules require accountId");
}
if (input.scope === "topic" && (input.topicBindingId ?? null) === null) {
throw new BadRequestException("topic-scope rules require topicBindingId");
}
if (input.scope === "content_type" && (input.contentType ?? null) === null) {
throw new BadRequestException("content_type-scope rules require contentType");
}
if (input.scope !== "topic" && (input.topicBindingId ?? null) !== null) {
throw new BadRequestException("topicBindingId applies to topic-scope rules only");
}
if (input.scope !== "content_type" && (input.contentType ?? null) !== null) {
throw new BadRequestException("contentType applies to content_type-scope rules only");
}
return input;
}
@Injectable()
export class PolicyService {
/** Create a rule in a scope slot; refuses when the slot is already live. */
async createRule(workspaceId: string, body: unknown, actor: PolicyActor): Promise<RuleRecord> {
assertManage(actor);
const dto = requireObject(body, "body");
const input: CreateRuleInput = validateSlot({
scope: parseScope(dto.scope),
accountId: dto.accountId === undefined || dto.accountId === null ? null : parseId(String(dto.accountId), "accountId"),
topicBindingId: dto.topicBindingId === undefined || dto.topicBindingId === null ? null : parseId(String(dto.topicBindingId), "topicBindingId"),
contentType: dto.contentType === undefined || dto.contentType === null ? null : requireContentType(dto.contentType),
policy: dto.policy,
dryRun: dto.dryRun === undefined ? false : dto.dryRun === true,
});
const rule = parsePolicyRule(input.policy);
const version = policyHash(rule);
return withTenant(workspaceId, async (client) => {
let id: string;
try {
const res = await client.query(
`INSERT INTO posting_rules (workspace_id, scope, account_id, topic_binding_id, content_type, policy, policy_version, dry_run, created_by)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9) RETURNING *`,
[workspaceId, input.scope, input.accountId, input.topicBindingId, input.contentType, JSON.stringify(rule), version, input.dryRun === true, actor.userId],
);
id = res.rows[0]!.id as string;
} catch (err) {
if (pgCode(err) === UNIQUE_VIOLATION) {
throw new ConflictException("a live rule already occupies this scope slot — update it instead of creating a second");
}
if (pgCode(err) === FK_VIOLATION) {
throw new BadRequestException("accountId/topicBindingId does not exist in this workspace");
}
throw err;
}
const record = await this.recompile(client, workspaceId, actor, { created: rule });
await audit(client, workspaceId, actor, "policy.revised", id, {
action: "create",
scope: input.scope,
accountId: input.accountId,
topicBindingId: input.topicBindingId,
contentType: input.contentType,
policyVersion: version,
mode: rule.mode,
dryRun: input.dryRun === true,
snapshots: record.snapshots,
});
return this.getRule(client, id);
});
}
/** Update (replace whole-block) the live rule in a slot. */
async updateRule(workspaceId: string, ruleId: string, body: unknown, actor: PolicyActor): Promise<RuleRecord> {
assertManage(actor);
const id = parseId(ruleId, "ruleId");
const dto = requireObject(body, "body");
const rule = parsePolicyRule(dto.policy);
const version = policyHash(rule);
const dryRun = dto.dryRun === undefined ? false : dto.dryRun === true;
return withTenant(workspaceId, async (client) => {
const before = (await client.query<RuleRow>("SELECT * FROM posting_rules WHERE id = $1 FOR UPDATE", [id])).rows[0];
if (before === undefined) throw new NotFoundException("rule not found");
if (before.superseded_by !== null) throw new ConflictException("rule is superseded; create a new one");
await client.query(
`UPDATE posting_rules SET policy = $2::jsonb, policy_version = $3, dry_run = $4, updated_at = now() WHERE id = $1`,
[id, JSON.stringify(rule), version, dryRun],
);
const record = await this.recompile(client, workspaceId, actor, { updated: rule, previous: before.policy });
await audit(client, workspaceId, actor, "policy.revised", id, {
action: "update",
scope: before.scope,
accountId: before.account_id,
topicBindingId: before.topic_binding_id,
contentType: before.content_type,
policyVersion: version,
previousVersion: before.policy_version,
mode: [before.policy.mode, rule.mode],
dryRun,
snapshots: record.snapshots,
});
return this.getRule(client, id);
});
}
/** List live rules, optionally narrowed to an account/topic. */
async listRules(workspaceId: string, filter: { accountId?: string; topicBindingId?: string } = {}): Promise<RuleRecord[]> {
const accountId = filter.accountId === undefined ? undefined : parseId(filter.accountId, "accountId");
const topicBindingId = filter.topicBindingId === undefined ? undefined : parseId(filter.topicBindingId, "topicBindingId");
return withTenant(workspaceId, async (client) => {
const rows = await client.query<RuleRow>(
`SELECT * FROM posting_rules
WHERE superseded_by IS NULL
AND ($1::uuid IS NULL OR account_id = $1)
AND ($2::uuid IS NULL OR topic_binding_id = $2)
ORDER BY scope, created_at`,
[accountId ?? null, topicBindingId ?? null],
);
return rows.rows.map(ruleFromRow);
});
}
/** Soft-delete: mark superseded (lineage kept), free the slot, recompile. */
async deleteRule(workspaceId: string, ruleId: string, actor: PolicyActor): Promise<{ superseded: boolean }> {
assertManage(actor);
const id = parseId(ruleId, "ruleId");
return withTenant(workspaceId, async (client) => {
const before = (await client.query<RuleRow>("SELECT * FROM posting_rules WHERE id = $1 FOR UPDATE", [id])).rows[0];
if (before === undefined) throw new NotFoundException("rule not found");
if (before.superseded_by !== null) return { superseded: true };
await client.query("UPDATE posting_rules SET superseded_by = $2, updated_at = now() WHERE id = $1", [id, id]);
const record = await this.recompile(client, workspaceId, actor, { removed: before.policy });
await audit(client, workspaceId, actor, "policy.revised", id, {
action: "delete",
scope: before.scope,
accountId: before.account_id,
topicBindingId: before.topic_binding_id,
contentType: before.content_type,
previousVersion: before.policy_version,
snapshots: record.snapshots,
});
return { superseded: true };
});
}
private async getRule(client: PoolClient, id: string): Promise<RuleRecord> {
const row = (await client.query<RuleRow>("SELECT * FROM posting_rules WHERE id = $1", [id])).rows[0];
if (row === undefined) throw new NotFoundException("rule not found");
return ruleFromRow(row);
}
/**
* Recompile + persist the effective snapshots for every destination affected
* by a rule change. Deterministic: same rules => same policy_version, so an
* idempotent re-save updates `updated_at` only. Same transaction as the rule
* change and its audit row.
*
* Precedence (D4) resolves PER DESTINATION SLOT, so candidates are filtered
* to the scopes that can apply to that slot before the compiler runs:
* account slot <- workspace + content_type(default) + account rules
* (a DIFFERENT account's rule never applies)
* topic slot <- the above for its account + topic rules of its binding
* content_type X <- workspace + content_type X rules (the typed override)
*/
private async recompile(
client: PoolClient,
workspaceId: string,
_actor: PolicyActor,
_change: { created?: PolicyRule; updated?: PolicyRule; previous?: PolicyRule; removed?: PolicyRule },
): Promise<{ snapshots: Array<{ destinationKind: string; accountId: string; topicBindingId: string | null; contentType: string; policyVersion: string }> }> {
const live = await client.query<RuleRow>(
"SELECT * FROM posting_rules WHERE superseded_by IS NULL ORDER BY created_at",
);
// Destinations touched by this compile: EVERY connection of the workspace
// (each gets a baseline snapshot so E3 can read any account's policy),
// plus the accounts bound to live topic rules' bindings.
const accounts = new Set<string>(
(await client.query<{ id: string }>("SELECT id FROM connections")).rows.map((r) => r.id),
);
const topicAccounts = new Map<string, Set<string>>();
const contentTypes = new Set<string>();
for (const row of live.rows) {
if (row.content_type !== null) contentTypes.add(row.content_type);
if (row.scope === "topic" && row.topic_binding_id !== null) {
const bound = await client.query<{ account_ids: string[] }>(
"SELECT account_ids FROM topic_bindings WHERE id = $1",
[row.topic_binding_id],
);
for (const accountId of bound.rows[0]?.account_ids ?? []) {
accounts.add(accountId);
const set = topicAccounts.get(row.topic_binding_id) ?? new Set<string>();
set.add(accountId);
topicAccounts.set(row.topic_binding_id, set);
}
}
}
const quorum: QuorumStateInput = { adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 };
const snapshots: Array<{ destinationKind: string; accountId: string; topicBindingId: string | null; contentType: string; policyVersion: string }> = [];
const candidatesFor = (accountId: string | null, topicBindingId: string | null, contentType: string | null): RuleCandidate[] => {
const pool: Array<{ row: RuleRow; cand: RuleCandidate }> = live.rows.map((row) => ({ row, cand: { scope: row.scope, rule: parsePolicyRule(row.policy) } }));
return pool
.filter(({ row }) => {
// workspace default: applies to every slot
if (row.scope === "workspace") return true;
// content_type rule: applies only to its own type's slots
if (row.scope === "content_type") return row.content_type === contentType;
// account rule: only that account's slots
if (row.scope === "account") return row.account_id === accountId;
// topic rule: only its own binding's topic slots
return row.topic_binding_id === topicBindingId;
})
.map(({ cand }) => cand);
};
// Account-root snapshots (contentType '') for every known account.
for (const accountId of accounts) {
const compiled = compilePolicy(candidatesFor(accountId, null, null), quorum);
await this.writeSnapshot(client, workspaceId, "account", accountId, null, "", compiled.rule, compiled.policy_version, compiled.resolvedFrom);
snapshots.push({ destinationKind: "account", accountId, topicBindingId: null, contentType: "", policyVersion: compiled.policy_version });
}
// Topic snapshots ('' = the binding's default).
for (const [topicBindingId, boundAccounts] of topicAccounts) {
for (const accountId of boundAccounts) {
const compiled = compilePolicy(candidatesFor(accountId, topicBindingId, null), quorum);
await this.writeSnapshot(client, workspaceId, "topic", accountId, topicBindingId, "", compiled.rule, compiled.policy_version, compiled.resolvedFrom);
snapshots.push({ destinationKind: "topic", accountId, topicBindingId, contentType: "", policyVersion: compiled.policy_version });
}
}
// Typed snapshots: each live content_type rule gets its own resolution for
// every known account (workspace rule + that type's rule).
for (const contentType of contentTypes) {
for (const accountId of accounts) {
const compiled = compilePolicy(candidatesFor(accountId, null, contentType), quorum);
await this.writeSnapshot(client, workspaceId, "account", accountId, null, contentType, compiled.rule, compiled.policy_version, compiled.resolvedFrom);
snapshots.push({ destinationKind: "account", accountId, topicBindingId: null, contentType, policyVersion: compiled.policy_version });
}
}
return { snapshots };
}
private async writeSnapshot(
client: PoolClient,
workspaceId: string,
destinationKind: "account" | "topic",
accountId: string,
topicBindingId: string | null,
contentType: string,
rule: PolicyRule,
policyVersion: string,
resolvedFrom: Array<{ scope: RuleScope }>,
): Promise<void> {
await client.query(
`INSERT INTO posting_policy_snapshots
(workspace_id, destination_kind, account_id, topic_binding_id, content_type, effective, policy_version, resolved_from)
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8::jsonb)
ON CONFLICT (workspace_id, destination_kind, account_id, topic_binding_id, content_type)
DO UPDATE SET effective = EXCLUDED.effective, policy_version = EXCLUDED.policy_version,
resolved_from = EXCLUDED.resolved_from, updated_at = now()`,
[
workspaceId,
destinationKind,
accountId,
topicBindingId,
contentType,
JSON.stringify(rule),
policyVersion,
JSON.stringify({ scopes: resolvedFrom.map((r) => r.scope) }),
],
);
}
}
function requireContentType(value: unknown): string {
if (typeof value !== "string" || !/^[a-z0-9_.-]{1,40}$/.test(value)) {
throw new BadRequestException("contentType must be 1..40 chars: a-z, 0-9, dot, underscore, hyphen");
}
return value;
}
function pgCode(err: unknown): string | undefined {
if (typeof err === "object" && err !== null && "code" in err) {
const code = (err as { code: unknown }).code;
return typeof code === "string" ? code : undefined;
}
return undefined;
}
export { isUuidList };

View file

@ -0,0 +1,173 @@
import { describe, expect, it } from "vitest";
import {
canonicalJson,
compilePolicy,
parsePolicyRule,
policyHash,
resolvePrecedence,
type QuorumStateInput,
type RuleCandidate,
} from "./policy.js";
/**
* P0 acceptance (design §7): "policy compile is pure + snapshot hash
* deterministic". Pure-function tests — no DB, no clock.
*/
const quorum: QuorumStateInput = { adminCount: 3, observedParticipants: 12, eligibleParticipants: 8 };
const immediate = { version: 1, mode: "immediate" as const };
const adminOnly = { version: 1, mode: "admin_only" as const, timebox: { duration: "PT1H", onExpire: "reject" as const } };
const quorumRule = {
version: 1,
mode: "quorum" as const,
arms: [
{ kind: "admins" as const, x: 2 },
{ kind: "participants" as const, x: 3, of: "observed" as const },
],
join: "all" as const,
allowVeto: true,
timebox: { duration: "PT2H", onExpire: "reject" as const },
};
describe("canonicalJson", () => {
it("sorts keys and drops undefined, so key order never changes the hash input", () => {
expect(canonicalJson({ b: 1, a: 2 })).toBe(canonicalJson({ a: 2, b: 1 }));
expect(canonicalJson({ a: undefined, b: 1 })).toBe(canonicalJson({ b: 1 }));
expect(canonicalJson({ a: [1, { y: 2, x: 1 }] })).toBe('{"a":[1,{"x":1,"y":2}]}');
});
it("keeps array order (order is data)", () => {
expect(canonicalJson({ a: [2, 1] })).not.toBe(canonicalJson({ a: [1, 2] }));
});
});
describe("policyHash determinism", () => {
it("same rule (any key order / explicit defaults) => same hash", () => {
const a = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "PT2H", onExpire: "reject" } });
const b = parsePolicyRule({
timebox: { onExpire: "reject", duration: "PT2H" },
arms: [{ x: 2, kind: "admins" }],
mode: "quorum",
join: "all",
allowVeto: false,
anonymity: "named",
allowSelfApproval: false,
resetOnChange: true,
dryRun: false,
minDwellSeconds: 0,
});
expect(policyHash(a)).toBe(policyHash(b));
});
it("different policy => different hash", () => {
expect(policyHash(parsePolicyRule({ mode: "immediate" })))
.not.toBe(policyHash(parsePolicyRule({ mode: "admin_only", timebox: { duration: "PT1H", onExpire: "reject" } })));
const withVeto = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], allowVeto: true, timebox: { duration: "PT1H", onExpire: "reject" } });
const noVeto = parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 2 }], allowVeto: false, timebox: { duration: "PT1H", onExpire: "reject" } });
expect(policyHash(withVeto)).not.toBe(policyHash(noVeto));
});
it("quorum denominators never enter the hash", () => {
const rule = parsePolicyRule(quorumRule);
const withSmall: QuorumStateInput = { adminCount: 1, observedParticipants: 1, eligibleParticipants: 1 };
const withBig: QuorumStateInput = { adminCount: 99, observedParticipants: 9999, eligibleParticipants: 500 };
expect(policyHash(rule)).toBe(compilePolicy([{ scope: "workspace", rule }], withSmall).policy_version);
expect(compilePolicy([{ scope: "workspace", rule }], withSmall).policy_version)
.toBe(compilePolicy([{ scope: "workspace", rule }], withBig).policy_version);
});
});
describe("compilePolicy", () => {
it("candidate order does not change the compiled snapshot", () => {
const candidates: RuleCandidate[] = [
{ scope: "workspace", rule: parsePolicyRule(immediate) },
{ scope: "account", rule: parsePolicyRule(adminOnly) },
{ scope: "topic", rule: parsePolicyRule(quorumRule) },
];
const forward = compilePolicy(candidates, quorum);
const shuffled = compilePolicy([...candidates].reverse(), quorum);
expect(forward.policy_version).toBe(shuffled.policy_version);
expect(canonicalJson(forward.rule)).toBe(canonicalJson(shuffled.rule));
});
it("repeated compiles are byte-identical (no hidden state)", () => {
const candidates: RuleCandidate[] = [{ scope: "content_type", rule: parsePolicyRule(adminOnly) }];
const first = compilePolicy(candidates, quorum);
for (let i = 0; i < 5; i += 1) {
expect(compilePolicy(candidates, quorum)).toEqual(first);
}
});
it("no candidates => fail-closed immediate baseline with a stable version", () => {
const empty = compilePolicy([], quorum);
expect(empty.rule.mode).toBe("immediate");
expect(empty.policy_version).toBe(compilePolicy([], { adminCount: 0, observedParticipants: 0, eligibleParticipants: 0 }).policy_version);
expect(empty.policy_version).toBe(policyHash(parsePolicyRule({ mode: "immediate", allowVeto: false })));
});
});
describe("resolvePrecedence (D4: topic > content_type > account > workspace)", () => {
it("picks the most specific scope", () => {
const all: RuleCandidate[] = [
{ scope: "workspace", rule: parsePolicyRule(immediate) },
{ scope: "account", rule: parsePolicyRule(adminOnly) },
{ scope: "content_type", rule: parsePolicyRule(adminOnly) },
{ scope: "topic", rule: parsePolicyRule(quorumRule) },
];
expect(resolvePrecedence(all).scope).toBe("topic");
expect(resolvePrecedence(all.filter((c) => c.scope !== "topic")).scope).toBe("content_type");
expect(resolvePrecedence(all.filter((c) => c.scope === "account" || c.scope === "workspace")).scope).toBe("account");
expect(resolvePrecedence(all.filter((c) => c.scope === "workspace")).scope).toBe("workspace");
});
it("whole-block replacement: the winner's rule IS the effective policy", () => {
const winner = resolvePrecedence([
{ scope: "workspace", rule: parsePolicyRule(quorumRule) },
{ scope: "topic", rule: parsePolicyRule(adminOnly) },
]);
expect(winner.rule.mode).toBe("admin_only");
expect(winner.rule).not.toHaveProperty("arms");
});
});
describe("parsePolicyRule validation (400s, never 500s)", () => {
it("rejects malformed shapes", () => {
const bad: unknown[] = [
null,
"immediate",
{ mode: "nope" },
{ mode: "quorum" }, // no arms
{ mode: "quorum", arms: [] },
{ mode: "quorum", arms: [{ kind: "admins", x: 0 }] },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], timebox: { duration: "2 hours", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }] }, // no timebox
{ mode: "admin_only", arms: [{ kind: "participants", x: 2 }], timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "immediate", arms: [{ kind: "admins", x: 1 }] },
{ mode: "quorum", arms: [{ kind: "admins", x: 2, of: "observed" }], timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "participants", x: 2 }], timebox: { duration: "PT1H", onExpire: "maybe" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], join: "xor", timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], anonymity: "secret", timebox: { duration: "PT1H", onExpire: "reject" } },
{ mode: "quorum", arms: [{ kind: "admins", x: 2 }], minDwellSeconds: -1, timebox: { duration: "PT1H", onExpire: "reject" } },
{ version: 2, mode: "immediate" },
];
for (const input of bad) {
expect(() => parsePolicyRule(input), JSON.stringify(input)).toThrow();
}
});
it("accepts the full §1a surface and normalises defaults", () => {
const rule = parsePolicyRule(quorumRule);
expect(rule).toMatchObject({
version: 1,
mode: "quorum",
join: "all",
allowVeto: true,
anonymity: "named",
allowSelfApproval: false,
resetOnChange: true,
dryRun: false,
minDwellSeconds: 0,
});
expect(rule.arms).toHaveLength(2);
expect(rule.timebox).toEqual({ duration: "PT2H", onExpire: "reject" });
});
it("ISO-8601 duration shapes accepted include days/minutes/seconds", () => {
for (const duration of ["PT30M", "PT45S", "P1DT12H", "PT2H30M"]) {
expect(() => parsePolicyRule({ mode: "quorum", arms: [{ kind: "admins", x: 1 }], timebox: { duration, onExpire: "escalateOwner" } })).not.toThrow();
}
});
});

View file

@ -0,0 +1,301 @@
import { createHash } from "node:crypto";
import { BadRequestException } from "@nestjs/common";
/**
* Chat governance policy primitives (chatgov-design.md §1a/§1d).
*
* These functions are PURE on purpose — no DB, no clock, no randomness. The
* compiled snapshot and its `policy_version` hash must be deterministic so
* that the same rules + quorum state always produce byte-identical output
* (P0 acceptance: "policy compile is pure + snapshot hash deterministic").
* Time-dependent values (opens_at/closes_at) never enter the hash input.
*/
export type GateMode = "immediate" | "quorum" | "admin_only";
/** Whose ballots count toward an arm. Eligibility is resolved at CLOSE time
* (TOCTOU guard, §1a) by the evaluator; the compile step only records the
* shape. */
export type ArmKind = "admins" | "participants";
/** Participants denominator: observed roster | explicit eligible roster | pure count. */
export type ParticipantsOf = "observed" | "eligible" | "none";
export interface QuorumArm {
kind: ArmKind;
/** Absolute count needed from this arm. */
x: number;
/** Participants arms only: how the denominator is derived. */
of?: ParticipantsOf;
}
export interface TimeboxSpec {
/** ISO-8601 duration (e.g. "PT2H"). Validated shape-only here. */
duration: string;
onExpire: "approve" | "reject" | "escalateOwner";
}
/** §1a pass-criteria rule object as stored in posting_rules.policy and
* polls.rule (compiled). */
export interface PolicyRule {
version: number;
mode: GateMode;
arms?: QuorumArm[];
/** all (AND, default) | any (OR). */
join?: "all" | "any";
allowVeto?: boolean;
/** Seconds; early close only when quorum ∧ dwell. */
minDwellSeconds?: number;
timebox?: TimeboxSpec;
/** named | anonymous — never mixed silently. */
anonymity?: "named" | "anonymous";
allowSelfApproval?: boolean;
/** Draft edit ⇒ approvals reset, poll re-arms. */
resetOnChange?: boolean;
/** Evaluate + log, never block/fire. */
dryRun?: boolean;
}
export const POLICY_SCHEMA_VERSION = 1;
/** Machine-readable reason codes (design §4). Kept here (not in the module
* file) so P1-P3 import them without dragging in Nest decorators. */
export const REASON_CODES = [
"INSUFFICIENT_QUORUM",
"INELIGIBLE_ACTOR",
"SELF_APPROVAL_DENIED",
"TIMEBOX_EXPIRED_AUTO_REJECT",
"TIMEBOX_EXPIRED_AUTO_APPROVE",
"RESET_ON_CHANGE",
"PENDING_POLL",
"VETOED",
"OUTSIDE_POLICY",
"POLICY_DRY_RUN",
"OVERRIDDEN_BY_OWNER",
"LOCKED_CHANNEL",
"NO_CHANNEL_BOUND",
"CONFIRM_EXPIRED",
] as const;
export type ReasonCode = (typeof REASON_CODES)[number];
export const GATES = ["E1", "E2", "E3"] as const;
export type Gate = (typeof GATES)[number];
/** Public (denormalised) chatgov permissions. Seeded into role_permissions by
* migration 024; mirrored here because packages/core's PERMISSIONS list is
* frozen for the WP2 slice and apps/web reads permissions from the API. */
export const CHATGOV_PERMISSIONS = {
/** Rules editor, topic registry, identity approval, policy compile. */
manage: "chatgov.manage",
/** Open/cast governance votes. */
vote: "chatgov.vote",
} as const;
const DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(\.\d+)?S)?)?$/;
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
export function isUuidList(value: unknown): value is string[] {
return Array.isArray(value) && value.every((v) => typeof v === "string" && UUID_RE.test(v));
}
/** Parse + validate a §1a-shaped policy object (unknown in, typed out). */
export function parsePolicyRule(input: unknown, field = "policy"): PolicyRule {
if (typeof input !== "object" || input === null || Array.isArray(input)) {
throw new BadRequestException(`${field} must be an object`);
}
const raw = input as Record<string, unknown>;
const version = raw.version === undefined ? POLICY_SCHEMA_VERSION : raw.version;
if (version !== POLICY_SCHEMA_VERSION) {
throw new BadRequestException(`${field}.version must be ${POLICY_SCHEMA_VERSION}`);
}
const mode = raw.mode;
if (mode !== "immediate" && mode !== "quorum" && mode !== "admin_only") {
throw new BadRequestException(`${field}.mode must be immediate, quorum or admin_only`);
}
const join = raw.join === undefined ? "all" : raw.join;
if (join !== "all" && join !== "any") throw new BadRequestException(`${field}.join must be all or any`);
const arms: QuorumArm[] = [];
if (raw.arms !== undefined) {
if (!Array.isArray(raw.arms)) throw new BadRequestException(`${field}.arms must be an array`);
if (raw.arms.length === 0) throw new BadRequestException(`${field}.arms must not be empty`);
if (raw.arms.length > 4) throw new BadRequestException(`${field}.arms supports at most 4 arms`);
for (const [index, entry] of raw.arms.entries()) {
if (typeof entry !== "object" || entry === null) throw new BadRequestException(`${field}.arms[${index}] must be an object`);
const arm = entry as Record<string, unknown>;
if (arm.kind !== "admins" && arm.kind !== "participants") {
throw new BadRequestException(`${field}.arms[${index}].kind must be admins or participants`);
}
const x = arm.x;
if (typeof x !== "number" || !Number.isInteger(x) || x < 1 || x > 1000) {
throw new BadRequestException(`${field}.arms[${index}].x must be an integer between 1 and 1000`);
}
if (arm.of !== undefined) {
if (arm.kind !== "participants") {
throw new BadRequestException(`${field}.arms[${index}].of applies to participants arms only`);
}
if (arm.of !== "observed" && arm.of !== "eligible" && arm.of !== "none") {
throw new BadRequestException(`${field}.arms[${index}].of must be observed, eligible or none`);
}
}
arms.push({ kind: arm.kind, x, ...(arm.of === undefined ? {} : { of: arm.of }) });
}
}
if (mode === "admin_only" && arms.some((arm) => arm.kind === "participants")) {
throw new BadRequestException(`${field}: admin_only mode has no participants arm`);
}
if (mode === "quorum" && arms.length === 0) {
throw new BadRequestException(`${field}: quorum mode requires arms`);
}
if (mode !== "quorum" && arms.length > 0) {
throw new BadRequestException(`${field}: ${mode} mode takes no arms`);
}
const timebox = raw.timebox;
let parsedTimebox: TimeboxSpec | undefined;
if (timebox !== undefined) {
if (typeof timebox !== "object" || timebox === null) throw new BadRequestException(`${field}.timebox must be an object`);
const spec = timebox as Record<string, unknown>;
if (typeof spec.duration !== "string" || !DURATION.test(spec.duration)) {
throw new BadRequestException(`${field}.timebox.duration must be an ISO-8601 duration`);
}
if (spec.onExpire !== "approve" && spec.onExpire !== "reject" && spec.onExpire !== "escalateOwner") {
throw new BadRequestException(`${field}.timebox.onExpire must be approve, reject or escalateOwner`);
}
parsedTimebox = { duration: spec.duration, onExpire: spec.onExpire };
}
if (parsedTimebox === undefined && mode !== "immediate") {
throw new BadRequestException(`${field}: ${mode} mode requires timebox`);
}
const anonymity = raw.anonymity === undefined ? "named" : raw.anonymity;
if (anonymity !== "named" && anonymity !== "anonymous") {
throw new BadRequestException(`${field}.anonymity must be named or anonymous`);
}
const allowVeto = raw.allowVeto === undefined ? false : requireBoolean(raw.allowVeto, `${field}.allowVeto`);
const allowSelfApproval = raw.allowSelfApproval === undefined ? false : requireBoolean(raw.allowSelfApproval, `${field}.allowSelfApproval`);
const resetOnChange = raw.resetOnChange === undefined ? true : requireBoolean(raw.resetOnChange, `${field}.resetOnChange`);
const dryRun = raw.dryRun === undefined ? false : requireBoolean(raw.dryRun, `${field}.dryRun`);
const minDwellSeconds = raw.minDwellSeconds === undefined ? 0 : raw.minDwellSeconds;
if (typeof minDwellSeconds !== "number" || !Number.isInteger(minDwellSeconds) || minDwellSeconds < 0 || minDwellSeconds > 86400) {
throw new BadRequestException(`${field}.minDwellSeconds must be an integer between 0 and 86400`);
}
return {
version: POLICY_SCHEMA_VERSION,
mode,
...(arms.length === 0 ? {} : { arms }),
join,
allowVeto,
minDwellSeconds,
...(parsedTimebox === undefined ? {} : { timebox: parsedTimebox }),
anonymity,
allowSelfApproval,
resetOnChange,
dryRun,
};
}
function requireBoolean(value: unknown, field: string): boolean {
if (typeof value !== "boolean") throw new BadRequestException(`${field} must be a boolean`);
return value;
}
/**
* Canonical JSON: keys sorted lexicographically, no insignificant whitespace,
* arrays keep order. This — not JSON.stringify of the source object — is the
* hash input, so an equivalent policy written with different key order or
* explicit defaults still compiles to the same version.
*/
export function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, v]) => v !== undefined)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`).join(",")}}`;
}
/** Deterministic snapshot hash: sha256 over the canonical JSON (hex, 16 chars
* — short enough for UI display, long enough to be collision-safe here). */
export function policyHash(value: unknown): string {
return createHash("sha256").update(canonicalJson(value)).digest("hex").slice(0, 16);
}
// ---------------------------------------------------------------------------
// Compile: rules + quorum state -> immutable snapshot.
// ---------------------------------------------------------------------------
export type RuleScope = "workspace" | "account" | "content_type" | "topic";
export interface CompiledPolicySnapshot {
rule: PolicyRule;
policy_version: string;
}
/** A live rule candidate offered to the compiler. */
export interface RuleCandidate {
scope: RuleScope;
rule: PolicyRule;
}
/** Quorum denominators AT COMPILE TIME. Recorded in the snapshot for
* observability only — never hashed — so a re-compile after roster drift
* yields the same policy_version unless the policy itself changed. */
export interface QuorumStateInput {
adminCount: number;
observedParticipants: number;
eligibleParticipants: number;
}
/** Precedence (D4): topic > content_type > account > workspace. */
const SCOPE_ORDER: readonly RuleScope[] = ["workspace", "account", "content_type", "topic"];
/**
* Resolve the winning rule among `candidates` (any order, duplicates allowed —
* most specific scope wins; ties resolve to the first candidate of that scope,
* which the caller keeps stable by ordering rules by created_at).
*/
export function resolvePrecedence(candidates: RuleCandidate[]): RuleCandidate {
let winner: RuleCandidate | null = null;
for (const candidate of candidates) {
if (winner === null || SCOPE_ORDER.indexOf(candidate.scope) > SCOPE_ORDER.indexOf(winner.scope)) {
winner = candidate;
}
}
if (winner !== null) return winner;
// Fail-closed baseline: an explicit `immediate` policy so every destination
// has a persisted snapshot (§1d fallback:block is an EVALUATION posture —
// OUTSIDE_POLICY at the gate — not a compile input).
return { scope: "workspace", rule: parsePolicyRule({ mode: "immediate", allowVeto: false }) };
}
export interface CompiledSnapshot {
rule: PolicyRule;
policy_version: string;
resolvedFrom: Array<{ scope: RuleScope }>;
quorum: QuorumStateInput;
}
/**
* Compile the effective policy for one destination. PURE + DETERMINISTIC:
* - candidates in any order produce the same winner and the same version;
* - the version hashes only the winning rule (quorum denominators are
* observability, not identity);
* - no clock, no DB, no randomness.
*/
export function compilePolicy(candidates: RuleCandidate[], quorum: QuorumStateInput): CompiledSnapshot {
const winner = resolvePrecedence(candidates);
const resolvedFrom = [...candidates]
.sort((a, b) => a.scope.localeCompare(b.scope))
.map((c) => ({ scope: c.scope }));
return {
rule: winner.rule,
policy_version: policyHash(winner.rule),
resolvedFrom,
quorum,
};
}

View file

@ -0,0 +1,71 @@
import type { PolicyRule, QuorumArm } from './policy.js';
export interface EvaluatedBallot {
ballot: 'yes' | 'no' | 'veto' | 'abstain';
optionId: string | null;
counted: boolean;
admin: boolean;
observed: boolean;
participant: boolean;
}
export interface OptionTally { id: string; seq: number; submittedAt: string; votes: number }
export interface ArmTally { kind: QuorumArm['kind']; of?: QuorumArm['of']; got: number; needed: number; possible: number | null }
export interface PollEvaluation {
arms: ArmTally[];
quorum: boolean;
winnerOptionId: string | null;
tallies: OptionTally[];
turnout: number;
state: 'passed' | 'failed' | 'vetoed' | 'quorum_unreachable' | null;
reason: 'quorum_early' | 'timebox' | 'veto' | 'quorum_unreachable' | null;
}
/** Pure decision; membership, self-approval and observed roster are resolved
* afresh by the service. A plurality never substitutes for legitimacy. */
export function evaluatePoll(input: {
kind: 'yes_no' | 'variant_selection'; rule: PolicyRule;
opensAt: Date; closesAt: Date; now: Date;
ballots: EvaluatedBallot[]; options: Omit<OptionTally, 'votes'>[];
possible: { admins: number; eligible: number; observed: number };
}): PollEvaluation {
const { rule, ballots } = input;
const selected = ballots.filter(b => b.counted && b.ballot === 'yes' && (input.kind === 'yes_no' || b.optionId !== null));
const tallies = input.options.map(o => ({ ...o, votes: selected.filter(b => b.optionId === o.id).length }));
const winner = [...tallies].sort((a, b) => b.votes - a.votes || Date.parse(a.submittedAt) - Date.parse(b.submittedAt) || a.seq - b.seq)[0];
const specs: QuorumArm[] = rule.mode === 'admin_only' ? [{ kind: 'admins', x: 1 }] : rule.arms ?? [];
const arms = specs.map(arm => {
const matches = (b: EvaluatedBallot): boolean => arm.kind === 'admins' ? b.admin : arm.of === 'observed' ? b.observed : b.participant;
return { kind: arm.kind, ...(arm.of === undefined ? {} : { of: arm.of }),
got: selected.filter(matches).length, needed: arm.x,
// Observed/count-only rosters can grow; never eagerly declare them impossible.
possible: arm.kind === 'admins' ? input.possible.admins : arm.of === 'eligible' ? input.possible.eligible : null };
});
const quorum = rule.mode === 'immediate' || (rule.join === 'any' ? arms.some(a => a.got >= a.needed) : arms.every(a => a.got >= a.needed));
const result: PollEvaluation = { arms, quorum, tallies, turnout: selected.length,
winnerOptionId: winner?.id ?? null, state: null, reason: null };
const choice = input.kind === 'yes_no' || winner !== undefined;
if (rule.allowVeto && ballots.some(b => b.counted && b.ballot === 'veto')) {
result.state = 'vetoed'; result.reason = 'veto';
} else if (input.now >= input.closesAt) {
result.state = quorum && choice && rule.timebox?.onExpire === 'approve' ? 'passed' : 'failed';
result.reason = 'timebox';
} else if (quorum && choice && input.now.getTime() - input.opensAt.getTime() >= (rule.minDwellSeconds ?? 0) * 1000) {
result.state = 'passed'; result.reason = 'quorum_early';
} else {
const impossible = (a: ArmTally): boolean => a.possible !== null && a.possible < a.needed;
if (arms.length && (rule.join === 'any' ? arms.every(impossible) : arms.some(impossible))) {
result.state = 'quorum_unreachable'; result.reason = 'quorum_unreachable';
}
}
return result;
}
/** Fixed-length ISO durations only; months/years are calendar-dependent. */
export function durationMs(duration: string): number {
const match = /^P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/.exec(duration);
if (!match) throw new Error('poll timebox requires a fixed duration (weeks/days/hours/minutes/seconds)');
const [, w, d, h, m, s] = match;
const ms = ((Number(w ?? 0) * 7 + Number(d ?? 0)) * 86400 + Number(h ?? 0) * 3600 + Number(m ?? 0) * 60 + Number(s ?? 0)) * 1000;
if (!Number.isFinite(ms) || ms <= 0 || ms > 365 * 86400_000) throw new Error('poll duration must be positive and at most 365 days');
return ms;
}

View file

@ -0,0 +1,30 @@
import { Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import { pool } from '../db/client.js';
import { PollService } from './poll.service.js';
/** Durable database timeboxes plus lazy read evaluation. A Temporal adapter
* can call the same exactly-once close without changing the state machine. */
@Injectable()
export class PollTimeboxService implements OnModuleInit, OnModuleDestroy {
private timer?: ReturnType<typeof setInterval>;
private running = false;
private readonly logger = new Logger(PollTimeboxService.name);
constructor(@Inject(PollService) private readonly polls: PollService) {}
onModuleInit(): void {
this.timer = setInterval(() => { void this.tick(); }, 1000);
this.timer.unref();
}
onModuleDestroy(): void { if (this.timer) clearInterval(this.timer); }
async tick(): Promise<void> {
if (this.running) return;
this.running = true;
try {
const due = await pool.query<{ workspace_id: string; id: string }>('SELECT * FROM chatgov_due_polls()');
for (const row of due.rows) {
try { await this.polls.close(row.workspace_id, row.id); }
catch (error) { this.logger.warn(`poll ${row.id} expiry will retry: ${String(error)}`); }
}
} catch (error) { this.logger.warn(`poll timebox discovery will retry: ${String(error)}`); }
finally { this.running = false; }
}
}

View file

@ -0,0 +1,37 @@
import { Body, Controller, Get, Inject, Param, Post, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { RbacGuard, RequirePermission, type AuthenticatedRequest } from '../rbac/rbac.guard.js';
import { requireSession } from '../http/validation.js';
import { PollService, type PollCard } from './poll.service.js';
@Controller('chatgov/polls')
@UseGuards(RbacGuard)
export class PollController {
constructor(@Inject(PollService) private readonly polls: PollService) {}
@Get(':id')
@RequirePermission('workspace.view')
async get(@Req() req: Request & AuthenticatedRequest, @Param('id') id: string): Promise<PollCard> {
const session = requireSession(req);
return this.polls.get(session.workspaceId, id, session.userId);
}
@Post()
@RequirePermission('chatgov.vote')
async open(@Req() req: Request & AuthenticatedRequest, @Body() body: unknown): Promise<PollCard> {
const session = requireSession(req);
return this.polls.open(session.workspaceId, body, { userId: session.userId });
}
@Post(':id/options')
@RequirePermission('chatgov.vote')
async fork(@Req() req: Request & AuthenticatedRequest, @Param('id') id: string, @Body() body: unknown): Promise<PollCard> {
const session = requireSession(req);
return this.polls.forkEdit(session.workspaceId, id, body, { userId: session.userId });
}
// Any authenticated member can store an ineligible ballot. Eligibility is
// enforced by the service; identity fields in the body are never trusted.
@Post(':id/vote')
@RequirePermission('workspace.view')
async vote(@Req() req: Request & AuthenticatedRequest, @Param('id') id: string, @Body() body: unknown): Promise<PollCard> {
const session = requireSession(req);
return this.polls.vote(session.workspaceId, id, body, { kind: 'user', userId: session.userId });
}
}

View file

@ -0,0 +1,380 @@
import { BadRequestException, ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import type { PoolClient } from 'pg';
import { withTenant } from '../db/client.js';
import { parseId, requireObject, requireString } from '../http/validation.js';
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 { parsePolicyRule, policyHash, type PolicyRule, type QuorumArm } from './policy.js';
import { durationMs, evaluatePoll, type EvaluatedBallot, type PollEvaluation } from './poll-evaluator.js';
export interface PollActor { userId: string; role?: string }
export interface PollVoter { kind: 'user' | 'chat_identity'; userId?: string; provider?: string; remoteId?: string; handle?: string }
interface PollRow {
id: string; workspace_id: string; draft_id: string | null; draft_revision: string | null;
channel_id: string | null; topic_binding_id: string | null; question: string;
kind: 'yes_no' | 'variant_selection'; state: string; rule: PolicyRule; policy_version: string;
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;
}
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;
ballot: EvaluatedBallot['ballot']; option_id: string | null; counted: boolean; eligible: boolean; ineligible_reason: string | null }
export interface PollCard {
poll: PollRow; options: OptionRow[]; evaluation: PollEvaluation;
votes: VoteRow[]; voterEligibility: { eligible: boolean; admin: boolean; reason: string | null } | null;
}
/** All writes use workspace → draft → poll lock order, matching publishing
* and membership epoch updates. The outbox claim and E2 transition share one
* transaction; rollback also releases the claim. Late ballots are no-ops. */
@Injectable()
export class PollService {
async open(workspaceId: string, body: unknown, actor: PollActor): Promise<PollCard> {
return withTenant(workspaceId, async client => {
await lockWorkspace(client, workspaceId);
const poll = await openPollInTransaction(client, workspaceId, body, actor);
return card(client, poll, actor.userId);
});
}
async forkEdit(workspaceId: string, pollId: string, body: unknown, actor: PollActor): Promise<PollCard> {
const dto = requireObject(body, 'body'); const text = variantText(dto.text);
return this.locked(workspaceId, pollId, async (client, poll) => {
await requireVoter(client, workspaceId, actor.userId);
await evaluateLocked(client, poll);
if (poll.state !== 'open' || poll.kind !== 'variant_selection') throw new ConflictException('only open variant polls accept forks');
const seq = Number((await client.query('SELECT COALESCE(max(seq), 0) + 1 AS seq FROM poll_options WHERE poll_id = $1', [poll.id])).rows[0]?.seq);
await insertOption(client, poll, seq, text, actor.userId);
await audit(client, poll, 'poll.option_submitted', actor.userId, { seq });
return card(client, poll, actor.userId);
});
}
async vote(workspaceId: string, pollId: string, body: unknown, voter: PollVoter): Promise<PollCard> {
const dto = requireObject(body, 'body');
const optionId = dto.optionId === undefined || dto.optionId === null ? null : parseId(requireString(dto.optionId, 'optionId'));
const ballot = dto.ballot ?? (optionId ? 'yes' : undefined);
if (!['yes', 'no', 'veto', 'abstain'].includes(String(ballot))) throw new BadRequestException('invalid ballot');
return this.locked(workspaceId, pollId, async (client, poll) => {
// Expiry is resolved before accepting a late ballot; retries after close
// render the frozen artifact without changing the decision.
if (new Date() >= poll.closes_at) await evaluateLocked(client, poll);
if (poll.state !== 'open') return card(client, poll, voter.userId);
if (poll.kind === 'variant_selection' && ballot === 'yes' && optionId === null) throw new BadRequestException('variant vote requires optionId');
if (optionId && (poll.kind !== 'variant_selection' || ballot !== 'yes')) throw new BadRequestException('optionId only applies to a variant choice');
if (optionId && !(await client.query('SELECT id FROM poll_options WHERE id = $1 AND poll_id = $2', [optionId, poll.id])).rowCount) throw new BadRequestException('option does not belong to this poll');
if (ballot === 'veto' && !poll.rule.allowVeto) throw new BadRequestException('veto is disabled');
let userId = voter.userId ?? null;
let key: string;
let identityId: string | null = null;
if (voter.kind === 'user') {
if (!userId) throw new BadRequestException('user voter needs userId');
parseId(userId, 'voter.userId'); key = `user:${userId}`;
} else {
const provider = requireString(voter.provider, 'provider');
const remote = requireString(voter.remoteId, 'remoteId');
if (!/^[0-9]{1,20}$/.test(remote) || !/^[a-z0-9_-]+$/.test(provider)) throw new BadRequestException('invalid immutable provider identity');
key = `${provider}:${remote}`;
// Never trust a supplied linked user id from the gateway.
const identity = (await client.query("SELECT id, user_id, status FROM chat_identities WHERE provider = $1 AND remote_user_id = $2", [provider, remote])).rows[0];
identityId = identity?.id as string | undefined ?? null;
userId = identity?.status === 'approved' ? identity.user_id as string | null : null;
}
// Linked identities and web users share ONE ballot. Alternate provider
// accounts cannot inflate turnout. Unlinked ballots are still retained.
if (userId) key = `user:${userId}`;
await client.query(`INSERT INTO poll_votes (workspace_id, poll_id, voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot, option_id, eligible, counted, voter_identity_id)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, false, false, $9)
ON CONFLICT (poll_id, voter_remote_id) DO UPDATE SET voter_kind = EXCLUDED.voter_kind,
voter_user_id = EXCLUDED.voter_user_id, voter_identity_id = EXCLUDED.voter_identity_id, voter_handle = EXCLUDED.voter_handle,
ballot = EXCLUDED.ballot, option_id = EXCLUDED.option_id, cast_at = clock_timestamp()`,
[workspaceId, poll.id, voter.kind, userId, key, voter.handle ?? key, ballot, optionId, identityId]);
await audit(client, poll, 'poll.voted', userId, { voter: key, ballot, optionId });
await evaluateLocked(client, poll);
return card(client, poll, userId ?? undefined);
});
}
async get(workspaceId: string, pollId: string, userId?: string): Promise<PollCard> {
return this.locked(workspaceId, pollId, async (client, poll) => {
await evaluateLocked(client, poll); return card(client, poll, userId);
});
}
async evaluate(workspaceId: string, pollId: string): Promise<PollCard> { return this.get(workspaceId, pollId); }
/** Deliberately re-evaluates the authoritative conditions; callers cannot
* force a pass or supply a stale tally. Suitable for competing timeboxes. */
async close(workspaceId: string, pollId: string): Promise<PollCard> { return this.get(workspaceId, pollId); }
async sweep(workspaceId: string): Promise<void> {
const ids = await withTenant(workspaceId, c => c.query<{ id: string }>("SELECT id FROM polls WHERE state = 'open' AND (closes_at <= now() OR opens_at + COALESCE((rule->>'minDwellSeconds')::integer,0) * interval '1 second' <= now())", []));
for (const { id } of ids.rows) await this.evaluate(workspaceId, id);
}
private async locked<T>(workspaceId: string, pollId: string, fn: (client: PoolClient, poll: PollRow) => Promise<T>): Promise<T> {
parseId(pollId, 'pollId');
return withTenant(workspaceId, async client => {
await lockWorkspace(client, workspaceId);
const found = (await client.query<PollRow>('SELECT * FROM polls WHERE id = $1', [pollId])).rows[0];
if (!found) throw new NotFoundException('poll not found');
if (found.draft_id) await client.query('SELECT id FROM drafts WHERE id = $1 FOR UPDATE', [found.draft_id]);
const poll = (await client.query<PollRow>('SELECT * FROM polls WHERE id = $1 FOR UPDATE', [pollId])).rows[0]!;
return fn(client, poll);
});
}
}
async function lockWorkspace(client: PoolClient, workspaceId: string): Promise<void> {
await client.query('SELECT id FROM workspaces WHERE id = $1 FOR UPDATE', [workspaceId]);
}
async function members(client: PoolClient, workspaceId: string): Promise<Member[]> {
return (await client.query<Member>(`SELECT m.user_id, m.role, EXISTS(SELECT 1 FROM role_permissions rp
WHERE rp.role = m.role AND rp.permission = 'chatgov.vote') AS can_vote
FROM memberships m WHERE workspace_id = $1 AND status = 'active'`, [workspaceId])).rows;
}
async function requireVoter(client: PoolClient, workspaceId: string, userId: string): Promise<void> {
parseId(userId, 'actor.userId');
if (!(await members(client, workspaceId)).some(m => m.user_id === userId && m.can_vote)) throw new ForbiddenException('chatgov.vote required');
}
function variantText(value: unknown): string {
const text = requireString(value, 'text');
if (text.length > 5000) throw new BadRequestException('variant text must be at most 5000 characters');
return text;
}
async function insertOption(client: PoolClient, poll: PollRow, seq: number, text: string, userId: string): Promise<void> {
await client.query('INSERT INTO poll_options (workspace_id, poll_id, seq, text, submitted_by) VALUES ($1,$2,$3,$4,$5)', [poll.workspace_id, poll.id, seq, text, userId]);
}
async function options(client: PoolClient, pollId: string): Promise<OptionRow[]> {
return (await client.query<OptionRow>(`SELECT o.*, u.display_name AS author FROM poll_options o
JOIN users u ON u.id = o.submitted_by WHERE poll_id = $1 ORDER BY seq`, [pollId])).rows;
}
async function audit(client: PoolClient, poll: PollRow, action: string, userId: string | null, diff: Record<string, unknown>): Promise<void> {
await client.query("SELECT audit_append($1,$2,$3,$4,'poll',$5,$6)", [poll.workspace_id, userId, userId ? 'Member' : 'System', action, poll.id,
scrub({ ...diff, policyVersion: poll.policy_version, actor: userId ? `user:${userId}` : `system:chat-poll-gate:${poll.id}` })]);
}
/** Snapshot all applicable arms. Different OR-policy groups cannot be
* flattened into a single §1a object without changing their meaning. */
export async function draftPollRule(client: PoolClient, draft: { post: DraftPost }, supplied?: unknown): Promise<PolicyRule | null> {
const accountIds = draftDestinationAccounts(draft.post);
const live = await client.query("SELECT 1 FROM posting_rules WHERE superseded_by IS NULL LIMIT 1");
const rules: PolicyRule[] = [];
if (accountIds.length && live.rowCount) {
for (const account of accountIds) {
const topic = typeof draft.post.providerMetadata.topicBindingId === 'string' ? draft.post.providerMetadata.topicBindingId : null;
const snapshot = (await client.query(`SELECT effective FROM posting_policy_snapshots WHERE account_id = $1
AND content_type = '' AND (topic_binding_id IS NULL OR topic_binding_id = $2)
ORDER BY topic_binding_id NULLS LAST LIMIT 1`, [account, topic])).rows[0];
if (!snapshot) throw new ConflictException('OUTSIDE_POLICY: destination snapshot missing');
const rule = parsePolicyRule(snapshot.effective);
if (rule.mode !== 'immediate' && !rule.dryRun) rules.push(rule);
}
}
let rule: PolicyRule | null = rules[0] ?? null;
if (rules.some(r => policyHash(r) !== policyHash(rule))) {
if (rules.some(r => r.join === 'any')) throw new ConflictException('incompatible OR policy groups require independent polls');
rule = parsePolicyRule({ ...rules[0], mode: 'quorum', join: 'all',
arms: rules.flatMap<QuorumArm>(r => r.mode === 'admin_only' ? [{ kind: 'admins', x: 1 }] : r.arms ?? []).filter((a, i, all) => all.findIndex(b => b.kind === a.kind && b.of === a.of && b.x === a.x) === i),
allowVeto: rules.some(r => r.allowVeto), allowSelfApproval: rules.every(r => r.allowSelfApproval),
resetOnChange: rules.some(r => r.resetOnChange), minDwellSeconds: Math.max(...rules.map(r => r.minDwellSeconds ?? 0)),
timebox: { duration: rules.reduce((a,b) => durationMs(a.timebox!.duration) >= durationMs(b.timebox!.duration) ? a : b).timebox!.duration, onExpire: 'reject' } });
}
if (supplied !== undefined) {
const requested = parsePolicyRule(supplied);
if (rule && policyHash(rule) !== policyHash(requested)) throw new BadRequestException('rule must match the compiled destination policy');
rule ??= requested;
}
return rule;
}
export async function openPollInTransaction(client: PoolClient, workspaceId: string, body: unknown, actor: PollActor): Promise<PollRow> {
const dto = requireObject(body, 'body');
await requireVoter(client, workspaceId, actor.userId);
if (dto.contentKind !== undefined && dto.contentKind !== 'short_post') throw new BadRequestException('document polls are a future track');
const draftId = dto.draftId == null ? null : parseId(requireString(dto.draftId, 'draftId'));
const draft = draftId ? (await client.query('SELECT * FROM drafts WHERE id = $1 FOR UPDATE', [draftId])).rows[0] : null;
if (draftId && !draft) throw new NotFoundException('draft not found');
if (draft && !['editing', 'in_review'].includes(String(draft.state))) throw new ConflictException('draft is not reviewable');
const rule = draft ? await draftPollRule(client, draft as { post: DraftPost }, dto.rule) : parsePolicyRule(dto.rule);
if (!rule) throw new BadRequestException('a rule or compiled chat-signal policy is required');
if (rule.anonymity !== 'named') throw new BadRequestException('anonymous sentiment belongs to the separate Telegram track; governance needs attributable ballots');
const kind = dto.kind ?? (draft ? 'variant_selection' : 'yes_no');
if (kind !== 'yes_no' && kind !== 'variant_selection') throw new BadRequestException('invalid poll kind');
if (kind === 'variant_selection' && !draft) throw new BadRequestException('variant selection requires a draft');
if (dto.variants !== undefined && !Array.isArray(dto.variants)) throw new BadRequestException('variants must be an array');
const variants = (dto.variants as unknown[] | undefined ?? []).map(v => variantText(typeof v === 'string' ? v : requireObject(v, 'variant').text));
if (kind === 'yes_no' && variants.length) throw new BadRequestException('yes/no polls do not accept variants');
if (variants.length > 99) throw new BadRequestException('at most 99 initial forks');
const eligibleIds = dto.eligibleUserIds === undefined ? null : (Array.isArray(dto.eligibleUserIds) ? dto.eligibleUserIds.map(v => parseId(requireString(v, 'eligibleUserId'))) : (() => { throw new BadRequestException('eligibleUserIds must be an array'); })());
const question = requireString(dto.question ?? 'Choose the post to approve', 'question');
if (question.length > 300) throw new BadRequestException('question too long');
let duration: number;
try { duration = durationMs(rule.timebox?.duration ?? 'PT1H'); } catch (error) { throw new BadRequestException(String(error)); }
const now = new Date();
const poll = (await client.query<PollRow>(`INSERT INTO polls
(workspace_id, draft_id, subject_kind, question, kind, rule, policy_version, opens_at, closes_at, created_by, draft_revision, parent_poll_id, channel_id, topic_binding_id, eligible_user_ids)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15) RETURNING *`,
[workspaceId, draftId, draftId ? 'draft' : 'none', question, kind, rule, policyHash(rule), now, new Date(now.getTime() + duration), actor.userId,
draft?.revision ?? null, dto.parentPollId == null ? null : parseId(requireString(dto.parentPollId, 'parentPollId')),
dto.channelId == null ? null : parseId(requireString(dto.channelId, 'channelId')),
dto.topicBindingId == null ? null : parseId(requireString(dto.topicBindingId, 'topicBindingId')), eligibleIds])).rows[0]!;
if (kind === 'variant_selection') {
await insertOption(client, poll, 1, variantText((draft!.post as DraftPost).text), String(draft!.author_user_id));
for (const [i, text] of variants.entries()) await insertOption(client, poll, i + 2, text, actor.userId);
}
await audit(client, poll, 'poll.opened', actor.userId, { draftId, kind, parentPollId: poll.parent_poll_id });
return poll;
}
async function resolveTally(client: PoolClient, poll: PollRow, persist: boolean): Promise<{ evaluation: PollEvaluation; votes: VoteRow[] }> {
const roster = await members(client, poll.workspace_id);
const draft = poll.draft_id ? (await client.query('SELECT author_user_id FROM drafts WHERE id = $1', [poll.draft_id])).rows[0] : null;
const opts = await options(client, poll.id);
const votes = (await client.query<VoteRow & { identity_status: string | null; identity_user_id: string | null; role_override: string | null; cast_at: Date; voter_identity_id: string | null }>(`SELECT v.*, ci.status AS identity_status, ci.user_id AS identity_user_id, ci.role_override
FROM poll_votes v LEFT JOIN chat_identities ci ON ci.id = v.voter_identity_id
WHERE poll_id = $1 ORDER BY cast_at DESC, voter_remote_id`, [poll.id])).rows;
// Ingress currently persists handles, not immutable remote ids. Only
// numeric author_handle evidence can bind to approved identities safely.
// A username must never become an authorization key.
const observed = new Set<string>();
if (poll.channel_id) {
const rows = await client.query<{ user_id: string }>(`SELECT DISTINCT ci.user_id FROM live_chat_messages lm
JOIN chat_identities ci ON ci.provider = lm.provider AND ci.remote_user_id = lm.author_handle
WHERE lm.channel_id = $1 AND lm.received_at >= $2 AND ci.status = 'approved' AND ci.user_id IS NOT NULL`, [poll.channel_id, poll.opens_at]);
for (const row of rows.rows) observed.add(row.user_id);
}
const seen = new Set<string>();
const ballots: EvaluatedBallot[] = [];
const eligibleMembers = roster.filter(m => m.can_vote);
const identities = (await client.query<{ user_id: string; role_override: string }>("SELECT user_id, role_override FROM chat_identities WHERE status = 'approved' AND role_override IN ('Admin','Owner')")).rows;
const adminIds = new Set(roster.filter(m => m.can_vote && ['Admin', 'Owner'].includes(m.role)).map(m => m.user_id));
for (const identity of identities) if (roster.some(m => m.user_id === identity.user_id)) adminIds.add(identity.user_id);
for (const vote of votes) {
const identityOkay = !vote.voter_identity_id || (vote.identity_status === 'approved' && vote.identity_user_id === vote.voter_user_id);
const member = roster.find(m => m.user_id === vote.voter_user_id);
const eligible = identityOkay && member !== undefined && (member.can_vote || ['Admin','Owner'].includes(vote.role_override ?? ''));
const selfDenied = !poll.rule.allowSelfApproval && vote.ballot === 'yes' && (poll.kind === 'variant_selection'
? opts.some(o => o.id === vote.option_id && o.submitted_by === vote.voter_user_id)
: draft?.author_user_id === vote.voter_user_id);
const duplicate = vote.voter_user_id !== null && seen.has(vote.voter_user_id);
if (eligible && vote.voter_user_id) seen.add(vote.voter_user_id);
vote.eligible = eligible;
vote.counted = eligible && !selfDenied && !duplicate;
vote.ineligible_reason = !eligible || duplicate ? 'INELIGIBLE_ACTOR' : selfDenied ? 'SELF_APPROVAL_DENIED' : null;
if (persist) await client.query(`UPDATE poll_votes SET eligible = $3, counted = $4, ineligible_reason = $5
WHERE poll_id = $1 AND voter_remote_id = $2`, [poll.id, vote.voter_remote_id, vote.eligible, vote.counted, vote.ineligible_reason]);
ballots.push({ ballot: vote.ballot, optionId: vote.option_id, counted: vote.counted,
admin: adminIds.has(vote.voter_user_id ?? ''), observed: observed.has(vote.voter_user_id ?? ''),
participant: poll.eligible_user_ids === null || poll.eligible_user_ids.includes(vote.voter_user_id ?? '') });
}
const eligible = eligibleMembers.filter(m => poll.eligible_user_ids === null || poll.eligible_user_ids.includes(m.user_id));
// Potential self-voters must have at least one option authored by someone
// else. A no/self ballot can be recast, so it is never subtracted twice.
const canChoose = (id: string): boolean => poll.rule.allowSelfApproval === true || (poll.kind === 'variant_selection'
? opts.some(o => o.submitted_by !== id) : draft?.author_user_id !== id);
const evaluation = evaluatePoll({ kind: poll.kind, rule: poll.rule, opensAt: poll.opens_at, closesAt: poll.closes_at, now: new Date(), ballots,
options: opts.map(o => ({ id: o.id, seq: o.seq, submittedAt: o.submitted_at.toISOString() })),
possible: { admins: [...adminIds].filter(canChoose).length, eligible: eligible.filter(m => canChoose(m.user_id)).length, observed: observed.size } });
// Keep the public DTO separate from identity resolution internals.
return { evaluation, votes: votes.map(v => ({ voter_kind: v.voter_kind, voter_user_id: v.voter_user_id,
voter_remote_id: v.voter_remote_id, voter_handle: v.voter_handle, ballot: v.ballot, option_id: v.option_id,
counted: v.counted, eligible: v.eligible, ineligible_reason: v.ineligible_reason })) };
}
async function evaluateLocked(client: PoolClient, poll: PollRow): Promise<void> {
if (poll.state !== 'open') return;
if (poll.draft_id && poll.draft_revision !== null) {
const draft = (await client.query('SELECT revision FROM drafts WHERE id = $1', [poll.draft_id])).rows[0];
if (String(draft?.revision) !== String(poll.draft_revision)) {
await stalePoll(client, poll); return;
}
}
const { evaluation } = await resolveTally(client, poll, true);
await audit(client, poll, 'policy.evaluated', null, { evaluation });
// Shadow polls retain their artifact and terminal outcome but cannot
// approve/reject drafts. Their E2 decision is pending, never a release.
if (!evaluation.state) return;
const claimed = await client.query(`UPDATE polls SET outbox_state = 'claimed', state = $2, closed_at = clock_timestamp(),
close_reason = $3, winner_option_id = $4, final_evaluation = $5
WHERE id = $1 AND state = 'open' AND outbox_state = 'none' RETURNING *`,
[poll.id, evaluation.state, evaluation.reason,
evaluation.state === 'passed' && poll.kind === 'variant_selection' ? evaluation.winnerOptionId : null, evaluation]);
if (!claimed.rowCount) return;
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) {
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 }),
reason: reasons[0] ?? 'quorum_early', escalate: evaluation.reason === 'timebox' && poll.rule.timebox?.onExpire === 'escalateOwner' });
}
await recordE2(client, poll, evaluation, poll.rule.dryRun ? ['POLICY_DRY_RUN'] : reasons);
await audit(client, poll, 'poll.closed', null, { state: poll.state, reason: poll.close_reason, winnerOptionId: poll.winner_option_id, evaluation });
await audit(client, poll, evaluation.state === 'passed' && !poll.rule.dryRun ? 'release.fired' : 'release.blocked', null, { reasons });
await client.query("UPDATE polls SET outbox_state = 'done' WHERE id = $1 AND outbox_state = 'claimed'", [poll.id]);
poll.outbox_state = 'done';
}
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 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]);
}
}
async function card(client: PoolClient, poll: PollRow, userId?: string): Promise<PollCard> {
const final = (poll as PollRow & { final_evaluation?: PollEvaluation }).final_evaluation;
const tally = final && poll.state !== 'open'
? { evaluation: final, votes: (await client.query<VoteRow>('SELECT voter_kind, voter_user_id, voter_remote_id, voter_handle, ballot, option_id, counted, eligible, ineligible_reason FROM poll_votes WHERE poll_id = $1 ORDER BY cast_at', [poll.id])).rows }
: await resolveTally(client, poll, false);
const member = userId ? (await members(client, poll.workspace_id)).find(m => m.user_id === userId) : undefined;
return { poll, options: await options(client, poll.id), evaluation: tally.evaluation, votes: tally.votes,
voterEligibility: userId ? { eligible: member?.can_vote ?? false, admin: member !== undefined && ['Admin','Owner'].includes(member.role), reason: member?.can_vote ? null : 'INELIGIBLE_ACTOR' } : null };
}
async function stalePoll(client: PoolClient, poll: PollRow): Promise<void> {
const { evaluation } = await resolveTally(client, poll, true);
const changed = await client.query(`UPDATE polls SET state = 'stale', closed_at = clock_timestamp(), close_reason = 'reset_on_change',
outbox_state = 'done', final_evaluation = $2 WHERE id = $1 AND state = 'open' AND outbox_state = 'none' RETURNING *`, [poll.id, evaluation]);
if (!changed.rowCount) return;
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;
if (poll.rule.resetOnChange) {
await openPollInTransaction(client, poll.workspace_id, {
draftId: poll.draft_id, kind: poll.kind, rule: poll.rule, question: poll.question,
parentPollId: poll.id, channelId: poll.channel_id, topicBindingId: poll.topic_binding_id,
...(poll.eligible_user_ids ? { eligibleUserIds: poll.eligible_user_ids } : {}),
}, { userId: await rearmActor(client, poll) });
} else {
// Stale content can never be auto-approved, even with an approve expiry
// policy. escalateOwner keeps it in review; all other stale gates reject.
await transitionPollDraft(client, { workspaceId: poll.workspace_id, draftId: poll.draft_id, pollId: poll.id,
passed: false, reason: 'RESET_ON_CHANGE', escalate: poll.rule.timebox?.onExpire === 'escalateOwner' });
}
}
async function rearmActor(client: PoolClient, poll: PollRow): Promise<string> {
const roster = await members(client, poll.workspace_id);
const actor = roster.find(m => m.user_id === poll.created_by && m.can_vote) ?? roster.find(m => m.can_vote && ['Owner','Admin'].includes(m.role));
if (!actor) throw new ConflictException('cannot re-arm without an eligible opener');
return actor.user_id;
}
/** Called under publishing's workspace/draft lock, after its revision trigger.
* The old options and ballots stay attached to the stale artifact forever. */
export async function markDraftPollsStale(client: PoolClient, workspaceId: string, draftId: string): Promise<void> {
const polls = (await client.query<PollRow>("SELECT * FROM polls WHERE workspace_id = $1 AND draft_id = $2 AND state = 'open' FOR UPDATE", [workspaceId, draftId])).rows;
for (const poll of polls) await evaluateLocked(client, poll);
}
/** Auto-arm on submit, using ONLY compiled destination snapshots. */
export async function autoOpenDraftPoll(client: PoolClient, workspaceId: string, draftId: string, actor: PollActor, post: DraftPost): Promise<void> {
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;
await openPollInTransaction(client, workspaceId, { draftId, kind: 'variant_selection', rule }, actor);
}