P1.4.2 — Denial Reason Taxonomy — Execution Packet (Step 3 / 5)

The packet locks the implementation plan before any code is written. Step 4 follows this plan exactly. PM gates implementation on packet approval (CLAUDE.md §6).

§1. Inputs

  • Audit: docs/audits/p1-4-2-denial-reasons-audit.md (Step 1, this PR)
  • Contract: docs/contracts/p1-4-2-denial-reasons-contract.md (Step 2, this PR)
  • Spec: docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.4.2
  • Existing surface: src/domains/rules/admission.ts (P1.4.1)
  • Reused machinery: src/domains/rules/canonical.ts (P1.5.4), src/domains/rules/determinism.ts (P1.1.2)

§2. Files to create / edit

File Verb Purpose
src/domains/rules/denial-reasons.ts CREATE Canonical DenialReason taxonomy + render + canonical-JSON round-trip.
src/__tests__/domains/rules/denial-reasons.test.ts CREATE Test matrix T1..T10 from contract §10. ≥ 60 tests.
src/domains/rules/admission.ts EDIT Replace local 4-discriminant DenialReason with import type + export type from denial-reasons.ts. No emission-site change.

No other file touches.

§3. Implementation outline — denial-reasons.ts

Module structure (sections in this order):

§1. Public types — DenialReason union + per-variant interfaces
§2. Discriminant constants — KIND_ALL, AXIOM_IDS, POLICY_DISCRIMINANTS, BUDGET_AXES
§3. isDenialReason — runtime predicate (used by parser)
§4. renderDenialReason — operator-readable string
§5. serializeDenialReason — canonical-JSON via canonicalize()
§6. parseDenialReason + DenialReasonParseError

§3.1. Types (§2 of contract — verbatim)

Export the discriminated union, the per-variant interfaces, and the helper type aliases (BudgetAxis, AxiomId, PolicyDiscriminant). Every field readonly.

§3.2. Discriminant constants

export const KIND_ALL = Object.freeze([
  'no_rule_matched',
  'budget',
  'effect_invariant_violated',
  'axiom_violation',
  'policy',
  'rule_version_mismatch',
  'ambiguous_ruleset',
  'rule_rejected',
] as const);

export const BUDGET_AXES: readonly BudgetAxis[] = Object.freeze([
  'integer_ops',
  'call_depth',
  'arg_count',
]);

export const AXIOM_IDS: readonly AxiomId[] = Object.freeze([
  'AX-01', 'AX-02', 'AX-03', 'AX-04', 'AX-05', 'AX-06', 'AX-07',
]);

export const POLICY_DISCRIMINANTS: readonly PolicyDiscriminant[] = Object.freeze([
  'P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'P8', 'P9', 'P10', 'P11', 'P12', 'P13',
]);

export const POLICY_SENTINELS = Object.freeze([
  'POLICY_TYPE_MISMATCH',
  'POLICY_EVAL_ERROR',
] as const);

These constants are exported to support test parameterization (T10) AND as the canonical lists for any future runtime branching.

§3.3. isDenialReason(value): value is DenialReason

Runtime predicate. Total — never throws. Returns true iff value is a plain object with kind matching one of KIND_ALL and the per-variant required fields all present with the correct primitive types and discriminant-set membership.

The predicate is the parser’s validation core. Implementation strategy:

  1. Reject non-object / null / array.
  2. Read value.kind. If not string or not in KIND_ALL, return false.
  3. Branch on kind and validate each required field:
    • String fields use typeof === 'string'.
    • Numeric fields use typeof === 'number' && Number.isFinite(n) (no NaN, no Infinity, no -0-canon-issue — Number.isFinite covers).
    • null fields use === null.
    • Discriminant-set membership: BUDGET_AXES.indexOf(...) !== -1, AXIOM_IDS.indexOf(...) !== -1, POLICY_DISCRIMINANTS.indexOf(...) !== -1 || POLICY_SENTINELS.indexOf(...) !== -1.
  4. Optional transition_type on no_rule_matched is validated only when present ('transition_type' in obj); otherwise skipped.

