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
DenialReasondiscriminated union covering the seven kinds required by the task prompt + the existingrule_rejectedcatch-all used by admission today. - A typed payload per variant — no freeform
message: stringfields. 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.tsre-export so admission.test.ts and admission consumers continue to importDenialReasonfrom./admission.jsunchanged.
Out of scope (deferred):
- Wiring
RuleBudgetExceededdirectly through admission as a typedbudgetdenial — admission today routes it asrule_rejected.rule_reason = 'budget:*'. Threading the typed shape is P1.4.3 / P1.4.4. - Wiring
AmbiguousRulesetErrorthrough 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
kindfield is always serialized (it’s the discriminant). JSON.parse(serializeDenialReason(r))re-emits an object whosekindequalsr.kind.serializeDenialReason(r1) === serializeDenialReason(r2)⟺r1andr2have the same field set with byte-equal string values, byte-equal number values, and the same null-positions (ambiguous_ruleset’stransition_type: null).
§5. Algorithm — parseDenialReason (validating)
parseDenialReason(json):
JSON.parse(json)— letobe the result. IfJSON.parsethrows, re-throw asDenialReasonParseErrorwith message"invalid_json: " + e.message.- Verify
typeof o === 'object' && o !== null && !Array.isArray(o). - Verify
typeof o.kind === 'string'. - 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.
BudgetAxisis one of three strings;AxiomIdis one of seven strings;PolicyDiscriminantis one of fifteen strings). - Unknown
kind: throwDenialReasonParseErrorwith message"unknown_kind: " + 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.
- 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.tsMUST 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.tsemission code (the four object-literal sites) MUST NOT change. The canonical types accept the existing field shape verbatim.- No other production module imports
DenialReasonat base SHA0b124c17. 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 validation — parseDenialReason 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-scan — inspectFunctionForbidden 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.