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:
- The canonical surface — three limit constants, one error class, one interface, one factory function — all currently inlined into
engine.ts(R85 ship), now extracted sobudget.tsis the source of truth andengine.tsre-exports. - A new observable class —
class BudgetTracker— with explicittickIntegerOp/pushCall/popCall/resetmethods, asubscribe(listener) → unsubscribeobserver pattern emittingBudgetTickevents, and asnapshot()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.mdchecklist 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_opsexceedsMAX_INTEGER_OPSafter atickIntegerOp()increment, orcall_depthexceedsMAX_CALL_DEPTHafter apushCall()increment, orarg_countof a single call exceedsMAX_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):
executeRulesetcallsfreshBudget()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. Afterreset(), the three counters are 0 and the logical event counteratis 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— valuesRuleBudgetExceeded— classBudgetTracker— interface (NOT the class, to preserve the “plain struct” intent of the engine evaluator’sContext.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'andimport { RuleBudgetExceeded } from './budget.js'resolve to the SAME class object.instanceofchecks 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 usefreshBudget()from a local import. bumpIntegerOpsandbumpCallDepthSTAY 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
BudgetTrackerINTERFACE shape is locked: 3 fields, exact names. Adding fields is a breaking change downstream ofContext.budget. - The
RuleBudgetExceededERROR shape is locked: 3 fields, exact names, exact message format (engine.test.ts F9.5 asserts). - The
BudgetTickSHAPE is new; it may be extended by adding fields. Removing or renamingkind/at/counter_snapshotis 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_LIMITSis 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.