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, R85d766db59) which defines theContexttype that wraps astate: Readonly<Record<string, unknown>>field. P1.3.3 adds the richerReadOnlyStateinterface and frozen-Map impl that hosts use to build and guard that state surface before passing toexecuteRuleset.
§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.tssrc/__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:
- Build their state from typed Maps (not arbitrary records).
- Validate immutability at construction time.
- Project into the engine’s
Context.statevia a serialization step (host-owned). - Use
with_bindingto bindactor/target/ etc., producing values compatible withContext.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 | nullpointer.
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
localeCompare—Array.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 fromrule_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.