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 forsrc/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 withproof_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 keyvalue: the value at that key (orundefinedif 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.stakesexpose ReadOnlyMap interfaces with no mutating methods.state.epoch,state.event_count,state.fork_id,state.rule_versionare TypeScriptreadonly— direct assignment is rejected at compile time.state.getReputation(node, domain)/getStake/getTokens/getBindingare 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 onstate.
Mathematically: before.with_binding(...) = after ⇒ before === 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>.valuematching whatgetXxx(...)would return for that key family, or the scalar value forepoch/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. ThetoEngineState()method returns a flatRecord<string, unknown>with the 7 keys; engine consumers wrap that intoContext.statethemselves. - 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.bindingsaccepts aReadonlyMap<string, bigint|string|boolean>. The state layer’sgetBindings()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, noasync function, noasync (. - No
import 'fs'orimport 'crypto'. - No float literals.
- String comparisons via
</>only — neverlocaleCompare.
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/getTokenssemantics (4 cases incl. missing). - F3 — Frozen-Map mutation rejection (2 cases via cast).
- F4 —
with_bindingimmutability (3 cases: ref-inequality, original unchanged, deep-equal pre-call snapshot). - F5 —
with_bindingO(1) probed at 0/100/1000 prior bindings (1 case). - F6 —
with_bindingchain semantics (2 cases: lookup precedence, parent traversal). - F7 —
computeDiffcodepoint key ordering (3 cases: unicode keys, locale-stable, ignore-bindings). - F8 —
computeDiffvalue semantics (3 cases: scalar diff, Map diff, no-change is empty). - F9 —
generateReadProofstub shape (3 cases: scalar key, map key, unknown key). - F10 —
makeReadOnlyStatevalidation throws (5 cases). - F11 — Determinism harness self-scan over all 3 entry points (1 case).
- F12 —
toEngineStateprojection 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.