P1.3.3 — κ State Access Layer — Audit

Step 1 of the 5-step executor chain (audit → contract → packet → implement → verify). Builds on the P1.3.1 engine (src/domains/rules/engine.ts, R85 d766db59) which defines the Context type that wraps a state: Readonly<Record<string, unknown>> field. P1.3.3 adds the richer ReadOnlyState interface and frozen-Map impl that hosts use to build and guard that state surface before passing to executeRuleset.

§1. Surface inventory

§1.1. Target files (greenfield for this task)

Path Exists at base d766db59? Purpose
src/domains/rules/state-access.ts No ReadOnlyState interface + ReadOnlyStateImpl class + with_binding immutability + computeDiff + Merkle-proof hook stub.
src/__tests__/domains/rules/state-access.test.ts No Jest tests (5+ fixture families per packet §P3).

§1.2. Touched but not owned

Path Delta Purpose
src/domains/rules/ already present with bps-constants.ts, canonical.ts, determinism.ts, engine.ts, integer-math.ts, lexer.ts, parser.ts, validator.ts (8 files) Adding state-access.ts as a peer — no edits to existing files.
package.json / package-lock.json none No new runtime deps. State layer uses only built-in Map. The Merkle hook stub returns a typed shape — no actual merkletreejs import (production wiring lands in η integration follow-up).
src/domains/proof/ not imported The contract specifies that the Merkle hook is a stub for future η integration. No proof-store imports in this slice.

§1.3. Test-file layout reconciliation

The task prompt places the test at src/domains/rules/__tests__/state-access.test.ts. Wave 1–4 of κ uses the convention tests live under src/__tests__/domains/<name>/ (verified at base):

  • src/__tests__/domains/rules/{bps-constants,canonical,determinism,engine,integer-math,lexer,parser,validator}.test.ts
  • src/__tests__/domains/{router,skills,tasks,proof,trail,integrations}/...

The state-access test will live at:

src/__tests__/domains/rules/state-access.test.ts

Same convention reconciliation as the engine, lexer, and parser audits — not a spec deviation.

§2. Authoritative spec sources

Source Path Weight
Heritage extraction §10 docs/reference/extractions/kappa-rule-engine-extraction.md §10 (ReadOnlyState Interface) Authoritative for the 7 keys (reputation, tokens, stakes, epoch, event_count, fork_id, rule_version) and the with_binding shape.
Task prompt §P1.3.3 docs/guides/implementation/task-prompts/p1.1-kappa-rule-engine.md lines 1354–1503 Authoritative for the acceptance criteria, ready-to-paste prompt, and writeback template.
Engine source src/domains/rules/engine.ts lines 141–148 (Context interface) The engine’s Context.state field consumes the surface this layer produces (host serializes ReadOnlyState into the readonly record).
Engine source src/domains/rules/engine.ts lines 144–148 (Context.bindings) The engine’s bindings: ReadonlyMap<string, bigint|string|boolean> is the canonical receiving shape for bindings introduced by with_binding — the state layer’s with_binding must produce values compatible with this.
Concept doc docs/3-world/physics/laws/rule-engine.md §Forbidden operations Motivates the immutability guarantees.
Spec docs/spec/s11-rule-engine.md Load-bearing semantic spec for κ.
Proof source src/domains/proof/merkle.ts Reference for the Merkle proof shape (MerkleProof type) the read-proof stub mimics in its return shape.

§3. Drift findings

§3.1. Two contexts: P1.3.3 ReadOnlyState vs P1.3.1 engine.Context

The P1.3.1 engine defines its own Context type (engine.ts §1.2.4, lines 141–148):

interface Context {
  readonly event: Readonly<Record<string, unknown>>;
  readonly state: Readonly<Record<string, unknown>>;
  readonly rule_version: string;
  readonly epoch: bigint;
  readonly bindings: ReadonlyMap<string, bigint | string | boolean>;
  readonly budget: BudgetTracker;
}

