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:
- Reject non-object / null / array.
- Read
value.kind. If notstringor not inKIND_ALL, returnfalse. - Branch on
kindand 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.isFinitecovers). nullfields use=== null.- Discriminant-set membership:
BUDGET_AXES.indexOf(...) !== -1,AXIOM_IDS.indexOf(...) !== -1,POLICY_DISCRIMINANTS.indexOf(...) !== -1 || POLICY_SENTINELS.indexOf(...) !== -1.
- String fields use
- Optional
transition_typeonno_rule_matchedis 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
CanonicalSerializationErroron 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/asynckeywords. - 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
renderDenialReasonis a function - F1.2
serializeDenialReasonis a function - F1.3
parseDenialReasonis a function - F1.4
isDenialReasonis a function - F1.5
DenialReasonParseErroris a class extending Error - F1.6
KIND_ALLis a frozen array of 8 strings - F1.7
BUDGET_AXESis a frozen array of 3 strings - F1.8
AXIOM_IDSis a frozen array of 7 strings - F1.9
POLICY_DISCRIMINANTSis a frozen array of 13 strings - F1.10
POLICY_SENTINELSis 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-varson aneverexhaustive-switch sink, suppress with the same pattern used inengine.ts:480(asciiCompareByName) or with an inline// eslint-disable-next-lineonly 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 testall 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)