/** * Pack conformance — runtime invariants for `PackV0`. * * `Pack.basisCodes` declares the refusal-code taxonomy a Pack's policy may * emit. The compile-time `satisfies PackV0<...>` clause catches structural * drift; this module catches drift that the type system cannot see. * * # Three conformance surfaces, three responsibilities * * The framework offers three Pack-conformance surfaces. They are * deliberately separate; understanding the split matters when wiring * a Pack into a new application. * * | Surface | When it runs | What it does | Blocking? | * |---|---|---|---| * | `assertPackConformance(pack, opts)` | **boot-time, sync** | Structural: required fields present, intents/basisCodes non-empty + unique, default polarity opt-in. | **Yes** — throws `PackConformanceError`. | * | `withBasisAudit(pack)` wrapper | **runtime, every decision** | Telemetry: REFUSE/basis/REWRITE-taint/DEFER-signal drift recorded as `recordSinkFailure(…)`. A taint-elevating REWRITE additionally fails closed to a SECURITY REFUSE (011/T5). | **Only** the taint-elevating REWRITE — all other drift passes through. | * | `runConformance(pack, opts)` from `@adjudicate/conformance` | **CI / boot-time, sync** | Property: probes many synthetic envelopes against the policy and asserts replay-determinism, taint protection, basis-vocabulary purity, guard ordering. | **Returns a report; caller decides.** | * * The first two live in this module. The third is in `@adjudicate/conformance` * because it depends on a deterministic PRNG and a check-set abstraction * that does not belong on the kernel hot path. * * # Pick the right surface * * - **Adopting a Pack in production?** Call `assertPackConformance(pack)` * at startup, wrap with `withBasisAudit(pack)` before passing to the * kernel, and run `runConformance(pack)` in CI. * - **Authoring a Pack?** All three surfaces target your Pack; the unit * tests in your Pack's repo should cover `runConformance` against * representative state fixtures. * - **Adding a kernel-emitted refusal code?** Add it to * `KERNEL_REFUSAL_CODES` below; `withBasisAudit` and `runConformance` * pick it up automatically. * * Kernel-vocabulary refusals (`schema_*`, `taint_*`, `default_deny`, * `kill_switch_active`, `kernel_deadline_exceeded`, * `ledger_replay_suppressed`, `guard_panic`) are exempt from drift — * those codes belong to the framework and live outside the Pack's * taxonomy. */ import { basis, BASIS_CODES, isKnownBasisCode } from "./basis-codes.js"; import { decisionRefuse, type Decision } from "./decision.js"; import type { IntentEnvelope, RecordedAuthoritySnapshot, } from "./envelope.js"; import { recordSinkFailure } from "./kernel/metrics.js"; import type { Guard, PolicyBundle } from "./kernel/policy.js"; import type { PackV0 } from "./pack.js"; import { refuse } from "./refusal.js"; import { taintRank } from "./taint.js"; /** * PerformanceReviewer-008: idempotency tag for `wrapGuard`. A guard the * wrapper has already decorated carries this symbol so a second * `withBasisAudit` pass (or re-`installPack` on shared module state) is a * no-op instead of double-nesting wrappers and running `auditDecision` * twice per decision. `Symbol.for` uses the global registry so the tag * survives module reloads in test environments that hot-swap modules. * * Internal only — never exported, never a hashed byte, never on a value * that flows into `intentHash`/`auditHash`. */ const BASIS_AUDIT_WRAPPED = Symbol.for("@adjudicate/core/basis-audit-wrapped"); /** * 033 — idempotency/carry tag for `recordAuthoritySnapshotOnPack`. Mirrors * `BASIS_AUDIT_WRAPPED`'s discipline: the RECORDED authority snapshot the kernel * decision was injected with is STAMPED onto the wrapped pack object under this * symbol (non-enumerable, never a hashed byte, never on a value that flows into * `intentHash`/`auditHash`). The impure audit shell reads it back via * `readRecordedAuthoritySnapshot(pack)` and records it onto each `AuditRecord` * so the decision replays bit-identically (§D-5, invariant #5). * * `Symbol.for` (global registry) so the tag survives module reloads, exactly * like the audit-wrap tag. Re-stamping is idempotent/non-blocking — it NEVER * mutates a guard, the policy, or any Decision (the recording is observe-only, * same posture as `withBasisAudit`). */ const RECORDED_AUTHORITY_SNAPSHOT = Symbol.for( "@adjudicate/core/recorded-authority-snapshot", ); export class PackConformanceError extends Error { constructor( public readonly packId: string, public readonly violations: ReadonlyArray, ) { super( `Pack "${packId}" failed conformance: ${violations.join("; ")}`, ); this.name = "PackConformanceError"; } } /** * Refusal codes the kernel itself may emit (not domain-specific). These * codes pass through `withBasisAudit` without contributing to drift — * they're the framework's vocabulary, not the Pack's. * * Stays in sync with the `BASIS_CODES.{kill,deadline,kernel,ledger,…}` * categories: every refusal code the kernel produces at adjudication * time (or that `adjudicateAndAudit` overlays on a kernel decision) * appears here. Adding a kernel-emitted code without adding it to this * set is a forensic regression — Packs would incorrectly see drift on * their own audit dashboards. */ export const KERNEL_REFUSAL_CODES: ReadonlySet = new Set([ "schema_version_unsupported", "taint_level_insufficient", "default_deny", "kill_switch_active", "kernel_deadline_exceeded", "ledger_replay_suppressed", // T-002: `_adjudicateImpl` converts a thrown guard into a SECURITY REFUSE // with this refusal code. Documented in ADR-106 (guard-exception isolation). // Adding this here lets `withBasisAudit` and `runConformance` treat the // kernel-internal panic refusal the same way as the other kernel codes. "guard_panic", ]); export interface AssertPackConformanceOptions { /** * When false (default), `policy.default = "EXECUTE"` throws * `PackConformanceError`. Adopters with read-only Packs explicitly opt * in. T4 (#20): the framework should refuse a fail-open default by * default — silent EXECUTE on no-guard-matched is the most direct * authority leak. */ readonly allowDefaultExecute?: boolean; } /** * Boot-time conformance check. Throws `PackConformanceError` if the Pack * violates any contract invariant. Adopters typically call this once at * startup (or `installPack` does it on their behalf). */ export function assertPackConformance< K extends string, P, S, C, >( pack: PackV0, options: AssertPackConformanceOptions = {}, ): void { const violations: string[] = []; if (typeof pack.id !== "string" || pack.id.length === 0) { violations.push("id must be a non-empty string"); } if (typeof pack.version !== "string" || pack.version.length === 0) { violations.push("version must be a non-empty string"); } if (pack.contract !== "v0") { violations.push(`contract must be "v0" (got ${String(pack.contract)})`); } if (!Array.isArray(pack.intents) || pack.intents.length === 0) { violations.push("intents must be a non-empty array"); } else { const seen = new Set(); for (const k of pack.intents) { if (seen.has(k)) { violations.push(`duplicate intent kind "${k}"`); } seen.add(k); } } if (!Array.isArray(pack.basisCodes) || pack.basisCodes.length === 0) { violations.push("basisCodes must be a non-empty array"); } else { const seen = new Set(); const collisions: string[] = []; for (const c of pack.basisCodes) { if (typeof c !== "string" || c.length === 0) { violations.push("basisCodes entries must be non-empty strings"); break; } if (seen.has(c)) { violations.push(`duplicate basis code "${c}"`); } seen.add(c); // SecurityReviewer-014 (refusal-taxonomy-stable invariant): a Pack // must not claim a code that the kernel itself emits. Collision lets a // Pack guard return a kernel-vocabulary refusal with a user-controlled // message and basis detail, breaking the refusal-taxonomy guarantee and // confusing `withBasisAudit` drift detection. if (KERNEL_REFUSAL_CODES.has(c)) { collisions.push(c); } } if (collisions.length > 0) { violations.push( `basisCodes collide with kernel-reserved codes: ${collisions.join(", ")} — remove these codes from the Pack's basisCodes (kernel-emitted refusals are the framework's vocabulary)`, ); } } if (pack.policy === undefined || pack.policy === null) { violations.push("policy is required"); } if (pack.planner === undefined || pack.planner === null) { violations.push("planner is required"); } // T4 #20: refuse default = EXECUTE unless opted in. The framework's // recommended polarity is REFUSE; an EXECUTE default is the most // direct authority leak and should be a deliberate, documented choice. if ( pack.policy && pack.policy.default === "EXECUTE" && options.allowDefaultExecute !== true ) { violations.push( "policy.default = \"EXECUTE\" requires explicit { allowDefaultExecute: true } opt-in", ); } // T4 #38 (partial): if signals are declared, validate their shape. if (pack.signals !== undefined) { if (!Array.isArray(pack.signals)) { violations.push("signals must be an array when present"); } else { const seen = new Set(); for (const s of pack.signals) { if (typeof s !== "string" || s.length === 0) { violations.push("signals entries must be non-empty strings"); break; } if (seen.has(s)) { violations.push(`duplicate signal "${s}"`); } seen.add(s); } } } if (violations.length > 0) { throw new PackConformanceError(pack.id ?? "", violations); } } /** * Wrap every guard in the Pack's PolicyBundle so the wrapper observes * drift events without altering Decisions: * * - REFUSE with `refusal.code` outside `pack.basisCodes ∪ KERNEL` * records `basis_code_drift`. * - Any decision with a basis category:code outside `BASIS_CODES` * records `basis_vocabulary_drift`. * - REWRITE with `rewritten.taint` of higher rank than `envelope.taint` * records `rewrite_taint_regression` AND fails closed to a SECURITY REFUSE * (011/T5) — a friction-decreasing rewrite is blocked, not merely observed. * - DEFER with a `signal` outside `pack.signals` (when declared) * records `defer_signal_drift`. * * All drift surfaces EXCEPT the taint-elevating REWRITE are observe-only: those * Decisions pass through unchanged. The taint-elevating REWRITE is the one * monotonicity-violating case the wrapper now blocks (§C / invariant #7). */ export function withBasisAudit< K extends string, P, S, C, >(pack: PackV0): PackV0 { const declaredCodes = new Set(pack.basisCodes); const declaredSignals = pack.signals ? new Set(pack.signals) : null; return { ...pack, policy: wrapBundle(pack.policy, declaredCodes, declaredSignals, pack.id), }; } /** * 033 — RECORD the injected authority snapshot onto a pack, reusing the * `withBasisAudit`/`wrapBundle` discipline: produce a NEW pack object (don't * mutate the input), STAMP the recorded snapshot under a symbol, and stay * NON-BLOCKING (no guard, policy, or Decision is altered — recording is * observe-only telemetry, like the audit wrap). The impure audit shell reads it * back with `readRecordedAuthoritySnapshot` and records it onto each * `AuditRecord` so the decision replays bit-identically (§D-5, invariant #5). * * Idempotent: re-stamping with an EQUAL snapshot (same `snapshotHash`) is a * no-op carry; re-stamping with a DIFFERENT snapshot replaces the tag (the * latest injected snapshot is the one recorded). The tag is non-enumerable, so * it never serializes into a record or perturbs a hash. NO authority guard is * wired here (that is 034) — `pack.policy.authGuards` is untouched. */ export function recordAuthoritySnapshotOnPack< K extends string, P, S, C, >( pack: PackV0, snapshot: RecordedAuthoritySnapshot, ): PackV0 { const existing = readRecordedAuthoritySnapshot(pack); if (existing !== undefined && existing.snapshotHash === snapshot.snapshotHash) { // Idempotent carry: the same snapshot is already recorded on this pack. return pack; } const recorded = { ...pack } as PackV0; Object.defineProperty(recorded, RECORDED_AUTHORITY_SNAPSHOT, { value: snapshot, enumerable: false, configurable: true, writable: false, }); return recorded; } /** * 033 — read the RECORDED authority snapshot stamped on a pack by * `recordAuthoritySnapshotOnPack`, or `undefined` when none was injected. * `undefined` is a permanent valid state — packs installed without an * `authoritySnapshot` carry no tag, so their audit records omit the field and * hash byte-identically to their pre-033 value. */ export function readRecordedAuthoritySnapshot( pack: object, ): RecordedAuthoritySnapshot | undefined { return (pack as Record)[RECORDED_AUTHORITY_SNAPSHOT] as | RecordedAuthoritySnapshot | undefined; } function wrapBundle( bundle: PolicyBundle, declaredCodes: ReadonlySet, declaredSignals: ReadonlySet | null, packId: string, ): PolicyBundle { const wrap = ( guards: ReadonlyArray>, ): ReadonlyArray> => guards.map((g) => wrapGuard(g, declaredCodes, declaredSignals, packId)); return { stateGuards: wrap(bundle.stateGuards), authGuards: wrap(bundle.authGuards), taint: bundle.taint, business: wrap(bundle.business), default: bundle.default, }; } function wrapGuard( guard: Guard, declaredCodes: ReadonlySet, declaredSignals: ReadonlySet | null, packId: string, ): Guard { // Idempotency: if this guard is already wrapped, return it unchanged. if ((guard as unknown as Record)[BASIS_AUDIT_WRAPPED] === true) { return guard; } const wrapped = (envelope: IntentEnvelope, state: S): Decision | null => { const decision: Decision | null = guard(envelope, state); if (decision !== null) { const substitute = auditDecision( decision, envelope, declaredCodes, declaredSignals, packId, ); // T5 (011): a taint-elevating REWRITE is no longer telemetry-only — the // wrapper substitutes a fail-closed REFUSE so the friction-decreasing // rewrite never reaches the kernel/executor (§C monotonicity, invariant #7). if (substitute !== null) return substitute; } return decision; }; // Tag the wrapper so a second withBasisAudit pass is a no-op. (wrapped as unknown as Record)[BASIS_AUDIT_WRAPPED] = true; return wrapped; } function auditDecision( decision: Decision, envelope: IntentEnvelope, declaredCodes: ReadonlySet, declaredSignals: ReadonlySet | null, packId: string, ): Decision | null { // ── 1. Refusal-code drift (existing behaviour). ─────────────────── if (decision.kind === "REFUSE") { const code = decision.refusal.code; if (!declaredCodes.has(code) && !KERNEL_REFUSAL_CODES.has(code)) { recordSinkFailure({ sink: "console", subject: `pack:${packId}:${code}`, errorClass: "basis_code_drift", consecutiveFailures: 1, }); } } // ── 2. T4: basis-vocabulary drift across all decision kinds. ────── for (const b of decision.basis) { if (!isKnownBasisCode(b)) { recordSinkFailure({ sink: "console", subject: `pack:${packId}:${b.category}:${String(b.code)}`, errorClass: "basis_vocabulary_drift", consecutiveFailures: 1, }); } } // ── 3. REWRITE taint regression — FAIL CLOSED (011/T5). ────────── // A REWRITE whose `rewritten.taint` outranks the original `envelope.taint` // is a friction-DECREASING substitution: it would launder UNTRUSTED-origin // bytes into a higher trust band. §C monotonicity forbids any non-deterministic // component lowering friction, so this is no longer telemetry-only — the // wrapper records the regression AND returns a SECURITY REFUSE that the kernel // sees instead of the rewrite (invariant #7). The kernel's own `gateRewrite` // is the second, belt-and-suspenders layer for packs not wrapped here. if (decision.kind === "REWRITE") { if (taintRank(decision.rewritten.taint) > taintRank(envelope.taint)) { recordSinkFailure({ sink: "console", subject: `pack:${packId}:rewrite:${envelope.taint}->${decision.rewritten.taint}`, errorClass: "rewrite_taint_regression", consecutiveFailures: 1, }); return decisionRefuse( refuse( "SECURITY", "taint_level_insufficient", "I can't perform this action with the information available.", `Pack ${packId} REWRITE would elevate taint ${envelope.taint} -> ${decision.rewritten.taint} (blocked)`, ), [ ...decision.basis, basis("taint", BASIS_CODES.taint.PROPAGATION_VIOLATION, { original: envelope.taint, rewritten: decision.rewritten.taint, packId, }), ], ); } } // ── 4. T4: DEFER signal vocabulary drift. ──────────────────────── if (decision.kind === "DEFER" && declaredSignals !== null) { if (!declaredSignals.has(decision.signal)) { recordSinkFailure({ sink: "console", subject: `pack:${packId}:defer:${decision.signal}`, errorClass: "defer_signal_drift", consecutiveFailures: 1, }); } } return null; }