§3.4. renderDenialReason(r): string

Pure dispatch on r.kind. Output strings exactly match contract §3 templates. Implementation uses a switch (r.kind) with case per kind and a default: const _exh: never = r; throw new Error(...) exhaustive fallthrough.

String(n) for numeric fields. Direct concatenation; no template-literal arithmetic. null for transition_type in ambiguous_ruleset emits the literal <none>.

For no_rule_matched, the optional-field branch checks r.transition_type === undefined to decide which template applies.

§3.5. serializeDenialReason(r): string

Strategy: build a fresh plain object that contains exactly the fields defined for r.kind, then call canonicalize(obj) from ./canonical.js.

The canonicalize function already:

  • Sorts keys by code-unit order at every level (canonical.ts §3 + §6).
  • Drops no fields (so we drop undefined-valued ones BEFORE passing in).
  • Emits no whitespace.
  • Throws CanonicalSerializationError on un-representable values (undefined, function, symbol, non-integer number).

We omit transition_type from the input object when it is undefined (so it’s absent in the JSON, not serialized as null). All other variants have all required fields present.

§3.6. parseDenialReason(json): DenialReason

Strategy:

let raw: unknown;
try {
  raw = JSON.parse(json);
} catch (e) {
  throw new DenialReasonParseError(
    'invalid_json: ' + (e instanceof Error ? e.message : String(e)),
  );
}
if (!isDenialReason(raw)) {
  throw new DenialReasonParseError('invalid_shape');
}
return projectToTaxon(raw);  // shallow rebuild dropping any extra keys

projectToTaxon rebuilds the variant by kind with only the contract fields, so unknown extra keys in the JSON (forward-compat from a future producer) are dropped at the parser boundary.

DenialReasonParseError is its own class extending Error; name set to 'DenialReasonParseError'. No data leak in messages — only structural descriptors ('invalid_json', 'invalid_shape', 'unknown_kind: <kind>', 'missing_field: <name>').

For the unknown-kind path: isDenialReason returns false and the parser throws 'invalid_shape'. The packet keeps the parser’s error vocabulary narrow (no per-field error codes) per contract §5 step 4 — implementation only distinguishes 'invalid_json' and 'invalid_shape'. The variant “unknown kind” message is folded into 'invalid_shape'. (Tests verify that unknown-kind input throws DenialReasonParseError.)

§3.7. Determinism corpus self-scan compatibility

Module-level checklist (audit §3 + contract §6):

  • No Math.* access.
  • No Date.* access.
  • No setTimeout / setInterval / setImmediate.
  • No fetch / XMLHttpRequest / crypto.*.
  • No process.hrtime / process.nextTick.
  • No await / async keywords.
  • No float literal (\b\d+\.\d+\b).
  • No localeCompare.

Every exported function passes inspectFunctionForbidden(f) returning [].

§4. Implementation outline — admission.ts edit

Replace this block (admission.ts:135–139):

export type DenialReason =
  | { readonly kind: 'rule_version_mismatch'; readonly expected: string; readonly actual: string }
  | { readonly kind: 'policy'; readonly policy_reason: string }
  | { readonly kind: 'rule_rejected'; readonly rule_name: string; readonly rule_reason: string }
  | { readonly kind: 'no_rule_matched' };

with:

export type { DenialReason } from './denial-reasons.js';

The four object-literal sites in evaluateAdmission (lines ~191, ~239, ~270, ~282) require NO change — they construct {kind: ..., ...} shapes that are valid instances of the canonical union (every required field already present, every type match).

Update the JSDoc above the type-export block to point at denial-reasons.ts for the canonical taxonomy, and shorten the migration note (P1.4.2 has shipped; the four canonical variants are the contract floor).

§5. Test matrix — denial-reasons.test.ts

Test groups follow contract §10:

