Audit — P1.4.3 Admission Budgets
Round: R87 κ Wave 7
Branch: feature/p1-4-3-budget
Worktree: .worktrees/claude/p1-4-3-budget
Base SHA: 0b124c17 (origin/main, 2026-05-07 morning)
Owner: T3 executor (Claude)
β task ID: 6f474dec-885f-433b-90f9-4bafb45cd725
Step: 1 of 5 — inventory the surface
1. Mandate (from task-prompts §P1.4.3)
Budget tracker class with counters:
integer_ops,call_depth,current_arg_count. Limits exported as constants:MAX_INTEGER_OPS=10_000,MAX_CALL_DEPTH=16,MAX_ARG_COUNT=8. On exceed: throwRuleBudgetExceededcarrying which-counter-fired field. Instrumentation hooks: emitbudget.tickevents for α audit (count only). Budget state resets per-rule. Limits documented as participating in rule version hash via P1.5.1.
Reference: docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md §P1.4.3 lines 1926–2066.
2. Pre-existing budget surface
The previous κ wave (P1.3.1, R85, PR #208 @ d766db59) shipped an inline budget mechanism inside src/domains/rules/engine.ts. Auditing line-by-line:
2.1. Constants (engine.ts lines 70–82)
MAX_INTEGER_OPS = 10_000
MAX_CALL_DEPTH = 16
MAX_ARG_COUNT = 8
These three exports are exactly the values §P1.4.3 mandates. They are imported by src/__tests__/domains/rules/engine.test.ts (lines 35–37) for the F4.x budget tests.
2.2. BudgetTracker interface (engine.ts lines 129–133)
export interface BudgetTracker {
integer_ops: number;
call_depth: number;
current_arg_count: number;
}
Plain mutable struct. No methods, no listeners, no reset, no snapshot. Used as a stack-local object in Context.budget and mutated by helper functions inside engine.ts.
2.3. RuleBudgetExceeded class (engine.ts lines 211–226)
export class RuleBudgetExceeded extends Error {
override readonly name = 'RuleBudgetExceeded';
readonly which: 'integer_ops' | 'call_depth' | 'arg_count';
readonly limit: number;
readonly observed: number;
constructor(which, limit, observed) { … }
}
Already carries the which, limit, observed fields §P1.4.3 mandates.
2.4. Helper functions (engine.ts lines 237–266)
freshBudget(): BudgetTracker— allocates{ integer_ops: 0, call_depth: 0, current_arg_count: 0 }. Called once per rule byexecuteRuleset(§5, line 712).bumpIntegerOps(b): void—b.integer_ops += 1; if (b.integer_ops > MAX_INTEGER_OPS) throw ….bumpCallDepth(b): void—b.call_depth += 1; if (b.call_depth > MAX_CALL_DEPTH) throw ….
2.5. Arg-count overflow (engine.ts line 594)
The arg-count check is inline inside evaluateExpr’s FuncCall case:
if (expr.args.length > MAX_ARG_COUNT) {
throw new RuleBudgetExceeded('arg_count', MAX_ARG_COUNT, expr.args.length);
}
There is no helper for it. The audit observation: an arg_count “tick” is implicitly only emitted when overflow throws — there is no per-call observability.
2.6. Per-rule reset
executeRuleset (engine.ts line 706–712) freshly constructs the Context per rule iteration with budget: freshBudget(). Reset is “fresh-instance per rule”; there is no reset() method.
2.7. What is missing vs §P1.4.3
| §P1.4.3 mandate | Status in pre-existing surface |
|---|---|
| 3 counters (integer_ops, call_depth, current_arg_count) | ✓ shipped (interface fields) |
| 3 limit constants (MAX_*) | ✓ shipped (named exports) |
Throw RuleBudgetExceeded with which-counter-fired |
✓ shipped (class + 3 throw sites) |
| Per-rule reset (no leak across rules) | ✓ shipped (fresh-instance per rule) |
| Limits in version hash (P1.5.1) | ⚠ implicit — see §6 below |
| Class with reset/snapshot/etc | ✗ missing — only an interface struct |
Instrumentation hooks (budget.tick events for α audit) |
✗ missing — no observer pattern |
BudgetTick event shape |
✗ missing |
Logical (non-wall-clock) at field |
✗ N/A (no events yet) |
subscribe(listener) → unsubscribe |
✗ missing |
snapshot(): BudgetSnapshot |
✗ missing |
freshTracker() factory or reset() method |
✗ missing |
Dedicated module file src/domains/rules/budget.ts |
✗ missing |
Dedicated test file src/__tests__/domains/rules/budget.test.ts |
✗ missing |
The 3-counter contract and the throw contract are already production-grade. The observability surface (BudgetTick listener pattern, snapshot, reset method, dedicated module) is what this round ships.
3. Existing test contract — engine.test.ts F4.x and F9.5
src/__tests__/domains/rules/engine.test.ts imports four budget symbols from engine.ts:
RuleBudgetExceeded (engine.test.ts:34)
MAX_INTEGER_OPS (engine.test.ts:35)
MAX_CALL_DEPTH (engine.test.ts:36)
MAX_ARG_COUNT (engine.test.ts:37)
Used in:
- F4.1, F4.2 — integer-ops cap (lines 372–404)
- F4.3, F4.4 — call-depth cap (lines 407–443)
- F4.5, F4.6 — arg-count cap (lines 446–478)
- F8.x — per-rule reset (lines 775–833) — relies on
executeRuleset’s fresh-instance behavior - F9.5 — RuleBudgetExceeded carries which/limit/observed (lines 833–838)
Constraint: the four imports above MUST continue to resolve identically. Renaming or moving them under non-re-exported paths breaks engine.test.ts. We therefore preserve them as engine.ts exports — by re-exporting from budget.ts.
3.1. Test surface helper construction
makeContext in engine.test.ts (line ~150) constructs a Context with budget: freshBudget() where freshBudget is not exported from engine.ts — the test re-implements it as {integer_ops:0, call_depth:0, current_arg_count:0} literal. This means the test treats BudgetTracker as an interface with mutable plain fields, NOT as a class instance. We must NOT change engine.ts’s BudgetTracker to a class without breaking this test.
Conclusion: keep the BudgetTracker interface in engine.ts (struct-shaped). Ship a NEW class BudgetTracker in budget.ts with the methods (reset, subscribe, snapshot, tickIntegerOp, pushCall, popCall) per §P1.4.3 — and ship freshBudget() as a re-export from budget.ts that returns the struct shape (compatible with the interface).
The class in budget.ts and the struct in engine.ts coexist by structural compatibility: a class instance whose first three fields are integer_ops/call_depth/current_arg_count IS a BudgetTracker (interface satisfied). Interfaces in TypeScript are structural; a class with extra methods passes any interface that names a strict subset of its fields.
4. Downstream consumers
4.1. src/domains/rules/admission.ts (P1.4.1)
Imports from engine.js:
import { CATEGORY_ORDER, executeRuleset } from './engine.js';
import type { Category, CategorizedRule, Mutation, RuleResult } from './engine.js';
Does NOT import budget surface — admission delegates to the engine, which holds the budget. P1.4.1 admission tests therefore do not depend on budget exports beyond what the engine surfaces.
4.2. src/domains/rules/policy-gate.ts (P1.3.4)
Imports from engine.js:
import { MAX_INTEGER_OPS } from './engine.js';
Pure constant import for cap-checking inside the policy expression evaluator. Will continue to work if MAX_INTEGER_OPS is re-exported from engine.ts (which it will be).
4.3. src/domains/rules/builtins.ts (P1.3.2)
Exports BUILTIN_COSTS: ReadonlyMap<string, number>. Does not import budget surface today. P1.4.3’s responsibility is the counter envelope, not the per-builtin cost charging — that wiring is a follow-up engine slice (per builtins.ts line 40 “Charging BUILTIN_COSTS against the engine budget — engine slice”). We document the relationship in the contract.
4.4. src/domains/rules/versioning.ts (P1.5.1)
computeVersionHash(ruleset, engine_version?) — hashes the canonical ruleset bytes plus the engine version string. Importantly: it does NOT take the budget limits as an explicit input. The limits’ participation in the version hash is implicit through ENGINE_VERSION — when limits change, ENGINE_VERSION must bump in lockstep, and the existing version hash recomputes.
This is the documentation gap. engine.ts does not state this. versioning.ts’s preamble does not state this. P1.4.3 will surface the relationship in the budget.ts file’s preamble + in a verification-doc note. The mechanism is unchanged; only the rationale becomes explicit.
5. Determinism scanner posture
determinism.ts exports inspectFunctionForbidden(fn) which scans fn.toString() for forbidden tokens (Math., Date., async, await, fetch, crypto.*, RNG, float literals). The corpus self-scan over budget.ts’s exports must return []. Specifically:
- No
Date.now()in tick-event timestamps. Use a logical monotonic counter (auto-incremented on every tick). - No
Math.random()anywhere. - No
crypto.*— budget.ts has no need to hash. - No
await/async— pure synchronous. - No float literals — all counts are integers.
- No setTimeout/setInterval/setImmediate — pure.
- No fetch / XMLHttpRequest — pure.
The test file will assert:
expect(inspectFunctionForbidden(BudgetTracker.prototype.tickIntegerOp)).toEqual([]);
expect(inspectFunctionForbidden(BudgetTracker.prototype.pushCall)).toEqual([]);
expect(inspectFunctionForbidden(BudgetTracker.prototype.popCall)).toEqual([]);
expect(inspectFunctionForbidden(BudgetTracker.prototype.reset)).toEqual([]);
expect(inspectFunctionForbidden(freshBudget)).toEqual([]);
6. Limits ↔ rule version hash dependency (P1.5.1)
The chain is:
budget.ts exports MAX_INTEGER_OPS / MAX_CALL_DEPTH / MAX_ARG_COUNT (literal int constants)
│
│ consumed by
▼
engine.ts re-exports them and uses them in bumpIntegerOps / bumpCallDepth / FuncCall arg check
│
│ any change to these literals requires
▼
versioning.ts ENGINE_VERSION bump (otherwise the engine evaluates differently from before
under the same rule_version_hash, breaking θ consensus)
│
│ computeVersionHash(ruleset, ENGINE_VERSION) outputs new hash
▼
arbiters reject votes with old rule_version → forced lockstep upgrade
Mechanism: the limits do NOT enter computeVersionHash’s direct input — they enter via ENGINE_VERSION as the human-managed lockstep token. The contract / docstrings on budget.ts document this explicitly: “any change to these constants is an engine-binary semantic change and MUST bump ENGINE_VERSION in versioning.ts.”
This is documented today only in versioning.ts’s general “engine version sensitivity” paragraph. P1.4.3 makes it explicit at the constant-declaration site (in budget.ts) so future maintainers see the obligation when editing the literals.
The verification doc records this as a checklist item.
7. Inline-tracker → module integration plan
The prompt offers (a) replace engine.ts’s inline tracker with imports from budget.ts (preferred for code sharing) or (b) ship parallel and document.
Choice: hybrid (a)+(b).
- (a) budget.ts becomes the canonical home of
MAX_INTEGER_OPS,MAX_CALL_DEPTH,MAX_ARG_COUNT,RuleBudgetExceeded,BudgetTracker(interface),freshBudget. engine.ts re-exports those names so all downstream test/code paths continue to work without edits. - (b) budget.ts adds a NEW
class BudgetTrackerImpl(fully observable via subscribe + snapshot, withtickIntegerOp/pushCall/popCall/resetmethods). engine.ts’s internalbumpIntegerOps/bumpCallDepthcontinue to operate on the plain interface (zero behavior change). The class is the future API; the interface is the present plumbing. Both ship today; engine refactor to consume the class is a follow-up that need not block §P1.4.3 acceptance.
Why hybrid? Pure (a) — moving the interface and helpers to budget.ts and re-exporting from engine.ts — is the minimum-viable extraction that satisfies §P1.4.3’s “dedicated module” mandate and avoids breaking engine.test.ts. Pure (b) — shipping a parallel class — satisfies the “class with observability hooks” mandate. Doing both in one file keeps the surface coherent: limit constants, error class, interface, helpers, AND the observable class all live in one canonical spot.
Naming: the class is BudgetTracker — same name as the interface. TypeScript permits a class to share a name with an interface; the resulting symbol is the union of both. Class instances satisfy the interface (because the class fields are a structural superset). This is the conventional pattern for “interface + default class implementation” and matches how downstream callers want to write new BudgetTracker().
8. Risk register
| Risk | Mitigation |
|---|---|
| engine.test.ts breaks if exports move | re-export the four symbols from engine.ts so import paths resolve identically |
| listener throws cascade into evaluator | dispatch listeners with try/catch; swallow listener errors silently (per §P1.4.3 forbidden #3 — listeners are observers, not gates) |
| listener mutates tracker state inside callback | freeze the tick event object before dispatch; document but do not enforce against mutation of tracker fields (callbacks see snapshot copies) |
| Date.now() leak via ide auto-import | scanner test inspectFunctionForbidden(BudgetTracker.prototype.tickIntegerOp) etc enforces |
| arg_count counter never observed (only thrown) | pushCall now emits a call_push BudgetTick BEFORE checking the arg cap, so observers see the would-be-overflow attempt |
| Per-rule reset semantics drift | freshBudget() allocates a NEW interface; BudgetTracker.prototype.reset() zeroes existing instance — both supported, both tested |
| Engine’s inline tracker still mutates fields not via methods | leave engine.ts inline-mutation behavior unchanged this round — class methods are an OPT-IN forward path |
9. Determinism / θ-consensus impact
Zero. The inline tracker behavior in engine.ts is unchanged (same bumpIntegerOps / bumpCallDepth paths, same arg_count guard at line 594). The class adds OBSERVATION on top, not new mutation paths. Two arbiters running the same executeRuleset(...) continue to produce bit-identical TransitionResult.all_mutations.
10. Files to touch
Add:
src/domains/rules/budget.ts(~250 LOC — class + interface + 3 constants + RuleBudgetExceeded + helpers + types)src/__tests__/domains/rules/budget.test.ts(~400 LOC — 25+ tests covering the matrix below)
Edit:
src/domains/rules/engine.ts— change the constants/error/interface/freshBudgetdeclarations into re-exports from./budget.js. No behavior change. InternalbumpIntegerOps/bumpCallDepth/FuncCall arg checkcontinue to use the locally-imported names.
Untouched:
src/domains/rules/admission.ts,policy-gate.ts,builtins.ts,versioning.ts— all current imports continue to resolve.src/__tests__/domains/rules/engine.test.ts— F4.x/F8.x/F9.5 imports unchanged.
11. Acceptance criteria coverage matrix
| §P1.4.3 AC | Audit verdict |
|---|---|
| Class with 3 counters | covered — class fields integer_ops, call_depth, current_arg_count |
| 3 limit constants (10000/16/8) | covered — exports |
Throw RuleBudgetExceeded with which field |
covered — error class re-exported, all 3 throw paths preserved |
Instrumentation hooks (budget.tick events) |
covered — BudgetTick shape, subscribe(listener) → unsubscribe |
| Per-rule reset | covered two ways — reset() method + freshBudget() factory |
| Limits in rule version hash | covered as DOCUMENTED dependency on ENGINE_VERSION (§6 above) |
| Determinism scanner clean | covered — no Date.now / no float / no async; corpus self-scan asserted in tests |
| engine.test.ts continues to pass | covered — engine.ts re-exports preserve all import paths |
Audit complete — 2026-05-07. Step 1 of 5 in the executor chain.