Contract — P1.4.3 Admission Budgets

Module: src/domains/rules/budget.ts Round: R87 κ Wave 7 Branch: feature/p1-4-3-budget Step: 2 of 5 — behavioral contract Predecessor audit: docs/audits/p1-4-3-budget-audit.md


1. Purpose

budget.ts is the canonical home of the κ rule-engine evaluation budget envelope — the three counters and three caps that bound any single rule’s evaluation. Per docs/3-world/physics/laws/rule-engine.md §Evaluation budget, the bounds are part of the rule version hash (transitively, via ENGINE_VERSION); a rule that exceeds any of them deterministically returns ADMISSION_DENIED(reason="budget:<which>").

The module ships:

  1. The canonical surface — three limit constants, one error class, one interface, one factory function — all currently inlined into engine.ts (R85 ship), now extracted so budget.ts is the source of truth and engine.ts re-exports.
  2. A new observable classclass BudgetTracker — with explicit tickIntegerOp / pushCall / popCall / reset methods, a subscribe(listener) → unsubscribe observer pattern emitting BudgetTick events, and a snapshot() method for α audit-layer integration.

The current engine evaluator does NOT consume the class today — it continues to mutate the plain interface struct directly (zero behavior change). The class is the forward API for any future α audit hooks or per-builtin cost-charging refactor.


2. Public surface

2.1. Constants (named exports)

export const MAX_INTEGER_OPS = 10_000;
export const MAX_CALL_DEPTH  = 16;
export const MAX_ARG_COUNT   = 8;

export const DEFAULT_LIMITS: {
  readonly integer_ops: 10_000;
  readonly call_depth:  16;
  readonly arg_count:   8;
};

DEFAULT_LIMITS is a frozen object containing the three caps as a structured tuple — convenient for callers that want the whole envelope as a single argument (e.g. new BudgetTracker(DEFAULT_LIMITS) or new BudgetTracker({...DEFAULT_LIMITS, integer_ops: 1000})). Its three field values MUST equal the three named constants.

2.2. Types

export interface BudgetSnapshot {
  readonly integer_ops: number;
  readonly call_depth: number;
  readonly current_arg_count: number;
  readonly limits: {
    readonly integer_ops: number;
    readonly call_depth: number;
    readonly arg_count: number;
  };
}

export type BudgetTickKind = 'integer_op' | 'call_push' | 'call_pop';

export interface BudgetTick {
  readonly kind: BudgetTickKind;
  /**
   * Logical event counter — auto-incremented on every tick across the
   * lifetime of a single tracker instance. NOT wall-clock time. Reset to
   * 0n by `reset()`. Type `bigint` for monotonic safety past 2^53.
   */
  readonly at: bigint;
  readonly counter_snapshot: BudgetSnapshot;
}

export type BudgetListener = (event: BudgetTick) => void;

export type BudgetUnsubscribe = () => void;

export interface BudgetTrackerLimits {
  readonly integer_ops: number;
  readonly call_depth: number;
  readonly arg_count: number;
}

2.3. BudgetTracker interface (struct shape)

export interface BudgetTracker {
  integer_ops: number;
  call_depth: number;
  current_arg_count: number;
}

Re-exported under the same name from engine.ts for backwards compat. Plain mutable struct — what engine.ts’s evaluator mutates today.

The presence of an interface AND a class under the same name BudgetTracker is intentional and idiomatic in TypeScript (“declaration merging” of interface + class). Class instances satisfy the interface structurally (the class fields are a strict superset of the interface fields). Existing engine.ts internals (bumpIntegerOps, bumpCallDepth, FuncCall arg check) operate against the interface and continue to work whether the underlying object is a plain struct from freshBudget() or a class instance from new BudgetTracker().

2.4. RuleBudgetExceeded error class

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: 'integer_ops' | 'call_depth' | 'arg_count',
    limit: number,
    observed: number,
  );
}

Identical to engine.ts’s R85 ship. Re-exported from engine.ts.

Message format: "RuleBudgetExceeded: <which> <observed> > <limit>" — preserved byte-identically from R85 because the error message is asserted in engine.test.ts F9.5 (line 836).

2.5. class BudgetTracker (observable implementation)

export class BudgetTracker {
  readonly limits: BudgetTrackerLimits;

  // Observable interface fields (structurally satisfy the BudgetTracker interface).
  integer_ops: number;
  call_depth: number;
  current_arg_count: number;

  /**
   * @param limits Optional partial limits override; missing fields fall back
   *               to DEFAULT_LIMITS. The merged limits object is frozen.
   */
  constructor(limits?: Partial<BudgetTrackerLimits>);

