P1.3.3 — κ State Access Layer — Behavioral Contract

Step 2 of the 5-step executor chain. Builds on docs/audits/p1-3-3-state-access-audit.md. Defines the public surface, semantics, and invariants for src/domains/rules/state-access.ts.

§1. Module identity

  • Path: src/domains/rules/state-access.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: NONE. Self-standing — preserves the determinism scanner clean-bill on this file. (The Merkle proof stub does not import from src/domains/proof/; it returns a typed shape with proof_path: [].)
  • No imports from src/db/*, src/middleware/*, src/server.ts, or other domain folders. No Node built-ins.

§2. Public API

The module exports the following named entities (7 exports + 5 interfaces).

§2.1. ReadOnlyMap<K, V> interface

export interface ReadOnlyMap<K, V> {
  readonly size: number;
  get(key: K): V | undefined;
  has(key: K): boolean;
  keys(): IterableIterator<K>;
  values(): IterableIterator<V>;
  entries(): IterableIterator<[K, V]>;
  forEach(callback: (value: V, key: K) => void): void;
  [Symbol.iterator](): IterableIterator<[K, V]>;
}

A read-only view over an underlying Map<K, V>. No set, delete, or clear. Implemented by an internal class FrozenMap<K,V> that wraps a real Map and exposes only the read methods.

§2.2. TokenRecord interface

export interface TokenRecord {
  readonly id: string;          // unique token identifier
  readonly amount: bigint;      // int64
  readonly minted_at: bigint;   // epoch when minted
}

Minimal token shape — extension via type intersection in domain-specific code is allowed.

§2.3. ReadOnlyState interface

export interface ReadOnlyState {
  readonly reputation: ReadOnlyMap<string, ReadOnlyMap<string, bigint>>;
  readonly tokens: ReadOnlyMap<string, readonly TokenRecord[]>;
  readonly stakes: ReadOnlyMap<string, bigint>;
  readonly epoch: bigint;
  readonly event_count: bigint;
  readonly fork_id: string;          // 64-char lowercase hex
  readonly rule_version: string;     // sha256 hex per spec s12

  /** Reputation lookup; missing entries return 0n. */
  getReputation(node: string, domain: string): bigint;
  /** Stake lookup; missing entries return 0n. */
  getStake(node: string): bigint;
  /** Tokens lookup; missing entries return []. */
  getTokens(node: string): readonly TokenRecord[];
  /** Binding lookup; returns undefined if not bound. */
  getBinding(name: string): bigint | string | boolean | undefined;
  /** All bindings as a ReadonlyMap (for engine.Context.bindings compatibility). */
  getBindings(): ReadonlyMap<string, bigint | string | boolean>;
  /** Returns a new ReadOnlyState with `name` bound to `value` (parent-pointer chain, O(1)). */
  with_binding(name: string, value: bigint | string | boolean): ReadOnlyState;
  /** Project the 7 keys into a flat record for engine.Context.state. */
  toEngineState(): Readonly<Record<string, unknown>>;
}

7 underlying keys + binding overlay + with_binding immutability + engine adapter.

§2.4. StateDiff interface

export interface StateDiff {
  readonly key: string;
  readonly old_value: unknown;
  readonly new_value: unknown;
}

Emitted by computeDiff. Keys are codepoint-sorted (Array.prototype.sort() default ordering).

§2.5. ReadProof interface

export interface ReadProof {
  readonly state_root: string;       // 64-char hex
  readonly key: string;              // queried key
  readonly value: unknown;           // value at key (undefined if absent)
  readonly proof_path: readonly { position: 'left' | 'right'; data: Buffer }[];
}

Mirrors MerkleProof from src/domains/proof/merkle.ts:74-83. Stub returns proof_path: [].

§2.6. ReadOnlyStateInit interface

export interface ReadOnlyStateInit {
  reputation?: Map<string, Map<string, bigint>>;
  tokens?: Map<string, readonly TokenRecord[]>;
  stakes?: Map<string, bigint>;
  epoch: bigint;
  event_count: bigint;
  fork_id: string;
  rule_version: string;
  bindings?: Map<string, bigint | string | boolean>;
}

Construction-time options for makeReadOnlyState.

§2.7. ReadOnlyStateError class

export class ReadOnlyStateError extends Error {
  override readonly name = 'ReadOnlyStateError';
  constructor(message: string);
}

