P1.3.2 — κ Built-in Functions — Behavioral Contract
Step 2 of the 5-step executor chain. Builds on
docs/audits/p1-3-2-builtins-audit.md. Defines the public surface, semantics, and invariants forsrc/domains/rules/builtins.ts.
§1. Module identity
- Path:
src/domains/rules/builtins.ts - Axis: κ — Rule Engine (Phase 1 Wave 5)
- Kind: pure synchronous module; no I/O, no DB access, no network, no env reads, no console output, no clock, no RNG, no async.
- Internal dependencies:
./integer-math.js—bps_mul,bps_div,decay,OverflowError,DivisionByZeroError,UnderflowError.node:crypto—createHashonly (deterministic transform). This is the only Node built-in this module imports; it is not a determinism violation becausecreateHash("sha256").update(s).digest("hex")is a pure mathematical function of its input.
- No imports from
./engine.js,./parser.js,./validator.js,src/db/*,src/middleware/*,src/server.ts, or any other domain folder. No HTTP, no event emitters.
§2. Public API
§2.1. Types
export type BuiltinValue = bigint | string | boolean;
export type BuiltinFn = (args: readonly BuiltinValue[]) => BuiltinValue;
export type BuiltinTable = ReadonlyMap<string, BuiltinFn>;
§2.2. Errors
export class BuiltinTypeError extends Error {
override readonly name: 'BuiltinTypeError';
readonly builtin: string;
readonly detail: string;
constructor(builtin: string, detail: string);
}
BuiltinTypeError carries builtin (the function name) and detail
(the precise mismatch). Throwing rather than returning lets the engine’s
errorToRejection map at engine.ts:459 route them to
{status: 'rejected', reason: <message>}. The default Error.message
format is "<builtin>: <detail>".
§2.3. The 13 built-in functions
Each is a BuiltinFn registered under its name in the table.
| # | Name | Arity | Arg types | Return | Semantics |
|---|---|---|---|---|---|
| 1 | min |
2 | bigint, bigint |
bigint |
a < b ? a : b |
| 2 | max |
2 | bigint, bigint |
bigint |
a > b ? a : b |
| 3 | abs |
1 | bigint |
bigint |
a < 0n ? -a : a |
| 4 | cap |
2 | bigint, bigint |
bigint |
min(v, m) |
| 5 | clamp |
3 | bigint, bigint, bigint |
bigint |
max(lo, min(v, hi)); throws BuiltinTypeError if lo > hi |
| 6 | isqrt |
1 | bigint |
bigint |
Newton’s method, floor of √n; rejects negatives with BuiltinTypeError |
| 7 | ilog2 |
1 | bigint |
bigint |
Floor of log₂(n); defined as 0n for n <= 0n per extraction §3 |
| 8 | decay |
2 | bigint, bigint |
bigint |
Single-epoch decay; delegates to integer_math.decay(v, rate_bps, 1n) |
| 9 | diminishing |
2 | bigint, bigint |
bigint |
(v * k) / (k + v); throws DivisionByZeroError if k + v === 0n |
| 10 | bps_mul |
2 | bigint, bigint |
bigint |
Delegates to integer_math.bps_mul |
| 11 | bps_div |
2 | bigint, bigint |
bigint |
Delegates to integer_math.bps_div |
| 12 | hash |
1 | string |
string |
SHA-256 hex of UTF-8 bytes via crypto.createHash("sha256") |
| 13 | vrf_verify |
3 | string, string, string |
boolean |
ADR-002 stub — see §4 |
§2.4. The exported table
export const BUILTINS: BuiltinTable;
A frozen Map<string, BuiltinFn> with exactly 13 entries, keyed by the
names in §2.3. Iteration order is insertion order matching the
table above; consumers must not depend on order (the engine looks up by
name) but the order is documented for diff stability.
§2.5. The cost table
export const BUILTIN_COSTS: ReadonlyMap<string, number>;
Per-builtin integer-op cost, consumed by the engine’s budget tracker
(currently staged — wiring lands in a follow-up slice that updates
engine.ts §3 evaluateExpr case 'FuncCall').
| Bucket | Cost | Members |
|---|---|---|
| trivial | 1 | min, max, abs, cap, clamp |
| non-trivial | 5 | isqrt, ilog2, decay, diminishing, bps_mul, bps_div |
| crypto-cheap | 100 | hash |
| crypto-expensive | 500 | vrf_verify |
Total of all costs is 5 * 1 + 6 * 5 + 100 + 500 = 635. Each entry’s
key matches an entry in BUILTINS; both maps have 13 entries.
§2.6. Optional helper — bindBuiltins
export function bindBuiltins(): BuiltinTable;
Returns BUILTINS (reference-equal). Provided so future engine code can
call bindBuiltins() and treat it as a context factory; the indirection
gives a place to insert per-rule cost charging without changing call
sites.
§3. Invariants
| ID | Invariant |
|---|---|
| I1 | Integer-only. No Math.*, no Number(n) for arithmetic, no float literals. Every numeric operand and return value is bigint or (for hash) string. |
| I2 | Pure & deterministic. Same arguments → same return value across hosts and runs. No I/O, no Math.random, no Date.now(), no crypto.randomBytes, no async, no event emission, no console. |
| I3 | crypto.createHash("sha256") is the only Node built-in used. Importing it does not violate I2 because SHA-256 is a pure deterministic transform of the input bytes. |
| I4 | Strict type validation. Every builtin checks (a) arity, (b) per-arg type. Mismatch throws BuiltinTypeError with a typed message before any inner work. |
| I5 | Errors propagate via throw. Builtins never return sentinel values for errors — they throw BuiltinTypeError, OverflowError, DivisionByZeroError, or UnderflowError. The engine catches at the rule boundary in executeRuleset. |
| I6 | Bigint isqrt without Number() coercion. Bootstrap uses bit-length: 1n << (BigInt(n.toString(2).length) / 2n + 1n). This stays correct for arbitrary-precision bigints. |
| I7 | VRF stub validates input shape. vrf_verify(pk, proof, input) returns false for malformed input (wrong length, non-hex), never true. The stub is permissive on the interface not on correctness. |
| I8 | No mutation of inputs. Builtins never mutate the args array, never mutate input strings/bigints (bigints and strings are immutable in JS, but the contract is stated explicitly). |
| I9 | BUILTINS and BUILTIN_COSTS keys are equal. Both maps have exactly the same 13 keys; a test asserts symmetric difference is empty. |
§4. VRF stub semantics (ADR-002 boundary)
vrf_verify(pk: string, proof: string, input: string) → boolean.
Validation gate (always runs first):
- Arity must be 3.
args.length !== 3→BuiltinTypeError. - Every arg must be
typeof === "string". Otherwise →BuiltinTypeError. pkmust be 64 hex chars (32-byte Ed25519 public key).proofmust be 160 hex chars (80-byte ECVRF proof).inputmust be a non-empty string. Hex check:/^[0-9a-fA-F]+$/. Any failure → returnfalse(not throw — bad input is “proof doesn’t verify”, not a contract violation).
Stub verifier (test-fixture-only):
The stub returns true iff proof === SHA-256(pk + ":" + input). This
is not ECVRF; it is a self-consistent test fixture that lets us
write tests with a known “valid proof” example. When the real VRF lands
(per ADR-002 §Decision), this body is replaced; the interface stays.
Safety property: there is no input for which the stub returns true
without pk + proof + input being well-formed (correct lengths and hex).
This means a future deployment that swaps the body for the real VRF
cannot introduce a security regression by accidentally accepting
malformed input — the validation gate already rejects it.
§5. Algorithm details
§5.1. isqrt (extraction §3 pseudocode, bigint-faithful)
function isqrt(n: bigint): bigint {
if (n < 0n) throw new BuiltinTypeError('isqrt', 'negative input');
if (n === 0n) return 0n;
// Bit-length bootstrap — never coerces through Number().
// n.toString(2).length is the bit-count. Half of that, rounded up, is
// the smallest x such that x*x >= n is guaranteed to fit; we add 1n
// to ensure x_0 >= sqrt(n) (Newton converges from above).
const bitLen = BigInt(n.toString(2).length);
let x = 1n << ((bitLen >> 1n) + 1n);
// Standard Newton iteration. Monotone non-increasing, converges in
// O(log log n) steps. Loop bound is safe — always terminates because
// x decreases strictly until y >= x.
while (true) {
const y = (x + n / x) >> 1n;
if (y >= x) return x;
x = y;
}
}
§5.2. ilog2 (extraction §3)
function ilog2(n: bigint): bigint {
if (n <= 0n) return 0n; // defined-as-zero for the edge case
return BigInt(n.toString(2).length) - 1n;
}
§5.3. diminishing
function diminishing(v: bigint, k: bigint): bigint {
const denom = k + v;
if (denom === 0n) {
throw new DivisionByZeroError(`diminishing: k + v === 0n (k=${k}, v=${v})`);
}
return (v * k) / denom;
}
§5.4. hash
function hash(s: string): string {
// crypto.createHash("sha256") — deterministic, no RNG, no I/O.
// .digest("hex") returns 64 lowercase hex chars; UTF-8 is the default
// encoding for string updates.
return createHash('sha256').update(s, 'utf8').digest('hex');
}
§5.5. vrf_verify (stub per §4)
function vrfVerify(pk: string, proof: string, input: string): boolean {
const HEX = /^[0-9a-fA-F]+$/;
if (pk.length !== 64 || !HEX.test(pk)) return false;
if (proof.length !== 160 || !HEX.test(proof)) return false;
if (input.length === 0) return false;
const expected = createHash('sha256')
.update(pk + ':' + input, 'utf8')
.digest('hex');
return proof === expected;
}
Note: stub matches a 64-char SHA-256 against a 160-char proof, so no
real-world input can return true accidentally — only the canonical
test fixture (which constructs a proof that ends with the SHA-256 digest
padded out to 160 hex chars) verifies. This gives the test suite a
“valid” example without compromising the post-replacement safety
property.
§6. Error contract
| Error class | Thrown by | Triggering condition |
|---|---|---|
BuiltinTypeError |
every builtin | wrong arity; wrong arg type; clamp(lo > hi); isqrt(negative) |
OverflowError |
bps_mul, bps_div, decay |
bubbles up from integer-math.ts (overflow in safe_mul) |
DivisionByZeroError |
bps_div, diminishing |
divisor zero |
UnderflowError |
decay |
bubbles up from integer-math.ts (epochs negative) |
vrf_verify and hash never throw post-validation — hash always
succeeds on any string input; vrf_verify returns false on malformed
input rather than throwing (per §4).
§7. Acceptance criteria (mapped to test cases)
| AC# | Statement | Tested by |
|---|---|---|
| AC-1 | BUILTINS has exactly 13 entries with the expected names |
'BUILTINS surface' |
| AC-2 | BUILTIN_COSTS keys === BUILTINS keys |
'cost table parity' |
| AC-3 | min/max/abs/cap/clamp arithmetic correctness |
'arithmetic' group |
| AC-4 | clamp rejects inverted range |
'clamp throws on lo > hi' |
| AC-5 | isqrt(100) → 10n, isqrt(101) → 10n, isqrt(0) → 0n, isqrt(-1n) throws |
'isqrt' group |
| AC-6 | isqrt correct for arbitrary-precision bigints (e.g. (2n**100n)) |
'isqrt large bigint' |
| AC-7 | ilog2(8n) → 3n, ilog2(1024n) → 10n, ilog2(0n) → 0n, ilog2(-1n) → 0n |
'ilog2' group |
| AC-8 | decay delegates to integer-math.decay(v, rate, 1n) |
'decay 1-epoch' |
| AC-9 | diminishing(500n, 1000n) → 333n; diminishing(-100n, 100n) throws DivisionByZeroError |
'diminishing' group |
| AC-10 | bps_mul/bps_div are reference-equal-by-result to integer-math versions |
'bps delegation' |
| AC-11 | hash("hello world") === "b94d27b9..." (the fixture SHA-256) |
'hash known vector' |
| AC-12 | hash digest length is exactly 64 hex chars for any input (incl. empty string) |
'hash digest length' |
| AC-13 | hash deterministic — same input twice → identical output |
'hash determinism' |
| AC-14 | vrf_verify returns true for canonical test vector |
'vrf canonical accept' |
| AC-15 | vrf_verify returns false for wrong pk length / non-hex / empty input / wrong arity-arg-types |
'vrf reject malformed' group |
| AC-16 | Wrong arity → BuiltinTypeError for each builtin |
'arity validation' |
| AC-17 | Wrong type → BuiltinTypeError for each builtin (e.g. min(5n, "foo")) |
'type validation' |
| AC-18 | Determinism harness (assertDeterministic) green for every numeric builtin |
'determinism harness' |
| AC-19 | bindBuiltins() returns BUILTINS (reference-equal) |
'bindBuiltins' |
| AC-20 | The 5-test forbidden-op self-scan against the file source returns [] for every exported function |
'forbidden-op scan' |
§8. Out-of-scope
- Wiring
BUILTIN_COSTSinto the engine’sbumpIntegerOps(engine slice). - Real ECVRF / RFC 9381 implementation (Phase 1.5+ per ADR-002).
rep(node)and other state-access functions (P1.3.3).- Rule-level FuncCall validation against the BUILTINS table (P1.2.3 / P1.2.4).
- Mutation-application semantics (P1.4.1).
§9. Approval to proceed
This contract closes Step 2. Step 3 is the packet — see
docs/packets/p1-3-2-builtins-packet.md.