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; 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; 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; 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) .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, }; }