  /** Increment integer_ops; throw RuleBudgetExceeded('integer_ops', ...) on overflow. */
  tickIntegerOp(): void;

  /**
   * Increment call_depth and set current_arg_count BEFORE checking caps.
   * @param arg_count Arity of the call about to be entered (>= 0).
   * Throws RuleBudgetExceeded('arg_count', ...) FIRST if arg_count > limit
   * (matches engine.ts:594 fail-fast semantics — arg cap fires before depth);
   * otherwise throws RuleBudgetExceeded('call_depth', ...) if depth + 1 > limit.
   * Both throws emit a BudgetTick of kind 'call_push' BEFORE the throw, so
   * observers see the attempt.
   */
  pushCall(arg_count: number): void;

  /** Decrement call_depth (clamped to 0) and clear current_arg_count. Emits 'call_pop' tick. */
  popCall(): void;

  /** Zero all three counters and the logical event counter. Does NOT clear listeners or limits. */
  reset(): void;

  /** Returns a frozen point-in-time copy of the counters + limits. Pure. */
  snapshot(): BudgetSnapshot;

  /**
   * Register a listener. Returns an unsubscribe thunk. Listeners receive
   * BudgetTick events on every successful tick (and on the throw path of
   * pushCall before the cap-exceeded error). Listener throws are caught and
   * swallowed silently — listeners are observers, not gates.
   */
  subscribe(listener: BudgetListener): BudgetUnsubscribe;
}

2.6. freshBudget() factory

export function freshBudget(): BudgetTracker;

Returns { integer_ops: 0, call_depth: 0, current_arg_count: 0 } — a plain interface struct, NOT a class instance. Cheap allocation. Used by engine.ts’s executeRuleset once per rule. Behavior identical to engine.ts R85’s internal freshBudget(), now hoisted to a public export.

The class’s reset() method is the moral equivalent for class instances. Callers that want a fresh interface struct should call freshBudget(); callers that want to recycle a class instance with subscribers attached should call instance.reset().


3. Invariants

I1. Limit values are LOCKED to engine semantics

MAX_INTEGER_OPS === 10_000, MAX_CALL_DEPTH === 16, MAX_ARG_COUNT === 8.

Changing any of these literals is an engine-binary semantic change and MUST be accompanied by a bump of ENGINE_VERSION in versioning.ts — otherwise two arbiters running different engine binaries (one with the old limit, one with the new) would compute the same rule_version_hash and return inconsistent admission decisions, breaking θ consensus.

This invariant is enforced by:

  • A docstring obligation on each constant.
  • A test (F1.x) asserting the literal values.
  • A verification.md checklist item reminding reviewers to bump ENGINE_VERSION when literals change.

I2. Three throw paths, three which values

RuleBudgetExceeded is thrown exactly when one of:

  • integer_ops exceeds MAX_INTEGER_OPS after a tickIntegerOp() increment, or
  • call_depth exceeds MAX_CALL_DEPTH after a pushCall() increment, or
  • arg_count of a single call exceeds MAX_ARG_COUNT.

Each path supplies a distinct which value, matching the four 'budget:<which>' reasons that engine.ts’s errorToRejection (line 460) maps to.

I3. Per-rule isolation

Each rule’s budget is independent. Two equivalent isolation paths:

  • Path A (current engine flow): executeRuleset calls freshBudget() per rule, allocating a brand new struct. Counter values from rule N do not leak into rule N+1.
  • Path B (new class flow): A long-lived class instance can be reset()‘d between rules. After reset(), the three counters are 0 and the logical event counter at is 0n. Subscribers and limits are preserved.

Tests assert both paths.

I4. at field is logical, not wall-clock

BudgetTick.at is an internal monotonic counter that auto-increments on every tick. Type bigint so it cannot wraparound. Reset to 0n by reset(). It does NOT use Date.now(), process.hrtime, or any wall-clock source.

Determinism corpus self-scan asserts no Date.* / Math.* / crypto.* etc inside the tracker methods.

I5. Listener errors are swallowed

When a listener throws, the tracker catches the error and continues. The tracker’s own state (counters, limits, listener list) is not affected. Other listeners after the throwing one still receive the same event.

This protects the deterministic core: the engine evaluates rules; observability is best-effort; a buggy α audit listener cannot deadlock or crash the engine.

I6. Listeners are observers, not gates

There is no API for a listener to deny an operation or modify the event. The tick event is delivered as a frozen object. The tracker proceeds regardless of listener actions.

If a listener WANTS to influence admission, that’s a rule-engine concern — it goes through policy-gate.ts or a κ rule, not via budget hooks.

I7. Frozen events

