P1.4.2 — Denial Reason Taxonomy — Audit (Step 1 / 5)

Inventory of every κ rejection path that exists today and whose denial currently flows through admission’s coarse-grained 4-discriminant DenialReason. P1.4.2 keeps that public surface stable while shipping a richer, structured-payload taxonomy in denial-reasons.ts so downstream consumers (audit / β tool-lock logging) can branch on a typed kind without parsing strings. This audit is read-only — no code changes here. The downstream contract (Step 2) and packet (Step 3) lock the consequences of these findings.

§1. Task framing

task-prompts/p1.1-kappa-rule-engine.md §P1.4.2 defines the headline goal:

Define the structured DenialReason discriminated union used by every κ rejection path.

Today, after R87 Wave 6 (PR #215), src/domains/rules/admission.ts ships a 4-discriminant local DenialReason union (rule_version_mismatch, policy, rule_rejected, no_rule_matched) — see admission.ts:135–139. The contract explicitly anticipates expansion at this seam:

docs/contracts/p1-4-1-admission-contract.md §2.3 — “P1.4.2 may extend this union with additional kinds for downstream consumers; the four below MUST remain.”

P1.4.2 ships the canonical taxonomy that every consumer will branch on, and re-aligns admission.ts so its narrow internal union remains a strict subset of (and is sourced from) the canonical one.

In scope:

  • A new pure module src/domains/rules/denial-reasons.ts that owns the full taxonomy as a discriminated union with structured (no freeform-string) payloads.
  • Operator-readable rendering (renderDenialReason) and canonical-JSON round-trip (serializeDenialReason / parseDenialReason).
  • Stability guard: an additive-only contract for new variants; AX-01..AX-07 axiom IDs and P1..P13 policy IDs are stable namespaces.
  • Test coverage covering every variant, plus an exhaustive-switch compile check (_: never fallthrough).
  • Backward compatibility with admission.ts: the four discriminants used in admission.ts continue to work — admission.ts is updated to import the type from denial-reasons.ts and re-export it for its own callers (no net surface change for admission consumers).

Out of scope (deferred):

  • Wiring the budget / axiom / ambiguous-ruleset variants through admission’s emission code (admission still emits the 4-discriminant subset). The evaluator does not yet route engine RuleBudgetExceeded through a typed budget denial — it surfaces as rule_rejected with reason: 'budget:*' per the engine boundary at engine.ts:461. Threading the typed shape is P1.4.3 / P1.4.4 territory.
  • Mutation conflict detection — β commit-time concern.
  • ζ audit-trail emission for denials — ζ integration follow-up.
  • Substantive axiom / policy predicate enforcement — those are stubs today (validator.ts:528–605 + policy-gate.ts:217–229).

§2. Existing surface — what already ships

§2.1. src/domains/rules/admission.ts (P1.4.1 — R87 Wave 6)

Public surface relevant to this audit (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' };

Consumers of this type today:

Consumer Path Use
src/__tests__/domains/rules/admission.test.ts F1.3, F2.1, F2.7, F2.8, F2.9, F3., F5., F6., F10. Pattern-matches on r.reason.kind. ≥ 30 distinct test cases reference at least one discriminant.

There are no other code consumers at base SHA 0b124c17:

$ grep -RIn "DenialReason" src/ --include="*.ts"
src/domains/rules/admission.ts:117: ...JSDoc...
src/domains/rules/admission.ts:135: export type DenialReason
src/__tests__/domains/rules/admission.test.ts:30:   DenialReason,

This is the migration window contemplated by the contract. A type replacement that preserves the four discriminants verbatim does not break admission.test.ts.

§2.2. src/domains/rules/engine.ts (P1.3.1)

The engine boundary catches RuleBudgetExceeded and converts to {status: 'rejected', reason: 'budget:<which>'} (engine.ts:461). The three budget axes are typed at engine.ts:213:

which: 'integer_ops' | 'call_depth' | 'arg_count'

Caps (engine.ts:70–82):

which constant value
integer_ops MAX_INTEGER_OPS 10_000
call_depth MAX_CALL_DEPTH 16
arg_count MAX_ARG_COUNT 8

These are the observed values when the cap fires. The structured budget variant in the taxonomy below carries limit + observed so a consumer never has to recompute or re-import constants.

Other engine error reasons that surface today as rule_rejected.rule_reason strings:

  • 'budget:integer_ops' / 'budget:call_depth' / 'budget:arg_count'
  • 'div_by_zero:<denominator>' (engine.ts catch around DivisionByZeroError)
  • 'undefined_variable:<path>' (resolveVarRef throws; engine catches)
  • 'overflow:<...>' / type-mismatch reasons emitted from integer-math
  • 'NO_MATCH' (special — collapses to no_rule_matched in admission)

The taxonomy keeps rule_rejected as a catch-all for engine-typed reasons (verbatim string passthrough, mirroring P1.4.1’s existing semantics) AND adds a typed budget variant for the three budget-overrun cases that are identifiable structurally. This is dual-shape — admission today emits the string form via rule_rejected; future P1.4.3 may upgrade the engine to emit the typed budget denial directly.

§2.3. src/domains/rules/policy-gate.ts (P1.3.4)

PolicyId enum (policy-gate.ts:79–93) — P1 through P13. Each policy’s rejection_reason is pre-registered (policy-gate.ts:217–229), e.g. 'P1_NOT_AUTHORIZED'. Module-level sentinels: 'POLICY_TYPE_MISMATCH', 'POLICY_EVAL_ERROR'.

Today admission emits these via {kind: 'policy', policy_reason}policy_reason is the verbatim string. The expanded taxonomy adds a typed policy:Pn shape that carries the PolicyId discriminant; admission continues to emit the string-form policy variant, but the taxonomy exposes the typed shape so future process_action callers can branch.

§2.4. src/domains/rules/registry.ts (P1.2.4)

AmbiguousRulesetError (registry.ts:205–228) carries rule1_name, rule2_name, specificity, and transition_type. Today this throws at registry construction time (loadRuleset Stage 6); admission never sees it because admission only runs after a registry exists. The taxonomy carries the structured ambiguous_ruleset variant for downstream consumers (loader / admission front-door / β middleware) that catch the error and need a typed shape to log.

§2.5. src/domains/rules/validator.ts (P1.2.3)

The validator’s axiom check (validator.ts:528–605) is a 7-stub fan-out covering AX-01..AX-07. Stubs today return []; future predicate-shape checks will reject ruleset loads with axiom violations. The taxonomy carries axiom_violation so the stable AX-01..AX-07 namespace is available even before the substantive checks land.

§2.6. docs/3-world/physics/laws/rule-engine.md

§Forbidden operations + §Evaluation budget pin the budget-axis names (integer_ops, call_depth, arg_count) and the constitutional axiom list (AX-01–AX-07). §Rule application algorithm pins the rejection hierarchy (policy first, then named rules). The taxonomy below is the typed mirror of these spec terms.

§2.7. Existing sibling layout — what to mirror

R86 / Wave 6 sibling tasks all chose src/domains/rules/<name>.ts for implementation and src/__tests__/domains/rules/<name>.test.ts for tests (NOT src/domains/rules/__tests__/). Verified at:

src/__tests__/domains/rules/admission.test.ts        (P1.4.1)
src/__tests__/domains/rules/versioning.test.ts       (P1.5.1)
src/__tests__/domains/rules/registry.test.ts         (P1.2.4)
src/__tests__/domains/rules/policy-gate.test.ts      (P1.3.4)
src/__tests__/domains/rules/state-access.test.ts     (P1.3.3)
src/__tests__/domains/rules/builtins.test.ts         (P1.3.2)
src/__tests__/domains/rules/engine.test.ts           (P1.3.1)

P1.4.2 follows the sibling layout exactly: src/domains/rules/denial-reasons.ts + src/__tests__/domains/rules/denial-reasons.test.ts. The task prompt’s suggestion of src/domains/rules/__tests__/denial-reasons.test.ts is mis-aligned with the actual test location convention; we follow the sibling pattern.

§3. Determinism guardrails (audit-side check)

The denial-reasons.ts module must satisfy inspectFunctionForbidden self-scan over every exported function (clean — empty array). Per docs/contracts/r83-a-determinism-contract.md §4 and the FORBIDDEN_PATTERNS table in determinism.ts:56–72, the module must avoid:

  • Math.*, Date.*, setTimeout/setInterval/setImmediate
  • fetch, XMLHttpRequest, crypto.* member access
  • process.hrtime/nextTick, await, async
  • Float literals matching \b\d+\.\d+\b
  • [native code]

renderDenialReason, serializeDenialReason, parseDenialReason must each pass the corpus self-scan. Canonical-JSON output uses key sorting via ASCII code-unit </> compare (NEVER localeCompare) — same rule the versioning + canonical modules already follow.

§4. Migration discipline (additive-only)

Per the task prompt’s Acceptance Criteria #3 (“Reason codes stable across upgrades — additive-only changes; no renumbering”) and Common Gotcha #3 (“Do not expose kind as an enum”):

  • New variants may be added; existing variants may NOT be renamed, removed, or have their payload fields renamed.
  • The four-discriminant subset used by admission.ts is locked at the four current names (rule_version_mismatch, policy, rule_rejected, no_rule_matched).
  • Axiom IDs (AX-01..AX-07) and policy IDs (P1..P13) are stable namespaces; new IDs may be added in their respective domains, but existing IDs may not shift.
  • renderDenialReason and the canonical JSON form are part of the stability surface. A change to either is a major-bump.

§5. Audit findings — what changes

  1. New filesrc/domains/rules/denial-reasons.ts. Sole owner of the DenialReason taxonomy. Pure, deterministic, self-scan-clean.
  2. New filesrc/__tests__/domains/rules/denial-reasons.test.ts. ≥ 1 test per variant + render + round-trip + exhaustive-switch + corpus self-scan.
  3. Editsrc/domains/rules/admission.ts. Replace the local DenialReason union with import { DenialReason } from './denial-reasons.js'; and export type { DenialReason } from './denial-reasons.js';. The four-discriminant subset re-exported here is identical to admission’s current local union. No emission-site changes — admission continues to construct the four discriminants by object literal, which now type-check against the canonical union.
  4. No changeadmission.test.ts, engine.ts, policy-gate.ts, registry.ts. The contract preserves admission’s emission shape verbatim.

§6. Risk register

Risk Mitigation
Adding a discriminant breaks admission consumers’ exhaustive switches Admission.ts re-exports DenialReason from denial-reasons.ts; existing consumers’ switch on the four current discriminants continues to be sound at runtime. TypeScript switch (r.kind) over the wider union without a default would warn; consumers add a default OR opt into the wider union. We accept this as a deliberate (additive) contract change.
Float literal in MAX_* constant value imported into renderer renderDenialReason does NOT import MAX_INTEGER_OPS — it operates on limit field carried in the payload, so no engine-side constant leaks into the renderer source.
localeCompare accidentally introduced for key sort in canonical JSON Audit-side check: contract §3 mandates code-unit </> compare. Reused canonical.ts where possible (it already does sorted-key JSON).
Test placement drift (src/domains/rules/__tests__/) Sibling layout audit (§2.7) confirms src/__tests__/domains/rules/ as the canonical path. Packet locks this.
Future P1.4.3 / P1.4.4 forced to rewrite admission emission This audit’s §1 makes the non-emission scope explicit; P1.4.3 owns the emission-site upgrade.

§7. Inputs to the contract step

  • The seven discriminants required by the task prompt (no_rule_matched, budget, effect_invariant_violated, axiom_violation, policy, rule_version_mismatch, ambiguous_ruleset) plus rule_rejected (the catch-all kept from admission’s surface for the engine-typed reason strings).
  • The structured payload shape per variant (no freeform strings).
  • Determinism corpus self-scan compatibility.
  • Backward compatibility with admission.ts and admission.test.ts.
  • Sibling test layout (src/__tests__/domains/rules/...test.ts).

These flow into docs/contracts/p1-4-2-denial-reasons-contract.md.


Back to top

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

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