Thrown when any mutation attempt is detected on a frozen Map or via direct property assignment.

§2.8. makeReadOnlyState factory

export function makeReadOnlyState(init: ReadOnlyStateInit): ReadOnlyState;

Constructs a fresh ReadOnlyState from optional Maps + required scalars. The factory copies the input Maps into internally-owned Maps before wrapping (so caller mutations of their input Map don’t bleed through). Each input nested Map (e.g. inner reputation map) is also copied. This is a one-time construction-cost copy, NOT the per-with_binding copy that the O(1) requirement forbids.

§2.9. computeDiff function

export function computeDiff(
  before: ReadOnlyState,
  after: ReadOnlyState,
): StateDiff[];

Returns an array of StateDiff records, one per top-level key whose value changed. Keys are sorted via Array.prototype.sort() default ordering (UTF-16 code units). The 7 top-level keys (epoch, event_count, fork_id, reputation, rule_version, stakes, tokens) are inspected; bindings are intentionally NOT included in diffs (bindings are per-evaluation, not durable state).

For Map-valued keys (reputation, tokens, stakes), the diff records old_value and new_value as the serialized form (a sorted-keys plain object) so deep-comparison and JSON serialization work uniformly. (Internal Maps are not directly comparable with ===.)

§2.10. generateReadProof function

export function generateReadProof(state: ReadOnlyState, key: string): ReadProof;

Stub. Returns:

  • state_root: state.rule_version (re-using the version hash as a proxy root for now)
  • key: the input key
  • value: the value at that key (or undefined if not a top-level key)
  • proof_path: []

Production wiring lands in η integration follow-up.

§3. Semantic invariants

§3.1. Immutability of the public surface (I1)

For any state: ReadOnlyState:

  • state.reputation, state.tokens, state.stakes expose ReadOnlyMap interfaces with no mutating methods.
  • state.epoch, state.event_count, state.fork_id, state.rule_version are TypeScript readonly — direct assignment is rejected at compile time.
  • state.getReputation(node, domain) / getStake / getTokens / getBinding are pure queries.

Direct property assignment via as any casts is out-of-band and not part of the contract; the type system is the documented enforcement layer.

§3.2. with_binding immutability of the original (I2)

For any state: ReadOnlyState, after state.with_binding(name, value):

  • state.getBinding(name) returns the same value it returned before the call (never the new value).
  • state.getBindings() returns a Map with the same entries it had before the call.
  • All other state fields (epoch, event_count, etc.) are unchanged on state.

Mathematically: before.with_binding(...) = afterbefore === before.snapshot() (deep-equal to its pre-call snapshot).

§3.3. with_binding produces a different reference (I3)

For any state: ReadOnlyState:

const next = state.with_binding('actor', 0n);
expect(next).not.toBe(state);
expect(next.getBinding('actor')).toBe(0n);
expect(state.getBinding('actor')).toBeUndefined();

Reference inequality is necessary for replay determinism — code that compares Context bindings must see two distinct objects.

§3.4. with_binding is O(1) (I4)

Implementation MUST use a parent-pointer chain. Adding a binding to a state with N existing bindings creates a new node holding ONE entry plus a parent pointer — never a copy of all N entries. The contract test asserts that with_binding runs in time independent of getBindings().size (probed via 0, 100, and 1000 prior bindings).

§3.5. Mutating ops on a frozen Map throw (I5)

The internal FrozenMap adapter MUST NOT expose set, delete, or clear on its public type signature. Any attempt to call those via prototype access (e.g. Object.getPrototypeOf(state.reputation).set.call(...)) is documented as a no-op or throw — the adapter does not extend Map so the prototype chain does not leak Map.prototype.set.

§3.6. computeDiff deterministic key ordering (I6)

computeDiff(a, b) always returns keys in the same order, independent of:

  • The locale of the host (no localeCompare).
  • The construction order of the input states.
  • Any object-property iteration order.

The order is the UTF-16 code-unit lexicographic sort of the top-level key names.

§3.7. computeDiff ignores bindings (I7)

Bindings are per-evaluation overlays, not durable state. Two states with identical 7-key contents but different bindings produce computeDiff = [].

§3.8. generateReadProof returns shape-stable stub (I8)

