Audit — P1.1.2 Determinism Verification Harness (R83.A)
Surface inventory
Target directory: src/domains/rules/
Target state: P1.1.1 landed on main via R81.A (PR #173). Directory now contains
exactly one file.
$ ls src/domains/rules/
integer-math.ts
P1.1.1 live surface (the module this harness wraps):
$ wc -l src/domains/rules/integer-math.ts
183 src/domains/rules/integer-math.ts
Exported symbols (cited for the contract’s AC#2):
| Line | Symbol | Kind |
|---|---|---|
| 37 | OverflowError |
exported class extends Error |
| 45 | DivisionByZeroError |
exported class extends Error |
| 53 | UnderflowError |
exported class extends Error |
| 85 | bps_mul(value, bps) |
exported function, (bigint, bigint) => bigint |
| 96 | bps_div(value, bps) |
exported function, throws on bps === 0n |
| 110 | apply_bps(value, bps) |
exported function |
| 126 | decay(value, rate_bps, epochs) |
exported function, throws on epochs < 0n |
| 153 | safe_mul(a, b) |
exported function, throws OverflowError |
| 178 | safe_div(a, b) |
exported function, throws DivisionByZeroError |
All six helpers accept bigint and return bigint. None take options objects. None
have side effects. This is the total surface P1.1.2 must prove deterministic.
Test location convention (same as R81.A §Test-file location note):
The pre-authored prompt at docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.1.2 line 246 names
src/domains/rules/__tests__/determinism.test.ts, but the shipped Phase 0 convention per
CLAUDE.md §9.1 is a single root at src/__tests__/ with the source tree mirrored under it
(e.g. src/__tests__/domains/router/scoring.test.ts matches src/domains/router/scoring.ts).
Every one of the 1123 existing tests on main follows this layout. R81.A audit already
locked this convention for κ tests at src/__tests__/domains/rules/integer-math.test.ts.
jest.config.ts line 16 has testMatch: ['**/__tests__/**/*.test.ts', …] so either
layout would run; we follow R81.A’s convention.
Related existing surfaces that must NOT be touched:
src/domains/tasks/(β) — task pipeline; unrelated.src/domains/router/(δ) — Phase 0 stubs; unrelated.src/domains/proof/(η) — Merkle surface; unrelated.src/domains/skills/(ε),src/domains/trail/(ζ),src/domains/integrations/(ν) — none touched.src/db/— harness is pure in-memory; no schema change.src/server.ts— no new MCP tool is registered by P1.1.2; library-only surface.
Source of authority (reading list)
docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md§P1.1.2 (lines 199–318) — the pre-authored canonical prompt, authoritative spec for files, acceptance, gotchas, writeback.docs/guides/implementation/task-breakdown.md§P1.1.2 (lines 482–492) — roadmap entry: “property test · no Math.random/Date.now · no async · fuzz test 10k tuples · static grep rejects Math.*/Date.* outside tests · suite <10s”.docs/3-world/physics/laws/rule-engine.md§Forbidden operations (lines 45–50, 137) — concept doc: “Clock reads forbidden; Randomness forbidden; Floating-point forbidden in rule bodies.”src/domains/rules/integer-math.ts— the P1.1.1 helpers under test.src/__tests__/domains/rules/integer-math.test.ts— prior-art test layout.CLAUDE.md— §3 worktree, §5 triple gate, §6 5-step chain, §7 writeback (hard-block atsrc/domains/tasks/writeback.ts:97).- Orchestrator dispatch prompt — adds a harness library file
(
src/domains/rules/determinism.ts) beyond what the canonical p1.1-… file specifies test-only. The library surface is the orchestrator’s expansion.
Scope divergence vs. task-prompts doc (recorded for reviewer)
The original p1.1-… file’s “Files to create” lists one file
(src/domains/rules/__tests__/determinism.test.ts), with the harness logic
inline inside the test. The R83 orchestrator dispatch prompt (the
authoritative spec for THIS execution per §7 of the orchestrator guidance)
splits that into two files:
| # | Path | Role |
|---|---|---|
| 1 | src/domains/rules/determinism.ts |
Reusable harness library (runN + code-inspection) |
| 2 | src/__tests__/domains/rules/determinism.test.ts |
Tests exercising the harness |
The reusable-library shape is strictly more capable than the inline-only
shape: P1.1.3, P1.3.1, P1.3.2 can all import determinism.ts to re-assert
their own helpers rather than re-rolling the harness. This aligns with the
orchestrator prompt’s framing: “This is a guardrail for all of Phase 1.”
Convention-wise, src/domains/rules/determinism.ts is production code so it
must itself honor the κ determinism invariants (no floats, no RNG, no I/O).
File plan
Two files. Both net-new.
| Path | Role | Est. LOC |
|---|---|---|
src/domains/rules/determinism.ts |
Pure harness library (runN + function-source inspector) | ~140 |
src/__tests__/domains/rules/determinism.test.ts |
Tests exercising harness + fs-side source scanner | ~270 |
API surface (for src/domains/rules/determinism.ts)
All functions are synchronous, pure, and operate on bigint or structurally
comparable values. No Math.*, no Date.*, no async, no RNG.
// Error class for determinism-violation reports
export class DeterminismError extends Error {}
// Run a function `n` times with the same args; throw DeterminismError if
// any pair of outputs (or thrown errors) differs from the first call.
// `n` defaults to 10.
export function assertDeterministic<A extends readonly unknown[], R>(
fn: (...args: A) => R,
args: A,
opts?: { iterations?: number; label?: string },
): R;
// Regex-scan a function body for forbidden-op tokens (Math., Date., random,
// setTimeout/setInterval, fetch, import 'fs', require('fs'), await, async,
// crypto.random, .hrtime, .nextTick, float literals like 3.14).
// Returns the list of offending tokens; empty array means clean.
export function inspectFunctionForbidden(fn: Function): readonly string[];
// Convenience wrapper: throws DeterminismError if inspectFunctionForbidden
// returns a non-empty list.
export function assertNoForbiddenOps(
fn: Function,
opts?: { label?: string },
): void;
// Deep-equality helper that understands bigint, array, plain-object, typed
// error (`.name` + `.message`), and primitives. Exported so tests can reuse it.
export function deepEqualDeterministic(a: unknown, b: unknown): boolean;
Forbidden-ops regex patterns (spec’d in contract §4)
The inspector compiles a fixed readonly list of regexes applied against
fn.toString():
| Pattern | Rejects |
|---|---|
/\bMath\.[A-Za-z_]\w*/ |
Math.random, Math.floor, any Math.* |
/\bDate\.[A-Za-z_]\w*/ |
Date.now, Date.parse, any Date.* |
/\bnew\s+Date\b/ |
new Date() |
/\bsetTimeout\b\|\bsetInterval\b\|\bsetImmediate\b/ |
timers |
/\bfetch\b\|\bXMLHttpRequest\b/ |
network |
/\brequire\s*\(\s*['"]fs['"]/ |
CJS fs |
/\bfrom\s+['"]fs['"]/ |
ESM fs |
/\bcrypto\.[A-Za-z_]\w*/ |
crypto randomness |
/\bprocess\.hrtime\b\|\bprocess\.nextTick\b/ |
process clock/queue |
/\bawait\b/ |
async |
/\basync\s+(function\|\()/ |
async function |
/\b\d+\.\d+\b/ (but not 1n) |
float literal |
Matched tokens are deduped. Regex semantics are intentionally token-level:
false positives are acceptable (tests can inline-ignore when reviewing),
false negatives are not (a missed Date.now breaks consensus).
Test strategy
Harness: Jest 29 (ts-jest ESM), same as R81.A.
Coverage target: 100% branches on determinism.ts. Jest runs with
collectCoverage: true per jest.config.ts, so npm test emits an lcov
report the verification step reads.
Test-case matrix (expanded in packet):
| Group | Cases | Purpose |
|---|---|---|
assertDeterministic happy path |
All six P1.1.1 helpers wrapped; 10 iterations each | AC#2 — real deterministic code passes |
assertDeterministic throw-path |
wrap a function that throws; assert harness treats consistent throws as deterministic | Error-equality branch |
assertDeterministic FAIL detection |
Deliberately non-deterministic mock () => Date.now() fails with DeterminismError mentioning the two differing outputs |
AC#3 — detects drift |
assertDeterministic label propagation |
Custom label flows into error message | Readability branch |
assertDeterministic iterations override |
iterations=2 exits after 2 calls; iterations=1 no-ops | Option-branch |
inspectFunctionForbidden happy path |
integer-math helpers return empty array | AC#4 — clean code reports clean |
inspectFunctionForbidden Math.* |
() => Math.random() returns ["Math.random"] |
RNG detection |
inspectFunctionForbidden Date.* |
() => Date.now() returns ["Date.now"] |
Clock detection |
inspectFunctionForbidden new Date |
() => new Date() returns ["new Date"] |
Constructor form |
inspectFunctionForbidden timers |
() => { setTimeout(()=>{},0); } returns ["setTimeout"] |
Timer detection |
inspectFunctionForbidden fetch |
() => fetch('x') returns ["fetch"] |
Network |
inspectFunctionForbidden crypto |
() => crypto.randomBytes(4) returns ["crypto.randomBytes"] |
Crypto |
inspectFunctionForbidden process.hrtime |
() => process.hrtime() returns ["process.hrtime"] |
hrtime |
inspectFunctionForbidden await |
async () => await x returns both ["async function", "await"] |
Async |
inspectFunctionForbidden float literal |
() => 3.14 returns ["3.14"] |
Float leakage |
assertNoForbiddenOps |
throws on any non-empty inspect result; no-op on clean; label-propagation check | AC#5 — wrapper branch |
deepEqualDeterministic |
equal bigints, unequal bigints, arrays, objects, nested, primitives, null/undefined, mixed-type, different-key-length | Equality helper branches |
DeterminismError |
instanceof DeterminismError, .name === 'DeterminismError', message contains label |
Typed-error surface |
| FS-side full-corpus scan | Read src/domains/rules/**/*.ts (exclude __tests__); assert NO forbidden tokens in any shipped κ file |
AC#4 prod-guard |
Expected test count: ~30 it blocks → ~30 new tests. Brings suite from
1123 → ~1153.
Integration / side-effects
- No DB writes. Pure library; no import from
src/db/*. - No MCP tool registration.
src/server.tsis untouched. Tool-surface count stays at 14. - No ADR change. P1.1.2 is a harness around P1.1.1; no new architecture decision needed.
- No docs outside the 5-step-chain quartet. No concept-doc edit; the κ concept doc already describes “Forbidden operations” at spec granularity.
- No frontmatter graduation. κ stays
colibri_code: noneuntil the evaluator loop ships (P1.3.1 earliest; P1.4.1 most likely).
Gate expectations
npm run build— clean. NodeNext strict mode; bigint literals OK attarget: ES2022. Noanyunless explicitlyFunction(the introspection target type has no better choice).npm run lint— clean.eqeqeq: error→ always===.curly: all→ brace single-line ifs.@typescript-eslint/no-explicit-any: warnmay require a single// eslint-disable-next-lineon theFunction-typed inspector argument (this is intentional: we accept any function shape).npm test— pre-existing 1123 + ~30 new ≈ 1153.startup — subprocess smokepre-existing flake may hit once; rerun-once policy.
Rollback
Single-commit revert is trivial because this task creates net-new files and
imports from nowhere outside src/domains/rules/integer-math.ts:
git revert <impl-sha>
Nothing downstream can regress because nothing else yet imports
src/domains/rules/determinism.ts.
Unblocks
- Guardrail for every compute sub-task in P1.2–P1.5 per task-prompts.md line 207.
- P1.1.3 (BPS Constants) can use
assertDeterministicto re-validate its constants-with-safe-arith composition. - P1.3.1 (Core evaluation loop) can use the FS-side scanner as a CI guard.
Done-criteria
- Target surface confirmed (P1.1.1 shipped; only
integer-math.tsinsrc/domains/rules/). - Authority documents read end-to-end.
- File plan fixed: 2 files, ~410 LOC total.
- Test strategy + case matrix drafted (expanded in packet).
- Test-file location convention aligned with R81.A (follows CLAUDE.md §9.1 + shipped layout).
- Scope divergence from p1.1-… doc (2-file vs 1-file) recorded.
- Integration surface confirmed (zero wiring; pure lib).
- Rollback plan defined.