P1.4.2 — Denial Reason Taxonomy — Behavioral Contract (Step 2 / 5)

The implementation cannot proceed without an approved contract. This file locks the public surface, the discriminant shape, the rendering grammar, the canonical-JSON form, the determinism guarantees, and the migration discipline. Step 4 (implementation) MUST honor every clause here. Step 5 (verification) tests against this contract, not against the implementation.

§1. Scope

P1.4.2 ships src/domains/rules/denial-reasons.ts — a pure module exposing the canonical DenialReason discriminated union, structured payload types for every variant, an operator-readable rendering function, and a canonical-JSON round-trip. It also re-aligns src/domains/rules/admission.ts so the four discriminants used in admission flow through the canonical type.

In scope:

  • The full DenialReason discriminated union covering the seven kinds required by the task prompt + the existing rule_rejected catch-all used by admission today.
  • A typed payload per variant — no freeform message: string fields.
  • renderDenialReason(r): string — deterministic operator-readable string.
  • serializeDenialReason(r): string — canonical JSON (sorted keys, no whitespace, code-unit ordering).
  • parseDenialReason(json: string): DenialReason — inverse of serialize, validates discriminant tag + payload shape.
  • Migration-disciplined, additive-only contract for new variants.
  • admission.ts re-export so admission.test.ts and admission consumers continue to import DenialReason from ./admission.js unchanged.

Out of scope (deferred):

  • Wiring RuleBudgetExceeded directly through admission as a typed budget denial — admission today routes it as rule_rejected.rule_reason = 'budget:*'. Threading the typed shape is P1.4.3 / P1.4.4.
  • Wiring AmbiguousRulesetError through a runtime emission path — registry constructs throw at load time; admission emission for this variant lands in P1.4.3.
  • Substantive axiom / policy enforcement — those stubs ship in later κ rounds.
  • ζ audit-trail emission shape — separate concern.

§2. Public surface (locked)

The exported names below are the entire public surface. Any addition is a contract amendment; any rename is a breaking change requiring a major-bump discussion.

§2.1. DenialReason discriminated union

export type DenialReason =
  | NoRuleMatchedReason
  | BudgetReason
  | EffectInvariantViolatedReason
  | AxiomViolationReason
  | PolicyReason
  | RuleVersionMismatchReason
  | AmbiguousRulesetReason
  | RuleRejectedReason;