Every BudgetTick event passed to a listener is Object.freeze‘d before dispatch. Listeners that try to mutate event.kind, event.at, or event.counter_snapshot receive a runtime TypeError (in strict mode) or a silent no-op (in sloppy mode). The snapshot inside the event is also frozen.

This is a defense-in-depth posture — listener authors can’t accidentally hand back a mutated event into a downstream chain.

I8. arg_count fail-fast precedes call_depth

In pushCall(n):

1. emit BudgetTick { kind: 'call_push', counter_snapshot: <pre-mutation> }
2. if n > MAX_ARG_COUNT      → throw RuleBudgetExceeded('arg_count', ...)
3. mutate this.call_depth += 1
4. mutate this.current_arg_count = n
5. if call_depth > MAX_CALL_DEPTH → throw RuleBudgetExceeded('call_depth', ...)

Order matches engine.ts:594’s if (expr.args.length > MAX_ARG_COUNT) check before bumpCallDepth(...). A pushCall with arg_count = 9 from an empty stack throws arg_count (not call_depth) — this matches engine.test.ts F4.5.

I9. popCall is safe at depth 0

popCall() decrements call_depth only if > 0 (clamps); never throws, never goes negative. Always emits a 'call_pop' tick (even at depth 0, for observability).

I10. Re-export contract from engine.ts

engine.ts re-exports the following identifiers from ./budget.js so all existing imports continue to resolve at the same path:

  • MAX_INTEGER_OPS, MAX_CALL_DEPTH, MAX_ARG_COUNT — values
  • RuleBudgetExceeded — class
  • BudgetTracker — interface (NOT the class, to preserve the “plain struct” intent of the engine evaluator’s Context.budget)

The new class BudgetTracker (with methods) is NOT re-exported from engine.ts. To use the class, import it from ./budget.js directly:

import { BudgetTracker } from './budget.js';   // class with methods

vs

import type { BudgetTracker } from './engine.js';   // interface (struct)

Both names refer to a TypeScript symbol — at runtime, the engine.ts re-export is a class identity (because the SAME identifier BudgetTracker exported from budget.ts is BOTH a class value and an interface; engine.ts re-exports it as export type { BudgetTracker } from './budget.js' — TYPE-only). The engine evaluator does NOT call new BudgetTracker() — it allocates plain structs via freshBudget(). This separation is intentional.


4. Determinism guarantees

Forbidden token Surface check
Date.now(), Date.*, new Date() inspectFunctionForbidden(BudgetTracker.prototype.*) returns []
Math.* scanner
Math.random(), crypto.randomBytes, crypto.getRandomValues scanner
setTimeout, setInterval, setImmediate scanner
fetch, XMLHttpRequest scanner
await, async function scanner
Float literal scanner
crypto.* scanner

The class methods read only this fields and the local listener list. They write only this fields and emit frozen events. Pure synchronous code path.


5. Backwards-compat contract

5.1. engine.ts internals

engine.ts lines 70–82 (constants), 211–226 (RuleBudgetExceeded class), 129–133 (interface BudgetTracker), 237–266 (helpers freshBudget/bumpIntegerOps/bumpCallDepth) become re-exports / consumers of ./budget.js:

import {
  MAX_INTEGER_OPS, MAX_CALL_DEPTH, MAX_ARG_COUNT,
  RuleBudgetExceeded, freshBudget,
} from './budget.js';
import type { BudgetTracker } from './budget.js';

export { MAX_INTEGER_OPS, MAX_CALL_DEPTH, MAX_ARG_COUNT, RuleBudgetExceeded };
export type { BudgetTracker };

Internal bumpIntegerOps and bumpCallDepth move to budget.ts as private (non-exported) helpers used by the class methods OR stay local to engine.ts (preferred: stay local, since engine still needs to mutate the interface struct without going through the class). We choose: keep bumpIntegerOps / bumpCallDepth as private helpers IN engine.ts (zero behavior change to the evaluator). The class in budget.ts has its own private helpers (private methods on the class) that perform the same checks against this.limits. There are TWO implementations of the bump check — one against module-level constants (engine.ts), one against per-instance this.limits (class). They do the same thing for the default-limits case.

This duplication is intentional for this round: it avoids any change to engine.ts’s evaluator flow. A future round can refactor engine.ts to consume the class.

5.2. engine.test.ts

All four imports (RuleBudgetExceeded, MAX_INTEGER_OPS, MAX_CALL_DEPTH, MAX_ARG_COUNT) at engine.test.ts:34–37 continue to work. F4.x and F9.5 tests pass without modification.

5.3. policy-gate.ts