P1.3.3 introduces a richer ReadOnlyState with named maps: reputation, tokens, stakes. This is not a redefinition of the engine’s Context — it is a strict superset that hosts use to:

  1. Build their state from typed Maps (not arbitrary records).
  2. Validate immutability at construction time.
  3. Project into the engine’s Context.state via a serialization step (host-owned).
  4. Use with_binding to bind actor / target / etc., producing values compatible with Context.bindings.

Resolution: the state layer declares its own surface and exposes a small adapter helper toEngineState() that returns a Readonly<Record<string, unknown>> with the 7 keys flattened — usable as Context.state. The with_binding method returns a new ReadOnlyState whose getBindings() produces a ReadonlyMap<string, bigint|string|boolean> (compatible with Context.bindings). The engine itself is not modified.

This keeps the two layers loosely coupled — the engine remains a pure AST evaluator unaware of state schema; the state layer is the schema authority.

§3.2. Object.freeze is shallow — Map mutation hazard

The task prompt’s “Critical gotchas” call out: Object.freeze is SHALLOW — a frozen object containing a Map does NOT freeze the Map; .set/.delete/.clear still work.

Resolution: wrap each underlying Map in a ReadOnlyMap<K, V> adapter class that implements only get, has, size, keys, values, entries, forEach, and [Symbol.iterator]never set, delete, or clear. The adapter throws ReadOnlyStateError if those mutating ops are called via Object.getPrototypeOf(...) style access. (We can’t strictly prevent prototype-pollution-style attacks, but the documented surface is read-only and the type system enforces it.)

The internal storage is a real Map<K, V>; the adapter never returns that real Map outside the class.

§3.3. with_binding O(1) requirement — structural sharing

The task prompt’s “Critical gotchas” call out: with_binding must be O(1) — copying the entire state on every call is wrong. Use structural sharing or a persistent data structure for bindings.

Resolution: use a parent-pointer chain for bindings only. The ReadOnlyStateImpl carries:

  • A reference to its 7 underlying Maps / scalars (shared by reference across siblings — Maps are read-only adapters, so sharing is safe).
  • A bindings: Map<string, bigint|string|boolean> that is replaced by a frozen ReadonlyMap at construction.
  • A parent: ReadOnlyStateImpl | null pointer.

with_binding(name, value) constructs a new ReadOnlyStateImpl with:

  • The same 7 underlying Maps / scalars (by reference).
  • A new bindings Map that ONLY contains the new (name, value) pair.
  • parent = this.

Lookups walk the parent chain (constant amortized cost since chains are typically depth ≤ 3 — actor + target + a per-clause local). For the immutability test, original’s getBindings() is identical (same Map reference) before and after a call to with_binding on it.

Why parent-pointer not full Map-copy: copying the entire bindings Map even for one new binding violates the O(1) contract. Parent-pointer chain is the standard persistent-data-structure pattern for this exact use case (see Clojure’s assoc, OCaml’s Map.add).

§3.4. computeDiff codepoint key ordering

The task prompt’s “Critical gotchas” call out: computeDiff key ordering is deterministic across implementations — sort by codepoint, not locale collation. Otherwise consensus fails across nodes with different locales.

Resolution: use Array.prototype.sort() with no comparator for top-level string keys (the default sort is lexicographic on UTF-16 code units, locale-independent — same convention engine.ts uses for rule names). For nested keys (e.g. reputation.alice.governance), join segments with . and sort the joined string. Never call localeCompare.

This matches the asciiCompareByName pattern at engine.ts:480-488.

§3.5. Merkle read-proof stub shape

The task prompt requires a stub. The shape mimics src/domains/proof/merkle.ts MerkleProof:

export interface ReadProof {
  state_root: string;       // 64-char hex (rule_version-derived for the stub)
  key: string;              // the queried key
  value: unknown;           // the value at that key
  proof_path: MerkleProofNode[];   // empty in stub; populated by η integration
}

The stub returns proof_path: [] and state_root derived from rule_version (which is itself a sha256 hex per spec s12). Production wiring lands in a future η integration PR and is not in this slice’s scope.

§4. ReadOnlyState shape (from extraction §10)

