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 indenial-reasons.tsso 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
DenialReasondiscriminated 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.tsthat 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 (
_: neverfallthrough). - 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
RuleBudgetExceededthrough a typedbudgetdenial — it surfaces asrule_rejectedwithreason: '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 tono_rule_matchedin 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/setImmediatefetch,XMLHttpRequest,crypto.*member accessprocess.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. renderDenialReasonand the canonical JSON form are part of the stability surface. A change to either is a major-bump.
§5. Audit findings — what changes
- New file —
src/domains/rules/denial-reasons.ts. Sole owner of theDenialReasontaxonomy. Pure, deterministic, self-scan-clean. - New file —
src/__tests__/domains/rules/denial-reasons.test.ts. ≥ 1 test per variant + render + round-trip + exhaustive-switch + corpus self-scan. - Edit —
src/domains/rules/admission.ts. Replace the localDenialReasonunion withimport { DenialReason } from './denial-reasons.js';andexport 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. - No change —
admission.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) plusrule_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.