player-chain-stablekey
Player chain stableKey (developer reference)
This note explains stableKey: what it identifies, how it is formed, where it appears on the wire, and what code must stay in sync. It complements the broader picture in Agent Play world model and player chain.
Definition
stableKey is a human-readable string that names one leaf in the player-chain Merkle tree. It is the field name for that leaf everywhere the system indexes leaves by identity (Redis hash, fanout hints, RPC), not the leaf digest (leafDigestHex) and not arbitrary user input.
Properties:
- Stable across a session for a given logical row: the same agent always uses the same key until that row leaves the snapshot (removal is signaled with the same key plus
removed: truein fanout). - Deterministic for occupants: derived only from occupant kind and id fields, not from position, name, or full JSON.
- Comparable for ordering: occupant leaves are sorted by
localeCompareon theirstableKeywhen building the chain.
What stableKey is not
- Not the SHA-256 digest of the leaf (that is
leafDigestHexin server code and Redis). - Not required to be unique across deployment restarts in any stronger sense than “unique among current occupants”: the chain also includes fixed
__genesis__and__header__keys that are reused for every snapshot build. - Not sent as
sid: incremental fetch usesgetPlayerChainNodewithstableKeyonly (same live session asgetWorldSnapshot).
The three kinds of keys
1. Genesis — fixed literal
| Value | Meaning |
|---|---|
__genesis__ |
First leaf; Merkle payload is trimmed genesis file text (see genesis loading in the world-model note). |
Constants exported for type-safe references:
packages/sdk/src/lib/world-chain-keys.ts:PLAYER_CHAIN_GENESIS_STABLE_KEY- Server: same string in
packages/web-ui/src/server/agent-play/player-chain/index.ts
2. Header — fixed literal
| Value | Meaning |
|---|---|
__header__ |
Second leaf; Merkle payload is stableStringify({ v: 1, sid, bounds }) from the resolved snapshot. |
Constant: PLAYER_CHAIN_HEADER_STABLE_KEY (same locations as genesis).
3. Occupants — computed: agent:{agentId} or mcp:{id}
| Occupant kind | stableKey format |
Source fields |
|---|---|---|
agent |
agent: + agentId |
PreviewWorldMapAgentOccupantJson.agentId (server preview types) / AgentPlayWorldMapAgentOccupant.agentId (SDK public types) |
mcp |
mcp: + id |
PreviewWorldMapMcpOccupantJson.id / MCP occupant id |
Canonical function (server): stableOccupantSortKey(occ) in packages/web-ui/src/server/agent-play/player-chain/index.ts.
The SDK merge path duplicates the same string rule in packages/sdk/src/lib/player-chain-merge.ts (local stableOccupantSortKey) so mergeSnapshotWithPlayerChainNode can filter and upsert occupants without importing server modules. Any change to the key format must update both (and tests).
Leaf order
Order is fixed:
__genesis____header__- All occupants, sorted ascending by
stableOccupantSortKey(occ)vialocaleCompare.
This order defines the Merkle leaf list and must match diff/fanout logic in the same module as buildLeafEntriesFromSnapshot.
Where stableKey is used
| Location | Role |
|---|---|
buildLeafEntriesFromSnapshot |
Each leaf row: { stableKey, leafDigestHex }. |
Redis agent-play:{hostId}:player-chain:leaves |
Hash: field name = stableKey, value = leaf digest hex (full map replace on persist). |
playerChainNotify (SSE / fanout) |
Each affected leaf: { stableKey, leafIndex, removed? } so clients know what to fetch. |
getPlayerChainNode RPC |
Request body { stableKey } (trimmed); response PlayerChainNodeResponse echoes stableKey on every variant. |
| Client merge | sortNodeRefsForSerializedFetch orders refs by removal index then additions; fetches use stableKey from each ref. |
RPC and validation (getPlayerChainNode)
Implementation: packages/web-ui/src/server/agent-play/read-player-chain-node.ts and packages/web-ui/src/app/api/agent-play/sdk/rpc/route.ts.
stableKeyis trimmed for lookup.- Empty after trim → rejected at the API layer (
invalid payload/readPlayerChainNodereturnsnullfor empty key). __genesis__→ genesis node (trimmed genesis text from the store).__header__→ header node (sid,boundsfrom resolved snapshot).- Any other string → treat as occupant key: find occupant where
stableOccupantSortKey(o) === stableKey. If none match →{ kind: "occupant", stableKey, removed: true }(incremental sync uses this to drop a row locally).
So “unknown” occupant keys still yield a valid RPC response (removed occupant), not a 400, as long as the key string is non-empty.
Client types (SDK)
Public shapes in packages/sdk/src/public-types.ts:
PlayerChainNotifyNodeRef:stableKey,leafIndex, optionalremoved, optionalupdatedAt.PlayerChainNodeResponse: discriminated bykind; genesis and header nodes use branded stable key types; occupant nodes usestableKey: stringfor theagent:/mcp:keys.
Parsing and merge: packages/sdk/src/lib/player-chain-merge.ts (parsePlayerChainFanoutNotify, parsePlayerChainNodeRpcBody, mergeSnapshotWithPlayerChainNode).
Contributor checklist when touching keys
- Occupant identity — If you add a new occupant kind or change how agents/MCP rows are keyed, you must define a single canonical
stableKeyformat and implement it in serverstableOccupantSortKeyand SDK merge helper (or share a tiny shared package if you dedupe). - Sorting — Leaf order must remain
localeCompareon those keys unless you version the chain format. - Wire/SSE — Fanout builders must emit the same strings Redis uses for leaf fields.
- Tests —
player-chain/index.test.ts,read-player-chain-node.test.ts,player-chain-merge.test.ts,remote-play-world.test.tscover behavior; extend them when key rules change.
Code map
| Topic | Path |
|---|---|
Leaf entries, Merkle, stableOccupantSortKey, Redis map, fanout diff |
packages/web-ui/src/server/agent-play/player-chain/index.ts |
RPC slice by stableKey |
packages/web-ui/src/server/agent-play/read-player-chain-node.ts |
| SDK constants | packages/sdk/src/lib/world-chain-keys.ts |
| Merge + fetch ordering + SSE parse | packages/sdk/src/lib/player-chain-merge.ts |
| Public API types | packages/sdk/src/public-types.ts |