P1.4.2 — Denial Reason Taxonomy — Verification (Step 5 / 5)
Test evidence for P1.4.2. Closes the 5-step executor chain. Audit, contract, and packet committed earlier in this PR; this doc records the proof that the implementation matches the contract and ships green across all three gates (build + lint + test).
§1. Inputs
- Audit:
docs/audits/p1-4-2-denial-reasons-audit.md(commitaad59fb4) - Contract:
docs/contracts/p1-4-2-denial-reasons-contract.md(commit36ab9754) - Packet:
docs/packets/p1-4-2-denial-reasons-packet.md(commit53ccea23) - Implementation:
feat(p1-4-2): denial reason taxonomy(commitd595d6d2)
§2. Files shipped
| Path | Verb | Lines (NET) | Purpose |
|---|---|---|---|
src/domains/rules/denial-reasons.ts |
CREATE | +651 | Canonical 8-variant DenialReason taxonomy + render + canonical-JSON round-trip + isDenialReason predicate. |
src/__tests__/domains/rules/denial-reasons.test.ts |
CREATE | +744 | F1..F10 test matrix. ≥ 60 tests. |
src/domains/rules/admission.ts |
EDIT | +41 / -22 | Re-export DenialReason from denial-reasons.ts; emission site for policy denial now also populates policy_id via the new derivePolicyDiscriminant helper. |
src/__tests__/domains/rules/admission.test.ts |
EDIT | +20 / -3 | F13.1 narrowed to admission’s 4 emission discriminants with explicit default for the 4 P1.4.2-reserved variants. |
§3. Gate evidence (build + lint + test)
All three gates run from the worktree at feature/p1-4-2-denial-reasons
HEAD (d595d6d2).
§3.1. npm run build — green
> colibri@0.0.1 build
> tsc
> colibri@0.0.1 postbuild
> node scripts/copy-migrations.mjs
copy-migrations: copied 6 migration(s) E:\AMS\.worktrees\claude\p1-4-2-denial-reasons\src\db\migrations -> E:\AMS\.worktrees\claude\p1-4-2-denial-reasons\dist\db\migrations
No TypeScript diagnostics. Strict-mode + noUncheckedIndexedAccess +
exactOptionalPropertyTypes clean.
§3.2. npm run lint — green
> colibri@0.0.1 lint
> eslint src
Zero ESLint warnings or errors. The exhaustive-switch sinks
(const _exh: never = r;) match the prefix-_ underscore-ignore pattern
in the project’s @typescript-eslint/no-unused-vars rule and pass without
suppression.
§3.3. npm test — green
Final clean run (after coverage/ cleared between runs):
Test Suites: 43 passed, 43 total
Tests: 2168 passed, 2168 total
Snapshots: 0 total
Time: 27.322 s
Net delta: +60 new tests in denial-reasons.test.ts. All 2168 tests
green; zero regressions in admission.test.ts (F13.1 updated to absorb
the wider taxonomy via a default branch).
§3.3.1. Pre-existing flake observed once (server.test.ts)
The first full-suite run hit the documented startup — subprocess smoke
flake (/[colibri] starting/ not seen on stderr under heavy load). The
flake matches the memory note feedback_phase0_first_code_hazards.md:
Pre-existing
startup — subprocess smokeflakiness under full-suite load — predates Wave H; all 4 R77 executors hit it once, always green on rerun.
Isolated re-run was green:
PASS src/__tests__/server.test.ts (14.408 s)
√ main() IIFE smoke — script invocation boots and writes a startup log (1635 ms)
√ main() IIFE smoke via spawnSync + tsx — startup log visible on stderr (1 ms)
Test Suites: 1 passed, 1 total
Subsequent full-suite runs (after coverage/ clear) were green —
2168/2168. No P1.4.2-introduced flake.
§4. Acceptance criteria — pass-by-pass
Tracking against the task prompt’s headline list and the contract’s
§Public surface lock.
| AC | Source | Result |
|---|---|---|
| Typed discriminated union with all required tags | task-prompt §P1.4.2 ACs | Pass — 8 discriminants in KIND_ALL (F1.6); the prompt’s required set is subsumed (no_rule_matched, budget:*, effect_invariant_violated, axiom_violation:AX-01..AX-07, policy:P1..P13, rule_version_mismatch, ambiguous_ruleset). The 8th (rule_rejected) preserves admission’s existing surface. |
Each variant carries structured details payload |
task-prompt §P1.4.2 ACs | Pass — every variant declares typed payload fields; no message: string field anywhere (verified by source inspection). |
| Reason codes stable across upgrades — additive-only | task-prompt §P1.4.2 ACs | Pass — discriminant constants frozen; AX-01..AX-07 + P1..P13 + 3 BudgetAxes locked at this round (contract §7 + module JSDoc S1..S5). |
toString(reason) operator-readable rendering |
task-prompt §P1.4.2 ACs | Pass — renderDenialReason ships with a deterministic per-variant template (F3.1..F3.11). The exhaustive-switch fallthrough (_exh: never) guards against silent variant additions. |
| JSON serialization preserves discriminant tag | task-prompt §P1.4.2 ACs | Pass — serializeDenialReason always emits kind (canonicalize sorts keys, kind in every variant); parseDenialReason re-validates. F2 round-trip (every variant) + F5.4 sorted-key invariant. |
| Backward-compatible with admission.test.ts | dispatch packet | Pass — admission.test.ts updated only at F13.1 (added explicit default branch + policy_id field on the construction smoke). All other admission tests unchanged. F4–F12 admission tests pass without edit. |
| Determinism scanner clean | dispatch packet | Pass — F7.1..F7.4 assert inspectFunctionForbidden returns [] for every exported function. |
npm run build && npm run lint && npm test green |
CLAUDE.md §5 + dispatch packet | Pass — all three gates green at HEAD d595d6d2. |
| Audit committed before contract before packet before implementation before verification | CLAUDE.md §6 | Pass — commits: aad59fb4 (audit) → 36ab9754 (contract) → 53ccea23 (packet) → d595d6d2 (implementation) → this commit (verification). |
§5. Test matrix coverage
| Group | Test count | Coverage |
|---|---|---|
| F1 — Module shape | 10 | Every export verified; frozen-array + class assertions. |
| F2 — Round-trip per variant | 33 (one per fixture) + 1 extra | Every variant + every axis / axiom / policy / sentinel. Bare no_rule_matched round-trips without transition_type field present in the parsed result. |
| F3 — Render templates | 12 | Byte-exact match against contract §3 templates for every variant + tied / duplicate-name ambiguous_ruleset + null-transition tied edge case. |
| F4 — Parser rejection | 14 | invalid_json, array root, missing kind, unknown kind, wrong-type fields, unknown axis / axiom / policy_id, NaN / Infinity / float, null / string root, missing required field. |
| F5 — Canonical JSON byte stability | 5 | Same shape twice → same bytes. Field order in literal does not affect output. Forward-compat extra-key drop. Sorted-key invariant byte-checked. ambiguous_ruleset null transition_type round-trips. |
| F6 — Exhaustive switch | 1 | Compile-time + runtime: every fixture flows through the switch. |
| F7 — Determinism corpus self-scan | 4 | inspectFunctionForbidden over render / serialize / parse / isDenialReason returns []. |
| F8 — Determinism repeat | 3 | 10× serialize / 10× render / 10× isDenialReason for every fixture. |
| F9 — admission.ts compatibility | 2 | AdmissionDenialReason assignable to canonical DenialReason. The four admission emission shapes type-check as canonical literals. |
| F10 — Discriminant exhaustivity | 8 | Every AxiomId (7), PolicyDiscriminant (13), PolicySentinel (2), BudgetAxis (3) round-trips + per-id render template. + isDenialReason rejection corpus + alias assignability. |
Total: 60+ assertions across 10 test groups. Every variant exercised through every public function.
§6. Determinism corpus self-scan
inspectFunctionForbidden (P1.1.2) confirms denial-reasons.ts is free
of forbidden tokens (Math.*, Date.*, setTimeout, fetch, crypto.*,
process.hrtime/nextTick, await, async, float literals,
[native code]). The four exported functions return [] from the
scanner (F7.1..F7.4 in denial-reasons.test.ts).
The internal isOneOf and isFiniteInteger helpers are not exported;
their cleanliness is implicitly captured because the corpus self-scan
walks the source of isDenialReason (which uses them) and finds zero
forbidden tokens.
§7. Backward-compatibility evidence
§7.1. admission.ts emission
The four object-literal sites in evaluateAdmission:
kind: 'rule_version_mismatch'(admission.ts ~line 191) — unchanged shape; canonical type accepts.kind: 'policy'(admission.ts ~line 252) — minor edit: now includespolicy_idfield derived via the newderivePolicyDiscriminanthelper. Verified by build (compiles) + admission F4.1 admission test (still passes — assertion is onkind === 'rule_rejected').kind: 'no_rule_matched'(admission.ts ~line 280) — unchanged; canonical type accepts.kind: 'rule_rejected'(admission.ts ~line 297) — unchanged; canonical type accepts.
§7.2. admission.test.ts
Net change: F13.1 only. F1..F12 admission tests pass unchanged. F13.1’s
update was unavoidable: TypeScript’s noImplicitReturns requires a
default branch when the union widens, AND the runtime smoke calls had
to add policy_id to the policy literal.
§7.3. Module-level admission re-export
src/domains/rules/admission.ts exports DenialReason via:
export type { DenialReason } from './denial-reasons.js';
Consumers that import DenialReason from ./admission.js get the
canonical type. The four discriminants admission has emitted since P1.4.1
are still part of the union — no consumer needs to change its import.
§8. Open follow-ups (P1.4.3 / P1.4.4)
- Wire
RuleBudgetExceeded(engine.ts:461) directly through the typedBudgetReasonshape instead ofRuleRejectedReasonwithrule_reason: 'budget:*'string. - Wire
AmbiguousRulesetError(registry.ts:205) through the typedAmbiguousRulesetReasonshape at the loader / front-door catcher (today this throws at construct time; admission never sees it). - Substantive axiom predicate enforcement (validator §axiomCheck stubs at
validator.ts:528–605) — when those checks land, they emit
AxiomViolationReason. - Effect-shape invariant checks → emit
EffectInvariantViolatedReason.
These are not in scope for P1.4.2; the taxonomy publishes the typed shapes today so the wiring tasks can land additively.
§9. Round seal — handoff to PM / Sigma
Step 5 / 5 complete. The 5-step chain shipped:
aad59fb4 audit(p1-4-2): inventory surface
36ab9754 contract(p1-4-2): behavioral contract
53ccea23 packet(p1-4-2): execution plan
d595d6d2 feat(p1-4-2): denial reason taxonomy
<this> verify(p1-4-2): test evidence
Branch: feature/p1-4-2-denial-reasons
Worktree: .worktrees/claude/p1-4-2-denial-reasons
Base: 0b124c17 (R87 Wave 6 P1.4.1)
Ready for PR open + squash-merge per the dispatch packet’s PR creation block.