Packet — P1.4.3 Admission Budgets
Module: src/domains/rules/budget.ts (new)
Round: R87 κ Wave 7
Branch: feature/p1-4-3-budget
Step: 3 of 5 — execution plan
Predecessors:
1. Goal
Ship src/domains/rules/budget.ts and src/__tests__/domains/rules/budget.test.ts. Convert src/domains/rules/engine.ts’s inline budget surface into re-exports of the new module. Zero behavior change to existing tests; new module satisfies all §P1.4.3 acceptance criteria.
2. File-by-file plan
2.1. src/domains/rules/budget.ts (new)
Sections in order (top to bottom):
§0 Module preamble (purpose + canonical refs + determinism guardrails + version-hash dep)
§1 Constants — MAX_INTEGER_OPS / MAX_CALL_DEPTH / MAX_ARG_COUNT / DEFAULT_LIMITS
§2 Types — BudgetTracker (interface) / BudgetSnapshot / BudgetTickKind
/ BudgetTick / BudgetListener / BudgetUnsubscribe / BudgetTrackerLimits
§3 Class RuleBudgetExceeded
§4 freshBudget() factory
§5 Class BudgetTracker
§5.1 Constructor + private state
§5.2 tickIntegerOp()
§5.3 pushCall(arg_count)
§5.4 popCall()
§5.5 reset()
§5.6 snapshot()
§5.7 subscribe(listener) → unsubscribe
§5.8 Private helpers — emit(), computeSnapshot()
LOC budget: ~250–280 lines including JSDoc.
Detailed implementation choices
-
Class field declarations. Use class-property syntax (TypeScript 5.x supports field initializers natively). Mutable fields
integer_ops,call_depth,current_arg_countare public (to satisfy structural interface compat). Helper state —private _at: bigint = 0n,private _listeners: BudgetListener[] = []— is private. -
Frozen tick events. Build the snapshot, build the event object literal,
Object.freeze(event.counter_snapshot.limits)thenObject.freeze(event.counter_snapshot)thenObject.freeze(event). Three freezes; no mutable seam left. - Listener dispatch.
private emit(kind: BudgetTickKind): void { if (this._listeners.length === 0) return; const snap = this.snapshot(); const event: BudgetTick = Object.freeze({ kind, at: this._at, counter_snapshot: snap }); this._at += 1n; // Snapshot the listener list — protect against mutation during iteration. const listeners = this._listeners.slice(); for (const l of listeners) { try { l(event); } catch { /* observers cannot block */ } } }The
atincrements AFTER the event is built but BEFORE listeners run, so each subsequent emission has a strictly greaterat. The first event hasat: 0n, the second1n, etc.reset()re-zeroes_at. -
No emit when no listeners. Performance: skip the snapshot allocation when nobody subscribed. This is observed-behavior-equivalent (an unobserved tick is not different from no tick).
pushCallordering (matches engine.ts:594 fail-fast):pushCall(arg_count: number): void { this.emit('call_push'); if (arg_count > this.limits.arg_count) { throw new RuleBudgetExceeded('arg_count', this.limits.arg_count, arg_count); } this.call_depth += 1; this.current_arg_count = arg_count; if (this.call_depth > this.limits.call_depth) { throw new RuleBudgetExceeded('call_depth', this.limits.call_depth, this.call_depth); } }Note: the emit happens FIRST, with the snapshot showing pre-increment depth. So observers see “we’re about to push N args at depth D” — useful for tracing the offending frame.
popCall:popCall(): void { this.emit('call_pop'); if (this.call_depth > 0) this.call_depth -= 1; this.current_arg_count = 0; }Clamp at 0 to handle unbalanced pop without crashing. The emit shows pre-decrement state so observers can match push/pop pairs by depth value.
tickIntegerOp:tickIntegerOp(): void { this.integer_ops += 1; this.emit('integer_op'); if (this.integer_ops > this.limits.integer_ops) { throw new RuleBudgetExceeded('integer_ops', this.limits.integer_ops, this.integer_ops); } }Increment first (so the snapshot in the tick reflects the new count), THEN emit, THEN check. Identical observable order to engine.ts’s
bumpIntegerOps(which increments then checks; no emit step there).reset():reset(): void { this.integer_ops = 0; this.call_depth = 0; this.current_arg_count = 0; this._at = 0n; }Listeners and limits preserved.
snapshot():snapshot(): BudgetSnapshot { const limitsCopy = Object.freeze({ integer_ops: this.limits.integer_ops, call_depth: this.limits.call_depth, arg_count: this.limits.arg_count, }); return Object.freeze({ integer_ops: this.integer_ops, call_depth: this.call_depth, current_arg_count: this.current_arg_count, limits: limitsCopy, }); }Frozen, allocation-bounded; called inside
emitand on demand by external observers.subscribe:subscribe(listener: BudgetListener): BudgetUnsubscribe { this._listeners.push(listener); return () => { const idx = this._listeners.indexOf(listener); if (idx >= 0) this._listeners.splice(idx, 1); }; }Linear-time unsubscribe; fine for small N. The unsubscribe is idempotent (calling twice is a no-op once removed).
- Constructor:
constructor(limits?: Partial<BudgetTrackerLimits>) { this.limits = Object.freeze({ integer_ops: limits?.integer_ops ?? DEFAULT_LIMITS.integer_ops, call_depth: limits?.call_depth ?? DEFAULT_LIMITS.call_depth, arg_count: limits?.arg_count ?? DEFAULT_LIMITS.arg_count, }); this.integer_ops = 0; this.call_depth = 0; this.current_arg_count = 0; this._at = 0n; this._listeners = []; }??not||so a hypotheticallimits: { integer_ops: 0 }is honored (falsy 0 not coerced to default).
2.2. src/domains/rules/engine.ts (edit)
Diff posture: REPLACE the inline declarations with re-exports; PRESERVE all internal helper bodies.
| Line range (current) | Action |
|---|---|
| 70–82 (constants) | Delete; replace with import { MAX_INTEGER_OPS, MAX_CALL_DEPTH, MAX_ARG_COUNT } from './budget.js'; and export { … } |
| 124–133 (BudgetTracker interface) | Delete; replace with export type { BudgetTracker } from './budget.js'; |
| 211–226 (RuleBudgetExceeded class) | Delete; replace with export { RuleBudgetExceeded } from './budget.js'; |
| 237–239 (freshBudget) | Delete; replace with import { freshBudget } from './budget.js'; (used at line 712 inside executeRuleset) |
| 249–266 (bumpIntegerOps, bumpCallDepth) | KEEP — internal helpers, unchanged behavior |
| 594 (FuncCall arg check) | KEEP — unchanged |
712 (budget: freshBudget()) |
KEEP — now uses the re-imported helper |
The bottom of the file does not need to change. Imports go at the top.
After edit, the file’s exported set is byte-identical (same names, same identities), and the evaluator’s hot path is unchanged.
2.3. src/__tests__/domains/rules/budget.test.ts (new)
Sections matching contract §6 test plan:
F1 — constants
F2 — RuleBudgetExceeded
F3 — freshBudget()
F4 — class construction
F5 — tickIntegerOp
F6 — pushCall
F7 — popCall
F8 — reset
F9 — subscribe
F10 — listener errors
F11 — frozen events
F12 — `at` is logical
F13 — snapshot
F14 — determinism scan
F15 — engine.ts re-exports identity
F16 — interface compat at runtime
Targeting ~50 tests, ~400–500 LOC. Heavy use of jest matchers; minimal helpers (one factory makeTracker(overrides?) for partial-limit cases).
Specific assertions
- F1.1
MAX_INTEGER_OPS === 10_000,MAX_CALL_DEPTH === 16,MAX_ARG_COUNT === 8. - F1.2
DEFAULT_LIMITS.integer_ops === MAX_INTEGER_OPS(and call_depth, arg_count). -
F1.3
DEFAULT_LIMITSis frozen (Object.isFrozen(DEFAULT_LIMITS)). - F2.1
new RuleBudgetExceeded('integer_ops', 10000, 10001)→ fields populated;.name === 'RuleBudgetExceeded';.message === 'RuleBudgetExceeded: integer_ops 10001 > 10000'. -
F2.2
instanceof Errorandinstanceof RuleBudgetExceeded. - F3.1
freshBudget()returns{integer_ops:0, call_depth:0, current_arg_count:0}. - F3.2 Two calls return distinct objects.
-
F3.3 Returned struct is mutable (assignment to
.integer_opssucceeds). - F4.1 Default-constructed tracker has counters at 0.
- F4.2
tracker.limitsequalsDEFAULT_LIMITSvalue-wise. - F4.3
new BudgetTracker({ integer_ops: 5 })has limits{integer_ops:5, call_depth:16, arg_count:8}. - F4.4
new BudgetTracker({ integer_ops: 0, call_depth: 0, arg_count: 0 })honors zero values (no||coercion). -
F4.5
tracker.limitsis frozen. - F5.1
tickIntegerOpincrementsinteger_ops. - F5.2 Tick emits one
'integer_op'event per call. - F5.3
MAX_INTEGER_OPSticks are OK; the (MAX+1)-th throwsRuleBudgetExceeded('integer_ops', 10000, 10001). -
F5.4 After throw,
integer_ops === 10001(throw is post-increment). - F6.1
pushCall(0)incrementscall_depthto 1. - F6.2
pushCall(8)is OK (== limit). - F6.3
pushCall(9)throwsRuleBudgetExceeded('arg_count', 8, 9)from depth 0 (NO depth mutation occurs). - F6.4 16 pushCall(0) calls OK; 17th throws
'call_depth'. - F6.5 Each pushCall emits one
'call_push'event BEFORE any throw. -
F6.6 After arg_count throw at depth 0,
call_depthis still 0 andcurrent_arg_countis still 0 (no mutation). - F7.1
popCallafterpushCall(2)decrements depth and zeroescurrent_arg_count. - F7.2
popCallat depth 0 does not throw, does not go negative. -
F7.3
popCallalways emits one'call_pop'event. - F8.1 After 5 ticks,
reset()zeroesinteger_ops. - F8.2
reset()zeroescall_depthandcurrent_arg_count. - F8.3
reset()zeroes the logicalatcounter (next emitted event hasat === 0n). - F8.4
reset()preserveslimits(still frozen, same values). -
F8.5
reset()preserves listeners (a previously-subscribed listener still receives subsequent events). - F9.1 Listener receives a tick on each
tickIntegerOp. - F9.2 Multiple listeners all fire for one event.
- F9.3 Unsubscribe stops delivery to that listener; others unaffected.
- F9.4 Unsubscribe is idempotent.
-
F9.5 Re-subscribing the same function after unsubscribe works (the tracker treats re-add as a new entry).
- F10.1 A listener that throws does not affect the tracker’s counter state.
- F10.2 A throwing listener does not abort other listeners on the same event.
-
F10.3 Subsequent
tickIntegerOpcalls after a listener throw still succeed. - F11.1 Event passed to listener is frozen (
Object.isFrozen(event)). - F11.2
event.counter_snapshotis frozen. - F11.3
event.counter_snapshot.limitsis frozen. -
F11.4 Listener attempt to mutate
event.kindis a no-op (or strict-mode TypeError); tracker unaffected. - F12.1 First emit has
at === 0n. - F12.2 Successive emits monotonically increase
atby 1n each. -
F12.3 No use of
Date.now()inside any tracker method (assertion via determinism corpus self-scan). - F13.1
snapshot()returns a frozen object. - F13.2
snapshot()reflects current counters. -
F13.3 Mutating the tracker after
snapshot()does not affect the returned snapshot’s fields. - F14.1
inspectFunctionForbidden(BudgetTracker.prototype.tickIntegerOp)returns[]. - F14.2 Same for pushCall, popCall, reset, snapshot, subscribe, and the constructor (via
BudgetTrackeritself). -
F14.3 Same for
freshBudget. - F15.1
RuleBudgetExceededfromengine.tsis the SAME class object as frombudget.ts(===). - F15.2
MAX_INTEGER_OPSfrom each module are equal. -
F15.3 An error thrown from
BudgetTracker.tickIntegerOppassesinstanceof RuleBudgetExceededagainst the engine.ts re-export. - F16.1 A class instance assigned to a variable typed as
import('./engine.js').BudgetTrackerworks at runtime (TypeScript compile-time + a runtime field-shape check via plain access). - F16.2 Engine’s evaluator can be exercised against a class instance: build a
Contextwithbudget: new BudgetTracker()and run a small admit rule; expect admitted result and post-eval inspection ofinstance.integer_ops > 0.
3. Implementation order
Sequential to keep noise low:
- Write
src/domains/rules/budget.ts(full module). - Write
src/__tests__/domains/rules/budget.test.tsskeleton with placeholder tests (one per F-section); confirm they import without errors. - Edit
src/domains/rules/engine.tsto import + re-export from./budget.js. - Run
npm run build— must compile. - Run
npm test -- --testPathPattern='engine|budget'first — fast feedback on scope. - Fill in the test bodies per §2.3.
- Run full suite:
npm run build && npm run lint && npm test. - If anything red: iterate. If clean: proceed to Step 4 commit.
4. Risk register
| Risk | Mitigation |
|---|---|
Identity equality of re-exported RuleBudgetExceeded is broken (e.g. by accidentally re-declaring) |
F15.1 test asserts === |
engine.ts’s internal freshBudget() shadowed by re-export, breaking F4.x order |
engine.ts deletes its local freshBudget and imports from ./budget.js — single source of truth |
| Class instance does not structurally satisfy interface | TypeScript fails to compile if mismatched; F16.x tests pass at runtime |
BudgetTick at: bigint chokes JSON serialization in some downstream — rare |
at is read-only; consumers that need to log can convert via String(event.at). Documented in JSDoc. |
| Listener throws cascades into evaluator | F10.x tests cover; the try/catch wrapper in emit() is the gate |
| Determinism scanner misses a forbidden token because it’s in a JSDoc comment | scanner reads fn.toString() which excludes JSDoc; safe |
ESM .js extension on test file imports |
mirror existing pattern: from '../../../domains/rules/budget.js' (test files use .js extension to match TypeScript ESM emission) |
5. Rollback plan
If the test suite goes red and cannot be fixed in-session: revert the four commits via:
cd .worktrees/claude/p1-4-3-budget
git reset --hard origin/main
This is local-only; the worktree is single-use. No remote impact (no push yet).
6. Verification gate
At the end of Step 4 (implement), the gate is:
cd .worktrees/claude/p1-4-3-budget
npm run build && npm run lint && npm test
ALL THREE green is the contract for proceeding to Step 5 (verify) + push + PR.
7. PR description sketch
Title: feat(p1-4-3-budget): admission budget tracker (R87 κ Wave 7)
Body:
## Summary
- Ship src/domains/rules/budget.ts — dedicated module for the κ evaluation
budget envelope. Hosts the 3 limit constants (MAX_INTEGER_OPS=10000,
MAX_CALL_DEPTH=16, MAX_ARG_COUNT=8), the RuleBudgetExceeded error class,
the BudgetTracker interface (struct shape), the freshBudget() factory,
and a NEW class BudgetTracker with explicit tickIntegerOp / pushCall /
popCall / reset / snapshot / subscribe methods. The class emits frozen
BudgetTick events (kind: 'integer_op' | 'call_push' | 'call_pop') for
α audit-layer observers; listeners are best-effort observers, not gates.
- Convert src/domains/rules/engine.ts's inline declarations to re-exports.
Identity of MAX_*, RuleBudgetExceeded, BudgetTracker (interface) preserved.
No behavior change to the evaluator hot path.
- Add 50+ tests at src/__tests__/domains/rules/budget.test.ts covering
the limit literals, throw paths, class methods, observer pattern, frozen
events, determinism corpus self-scan, engine.ts re-export identity, and
interface compat at runtime.
- Document the limits → ENGINE_VERSION → rule_version_hash dependency in
the budget.ts preamble (§P1.4.3 acceptance: limits participate in version
hash via P1.5.1).
## β task
- ID: 6f474dec-885f-433b-90f9-4bafb45cd725
- Round: R87 κ Wave 7
- Gates: P1.3.1 ✓
- Unblocks: P1.4.4
## Test plan
- [x] npm run build (green)
- [x] npm run lint (green)
- [x] npm test (green; total <count>; +<delta> from main)
- [x] limit-exceed throws RuleBudgetExceeded with correct counter
- [x] budget.tick events emitted (frozen, count only)
- [x] per-rule reset; engine's freshBudget continues to allocate per rule
- [x] determinism scanner clean (inspectFunctionForbidden returns [])
- [x] engine.ts re-export identity preserved (=== check)
- [x] engine.test.ts F4.x and F9.5 unchanged and green
8. Out of scope (this round)
- Wiring the new class into engine.ts’s evaluator hot path. The class is the forward API; the evaluator continues to use the interface struct. A future round can refactor.
- Charging
BUILTIN_COSTSagainst the budget. That’s an engine slice —builtins.tsline 40 explicitly defers it. - Adding budget limits as explicit inputs to
computeVersionHash. Documented dependency only — the lockstep mechanism stays viaENGINE_VERSION. - Per-listener filtering (e.g. “only emit
'integer_op'ticks”). Subscribers receive all kinds; filtering is the listener’s responsibility. - Async listeners. Sync only. Determinism non-negotiable.
Packet complete — 2026-05-07. Step 3 of 5 in the executor chain.