For valid keys, generateReadProof returns a ReadProof with:

  • state_root === state.rule_version.
  • key === <input>.
  • value matching what getXxx(...) would return for that key family, or the scalar value for epoch/event_count/fork_id/rule_version.
  • proof_path === [] (always, until η integration ships).

For unknown keys, returns value: undefined.

§3.9. Deterministic hash discipline (I9)

The state layer is fully deterministic. inspectFunctionForbidden over every export of this module returns []. The test file asserts this explicitly per each export name in the public API (matches engine.test.ts F7 pattern).

§3.10. Synchronous only (I10)

No async, no await, no Promise. All getters and with_binding return immediately.

§4. Error semantics

Condition Throws Reason
makeReadOnlyState called with epoch < 0n ReadOnlyStateError “epoch must be >= 0n”
makeReadOnlyState called with event_count < 0n ReadOnlyStateError “event_count must be >= 0n”
makeReadOnlyState called with fork_id not a 64-char lowercase hex string ReadOnlyStateError “fork_id must be a 64-char lowercase hex string”
makeReadOnlyState called with rule_version not a 64-char lowercase hex string ReadOnlyStateError “rule_version must be a 64-char lowercase hex string”
makeReadOnlyState called with stakes containing a negative bigint ReadOnlyStateError “stake values must be >= 0n”
with_binding(name, value) called with empty name ReadOnlyStateError “binding name must be non-empty”
with_binding(name, value) with non-bigint/string/boolean value (TypeScript compile error) type system enforced

All other operations are total — they return well-defined values for any input (including missing keys, where they return 0n / [] / undefined).

§5. Coupling rules

  • The state layer does not import engine.ts. The toEngineState() method returns a flat Record<string, unknown> with the 7 keys; engine consumers wrap that into Context.state themselves.
  • The state layer does not import parser.ts, lexer.ts, or any other κ module. Self-standing.
  • The state layer does not import proof/merkle.ts. The Merkle hook is a typed stub; production wiring lands in a future η integration PR.
  • engine.Context.bindings accepts a ReadonlyMap<string, bigint|string|boolean>. The state layer’s getBindings() returns exactly that type.

§6. Determinism contract

Per determinism.ts §FORBIDDEN_PATTERNS:

  • No Math.*, Date.*, setTimeout, setInterval, setImmediate, fetch, XMLHttpRequest, crypto.*, process.hrtime, process.nextTick.
  • No await, no async function, no async (.
  • No import 'fs' or import 'crypto'.
  • No float literals.
  • String comparisons via < / > only — never localeCompare.

assertNoForbiddenOps(makeReadOnlyState), assertNoForbiddenOps(computeDiff), assertNoForbiddenOps(generateReadProof) all return [].

§7. Test matrix (reference for packet)

The packet specifies the test fixtures. Summary:

  • F1 — Construction + 7 keys exposed (3 cases).
  • F2 — getReputation / getStake / getTokens semantics (4 cases incl. missing).
  • F3 — Frozen-Map mutation rejection (2 cases via cast).
  • F4 — with_binding immutability (3 cases: ref-inequality, original unchanged, deep-equal pre-call snapshot).
  • F5 — with_binding O(1) probed at 0/100/1000 prior bindings (1 case).
  • F6 — with_binding chain semantics (2 cases: lookup precedence, parent traversal).
  • F7 — computeDiff codepoint key ordering (3 cases: unicode keys, locale-stable, ignore-bindings).
  • F8 — computeDiff value semantics (3 cases: scalar diff, Map diff, no-change is empty).
  • F9 — generateReadProof stub shape (3 cases: scalar key, map key, unknown key).
  • F10 — makeReadOnlyState validation throws (5 cases).
  • F11 — Determinism harness self-scan over all 3 entry points (1 case).
  • F12 — toEngineState projection shape (1 case).

12 fixture families. Total cases: ~31. Test file size: ~700 lines.

§8. Contract completion

  • §1 module identity bounded.
  • §2 public API enumerated (7 exports + 5 interfaces).
  • §3 ten semantic invariants (I1–I10).
  • §4 error semantics tabulated.
  • §5 coupling rules: no imports from engine, parser, proof.
  • §6 determinism contract honored.
  • §7 test matrix sketched (12 families, ~31 cases).

Contract gates the packet step. Packet proceeds at docs/packets/p1-3-3-state-access-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.