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
decaysemantics 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) === vfor anyvandr. (Compound-zero semantics, AC#5 of R81.A contract.) - I3
decay(v, r, k>0)for in-rangekreturns the same value as before this PR. - I4
decay(v, r, -1n)still throwsUnderflowError. The class is unchanged; the message is enriched to include the offending value (still matches the existing test regexdecay: negative epochs). - I5
decay(v, r, MAX_DECAY_EPOCHS + 1n)throwsEpochCeilingError. 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 frominteger-math.tsand re-exported frombps-constants.ts. Reference equality is preserved across the re-export. - I9
MAX_DECAY_EPOCHSis abigintliteral (10_000n), exported asconst, no float, noBigInt(10000)constructor call. - I10
assertNoForbiddenOps(decay)returns[]. The new function body introduces noMath.*,Date.*, RNG, async, fs, or float-literal token. - I11 The corpus self-scan in
determinism.test.tsstays green overinteger-math.ts. - I12
assertDeterministicoverdecaywith in-range inputs continues to pass (existing test atdeterminism.test.ts:73is 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 testall 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
RangeErrordirectly. Loses the named-class affordance the project uses for every other arithmetic error. Breaksinstanceof EpochCeilingErrorfor 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_EPOCHSfrombps-constants.ts. Re-exports there are confined to theOverflowError-class pattern. The constant lives ininteger-math.tsand 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 (
decayis 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).