Contract — Fix Decay Epoch Ceiling

1. Scope

Harden decay(value, rate_bps, epochs) in src/domains/rules/integer-math.ts against unbounded loops driven by adversarial or accidental large epochs values. Adds a new constant, a new typed error, and one new guard. Re-exports the typed error from bps-constants.ts to keep the public-error surface consistent. Does not change decay’s floor-monotone semantics, integer-only arithmetic, or single-epoch behaviour.

This contract supersedes the R81.A “must not change” line on integer-math.ts for the strict scope of:

  • Adding MAX_DECAY_EPOCHS.
  • Adding EpochCeilingError.
  • Two new guard branches inside decay (ceiling check) and one message enrichment in the existing negative-epochs branch.

All other R81.A semantics are preserved verbatim.

2. Modules touched

Path Direction Net LOC Source of truth
src/domains/rules/integer-math.ts modify +≈ 30 this contract
src/domains/rules/bps-constants.ts modify +≈ 2 this contract (re-export only)
src/__tests__/domains/rules/integer-math.test.ts modify +≈ 40 this contract (new test cases)
src/__tests__/domains/rules/bps-constants.test.ts modify +≈ 10 this contract (re-export reference equality)
docs/contracts/r83-a-determinism-contract.md addendum +≈ 10 this contract
docs/contracts/r83-b-bps-constants-contract.md addendum +≈ 10 this contract
src/domains/rules/determinism.ts must not change 0 R83.A PR #190 (sealed)
src/domains/rules/lexer.ts must not change 0 R83.C PR #189 (sealed)
src/__tests__/domains/rules/determinism.test.ts must not change 0 R83.A PR #190 (sealed)
src/__tests__/domains/rules/lexer.test.ts must not change 0 R83.C PR #189 (sealed)