Every variant carries a literal-typed kind discriminant. Fields are readonly. No kind is an enum (per Common Gotcha #3 of the task prompt).

§2.2. Variant payloads

export interface NoRuleMatchedReason {
  readonly kind: 'no_rule_matched';
  /** Optional transition-type hint when admission can recover it. */
  readonly transition_type?: string;
}

export type BudgetAxis = 'integer_ops' | 'call_depth' | 'arg_count';

export interface BudgetReason {
  readonly kind: 'budget';
  readonly axis: BudgetAxis;
  /** Cap that fired. e.g. 10000 for integer_ops, 16 for call_depth, 8 for arg_count. */
  readonly limit: number;
  /** Counter value at the moment the cap fired (always > limit). */
  readonly observed: number;
  /** Originating rule name. Empty string when unknown. */
  readonly rule_name: string;
}

export interface EffectInvariantViolatedReason {
  readonly kind: 'effect_invariant_violated';
  readonly rule_name: string;
  /** Stable invariant ID; e.g. 'I-EFFECT-DETERMINISTIC'. */
  readonly invariant_id: string;
  /** Structured detail — never user-facing prose. */
  readonly details: string;
}

export type AxiomId =
  | 'AX-01'
  | 'AX-02'
  | 'AX-03'
  | 'AX-04'
  | 'AX-05'
  | 'AX-06'
  | 'AX-07';

export interface AxiomViolationReason {
  readonly kind: 'axiom_violation';
  readonly axiom: AxiomId;
  readonly rule_name: string;
}

export type PolicyDiscriminant =
  | 'P1'
  | 'P2'
  | 'P3'
  | 'P4'
  | 'P5'
  | 'P6'
  | 'P7'
  | 'P8'
  | 'P9'
  | 'P10'
  | 'P11'
  | 'P12'
  | 'P13';

export interface PolicyReason {
  readonly kind: 'policy';
  /**
   * Either a typed PolicyDiscriminant ('P1'..'P13') OR a sentinel reason
   * string emitted by the gate ('POLICY_TYPE_MISMATCH', 'POLICY_EVAL_ERROR').
   * The two forms coexist because admission emits the verbatim policy_reason
   * string today.
   */
  readonly policy_id: PolicyDiscriminant | 'POLICY_TYPE_MISMATCH' | 'POLICY_EVAL_ERROR';
  /**
   * The rejection_reason string registered in policy-gate POLICIES table,
   * e.g. 'P1_NOT_AUTHORIZED'. May equal `policy_id` for sentinel reasons.
   */
  readonly policy_reason: string;
}

export interface RuleVersionMismatchReason {
  readonly kind: 'rule_version_mismatch';
  /** Registry's authoritative view (sha256:... 71 chars). */
  readonly expected: string;
  /** Caller's claimed view. */
  readonly actual: string;
}

export interface AmbiguousRulesetReason {
  readonly kind: 'ambiguous_ruleset';
  readonly rule1_name: string;
  readonly rule2_name: string;
  /**
   * The tied specificity score, or -1 for duplicate-name collisions
   * (matches AmbiguousRulesetError's convention at registry.ts:218).
   */
  readonly specificity: number;
  /** Transition type the tie occurred under, or null for duplicate-name collisions. */
  readonly transition_type: string | null;
}

export interface RuleRejectedReason {
  readonly kind: 'rule_rejected';
  readonly rule_name: string;
  /**
   * Engine-typed reason string, verbatim from the engine boundary
   * ('budget:integer_ops', 'div_by_zero:0', 'undefined_variable:event.x',
   * an explicit `reject "..."` reason, etc.). Kept as a string for
   * forward-compatibility with new engine reasons.
   */
  readonly rule_reason: string;
}

§2.3. Discriminant set (locked, additive-only)

The eight kind values below are part of the public surface. New discriminants may be added in future rounds; existing ones MUST NOT be renamed, removed, or have their payload fields renamed.

kind Payload fields
no_rule_matched (optional) transition_type
budget axis, limit, observed, rule_name
effect_invariant_violated rule_name, invariant_id, details
axiom_violation axiom, rule_name
policy policy_id, policy_reason
rule_version_mismatch expected, actual
ambiguous_ruleset rule1_name, rule2_name, specificity, transition_type
rule_rejected rule_name, rule_reason

§2.4. Functions

export function renderDenialReason(r: DenialReason): string;
export function serializeDenialReason(r: DenialReason): string;
export function parseDenialReason(json: string): DenialReason;

isDenialReason(value: unknown): value is DenialReason is exported as a type-narrowing predicate used by parseDenialReason. The predicate is total — never throws.

§2.5. Re-export from admission.ts

src/domains/rules/admission.ts MUST replace its local 4-discriminant union with:

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

Admission.ts continues to construct the four discriminants by object literal at every emission site (no source change to construction code is required — the literal shape {kind: 'rule_version_mismatch', expected, actual} etc. all match the canonical types verbatim).

§3. Algorithm — renderDenialReason (deterministic)

The renderer produces a single-line operator-readable string per variant. Output is fixed by this contract; consumers may rely on the prefix (<kind> or <kind>:<sub-tag>) for dispatch.

Variant Output template
no_rule_matched (no transition_type) no_rule_matched
no_rule_matched (with transition_type) no_rule_matched (transition_type=<t>)
budget budget:<axis> (limit=<L>, observed=<O>, rule=<rule_name>)
effect_invariant_violated effect_invariant_violated (rule=<rule_name>, invariant=<invariant_id>, details=<details>)
axiom_violation axiom_violation:<axiom> (rule=<rule_name>)
policy policy:<policy_id> (<policy_reason>)
rule_version_mismatch rule_version_mismatch (expected=<expected>, actual=<actual>)
ambiguous_ruleset (specificity ≥ 0) ambiguous_ruleset (rule1=<rule1_name>, rule2=<rule2_name>, specificity=<s>, transition_type=<tt|<none>>)
ambiguous_ruleset (specificity < 0, duplicate name) ambiguous_ruleset:duplicate_name (rule=<rule1_name>)
rule_rejected rule_rejected (rule=<rule_name>, reason=<rule_reason>)

The renderer does NOT call JSON.stringify. All values are concatenated as strings; numbers via String(n) (which is locale-independent). transition_type === null is rendered as the literal <none>.

§4. Algorithm — serializeDenialReason (canonical JSON)

The canonical form sorts keys by code-unit order at every nesting level, no whitespace, no trailing newline. Implementation reuses canonicalize from ./canonical.js to share the well-tested core.

Pre-serialization step: drop fields whose value is undefined. The optional transition_type field on NoRuleMatchedReason is dropped when absent (it is not serialized as "transition_type": null). All other payload fields are required and present.

Post-serialization invariants:

  • The kind field is always serialized (it’s the discriminant).
  • JSON.parse(serializeDenialReason(r)) re-emits an object whose kind equals r.kind.
  • serializeDenialReason(r1) === serializeDenialReason(r2)r1 and r2 have the same field set with byte-equal string values, byte-equal number values, and the same null-positions (ambiguous_ruleset’s transition_type: null).

§5. Algorithm — parseDenialReason (validating)

parseDenialReason(json):

  1. JSON.parse(json) — let o be the result. If JSON.parse throws, re-throw as DenialReasonParseError with message "invalid_json: " + e.message.
  2. Verify typeof o === 'object' && o !== null && !Array.isArray(o).
  3. Verify typeof o.kind === 'string'.
  4. Branch on o.kind:
    • For each known kind: verify the required payload fields exist with correct primitive types (string / number / null per the variant table), and (where applicable) that the discriminant value matches the kind’s allowed set (e.g. BudgetAxis is one of three strings; AxiomId is one of seven strings; PolicyDiscriminant is one of fifteen strings).
    • Unknown kind: throw DenialReasonParseError with message "unknown_kind: " + kind.
  5. Return a fresh object literal with EXACTLY the contract fields — extra keys are dropped (forward-compat with future variant additions in newer versions of the type the parser sees from older code).

parseDenialReason throws on validation failure (no defensive undefined-return). The caller handles errors via try / catch. The class name DenialReasonParseError is exported.

§6. Determinism guarantees

I1. Pure: no I/O, no DB, no network, no env reads, no console output, no clock, no RNG, no async. I2. inspectFunctionForbidden(renderDenialReason) returns []. I3. inspectFunctionForbidden(serializeDenialReason) returns []. I4. inspectFunctionForbidden(parseDenialReason) returns []. I5. inspectFunctionForbidden(isDenialReason) returns []. I6. Same input → same output across N calls (assertDeterministic over every variant in the F2 corpus, 10 iterations, deep-equal). I7. No localeCompare anywhere in the module — sorts use code-unit </> (canonical.ts already enforces this for the JSON path). I8. No float literal in source. Numbers in payloads are bigint-free — BudgetReason.limit and BudgetReason.observed are JS number because the engine’s RuleBudgetExceeded.limit and .observed are number (engine.ts:213). They round-trip through canonical JSON. I9. The renderer’s String(n) of a JS number is canonical (no locale formatting, no exponent for integers ≤ 21 digits — sufficient for the budget caps which top out at 10_000).

§7. Stability surface (additive-only)

The eight discriminants in §2.3 are stable. New discriminants may be added in future rounds; existing ones MUST NOT change name or payload shape. Specifically:

S1. AX-01..AX-07 is the locked AxiomId namespace. New axioms get new IDs (AX-08, …); existing ones are not renumbered. S2. P1..P13 is the locked PolicyDiscriminant namespace. Same rule. S3. BudgetAxis is locked at three values today. New budget axes (e.g. 'memory' for a future κ phase) require a contract amendment. S4. The renderer’s output grammar in §3 is stable. Consumers parsing the rendered form (e.g. log-grep) may rely on the prefix-based dispatch. S5. The canonical-JSON form is stable. Persisted denials in audit logs (ζ writeback) MUST round-trip.

A future P1.4.3 / P1.4.4 may extend the union. Admission consumers exhaustively switching on r.kind will get a TypeScript error when a new variant lands — they opt into the wider taxonomy explicitly.

§8. Backward compatibility

  • admission.test.ts MUST pass unchanged. It pattern-matches on the four discriminants admission currently emits. Every match site uses narrow-and-check (r.reason.kind === '...'), which TypeScript widens cleanly when the union grows.
  • admission.ts emission code (the four object-literal sites) MUST NOT change. The canonical types accept the existing field shape verbatim.
  • No other production module imports DenialReason at base SHA 0b124c17. There are no hidden consumers to migrate.

§9. Risk register

Risk Mitigation
Adding the wider union breaks admission’s emission type-checks The four canonical variants used by admission have IDENTICAL field shapes to admission’s current local union. Verified by enumeration (§2.2 vs admission.ts:135–139).
JSON.stringify-based naive serialize gives different field order across hosts Serializer reuses canonicalize from ./canonical.js — already audited for code-unit-sorted output.
parseDenialReason admits malformed input via duck typing Parser explicitly validates discriminant and required fields per variant; rejects with typed DenialReasonParseError.
Float literal accidentally introduced via budget cap default Caps come from engine.ts constants; renderer uses String(n) not template literals with float arithmetic.
Test layout drift Audit §2.7 confirms src/__tests__/domains/rules/. Packet locks this.

§10. Test strategy (handed to the packet)

T1. Module shape — every export exists with correct type. T2. Round-trip — for each variant, build a representative instance, serialize, parse, deep-equal. T3. Render — for each variant, output matches the §3 template verbatim (string equality). T4. Discriminant validationparseDenialReason rejects unknown kind, missing fields, wrong-type fields. T5. Canonical JSON — same variant constructed two ways → byte-identical serialized output. T6. Exhaustive switch — TypeScript-level: a function unreachable(r: never) consumer in the test file forces a compile error if a new variant is added without handling. Used as a positive test; a branching test covers all eight kinds. T7. Determinism corpus self-scaninspectFunctionForbidden over every export returns []. T8. Determinism repeat — 10× serialize / render of the same variant returns deep-equal results. T9. admission.test.ts compatibility — sanity test that imports DenialReason from admission.ts and from denial-reasons.ts and constructs the four common variants without type errors. T10. AxiomId / PolicyDiscriminant exhaustivity — a parameterized test exercises every axiom (7 cases) and every policy (13 cases) through render + round-trip.

Coverage target: ≥ 95% lines, ≥ 90% branches.


Back to top

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

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