F1 — Module shape

  • F1.1 renderDenialReason is a function
  • F1.2 serializeDenialReason is a function
  • F1.3 parseDenialReason is a function
  • F1.4 isDenialReason is a function
  • F1.5 DenialReasonParseError is a class extending Error
  • F1.6 KIND_ALL is a frozen array of 8 strings
  • F1.7 BUDGET_AXES is a frozen array of 3 strings
  • F1.8 AXIOM_IDS is a frozen array of 7 strings
  • F1.9 POLICY_DISCRIMINANTS is a frozen array of 13 strings
  • F1.10 POLICY_SENTINELS is a frozen array of 2 strings

F2 — Round-trip per variant

A representative instance per kind, then parse(serialize(r)) === r (deep equal). Includes the optional transition_type on / off variants.

  • F2.1 no_rule_matched (no transition_type) — round-trip
  • F2.2 no_rule_matched (with transition_type) — round-trip
  • F2.3 budget — integer_ops
  • F2.4 budget — call_depth
  • F2.5 budget — arg_count
  • F2.6 effect_invariant_violated
  • F2.7 axiom_violation — every AX (7 cases via parameterization)
  • F2.8 policy — every PolicyDiscriminant (13 cases)
  • F2.9 policy — POLICY_TYPE_MISMATCH sentinel
  • F2.10 policy — POLICY_EVAL_ERROR sentinel
  • F2.11 rule_version_mismatch
  • F2.12 ambiguous_ruleset (specificity ≥ 0)
  • F2.13 ambiguous_ruleset (specificity = -1, transition_type = null, duplicate name)
  • F2.14 rule_rejected (admission’s 'budget:integer_ops' form)
  • F2.15 rule_rejected (admission’s verbatim reject “…” form)

F3 — Render templates

For each variant, assert the rendered string equals the §3 template byte exactly.

  • F3.1 no_rule_matched (no transition) → 'no_rule_matched'
  • F3.2 no_rule_matched (with transition) → 'no_rule_matched (transition_type=Yield)'
  • F3.3 budget → 'budget:integer_ops (limit=10000, observed=10001, rule=R)'
  • F3.4 axiom_violation → 'axiom_violation:AX-03 (rule=A)'
  • F3.5 policy → 'policy:P1 (P1_NOT_AUTHORIZED)'
  • F3.6 policy sentinel → 'policy:POLICY_TYPE_MISMATCH (POLICY_TYPE_MISMATCH)'
  • F3.7 rule_version_mismatch → fixed string with sha256 placeholders
  • F3.8 ambiguous_ruleset (tied) → fixed string with all four fields
  • F3.9 ambiguous_ruleset (duplicate) → 'ambiguous_ruleset:duplicate_name (rule=R)'
  • F3.10 rule_rejected → 'rule_rejected (rule=R, reason=budget:integer_ops)'
  • F3.11 effect_invariant_violated → fixed string with all three fields

F4 — Discriminant validation (parser rejection)

  • F4.1 invalid JSON → throws DenialReasonParseError with invalid_json: prefix
  • F4.2 array root → throws (invalid_shape)
  • F4.3 missing kind → throws
  • F4.4 unknown kind → throws
  • F4.5 wrong-type field (e.g. budget with string limit) → throws
  • F4.6 budget with unknown axis → throws
  • F4.7 axiom_violation with unknown axiom → throws
  • F4.8 policy with unknown policy_id → throws
  • F4.9 ambiguous_ruleset with non-null non-string transition_type → throws
  • F4.10 NaN / Infinity in budget.limit → throws (Number.isFinite gates)
  • F4.11 null root → throws
  • F4.12 string root → throws

F5 — Canonical JSON byte stability

  • F5.1 Same variant constructed twice → byte-identical serialize output
  • F5.2 Field order of input object literal does NOT affect serialize output (build twice with reversed field order, expect same bytes)
  • F5.3 Forward compat: parse(serialize(r) with extra key inserted) drops extra key (manual JSON edit, parser projection)

F6 — Exhaustive switch (TypeScript-level)