3. Invariants (must all hold at merge)

  • I1 Existing decay semantics for legal inputs (0n ≤ epochs ≤ MAX_DECAY_EPOCHS) are byte-for-byte unchanged: same floor-monotone, integer-only loop body.
  • I2 decay(v, r, 0n) === v for any v and r. (Compound-zero semantics, AC#5 of R81.A contract.)
  • I3 decay(v, r, k>0) for in-range k returns the same value as before this PR.
  • I4 decay(v, r, -1n) still throws UnderflowError. The class is unchanged; the message is enriched to include the offending value (still matches the existing test regex decay: negative epochs).
  • I5 decay(v, r, MAX_DECAY_EPOCHS + 1n) throws EpochCeilingError. The error includes both the offending value and the ceiling.
  • I6 decay(v, r, MAX_DECAY_EPOCHS) succeeds (boundary is inclusive).
  • I7 The ceiling check fires before the loop. A rejected call performs O(1) work.
  • I8 EpochCeilingError extends RangeError. The class is exported from integer-math.ts and re-exported from bps-constants.ts. Reference equality is preserved across the re-export.
  • I9 MAX_DECAY_EPOCHS is a bigint literal (10_000n), exported as const, no float, no BigInt(10000) constructor call.
  • I10 assertNoForbiddenOps(decay) returns []. The new function body introduces no Math.*, Date.*, RNG, async, fs, or float-literal token.
  • I11 The corpus self-scan in determinism.test.ts stays green over integer-math.ts.
  • I12 assertDeterministic over decay with in-range inputs continues to pass (existing test at determinism.test.ts:73 is untouched).
  • I13 Tool-surface count stays at 14 — no new MCP tool, no registration in src/server.ts.
  • I14 All 1357 (R83 baseline) + new tests pass; npm run build && npm run lint && npm test all green.

4. Public surface delta

4.1 New exports from integer-math.ts

/**
 * Maximum allowable epochs in a single `decay` call.
 *
 * Rationale (audit §6): κ uses 1–10% per-epoch decay rates. After 1000 epochs
 * at 1%, the original value is reduced to ~4.3×10⁻⁵ — already negligible.
 * 10_000 epochs is well past any meaningful semantic range and bounds a single
 * decay call to <50 ms wall time. Hardens against adversarial / accidental
 * unbounded loops driven by `epochs = MAX_INT64` etc.
 */
export const MAX_DECAY_EPOCHS = 10_000n;

/**
 * Thrown by `decay` when `epochs` exceeds `MAX_DECAY_EPOCHS`.
 *
 * Distinct from `OverflowError` — the latter signals an int64-range breach in
 * arithmetic. `EpochCeilingError` signals a domain-level safety cap.
 *
 * Extends `RangeError` (the JS standard built-in for "value outside expected
 * range") so general-purpose handlers can catch it as one.
 */
export class EpochCeilingError extends RangeError {
  override readonly name = 'EpochCeilingError';
  constructor(message: string) {
    super(message);
  }
}

4.2 Modified decay body

/**
 * Compound per-epoch basis-point decay.
 *
 *   for epoch in 0..epochs-1:
 *     value = apply_bps(value, rate_bps)
 *
 * Each epoch is floored independently, so compounding drift is
 * deterministic-given-inputs (concept doc §Basis-point arithmetic).
 *
 *   - epochs === 0n returns value unchanged (compound-zero semantics).
 *   - epochs <  0n throws UnderflowError.
 *   - epochs >  MAX_DECAY_EPOCHS throws EpochCeilingError.
 *
 * The ceiling guards against adversarial / accidental unbounded loops; see
 * `MAX_DECAY_EPOCHS` for the rationale.
 */
export function decay(
  value: bigint,
  rate_bps: bigint,
  epochs: bigint,
): bigint {
  if (epochs < 0n) {
    throw new UnderflowError(`decay: negative epochs (got ${epochs})`);
  }
  if (epochs > MAX_DECAY_EPOCHS) {
    throw new EpochCeilingError(
      `decay: epochs ${epochs} exceeds MAX_DECAY_EPOCHS (${MAX_DECAY_EPOCHS})`,
    );
  }
  let v = value;
  for (let i = 0n; i < epochs; i++) {
    v = apply_bps(v, rate_bps);
  }
  return v;
}

4.3 New re-export from bps-constants.ts

export {
  safe_mul,
  safe_div,
  OverflowError,
  DivisionByZeroError,
  UnderflowError,
  EpochCeilingError,   // ← new
} from './integer-math.js';

MAX_DECAY_EPOCHS is not re-exported (matches the existing pattern: INT64_MAX/INT64_MIN are mirrored as MAX_INT64/MIN_INT64 rather than re-exported).

5. Error-class taxonomy

Class Extends Thrown by Meaning
OverflowError Error safe_mul, bps() Result outside int64 range.
DivisionByZeroError Error bps_div, safe_div Explicit divide-by-zero.
UnderflowError Error decay (negative epochs), bps() Negative input where non-negative required.
EpochCeilingError RangeError decay (epochs > cap) NEW — domain-level safety cap.

The choice of RangeError (vs. Error like the existing three) is intentional: EpochCeilingError semantically matches the JavaScript built-in RangeError, which is “a value is not in the set or range of allowed values”. General-purpose error handlers that already special-case RangeError will catch this without further changes. The other three errors signal arithmetic / integer-domain conditions that don’t have a direct JS built-in equivalent.

6. Acceptance criteria

AC Statement Test
AC-1 MAX_DECAY_EPOCHS is exported from integer-math.ts, equals 10_000n, is typeof 'bigint'. integer-math.test.ts
AC-2 EpochCeilingError extends RangeError, has name === 'EpochCeilingError', preserves the message. integer-math.test.ts
AC-3 decay(1000n, 100n, MAX_DECAY_EPOCHS) succeeds (returns a bigint). integer-math.test.ts
AC-4 decay(1000n, 100n, MAX_DECAY_EPOCHS + 1n) throws EpochCeilingError. integer-math.test.ts
AC-5 EpochCeilingError message contains both the offending value and MAX_DECAY_EPOCHS. integer-math.test.ts
AC-6 decay(1000n, 100n, -1n) throws UnderflowError (regression). Message regex /decay: negative epochs/ still matches. integer-math.test.ts
AC-7 decay(v, r, 0n) returns v unchanged (regression: compound-zero). integer-math.test.ts
AC-8 All previous decay tests pass unmodified (1-epoch, 2-epoch, 100-epoch, monotonicity). integer-math.test.ts
AC-9 EpochCeilingError re-exported from bps-constants.ts is reference-equal to the integer-math source. bps-constants.test.ts
AC-10 An EpochCeilingError thrown by decay is instanceof RangeError and instanceof EpochCeilingError. bps-constants.test.ts
AC-11 inspectFunctionForbidden(decay) returns []. Re-asserted by the existing test at determinism.test.ts:343 (unchanged). determinism.test.ts (unchanged)
AC-12 Corpus self-scan over src/domains/rules/*.ts reports zero forbidden tokens. Re-asserted by the existing test at determinism.test.ts:834 (unchanged). determinism.test.ts (unchanged)
AC-13 assertDeterministic(decay, [1000n, 150n, 2n]) returns 971n. Re-asserted by the existing test at determinism.test.ts:73 (unchanged). determinism.test.ts (unchanged)
AC-14 npm run build && npm run lint && npm test all green; total test count ≥ R83 baseline + new cases. CI gate

7. Failure modes & rejected designs

  • Rejected: cap at 1n << 32n (~4 × 10⁹). Wall time would be hours per call. Defeats the purpose.
  • Rejected: cap at 100n. Too tight for forward-looking κ rules — the audit cites a planning note about long-horizon governance decay.
  • Rejected: EpochCeilingError extends OverflowError. Would imply the result overflowed int64, which it didn’t — the input was rejected pre-loop. Misleading to log readers.
  • Rejected: skip the new error class, throw RangeError directly. Loses the named-class affordance the project uses for every other arithmetic error. Breaks instanceof EpochCeilingError for future fine-grained handlers.
  • Rejected: log + clamp instead of throw. Silently changing semantics is worse than rejecting. Also: this is the rule-engine, where determinism is a hard contract.
  • Rejected: re-export MAX_DECAY_EPOCHS from bps-constants.ts. Re-exports there are confined to the OverflowError-class pattern. The constant lives in integer-math.ts and is importable directly.

8. Out of scope

  • Performance tuning of the loop body (unchanged).
  • A wider audit of every public κ helper for similar unbounded-loop hazards (decay is currently the only one).
  • Generalising the constant to be configurable per call (would break determinism if exposed via env var; defer if ever needed).
  • Updating the runtime task-breakdown to track this hygiene PR (it is a security-hardening hotfix, not a Phase-1 sub-task).

Back to top

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

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