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 for src/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.jsbps_mul, bps_div, decay, OverflowError, DivisionByZeroError, UnderflowError.
    • node:cryptocreateHash only (deterministic transform). This is the only Node built-in this module imports; it is not a determinism violation because createHash("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):

  1. Arity must be 3. args.length !== 3BuiltinTypeError.
  2. Every arg must be typeof === "string". Otherwise → BuiltinTypeError.
  3. pk must be 64 hex chars (32-byte Ed25519 public key). proof must be 160 hex chars (80-byte ECVRF proof). input must be a non-empty string. Hex check: /^[0-9a-fA-F]+$/. Any failure → return false (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_COSTS into the engine’s bumpIntegerOps (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.


Back to top

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

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