Define function unreachable(_: never): never { throw … } consumer in the test that switches on every kind and returns. Compile success IS the test (Jest doesn’t run a runtime check; the test asserts a sentinel returned). A rebuild that adds a discriminant without updating the test breaks compilation.

  • F6.1 exhaustive switch returns sentinel for every kind

F7 — Determinism corpus self-scan

  • F7.1 inspectFunctionForbidden(renderDenialReason) === []
  • F7.2 inspectFunctionForbidden(serializeDenialReason) === []
  • F7.3 inspectFunctionForbidden(parseDenialReason) === []
  • F7.4 inspectFunctionForbidden(isDenialReason) === []

F8 — Determinism repeat (10×)

  • F8.1 serialize(r) × 10 returns byte-identical strings (every variant)
  • F8.2 render(r) × 10 returns byte-identical strings (every variant)

F9 — admission.ts compatibility

  • F9.1 import { DenialReason } from '../../../domains/rules/admission.js' type-checks against admission’s existing emission shapes
  • F9.2 import { DenialReason } from '../../../domains/rules/denial-reasons.js' is referentially the same type as the admission re-export (compile-time via assignability)
  • F9.3 admission.ts continues to construct rule_version_mismatch / policy / rule_rejected / no_rule_matched literals that satisfy the canonical types (build-time check; verified by npm run build)

F10 — Parameterized AxiomId / PolicyDiscriminant exhaustivity

  • F10.1 every AXIOM_IDS entry round-trips via axiom_violation
  • F10.2 every POLICY_DISCRIMINANTS entry round-trips via policy
  • F10.3 every BUDGET_AXES entry round-trips via budget

Total ≥ 60 tests. Coverage target ≥ 95% lines, ≥ 90% branches.

§6. Build / lint / test gate

Per CLAUDE.md §5 (lint included explicitly per feedback_lint_gate_escape):

cd .worktrees/claude/p1-4-2-denial-reasons
npm run build && npm run lint && npm test

All three must pass before push. Expected outcomes:

  • build: clean compile across all sources.
  • lint: ESLint clean. If denial-reasons.ts triggers no-unused-vars on a never exhaustive-switch sink, suppress with the same pattern used in engine.ts:480 (asciiCompareByName) or with an inline // eslint-disable-next-line only if no codebase- wide pattern exists.
  • test: full suite passes. Net delta: +60 tests in the new file + zero modifications to admission.test.ts (admission’s tests must remain green).

§7. Risk + rollback

Risk Probability Impact Mitigation
npm run lint flag on the _exh: never exhaustive-switch sink Low Low Pattern is established in admission.ts §3; reuse comment shape.
canonicalize() rejects a numeric field (non-integer) Low Medium BudgetReason.limit/observed are integer caps from engine.ts; we control the input. Tests cover.
transition_type: null in ambiguous_ruleset fails round-trip via canonicalize Low Medium canonicalize in the codebase already supports null values (canonical.ts §6 emits null as a literal). Verified by reading canonical.ts.
admission emission objects need a structural change to satisfy canonical types Low High Type-checked at packet time: every admission.ts emission site (admission.ts:191, 239, 270, 282) builds the four canonical shapes verbatim. No code change to emission.
Duplicate type names between admission and denial-reasons cause import ambiguity Low Low Admission re-exports type only via export type {...}; emission code uses object literals (no new DenialReason() form).

Rollback: revert the merge commit. No DB migration, no migration script, no schema change, no runtime state, no breaking type rename.

§8. Acceptance criteria — ready for sign-off

  • Audit committed (Step 1)
  • Contract committed (Step 2)
  • Packet committed (Step 3 — this commit)
  • Implementation committed (Step 4)
  • Verification committed (Step 5)
  • npm run build && npm run lint && npm test all green
  • PR opened with link to all five docs
  • CI green (gh pr checks –watch)
  • Squash-merge to origin/main
  • Worktree removed; remote branch deleted
  • Writeback (thought_record then task_update DONE)

Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.