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_count are 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) then Object.freeze(event.counter_snapshot) then Object.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 at increments AFTER the event is built but BEFORE listeners run, so each subsequent emission has a strictly greater at. The first event has at: 0n, the second 1n, 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).

  • pushCall ordering (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 emit and 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 hypothetical limits: { 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_LIMITS is 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 Error and instanceof 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_ops succeeds).

  • F4.1 Default-constructed tracker has counters at 0.
  • F4.2 tracker.limits equals DEFAULT_LIMITS value-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.limits is frozen.

  • F5.1 tickIntegerOp increments integer_ops.
  • F5.2 Tick emits one 'integer_op' event per call.
  • F5.3 MAX_INTEGER_OPS ticks are OK; the (MAX+1)-th throws RuleBudgetExceeded('integer_ops', 10000, 10001).
  • F5.4 After throw, integer_ops === 10001 (throw is post-increment).

  • F6.1 pushCall(0) increments call_depth to 1.
  • F6.2 pushCall(8) is OK (== limit).
  • F6.3 pushCall(9) throws RuleBudgetExceeded('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_depth is still 0 and current_arg_count is still 0 (no mutation).

  • F7.1 popCall after pushCall(2) decrements depth and zeroes current_arg_count.
  • F7.2 popCall at depth 0 does not throw, does not go negative.
  • F7.3 popCall always emits one 'call_pop' event.

  • F8.1 After 5 ticks, reset() zeroes integer_ops.
  • F8.2 reset() zeroes call_depth and current_arg_count.
  • F8.3 reset() zeroes the logical at counter (next emitted event has at === 0n).
  • F8.4 reset() preserves limits (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 tickIntegerOp calls after a listener throw still succeed.

  • F11.1 Event passed to listener is frozen (Object.isFrozen(event)).
  • F11.2 event.counter_snapshot is frozen.
  • F11.3 event.counter_snapshot.limits is frozen.
  • F11.4 Listener attempt to mutate event.kind is a no-op (or strict-mode TypeError); tracker unaffected.

  • F12.1 First emit has at === 0n.
  • F12.2 Successive emits monotonically increase at by 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 BudgetTracker itself).
  • F14.3 Same for freshBudget.

  • F15.1 RuleBudgetExceeded from engine.ts is the SAME class object as from budget.ts (===).
  • F15.2 MAX_INTEGER_OPS from each module are equal.
  • F15.3 An error thrown from BudgetTracker.tickIntegerOp passes instanceof RuleBudgetExceeded against the engine.ts re-export.

  • F16.1 A class instance assigned to a variable typed as import('./engine.js').BudgetTracker works 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 Context with budget: new BudgetTracker() and run a small admit rule; expect admitted result and post-eval inspection of instance.integer_ops > 0.

3. Implementation order

Sequential to keep noise low:

  1. Write src/domains/rules/budget.ts (full module).
  2. Write src/__tests__/domains/rules/budget.test.ts skeleton with placeholder tests (one per F-section); confirm they import without errors.
  3. Edit src/domains/rules/engine.ts to import + re-export from ./budget.js.
  4. Run npm run build — must compile.
  5. Run npm test -- --testPathPattern='engine|budget' first — fast feedback on scope.
  6. Fill in the test bodies per §2.3.
  7. Run full suite: npm run build && npm run lint && npm test.
  8. 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_COSTS against the budget. That’s an engine slice — builtins.ts line 40 explicitly defers it.
  • Adding budget limits as explicit inputs to computeVersionHash. Documented dependency only — the lockstep mechanism stays via ENGINE_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.


Back to top

Colibri — documentation-first MCP runtime. Apache 2.0 + Commons Clause.

This site uses Just the Docs, a documentation theme for Jekyll.