interface ReadOnlyState:
    reputation:    Map<NodeId, Map<Domain, int64>>
    tokens:        Map<NodeId, TokenRecord[]>
    stakes:        Map<NodeId, int64>
    epoch:         int64
    event_count:   int64
    fork_id:       bytes32
    rule_version:  string

    function with_binding(name: string, value: any) -> ReadOnlyState

7 keys total. TypeScript-mapped:

Key Pseudocode TypeScript
reputation Map<NodeId, Map<Domain, int64>> ReadOnlyMap<string, ReadOnlyMap<string, bigint>>
tokens Map<NodeId, TokenRecord[]> ReadOnlyMap<string, readonly TokenRecord[]>
stakes Map<NodeId, int64> ReadOnlyMap<string, bigint>
epoch int64 bigint
event_count int64 bigint
fork_id bytes32 string (64-char lowercase hex)
rule_version string string (sha256 hex per spec s12)

TokenRecord is a domain primitive — defined as a small interface in this file (extraction does not name fields, so we use a minimal { id: string; amount: bigint; minted_at: bigint } shape that satisfies known κ rule needs and is structurally compatible with future Phoenix-derived token semantics; downstream consumers extend via type intersection).

§5. Drift findings (continued)

§5.1. getReputation(node, domain) returns 0n when missing

Per task prompt §P1.3.3 inline spec: getReputation(node, domain): bigint — throws if not found? No — returns 0n per κ convention.

This matches κ semantics — a node that has never accrued reputation in a domain is treated as having 0n, not an error. Same for getStake (returns 0n) and getTokens (returns []).

§5.2. State immutability does not propagate to TokenRecord[]

getTokens(node) returns a readonly TokenRecord[]. The TypeScript type system enforces that callers cannot push/pop/splice. But the underlying Token objects are themselves readonly interface fields — so a caller cannot do tokens[0].amount = 999n either. We do not call Object.freeze on each TokenRecord (would violate the O(1) construction promise for large token arrays). Instead, the type system is the line of defense; runtime mutation through as any casts is out-of-band and documented as an explicit caller-bug in the contract.

§5.3. Public surface kept minimal

The task prompt mentions specific helpers. Final public surface:

// Types
export interface ReadOnlyState { ... }                  // interface
export interface TokenRecord { ... }                    // interface
export interface StateDiff { ... }                      // interface
export interface ReadProof { ... }                      // interface
export interface ReadOnlyMap<K, V> { ... }              // interface
export class ReadOnlyStateError extends Error { ... }   // error class
export class ReadOnlyStateImpl implements ReadOnlyState { ... }  // impl class

// Construction
export function makeReadOnlyState(opts: ReadOnlyStateInit): ReadOnlyState

// Diff + Merkle hook
export function computeDiff(before: ReadOnlyState, after: ReadOnlyState): StateDiff[]
export function generateReadProof(state: ReadOnlyState, key: string): ReadProof

7 exports + 5 interfaces. No more, no less.

§6. Determinism guardrails

The state layer must satisfy inspectFunctionForbidden zero-hits self-scan (§I1 from determinism.ts):

  • No Math.*, Date.*, setTimeout, fetch, crypto.* — none of the public API needs them.
  • No localeCompareArray.prototype.sort() (no comparator) only.
  • No await, async, Promise — synchronous only.
  • No float literals — none expected; bigint everywhere.
  • No import 'crypto' or 'fs' — the Merkle stub returns a stub state_root derived from rule_version (the host already validated it as sha256 hex; no rehashing needed in this slice).

§7. Audit completion

  • §1 surface inventory committed.
  • §2 authoritative spec sources cited.
  • §3 drift findings documented (4 items: dual-context, frozen Map hazard, with_binding O(1), codepoint sort).
  • §4 7-key shape mapped to TypeScript.
  • §5 public surface bounded to 7 exports + 5 interfaces.
  • §6 determinism guardrails listed.

Audit blocks the contract step. Contract proceeds at docs/contracts/p1-3-3-state-access-contract.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.