import { MAX_INTEGER_OPS } from './engine.js' at policy-gate.ts continues to resolve.

5.4. admission.ts

No budget imports today — unaffected.

5.5. Versioning

computeVersionHash (versioning.ts) is NOT modified. The limits are not added as an explicit input to the hash. The dependency on ENGINE_VERSION continues to be the discipline mechanism — documented in budget.ts’s preamble and verified by a verification.md checklist item.


6. Test plan (sketch — full plan in packet)

Suite Coverage
F1 — constants MAX_* literal values; DEFAULT_LIMITS shape
F2 — RuleBudgetExceeded which/limit/observed fields; .name; message format
F3 — freshBudget() returns zeroed struct; new instance per call; satisfies interface
F4 — class construction default limits; custom partial limits; counters start at 0
F5 — tickIntegerOp normal increment; emit tick; throw at limit+1
F6 — pushCall arg_count fail-fast; depth fail; normal push; emits tick
F7 — popCall normal decrement; safe at depth 0; emits tick
F8 — reset zeroes counters; preserves limits; preserves listeners; resets at to 0n
F9 — subscribe listener receives tick; unsubscribe stops delivery; multiple listeners
F10 — listener errors throwing listener does not affect state; other listeners still fire
F11 — frozen events event.kind / event.at / event.counter_snapshot are frozen
F12 — at is logical not wall-clock; monotonic; survives 100k ticks; reset zeroes it
F13 — snapshot returns frozen copy; does not affect future mutations
F14 — determinism scan inspectFunctionForbidden over class methods returns []
F15 — engine.ts re-exports identity equality of MAX_* / RuleBudgetExceeded between engine.ts and budget.ts
F16 — interface compat class instance assigns to BudgetTracker interface variable (compile-time + run-time field check)

Approx 50+ tests targeted.


7. Migration semantics

This round modifies engine.ts in a NON-OBSERVABLE way:

  • The four exports (MAX_INTEGER_OPS, MAX_CALL_DEPTH, MAX_ARG_COUNT, RuleBudgetExceeded, BudgetTracker-as-interface) move their declaration site from inline-in-engine.ts to budget.ts, then engine.ts re-exports them.
  • Identity equality is preserved: import { RuleBudgetExceeded } from './engine.js' and import { RuleBudgetExceeded } from './budget.js' resolve to the SAME class object. instanceof checks against either import work identically.
  • engine.ts’s freshBudget (previously private) is now public-via-re-export from budget.ts — engine.ts no longer declares it locally. Existing engine.ts call sites use freshBudget() from a local import.
  • bumpIntegerOps and bumpCallDepth STAY in engine.ts as private helpers. They remain unchanged in body and call sites.

External code that depended on the engine.ts surface SEES NO CHANGE. There is no rename, no behavior shift, no new throw, no removed throw. The diff against engine.ts is essentially “convert local declarations to re-exports + add an import line”.


8. Stability promise

  • The four constants’ VALUES are locked (I1).
  • The BudgetTracker INTERFACE shape is locked: 3 fields, exact names. Adding fields is a breaking change downstream of Context.budget.
  • The RuleBudgetExceeded ERROR shape is locked: 3 fields, exact names, exact message format (engine.test.ts F9.5 asserts).
  • The BudgetTick SHAPE is new; it may be extended by adding fields. Removing or renaming kind/at/counter_snapshot is a breaking change.
  • The class METHOD set is new; methods may be added. Renaming or removing tickIntegerOp/pushCall/popCall/reset/subscribe/snapshot/limits is a breaking change.
  • DEFAULT_LIMITS is locked to current values.

Future P1.4.4 tool-lock integration may EXTEND BudgetTick.kind with new variants (e.g. 'builtin_charge') — non-breaking addition.


9. Open questions resolved

Question Decision
Should the new class replace engine.ts’s interface? NO — keep both, class structurally satisfies interface
Should engine.ts’s evaluator consume the class? NO this round — defer to future engine refactor; preserves zero-behavior-change posture
Should we add limits to computeVersionHash’s explicit inputs? NO — keep the ENGINE_VERSION lockstep mechanism unchanged; document the dependency
Should listeners be sync or async? SYNC — deterministic; no async in κ corpus
Should listener exceptions propagate? NO — caught and swallowed; observers are not gates
Should the at field be number or bigint? bigint — eliminates 2^53 boundary concern; engine.ts uses bigint for epoch already
Should popCall underflow be a thrown error? NO — clamp to 0; defensive, simpler to reason about
Should reset() clear listeners? NO — listeners persist across reset; reset is for counter recycling within a rule loop, not session teardown

Contract complete — 2026-05-07. Step 2 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.