SDK Operations — Normative Specification

Version: 1.11

v1.11 — service-owning handlers (new §11.6.9). §11.6 covered request/response dispatch only, so a handler owning a resource that lives between calls — a listener, a background loop, a watcher — had no contract: an implementation could only spawn it inside the body (unstoppable, unowned) or hardcode it into peer startup as a privileged built-in, which defeats the handler abstraction. §11.6.9 adds the opt-in lifecycle (start after tree writes and before dispatch, failure compensating per §11.6.4; stop before dispatch-unregister, extending §11.6.2's ordering to stop service → dispatch index → tree entries), a mandatory tree declaration at system/runtime/owned-services/{handler-pattern} (closed kind / exposure enums plus a free-form descriptor), and the declared-not-gated boundary rule: declaring a service does not capability-check its traffic, it makes the hole visible. Two MUSTs carry the audit story — an unmediated-public service MUST publish its bind address/port, and an unrecognized enum value MUST be surfaced as unassessable rather than ignored (a deliberate departure from MUST-ignore-unknowns, which is right for wire extensibility and wrong for auditing). Entity-native handlers MUST NOT be service-owning. Folds PROPOSAL-SDK-HANDLER-OWNED-SERVICES including its §6.1 field-shape ruling (2026-07-29). Touches no core-protocol type: the declaration is a system/runtime/ entity (§11.6.7), deliberately not a field on system/handler, which is core's (ENTITY-CORE-PROTOCOL.md §3.7) — and which is also the handler-private entity a remote auditor cannot read. Additive: no existing handler is service-owning; absent a declaration, behavior is exactly as today. Build state (observed 2026-07-31): no service declaration is present in any of the three reference implementations (exhaustive name search). EXTENSION-SIGNALING.md §9's unwrapped surface is the intended first proof.

Status: Active Depends: ENTITY-CORE-PROTOCOL.md (v7.40+), SYSTEM-COMPOSITION.md (v1.5+), EXTENSION-COMPUTE.md (v3.18+)


Scope. This document specifies the operations an SDK implementation MUST, SHOULD, and MAY expose for application code to interact with the entity system. It defines operation semantics — what goes in, what comes out, what errors can occur — not API shapes. Language-idiomatic differences (method chaining, options pattern, fluent builder) are expected and encouraged.

Audience. SDK implementors in any language. Application developers reference per-language SDK documentation, not this spec.

What this spec does NOT cover. Peer roles, namespace conventions, and path organization are advisory patterns documented in GUIDE-PEER-CONCERNS-AND-NAMESPACES.md. This spec is about the interface — it works regardless of how you organize your tree.


1. SDK Levels

LevelNameWhat it providesAudience
L0Wire protocolCBOR framing, envelope construction, handshakeCore library internals
L1OperationsTree ops, dispatch, query, subscription, connection, lifecycleAll SDK consumers
L2PatternsScoped handles, state management, type rendering (see GUIDE-SDK-PATTERNS.md)Application developers
L3Extension operationsExtension handler wrappers (revision, subscription, history, query, etc.), handler registrationEntity-native applications
L4Composition patternsReactive pipelines, cross-peer workflows, workspace transferAdvanced applications

This specification covers L1 and advisory L2 patterns. L0 is internal to the core library. L3 and L4 are documented in guides/ as patterns mature.


2. How Operations Actually Work

The SDK manages one or more local peers. When you call an operation, you're calling it on one of those managed peers. How the peer was created — its keypair, storage, extensions, listener, grants — determines what happens. An ephemeral client peer with no extensions handles a get() differently than a full listening peer with revision and subscription (§2.6 covers the spectrum, §2.7 covers the access levels).

This section describes the full flow from peer creation through operation execution.

2.1 Peer Startup and Capability Bootstrap

When a peer is created:

  1. Keypair loaded or generated. The peer's identity (Ed25519 keypair) is its cryptographic root.
  2. Storage initialized. Content store (hash → entity) and location index (path → hash).
  3. Bootstrap handlers pre-loaded. system/tree, system/handler, system/type, system/protocol/connect are installed before the capability system exists (V7 §6.9). They have to be — they're needed to install everything else.
  4. Self-grants generated. For each registered handler, the peer creates a capability grant entity at system/capability/grants/{pattern}. The grant is derived from the peer's root capability, attenuated to the handler's declared scope (internal_scope). This is how handlers get permission to do their work.
  5. Extensions registered. Each extension contributes a handler, emit consumers, and/or an engine. Extensions are registered in the order specified by SYSTEM-COMPOSITION.md §2.2.
  6. Peer is live. Engines started, listener bound (if configured), ready for operations.

The capability chain is fundamental. Every operation — get, put, execute, query — goes through capability verification. The chain is: peer root capability → handler grant → caller's capability token. The four scope dimensions checked on every dispatch are: handler pattern, operation name, peer identity, and resource path (V7 §5.2 check_permission).

The SDK manages this transparently. When application code calls get("knowledge/articles/intro"), the SDK constructs the authorized request internally — the application doesn't build EXECUTE entities or manage tokens by hand. The SDK holds a scoped capability grant for the code running against it. The application knows what permissions it has because the SDK was configured with those grants at instantiation.

For local operations, the SDK handles authorization internally. For remote operations, capability tokens come from connection grant exchange. Either way, the application code looks the same — the SDK is the interface that manages grants, connections, and scoped access on behalf of the application.

2.2 Connection and Grant Exchange

When two peers connect:

  1. Two-round-trip handshake (V7 §4). HELLO (request/response) → AUTHENTICATE (request/response). Each step is a dispatched EXECUTE to the system/protocol/connect handler, enforced in order.
  2. Grant exchange. The authenticate response carries the initial capability grant. The grant specifies four scope dimensions:
    • handlers: which handlers the remote peer can call (e.g., system/tree, local/files)
    • operations: which operations (e.g., get, put, list)
    • resources: which paths (e.g., knowledge/*, public/*)
    • peers: which peer identities (usually the local peer)
  3. Connection established. Remote operations are now authorized by the exchanged grants.

For development, the SDK MAY provide a debug/full-access grant mode. For production, grants SHOULD be scoped per-identity and per-connection. See §11 for the capability management surface.

2.3 Operation Dispatch Flow

In the examples below, peer is a managed local peer — created via create_peer() (§8.1) and held by the application. The peer's configuration (§2.6) and access level (§2.7) determine the actual dispatch pathway. This section shows the full Level 1 handler dispatch path; Level 0 and Level 2 are described in §2.7.

Every Level 1 SDK operation follows this path:

Application calls peer.get("knowledge/articles/intro")
  │
  ├─ SDK constructs EXECUTE entity:
  │    type: "system/protocol/execute"
  │    data: {uri: "entity://{peer}/system/tree",
  │           operation: "get",
  │           resource: {targets: ["knowledge/articles/intro"]},
  │           author: {peer's identity hash},
  │           capability: {grant hash}}
  │
  ├─ SDK signs the EXECUTE, builds envelope with included entities
  │    (author identity, capability token, signature)
  │
  ├─ Dispatch chain (V7 §6.5):
  │    1. Verify content hashes
  │    2. Verify signature
  │    3. Verify capability chain (author → grantee match, chain to peer root)
  │    4. Canonicalize URI path
  │    5. Resolve handler (longest-prefix match in tree → system/tree)
  │    6. Check permission (handler × operation × peer × resource)
  │    7. Build handler execution context
  │    8. Handler processes request
  │
  └─ Handler returns response → SDK returns entity to application

For local calls, the SDK MAY optimize this path (skip envelope construction, skip signature verification for self-signed local calls). The observable behavior MUST be equivalent — if a capability check would fail, the operation MUST fail with 403 even for local calls.

The application doesn't see any of this. The application calls peer.get(path) on a managed local peer. The peer knows its identity, its grants, and its connections. The SDK constructs the request, handles dispatch, and returns the result.

2.4 The Reactive Cycle

The SDK operations compose into a reactive cycle: write → emit → notify → process → write. Every tree mutation fires the emit pathway (SYSTEM-COMPOSITION.md §1), which triggers extension consumers (history, subscription, query indexing, clock, revision). Subscriptions deliver notifications to inboxes. Continuations advance to next steps. Resulting writes trigger further emit.

The SDK operations (put, watch, execute) are the application's entry and exit points into this cycle. See GUIDE-SDK-PATTERNS.md §4 for the full cycle description and change detection mechanisms.

2.5 Path Model

Every path in the entity system is absolute — rooted at a peer identity. The path /{peer_id}/knowledge/articles/intro is how data actually lives in the tree. There is no path outside a peer namespace. The location index stores absolute paths. Wire messages carry absolute paths (after URI normalization). Storage is absolute.

When the SDK accepts a path without a leading / (like knowledge/articles/intro), this is peer-relative notation — a caller convenience. The SDK resolves it to /{local_peer_id}/knowledge/articles/intro before any operation. This is not a different addressing mode. It's shorthand.

; These are equivalent on the local peer:
peer.get("knowledge/articles/intro")
peer.get("/{local_peer_id}/knowledge/articles/intro")

; This targets a remote peer's tree:
peer.get("/{remote_peer_id}/knowledge/articles/intro")

The universal namespace is the reality. Every peer sees one tree with paths organized by peer identity. Under /{local_peer_id}/ is the peer's authoritative data. Under /{other_peer_id}/ is cached remote data — what this peer knows about that peer, which may be stale or incomplete. Only the key holder for a peer_id is the authority for that namespace.

Scoped handles and tree scopes are convenience. A scoped handle bound to prefix knowledge/ on the local peer provides get("articles/intro") which resolves to /{local_peer_id}/knowledge/articles/intro. The scoping is syntactic sugar over the universal namespace.

See V7 §1.4 for the full path model. See GUIDE-PEER-CONCERNS-AND-NAMESPACES.md for path organization conventions (reserved prefixes: system/, host/, app/{app-id}/, bridge/).

2.6 The Peer Spectrum

A peer is always rooted in a keypair. Everything else is progressive enhancement:

ConfigurationStorageListenerExtensionsExample
Ephemeral clientIn-memoryNoneNoneCLI tool: load keypair, connect, execute, exit
Long-running clientIn-memory or SQLiteNoneStandard setApplication's internal peer: maintains connections, stores state
Listening peerSQLiteTCP/WSStandard setServer: accepts inbound connections, serves content
Persistent serviceSQLiteTCP/WSStandard + domainSystem service: starts on boot, runs as daemon

The keypair is the constant — you can't operate on the network without identity. Storage, extensions, listener, and lifecycle are additive. The SDK serves the entire spectrum.

2.7 Access Levels

The SDK exposes three access levels to a local peer. All three are always available.

L1 is the default; L0 is the carve-out (normative). Application-level writes SHOULD go through L1 (capability-checked dispatch) by default. L0 (direct store access) is a documented carve-out for purely-internal bookkeeping that no peer will ever observe (e.g., window geometry, host-private layout caches, ephemeral per-process state) — and for the bootstrap / handler-internal cases enumerated in §2.7.1. The moment L0 data becomes subject to multi-peer / service-peer profile access, L0-by-default code paths require restructuring; establishing L1 as default keeps the multi-peer option open structurally. Per the kernel-vs-handler principle (§2.7.2), application grants SHOULD cover handler create operations (continuation install, subscription subscribe, role assign, compute install) rather than raw tree:put to handler-managed namespaces. The Rust EOS-discipline reframe documents the rationale and a worked enforcement pattern for this discipline.

Level 0 — Direct store access. Synchronous read/write to the peer's content store and location index. No handler dispatch. The application is operating as the peer.

Level 1 — Local handler dispatch. execute() on the local peer. Resolves the handler (longest-prefix match), builds execution context (handler grant, caller capability, scoped tree access), dispatches. The handler processes the operation and returns a result. Capability attenuation applies for handler-to-handler sub-dispatch.

Level 2 — Remote dispatch. Same execute() call, but the URI targets a different peer_id. The local peer resolves the target and gets or creates a connection by walking the EXTENSION-NETWORK.md §10 reachability-class ladder (§2.8 — active connection → held-capability reconnect-skip → transport-profile resolution → §10.3 traversal → §10.2 store-and-forward), constructs a signed EXECUTE envelope with the capability token (from the durable system/peer/session/{peer} entity on reconnect, or from a first-time handshake), sends it on the wire, receives the response.

The application doesn't choose the level for execute(). The same execute() function handles both Level 1 and Level 2. If the peer_id in the URI matches the local peer, it's Level 1. If it's different, it's Level 2. Peer-relative paths always resolve to local.

Level 0 is a separate, explicit API surface. Direct store operations are distinct from dispatched operations. The developer explicitly opts in. This is the same pattern as ORMs that separate query builders (safe by default, parameterized) from raw SQL access (powerful, bypasses protections, developer takes responsibility):

; Dispatched (Level 1) — goes through tree handler, capability-checked
peer.get("knowledge/articles/intro")
peer.execute("system/tree", "get", {resource: {targets: ["knowledge/articles/intro"]}})

; Direct store (Level 0) — bypasses dispatch, peer owner's authority
peer.store.get("knowledge/articles/intro")      ; or: peer.direct.get(), peer.raw_get()
peer.store.put("knowledge/articles/intro", entity)

The naming is language-idiomatic — the spec doesn't prescribe method names. What the SDK MUST do is make the boundary visible: direct store operations SHOULD be accessed through a distinct interface (separate namespace, separate accessor, or clearly differentiated names) so that a developer reading the code can tell which level they're operating at. Mixing dispatched and direct operations under the same names (both called get) is the anti-pattern — it hides the security boundary.

Application developers building higher-level abstractions (custom handlers, application frameworks, multi-user systems) should route through dispatched operations (execute(), get()) to get capability enforcement. Developers who need Level 0 for performance know they're operating as the peer and accepting the responsibility.

The SDK SHOULD NOT expose Level 0 to code that runs under delegated or attenuated grants (handlers processing remote requests, compute expressions evaluating under installation grants). Those contexts use the execute() function from their handler context, which enforces capability attenuation. Level 0 is for the peer owner's code — the code that built and configured the peer.

Conformance under open-grants mode. Until §11 grant enforcement lands kernel-side, "the execution context carries the peer's standing grant" is forward-looking. SDKs operating in open-grants mode satisfy §2.7 by preserving the emit pathway (every L0 mutation fires the broadcast); the standing-grant-context aspect is satisfied vacuously when grants are unenforced. Once §11.2 / §11.2A enforcement lands, the standing-grant context becomes load-bearing and SDKs MUST plumb it through L0.

2.7.1 L0 Use Cases

L0 (direct store access) is a well-defined access level — not a hack or debug mode. Every L0 tree mutation fires the emit pathway (history, subscription, query, clock all observe it). The distinction from L1 is: no handler dispatch, no capability verification at the dispatch layer.

Use caseL0 acceptable?Why
Bootstrap (pre-handler tree writes)Yes, requiredNo handlers exist yet to dispatch through
Identity bootstrap (:configure first call, before local peer→controller cap exists)Yes, requiredPer EXTENSION-IDENTITY.md §6.5 startup boundary — no controller authority exists yet. SDK MUST route through the bootstrap helper library defined in SDK-IDENTITY-INFRASTRUCTURE.md §7.
Identity ops post-bootstrap (:create_attestation, :supersede_attestation, :publish_attestation, :rotate_*, etc.)NoAfter local cap exists, dispatched EXECUTE under controller authority is the only conformant path.
Peer-owner application codeYes, explicitOwner authority; naming discipline makes bypass visible
Render-time reads (sync UI)Yes, acceptableRead-only; no security or semantic implications
Pre-runtime state seeding (application startup)Yes, with rationaleSame as bootstrap — runs before dispatch is meaningful
Handler-internal writes to own namespaceYes, by designHandlers need direct tree access; authorization recorded via mutation context
Handler-internal writes beyond own namespaceCautionShould use execute_fn for cap-checked cross-handler calls
Delegated/attenuated codeNoBypasses restrictions the grant was designed to enforce
Extension state writes that bypass handler semanticsNoProduces state the extension handler doesn't expect

Principle: L0 is the peer owner's prerogative. It's well-defined, not a bug, and not going away. The SDK's job is to make it visible, not to prevent it.

SDKs MAY provide cloneable L0 read handles for use in callbacks and async contexts where the originating scope has ended. These handles MUST be accessed through the L0 API surface (store()) and SHOULD be read-only.

2.7.2 Kernel-vs-handler principle

tree:put is kernel-level access. The tree handler provides direct location-index and content-store mutation, bypassing every domain handler's validation logic. This is intentional — but for load-bearing entity types (types whose data carries semantic content that affects later behavior: capability references, dispatch targets, state-machine fields, scoped grants), the proper creation path is the owning handler's operation.

What this means for SDK applications:

Handler operations validate embedded capabilities, check authority chains, and enforce domain invariants. tree:put bypasses all of this. An actor with tree:put on a system handler's namespace can embed arbitrary capability references in load-bearing entities — capabilities they don't legitimately hold — and trigger later dispatches that wield those capabilities.

SDK guidance: application-level capability grants SHOULD cover handler create operations, not raw tree:put to handler-managed namespaces. L0 direct store access on these namespaces falls under the same caution: if the L0 writer embeds capability references, those references are unvalidated. L0 writes by the peer owner (author = local peer identity) are safe by construction (their embedded caps chain to the local peer), but L0 should never be exposed to delegated or attenuated code.

See V7 §6.3 for the normative statement. See SDK-EXTENSION-OPERATIONS.md §15.1 for per-extension Coherent Capability guidance.

2.8 You Operate ON a Local Peer

There is no "remote peer handle." You operate on your local peer. Your local peer routes to remote peers when the URI says so. The connection is an internal implementation detail — cached in the peer's connection pool, keyed by peer_id, reused for subsequent requests.

; Create your local peer
peer = create_peer(config)

; Local operations (Level 0 or Level 1)
peer.tree_get("knowledge/articles/intro")                        ; Level 0: direct store
peer.execute("system/query", "find", {type_filter: "article"})   ; Level 1: handler dispatch

; Remote operations (Level 2 — routed by URI peer_id)
peer.execute("entity://{remote_id}/system/tree", "get",
    {resource: {targets: ["knowledge/articles/intro"]}})
  ; → resolves address from system/peer/transport/{remote_id}
  ; → dials + handshakes if no cached connection
  ; → sends EXECUTE on wire, receives response

; connect() pre-establishes a connection (optional — execute() connects on demand)
peer.connect("192.168.1.5:9100")
  ; → dial, hello exchange, authenticate exchange, grant exchange
  ; → connection cached, keyed by remote peer_id
  ; → subsequent execute() calls to that peer_id use the cached connection

Transport addresses are entities. Remote peer addresses live at system/peer/transport/{peer_id} in the local tree. The execute function resolves them on demand. The connect() operation can also store the transport address after handshake. See SDK-EXTENSION-OPERATIONS.md §9 (network extension) for automated lifecycle management via continuation pipelines.

Outbound dispatch is a reachability-class ladder (EXTENSION-NETWORK.md §10, Amendments 8/11/14). When a remote dispatch has no live pooled connection, the network extension's §10 dispatcher does not simply "dial + handshake." It walks a normative ladder — active connection → held-capability reconnect (skip the handshake by reading system/peer/session/{peer}:held_capability, §6.6) → durable transport-profile resolution by reachability class ((priority asc, profile-id lex)) → §10.3 establish_live(ctx, peer_id) (step 3b traversal seam: NAT punch / WebRTC, returns a connection that re-enters ordinary dispatch and is pooled) → §10.2 dispatch_fallback(peer_id, execute) (step 4 store-and-forward when the peer cannot be reached now) → terminal queue/502. Ordering is a MUST: live first, store-and-forward last. establish_live is deliberately internal to one peer and unpinned (the connection type + handshake boundary are impl-idiomatic) — the SDK does not expose it as an operation; it only surfaces on the pooled connection the ladder yields. dispatch_fallback and the traversal seam are null/no-op unless the RELAY / SIGNALING extensions are installed, in which case behavior is byte-identical to the pre-seam ladder.

Sessions outlive connections; the durable session entity is the reconnect authority (EXTENSION-NETWORK.md §6.6, Amendment 8). Per-peer auth state — the capability this peer holds to dispatch to the remote and the handshake cap it minted in return — is a durable tree entity at system/peer/session/{remote_peer_id} (held_capability + handshake bookkeeping), not per-connection memory. Because it lives in the tree it survives a connection drop and (for a persisted identity) a process restart, enabling handshake-skip / zero-RTT reconnect: the ladder reads held_capability instead of re-running AUTHENTICATE. The entity is NOT deleted on disconnect — that persistence is the point (§6.6, §6.3 held-cap reconnection).

Connection failure. If a cached connection fails during a remote dispatch, the operation returns a transport error (500) to the caller. The connection pool removes the failed connection. The SDK does not automatically retry at the pool level — reconnection is either explicit (connect() again) or automated by the network extension (maintain-peer builds reconnection continuation pipelines). Because the durable system/peer/session/{peer} entity (above) outlives the dropped connection, a subsequent dispatch re-enters the §10 ladder and reuses the held capability rather than re-handshaking.


3. Core Tree Operations (L1)

All tree operations accept absolute paths (/{peer_id}/path) or peer-relative paths (path). Peer-relative paths resolve to the local peer's namespace (§2.5). The access pathway — local dispatch or remote execute — is determined by peer_id resolution (§2.8).

3.1 get

get(path: string) → Entity | null

  Returns: The entity at path, or null if no binding exists.

  Errors:
    403  Capability grant does not cover this path.
    500  Internal error.

3.2 put

put(path: string, type: string, data: any) → hash

  Returns: The content hash of the stored entity.

  Errors:
    400  Invalid path, type, or data.
    403  Capability grant does not cover this path or operation.
    409  Conflict (compare-and-swap failure when applicable).
    500  Internal error.

Implementations MAY accept an already-constructed entity instead of separate type and data parameters.

A put triggers the emit pathway (SYSTEM-COMPOSITION.md §1). The operation returns after all synchronous emit consumers have completed (Phase 1). Phase 2 async notifications fire after return.

Compare-and-swap. The SDK SHOULD support conditional puts:

put_cas(path: string, type: string, data: any, expected_hash: hash?) → hash

  Writes only if the current binding matches expected_hash.
  expected_hash = null means "path must not exist" (create-only).

  Errors:
    409  Current binding does not match expected_hash.

V7 §3.9 v7.50 cross-reference (normative). The SDK's expected_hash parameter maps to V7 §3.9's three-value system/tree/put-request.expected_hash semantics:

SDK callV7 wire expected_hashSemantics
put(...) (no CAS)field absentunconditional
put_cas(... expected_hash=null) (or equivalent language sentinel)zero-hashexpect-absent — succeeds only if path is currently unbound (CAS-create)
put_cas(... expected_hash=H) for non-zero HHsucceeds only if current binding equals H

SDK implementations MUST translate the language-native "null" sentinel into V7's zero-hash on the wire. A server receiving a put-request with expected_hash = zero-hash treats it as CAS-create per V7 §3.9 v7.50; the previously-undefined zero-value case is now distinguished from the absent-field case.

3.3 list

list(prefix: string) → [Entry]

  Entry := {
    name:         string       ; Segment name (not full path)
    path:         string       ; Full path
    content_hash: hash
    has_children: bool
  }

  Returns: Direct children of prefix. Empty array if none. Ordering
           is implementation-defined.

  Errors:
    403  Capability grant does not cover this prefix.
    500  Internal error.

Single-level. Direct children only, not recursive.

L0 vs L1 list shapes — principled divergence. The Entry shape defined above is normative for L1 dispatched list. L0 implementations MAY return a different shape (e.g., a recursive index walk) reflecting raw store access semantics. The divergence is principled: L1 is the dispatched protocol surface where single-level immediate-children matches the dispatch contract; L0 is raw store access where the underlying index naturally supports recursive walks at lower cost than chained dispatch. SDKs that expose both levels SHOULD use distinct return types to make the level distinction explicit, and document the L0 shape's divergence in their SDK reference.

Conformance: L1 list MUST return entries conformant to the Entry shape (single-level, immediate children, all four fields populated).

3.4 remove

remove(path: string) → ()

  Errors:
    403  Capability grant does not cover this path or operation.
    404  No binding at path (implementations MAY silently succeed).
    500  Internal error.

Unbinds the path. Entity remains in content store. Triggers emit pathway.

3.5 has

has(path: string) → bool

  Errors:
    403  Capability grant does not cover this path.
    500  Internal error.

Convenience. Implementations MAY implement as get(path) != null.

3.6 snapshot

Note on SDK exposure (§3.6-3.9). §3.6-3.9 specify operations on the tree handler. SDKs reach them via the standard handler-dispatch primitive (execute("system/tree", "snapshot", ...)); typed SDK wrappers are MAY-tier convenience. They are not in §16 conformance because the SDK contract is the dispatch primitive, not the typed wrappers. Implementations that ship typed wrappers SHOULD use the parameter and return shapes defined in §3.6-3.9.

snapshot(prefix: string) → hash

  Captures current tree state under prefix as a Merkle trie.
  Returns the root hash of the snapshot.

  Errors:
    403  Capability grant does not cover this prefix.
    500  Internal error.

3.7 diff

diff(from: hash, to: hash) → [Change]

  Compares two snapshots. Trie-aware — skips matching subtrees.

  Change := {
    path:      string
    change:    string       ; "added" | "removed" | "modified"
    old_hash:  hash?
    new_hash:  hash?
  }

  Errors:
    400  Invalid snapshot hash (not found in content store).
    500  Internal error.

3.8 merge

merge(base: hash, ours: hash, theirs: hash) → MergeResult

  Three-way tree merge at the trie level.

  MergeResult := {
    status:    uint         ; 200 clean, 409 conflicts
    root:      hash?        ; Merged trie root (if clean)
    conflicts: [{path: string, base: hash?, ours: hash?, theirs: hash?}]
  }

  Errors:
    400  Invalid snapshot hash.
    409  Conflicts detected (returned in MergeResult.conflicts).
    500  Internal error.

3.9 extract

extract(snapshot: hash, prefix: string) → Envelope

  Extracts a subtree from a snapshot as a transferable envelope.
  Includes trie nodes + entity content for the prefix scope.

  Errors:
    400  Invalid snapshot hash.
    403  Capability grant does not cover prefix.
    500  Internal error.

These operations are in the tree handler alongside get/put/list/remove. They're the foundation for the revision extension's version management.


4. Handler Dispatch (L1)

4.1 execute

execute(target: string, operation: string, params?: any) → Response

  target:    Path or URI identifying the handler target.
  operation: Operation name.
  params:    Operation-specific parameters (optional).

  Response := {
    status:   uint           ; HTTP-style status code
    type:     string         ; Response entity type
    data:     any            ; Response payload
    hash:     hash           ; Content hash of response entity
    included: [Entity]?      ; Supporting entities (signatures, capabilities, multi-entity results)
  }

  Errors:
    400  Invalid target, operation, or params.
    403  Capability grant does not cover this handler, operation, or resource.
    404  No handler matched the target path (longest-prefix match failed).
    429  Rate limited.
    500  Handler error.
    501  Handler does not support the requested operation.

Execute is the universal dispatch mechanism. Tree operations (§3) are ergonomic wrappers around execute calls to system/tree. The SDK exposes both: tree operations for the common case, execute for everything else (extension handlers, domain handlers, custom handlers).

Handler resolution uses longest-prefix matching against the target path (V7 §6.6). The handler at system/tree catches system/tree/any/sub/path. A handler at local/files catches local/files/readme.md.

4.1.1 Result carrier and dispatch-surface equivalence (V7 §3.3 v7.49, normative)

A handler returning multiple entities MUST wrap them as a system/envelope carrying the domain subtree in its included map. The execute() result shape is identical across external (cross-peer), internal sub-dispatch, and remote dispatch surfaces — internal and remote MUST NOT drop the envelope's included. SDK wrappers materializing execute() returns into language-native shapes MUST preserve included across all three surfaces; an internal optimization that strips included for in-process dispatch is non-conformant. The in-process representation is implementation-private; the surface contract is invariant.

4.1.2 Internal sub-dispatch authorization (V7 §6.8 v7.49, normative)

When a handler dispatches internally (a sub-call inside the handler's own execution), the authorization decision gates on the executing handler's grant, not on the caller's propagated caller_capability. The propagated caller_capability is for caller-specified-path checks and history attribution only and MUST NOT be a dispatch gate (the confused-deputy dual of "No silent escalation"). SDK wrappers providing internal-dispatch helpers MUST honor this rule.

4.1.3 Request-side included preservation (V7 §3.3 v7.51, normative)

A dispatcher routing an EXECUTE envelope (whether to a local handler, an internal sub-dispatch, or a remote peer) MUST preserve the envelope's included map across the surface. A dispatcher MUST NOT drop included before the wire — bundled hash-refs in EXECUTE.data resolve against included; if dropped, the handler and any downstream continuations see inconsistent referents. SDK wrappers materializing outbound EXECUTEs MUST forward included across local/internal/remote dispatch boundaries identically. Load-bearing for deref_included (EXTENSION-CONTINUATION §2.2; SDK §2 Continuation transform_ops) consuming an include_payload-bundled entity.


5. Query (L1)

5.1 query

query(expression: any) → [Result]

  expression: Query expression per EXTENSION-QUERY.md.

  Result := {
    path:         string
    content_hash: hash
    entity:       Entity?    ; If requested in expression
  }

  Errors:
    400  Invalid query expression.
    403  Capability grant restricts query scope.
    500  Internal error.

Dispatches to system/query handler, operation find. The query handler's capability scope limits which tree prefixes the query can see.

The SDK SHOULD provide query builder helpers at L3 but MUST accept raw expression data at L1.


6. Change Notification (L1)

Conformance note. The watch(pattern) → ChangeStream shape is the spec's logical surface — the semantic pieces (pull-style change notification, pattern-filtered, exact-or-prefix-glob) are conformance-bearing. The exact factory name and return-type construction are platform-idiomatic: Rust uses mpsc::Receiver<ChangeEvent> or a callback shape; Go uses chan ChangeEvent; Python uses async iterators; Godot uses signals. SDKs satisfy §6 conformance by exposing pull-style change notification with the specified semantics; cross-impl conformance is on the capability, not the literal factory signature.

watch() is an L1 operation with implementation-defined backend. It MAY be powered by the subscription extension (cross-peer capable, full pattern matching), the raw emit pathway event stream (local only, all events), or polling (generation counter). The backend choice is transparent to the application — the watch() contract is the same regardless.

For full control over cross-peer subscriptions, deliver tokens, and limits, use the subscription extension directly via execute("system/subscription", "subscribe", ...) — see SDK-EXTENSION-OPERATIONS.md §3.

6.1 watch

watch(pattern: string) → ChangeStream

  pattern: Path pattern. Two forms only:
    "knowledge/articles/intro"    exact path match
    "knowledge/articles/*"        prefix match (all paths under prefix)

  Only exact and prefix/* patterns are specified. Deeper patterns
  (e.g., "knowledge/*/intro") are reserved for future specification.

  ChangeStream: Platform-specific delivery.
    Go:     chan ChangeEvent
    Rust:   mpsc::Receiver<ChangeEvent> or callback
    Python: async iterator / asyncio.Queue
    Godot:  signal

  ChangeEvent := {
    event_type: string     ; "put" | "remove"
    path:       string     ; Path that changed
    new_hash:   hash?      ; New content hash (null on remove)
  }

  Errors:
    400  Invalid pattern syntax.
    403  Capability grant does not cover the pattern scope.
    500  Internal error.

Delivery semantics:

6.2 unwatch

unwatch(handle: SubscriptionHandle) → ()

Implementations SHOULD auto-cancel on handle drop/GC. Explicit unwatch MUST also be available.

6.3 Raw Event Stream

The SDK MAY expose the raw tree change event stream below the watch() abstraction:

subscribe_events() → EventStream    ; All tree mutations, unfiltered

  TreeChangeEvent := {
    event_type:    string     ; "put" | "remove"
    path:          string
    new_hash:      hash?
    previous_hash: hash?
  }

These are the local emit pathway Phase 2 notifications (SYSTEM-COMPOSITION.md §1.3). Useful for application frameworks that need all events regardless of pattern.

6.4 Generation Counter (Optional)

The SDK MAY expose a generation counter for polling-based change detection:

generation() → uint         ; Monotonically non-decreasing

Semantics (when exposed). The counter is monotonically non-decreasing and MUST advance at every tree mutation observable to readers. Concretely: if a sequence of tree:put calls completes and a subsequent get would return new data, generation() must have advanced at least once between those points. Implementations MAY coalesce a batch of mutations into a single bump at a commit boundary (e.g., a transaction or revision merge), but the counter MUST NOT lag behind reader-visible state — a reader that observes a value and re-reads generation() must see the bump.

Bulk-write performance is the SDK's problem, not the reader's. Applications observing this counter (game-loop polls, immediate-mode UI) rely on the counter to reflect reality with bounded latency. If bulk operations are slow at the bump granularity, the SDK is responsible for batching mutations behind a single bump (e.g., transactional commit), not for skipping bumps and forcing readers to scan.

This is one of several change-detection styles, not the default. Three styles in common use:

StyleMechanismBest for
Counter pollinggeneration() checked each frame/tick; if changed, scan state of interest.Immediate-mode UI with simple state, game loops where a per-frame check is already happening.
Path-targeted observationstore().watch(prefix) (§6.5 L0) — pattern-filtered local event stream, no global counter scan.Retained-mode UI with many independent panels watching disjoint subtrees. Avoids the per-consumer rescan cost of a global counter.
Dispatched subscriptionsubscribe(pattern, callback) (§6.5 L1) — capability-checked, cross-peer capable.Cross-peer reactivity, capability-gated subscribers, anything that needs the inbox handler delivery model.

generation() is the simplest and most coarse; watch(pattern) is finer and cheaper at scale; subscribe is the only one that crosses peer boundaries. Application teams choose per panel/component based on retained vs immediate rendering, fan-out, and whether the observation needs to leave the local peer. Implementations that don't need polling-based change detection can omit generation() entirely.

6.5 Notification Access Levels

The §2.7 access-level boundary applies to change notification. Three observation primitives, at two access levels:

Level 0 — Raw observation (always available, local-only):

Level 1 — Dispatched observation (requires subscription extension, cross-peer capable):

The SDK MUST expose L0 observation through the Level 0 API surface (the store() accessor or equivalent) and L1 observation through the Level 1 API surface. The naming MUST carry the boundary.

When the subscription extension is not installed: L0 observation primitives (store().subscribe_events(), store().watch()) are always available. L1 subscribe() returns an error indicating the subscription extension is required. A minimal peer (no extensions) still has pattern-filtered local observation via store().watch().


7. Connection (L1)

Connection operations run on a local peer — the local peer provides the identity (keypair) and the grants to issue to the remote peer.

Inbound frame processing concurrency (V7 §4.8 v7.48, normative). SDK implementations of the connection surface MUST support inbound frame processing concurrent with outbound dispatch initiated from handlers. While a handler is processing a frame received on a connection, the SDK MUST be able to read and dispatch additional frames received on that same connection, and to send outbound EXECUTEs (including responses to those additional frames) without waiting for the original handler to complete. This is a correctness requirement — without it, bidirectional symmetric P2P deadlocks. SDKs MAY bound concurrency via worker pools, semaphores, or back-pressure; they MUST NOT serialize inbound processing on outbound dispatch on the same connection. Architecture is impl-defined (per-frame goroutines, request-response multiplexing, async-task-per-frame all valid); the forbidden shape is the synchronous-inline-dispatch default.

7.1 connect

connect(address: string) → Connection

  address: "host:port" for TCP, "ws://host:port" for WebSocket.

  Steps:
    1. Dial transport to address.
    2. HELLO exchange: send local peer_id + nonce + supported protocols,
       receive remote peer_id + nonce + protocols. Negotiate common protocol.
    3. AUTHENTICATE exchange: send public key + signed nonce,
       receive remote public key + signed nonce + capability grant.
       Send our capability grant to the remote peer.
    4. Connection established. Both sides hold:
       - The other peer's identity (peer_id, public key)
       - The capability grant received from the other peer
       - The capability grant issued to the other peer

  Connection := {
    remote_peer_id: PeerID     ; Remote peer's identity
    address:        string
    protocols:      [string]   ; Negotiated versions
    grants:         [GrantEntry] ; Grant entries received from remote peer
  }

  Errors:
    400  Invalid address.
    403  Handshake failed (incompatible protocols, signature verification
         failed, or remote peer rejected connection).
    500  Transport error (dial failed, connection reset).

After connect, the local peer can dispatch operations to the remote peer using the received grants (see §2.8). The connection is pooled — subsequent operations to the same remote peer reuse it.

The grants we issue to the remote peer come from the local peer's connection grant configuration (§8.1 grants in PeerConfig). The grants we receive are what the remote peer chose to give us — they determine what operations we can dispatch to that peer.

Connection auth is durable, not just pool state (EXTENSION-NETWORK.md §6.6, Amendment 8). The Connection object above models the live connection, but the authoritative auth state is the durable tree entity system/peer/session/{remote_peer_id} (held_capability = the cap the remote granted us; plus the handshake cap we minted in return). That entity — not the in-memory Connection — is what survives a drop and enables handshake-skip reconnect (§2.8). An SDK SHOULD treat the Connection.grants slot as a live-connection view over that durable session entity, not the source of truth.

SDK-GAP (2026-08-05): the grants slot conflates two distinct authorities. EXTENSION-SIGNALING.md §6.5(b) establishes a second, distinct grant: the connection-scoped reciprocal originating grant minted at live-establishment (dialer mints for acceptor; grantee = the acceptor's identity-entity content hash per §11.2; the cap is signed by the granter identity). It is connection-scoped — held with the live connection, not written to system/peer/session/{peer} — and it governs origination authority, distinct from the durable held_capability's reconnect-skip authority. Precedence is normative: where both exist, the connection-scoped reciprocal grant wins over the durable held_capability (a durable cap can predate the live establishment and MUST NOT shadow it), and origination MUST gate on the reciprocal grant after it is received. The Connection object needs two differentiated slots — first pass:

Connection := {
  ...
  grants:              [GrantEntry]   ; durable-session view (held_capability; §6.6)
  reciprocal_grant:    Grant?         ; connection-scoped originating authority (SIGNALING §6.5(b));
                                      ;   wins over held_capability for origination; gate-on-grant-received
}

Left as a GAP because the reciprocal-grant flow rides the §10.3 establish_live traversal seam (unbuilt, S3), so the SDK ergonomic surface is not yet designed. The wire carriage is settled (EXTENSION-SIGNALING.md §6.5(b), corrected 2026-08-05, Go↔Rust V3 4/4): wielding is an ordinary EXECUTE rooted at capability = the cap hash; the dialer resolves from its minted-and-delivered ledger, NOT the content store — the §7a.2a in-band triple (§7.1 below) is the conformance-handler reentry shape, not this. Pin the two-slot model + precedence now; refine the SDK surface when the seam lands.

7.2 listen

listen(address: string) → Listener

  Binds the address and accepts incoming connections. For each incoming
  connection, the local peer acts as the responder in the handshake:
    1. Receive HELLO, respond with local peer_id + nonce.
    2. Receive AUTHENTICATE, verify signature, respond with capability grant.
    3. Connection established — remote peer can now dispatch operations
       authorized by the grant we issued.

  The grants issued to connecting peers come from the local peer's
  connection grant configuration (§8.1).

  Errors:
    400  Invalid address or port in use.
    500  Transport error.

7.3 connected_peers

connected_peers() → [PeerInfo]

  PeerInfo := {
    peer_id:   PeerID
    address:   string
    direction: string       ; "inbound" | "outbound"
  }

This is a pool snapshot, not liveness — read system/peer/status for that. connected_peers reports what the connection pool currently holds, which reads like liveness and is not: a mid-session drop leaves a stale entry that still says connected until something reaps it. Two implementations have now built the snapshot-as-status shape, one of them finding it reported Connected straight through a drop and replacing it with a read-model over system/peer/status (EXTENSION-NETWORK §5.4.1, which is transition-written). Non-normative, and recorded here because this is the surface a reader reaches for first.

7.4 Reentry Authority Carriage

The concurrency floor above lets a handler originate an outbound EXECUTE back to the caller over the same inbound connection while still servicing the caller's frame — the §6.11 reentry seam (GUIDE-CONFORMANCE.md §7a.2a; V7 §6.13(b)). That reentry direction (this peer → caller) can only be authorized by a capability valid at the caller, so the caller must hand the reentry authority over as part of the request. The SDK is the surface for carrying it.

In-band params carriage (ratified — GUIDE-CONFORMANCE.md §7a.2a shape (a)). The reentry authority travels as three in-band fields nested in the request params, not via the envelope included set:

; Caller side — hand reentry authority to a handler that will call back
peer.execute(target, operation, {
  ...operation_params,
  reentry_capability:    Hash,   ; the cap authorizing this-peer → caller, rooted at the caller
  reentry_granter:       Hash,   ; the caller's granter identity (identity-entity content hash)
  reentry_cap_signature: bytes,  ; signature over the reentry cap
})

Shape (a) — in-band, nested in params — is the ratified carriage: it is self-contained, transport-agnostic, and does not depend on the session API exposing the included set. All three keystone reference peers implement it. An SDK exposing a reentry-capable handler surface SHOULD marshal these three fields into params and, on the handler side, hand them to the reentry sender that originates the callback.

SDK-GAP (2026-08-05): first-pass shape only. GUIDE-CONFORMANCE §7a.2a records (a) as ratified but notes the final Go validator-side ruling as the one open item; the three field names above are pinned, but the SDK-level ergonomic wrapper (a typed reentry handle vs. raw params fields) is not yet designed. Pin the carriage convention now; refine the wrapper when the origination-extension SDK surface is specified.


8. Peer Lifecycle (L1)

8.1 create_peer

create_peer(config: PeerConfig) → Peer

  PeerConfig := {
    keypair:     Keypair?       ; Identity. Generated if omitted.
    storage:     StorageConfig?  ; Backend. In-memory if omitted.
    handlers:    [Handler]?      ; Custom application handlers.
    listen_addr: string?
    grants:      [Grant]?       ; Connection-time grants for remote peers.
  }

See §2.1 for the full startup flow. The peer builder:

  1. Loads/generates keypair.
  2. Initializes storage.
  3. Pre-loads bootstrap handlers (tree, handler, type, connect).
  4. Registers system extensions. Each contributes handler + emit consumer + engine, in SYSTEM-COMPOSITION.md §2.2 order.
  5. Registers custom application handlers (from config).
  6. Generates self-grants for each handler at system/capability/grants/{pattern}.
  7. Starts engines.

System extensions vs custom handlers. System extensions (subscription, continuation, inbox, revision, history, query, clock) are the standard set defined by the extension specs. How they're enabled is language-specific:

Custom application handlers register through the builder alongside system extensions. They participate in the same dispatch, emit, and capability system. The difference: system extensions are defined by the extension specs; application handlers are defined by the application developer. See §11.3 for the handler registration contract.

Connection grants (grants in config) define what remote peers can do when they connect. These are the grants exchanged during the handshake (§2.2). The SDK is the interface for managing these grants — creating scoped grants for different connections, attenuating grants for delegation, revoking grants when needed. See §11 for the full capability management surface.

Application grant. The application code itself runs within a capability scope. The SDK holds the grant that authorizes the application's operations. The application can further attenuate and delegate within its scope but cannot escalate. This is how an application "knows the permissions the code is running at" — the SDK was configured with that grant at instantiation.

Multi-peer. The SDK MUST support creating multiple independent peers in the same process. Each peer has its own identity, storage, handlers, and grant context. Multi-peer is a runtime concern, not a protocol concern.

Local-only mode. The SDK MUST support peers that run without networking — no listener, no connections. This is the embedded/library/WASM deployment mode. The peer handles local operations only, via execute(). Extensions still work (emit pathway, history recording, query indexing). Networking operations (connect, listen) are unavailable.

8.2 close_peer

close_peer() → ()

  Graceful shutdown:
    1. Stop accepting new connections.
    2. Flush pending async deliveries (best-effort, with timeout).
    3. Close all active connections.
    4. Stop engines (subscription, continuation, etc.).
    5. Release storage resources.

  Errors:
    500  Shutdown error (partial cleanup).

Implementations SHOULD support a timeout parameter. Implementations SHOULD cancel in-flight operations with context cancellation.

8.3 peer_id

peer_id() → PeerID

  PeerID: Base58(key_type || hash_type || hash_bytes)

8.4 Diagnostics

entity_count() → uint      ; Total entities in content store
path_count() → uint         ; Total bindings in location index

Direct store queries, not handler-dispatched. Useful for UI status displays, health checks, and debugging. These bypass the dispatch chain — they're store-level introspection.


9. Discovery (L1)

9.1 discover_handlers

discover_handlers() → [HandlerInfo]

  HandlerInfo := {
    pattern:    string
    name:       string
    operations: [OperationInfo]
  }

  OperationInfo := {
    name:        string
    input_type:  string?
    output_type: string?
  }

HandlerInfo.pattern semantics (normative). The pattern field carries the manifest's advertisement pattern — which MAY include glob notation (e.g., "system/type/constraint/*") per V7 §3.7. The dispatcher does NOT interpret this field; handler resolution is by V7 §6.6 longest-prefix walk against handler entities at literal prefix paths. Consumers building EXECUTE targets from HandlerInfo.pattern MUST handle the convention per V7 §6.6 / GUIDE-EXTENSION-DEVELOPMENT.md §4.9: strip trailing /* to obtain the dispatch prefix, or rely on the dispatcher's longest-prefix walk-back at execute time. The pattern is advertisement-only, NOT a dispatch URI.

Implemented as: a prefix scan under system/handler/ + reading each manifest/interface entity. The SDK SHOULD provide this as a typed helper.

The scan MUST be recursive; a single-level list returns nothing useful here (0.8.1, SA-1). A handler interface is stored at system/handler/{pattern}, and a pattern is itself a path — system/tree lives at system/handler/system/tree. §3.3's list is single-level by conformance, so list("system/handler/") returns the intermediate branch and zero interfaces, which violates §9.3's membership MUST two sections later. Implement this as a recursive walk over §3.3 list, or as an L0 location-index prefix scan where one is available; both satisfy §9.3 and neither is the single-level operation this note previously named. The failure mode of the literal reading is an empty result rather than an error — it looks like a peer with no handlers, not like a bug, which is why it survived review.

9.2 discover_types

discover_types() → [TypeInfo]

  TypeInfo := {
    type_path: string
    fields:    [FieldInfo]
  }

  FieldInfo := {
    name:     string
    type_ref: string
    optional: bool
  }

Storage shape vs typed output (normative). The schemas above describe the SDK's typed caller-facing output (Array<FieldInfo>). The stored tree-entity shape is distinct and pinned at ENTITY-NATIVE-TYPE-SYSTEM.md §4.1 (system/type) and §4.2 (system/type/field-spec):

system/type.data := {
  name:        system/type/name,
  fields:      map_of system/type/field-spec    ; Map<field_name, field-spec>
  (extends, layout, type_params, type_args: optional)
}

; NOT A DEFINITION — shape sketch only. Canonical: ENTITY-CORE-PROTOCOL.md §2.2 `system/type/field-spec`.
system/type/field-spec ~ {
  ; exactly one of:
  type_ref:    system/type/name
  array_of:    system/type/field-spec
  map_of:      system/type/field-spec
  union_of:    array of system/type/field-spec
  type_param:  primitive/string
  ; plus modifiers: optional, default, key_type, type_args, byte_size, name
}

system/type/field-spec is defined in ENTITY-CORE-PROTOCOL.md §2.2 and is canonical there. The block above is an abridged shape sketch for the surrounding SDK passage — it omits fields and optionality markers and MUST NOT be read as the type's definition. It is written with ~ rather than := for exactly that reason. Implement against the canonical declaration (SPECIFICATION-FORMAT.md §8.4.2 — reference, do not restate). (ENTITY-CORE-MACHINE-SPEC.md is retired and is not a citable source for this or any type — it was a derived, downstream condensed summary. Same standing note EXTENSION-COMPUTE.md §2.4 carries for compute/error.)

The stored entity's envelope type is the canonical meta-type system/type (ENTITY-NATIVE-TYPE-SYSTEM §4.1, §2.6, §4.4). The SDK reader (e.g., entity-core-rust/bindings/sdk/src/sdk.rs:TypeInfo::from_entity) reads the stored Map<field_name, field-spec> and synthesizes the typed Array<FieldInfo> for callers. Implementations writing type entities MUST use the storage shape per §4.1 / §4.2; writing in the typed-output shape (Array<FieldInfo>) produces structurally invalid type entities even if the bytes are valid CBOR.

The array_of / map_of / union_of keys in field-spec are part of the public alphabet (§4.2). The exactly-one-of invariant applies. Open-types semantics (ENTITY-NATIVE-TYPE-SYSTEM.md §2.4) permit additional keys to be present without rejection, BUT ENTITY-NATIVE-TYPE-SYSTEM.md §2.5 normatively forbids documentation fields (description, doc, etc.) in type entity data to prevent content-hash divergence across implementations. Implementations needing per-field documentation MUST keep it in companion entities, NOT in the canonical type entity.

Implemented as: a recursive prefix scan under system/type/ + reading each type definition entity. The §9.1 note applies unchanged: a type name is a path (system/tree/listing lives at system/type/system/tree/listing), so a single-level list returns branches rather than definitions (0.8.1, SA-1).

9.3 Cross-impl conformance (normative)

The discover_handlers and discover_types operations are SDK helpers over tree state — their typed outputs MUST conform to §9.1 / §9.2 across implementations. The following clauses pin behavior that has been observed-equivalent across the Rust / Go / Python SDKs and was previously implicit:

Ordering. Result ordering is implementation-defined. SDKs MAY sort (e.g., the Rust SDK sorts by pattern for stable UI rendering). Consumers MUST NOT depend on result ordering across SDK implementations. Consumers that require stable presentation MUST sort caller-side.

Membership. The returned set MUST include every system/handler/interface (resp. system/type) entity reachable under the caller's capability scope, modulo the V7 §6.6 advertisement-vs-registration-path convention. Handlers and types blocked by capability scope MUST NOT appear; their absence is not an error and is not signaled.

Encoding equivalence. Host-language-specific shaping at the SDK boundary (e.g., a Godot binding's Array[Dictionary] vs the Rust SDK's Vec<HandlerInfo> struct) is permitted. The contract is over the semantic content of the typed schema (§9.1 / §9.2 field names, types, optionality), not over the in-memory representation. Frontends building shared visual patterns MUST consume the typed schema, not the host-language representation. See EXPLORATION-WIRE-ENCODING-AND-INTEGRATION.md §6.2 on host-language frontend boundary integration as a recurring divergence class.


10. Protocol-First Default

The SDK's standard operations (§3-§6) route through handler dispatch. This is the default and recommended path because it makes local and remote peers interchangeable — get("knowledge/articles/intro") works the same on a local peer as on a remote peer.

The SDK SHOULD default to handler-dispatched operations. The SDK MAY also expose lower-level access (direct store reads, raw execute construction, content store operations) for advanced use cases, debugging, and extension development. The SDK doesn't prevent you from working at whatever level you need.

Default configuration SHOULD favor higher security and capability-scoped access. But the SDK is a tool for using the system — not a gatekeeper.


11. Capability Management (L1-L3)

The SDK's core job is managing grants and scoped access. This section covers what exists, what's needed, and what's not yet specified.

Identity-aware peers. When the identity, attestation, quorum, role, or group extensions are registered, an additional SDK surface is available — bootstrap helpers, identity-stack operations, rotation lifecycle hooks, and rotation_reissue_outstanding_grants. See SDK-IDENTITY-INFRASTRUCTURE.md for the dedicated surface and GUIDE-IDENTITY-SDK.md for the application-developer walkthrough. The configuration-directory formalization (§15) covers the on-disk layout the helpers operate on.

11.1 Grant Lifecycle

Self-grants during build. The peer builder generates a capability grant for each handler at system/capability/grants/{pattern}. These authorize the peer's own operations.

Connection grants. On connect, peers exchange grants (§2.2). Four scope dimensions: handlers, operations, resources, peers. The SDK SHOULD support both static grant configuration (config files) and dynamic per-identity grant generation.

The capability handler. V7 §6.2 defines system/capability with operations: request, delegate, revoke. The SDK SHOULD expose these as typed operations.

11.2 What the SDK Needs to Expose

The SDK is the interface for applications to manage their capability context.

GrantScope — the four dimensions that define what a grant allows:

GrantScope := {
  handlers:   {include: [string], exclude: [string]?}   ; Handler patterns
  operations: {include: [string], exclude: [string]?}   ; Operation names
  resources:  {include: [string], exclude: [string]?}   ; Path patterns
  peers:      {include: [string], exclude: [string]?}   ; Peer ID patterns
}

; Each dimension has include (what's allowed) and optional exclude (what's denied).
; A dispatch must match all four dimensions from a single grant entry.
; This is the same structure as system/capability/scope in V7 §3.6.

SDK operations for capability management:

create_grant(scope: GrantScope, grantee?: Hash) → Grant   ; grantee = identity-entity content hash, NOT the peer-id
delegate_grant(parent: Grant, attenuated_scope: GrantScope, grantee: Hash) → Grant   ; grantee = identity-entity content hash
revoke_grant(grant: Grant) → ()
inspect_grants(connection?: PeerID) → [GrantInfo]

Grantee is the identity-entity content hash, not the peer-id (normative, #67 id-encoding contract). A capability grantee MUST be the system/hash content hash of the grantee's system/peer identity entity — the value the core verify contract resolves and compares against the cap author — NOT the Base58 system/peer-id. The two have different preimages — the identity-entity content hash is 0x00 || SHA256(ECF(system/peer entity)), whereas the peer-id is a self-describing multikey whose Ed25519 digest is the public key (EXTENSION-SIGNALING.md §6.3 — not a SHA-256 of it) — so conflating them mints a cap that fails grantee_mismatch at verification and cannot be resolved cross-peer. See EXTENSION-SIGNALING.md §6.5 (the reciprocal-grant mint) and EXTENSION-ROLE.md §744 (grantee field encoding, normative); the id-encoding hazard is the #67 contract (). inspect_grants still keys on PeerID because that is a query filter, not a cap field.

These dispatch to the system/capability handler. An application instantiated with a scoped grant can further attenuate and delegate within its scope but cannot escalate beyond it.

11.2A Grant Progression

Capability management has a natural progression. Each level is independently useful.

Level 0 — Open access. Single grant entry, wildcard on all four dimensions. Development/single-developer use.

grants: [{
  handlers:   {include: ["*"]}
  operations: {include: ["*"]}
  resources:  {include: ["*"]}
  peers:      {include: ["*"]}
}]

Level 1 — Per-handler entries. Multiple grant entries, each scoped to specific handlers, operations, and resource paths. First production step.

grants: [
  ; Read-only tree access to public content
  {handlers: {include: ["system/tree"]},
   operations: {include: ["get", "list"]},
   resources: {include: ["public/*", "knowledge/*"]}},

  ; Query access
  {handlers: {include: ["system/query"]},
   operations: {include: ["find", "count"]},
   resources: {include: ["public/*", "knowledge/*"]}},

  ; Subscribe to changes
  {handlers: {include: ["system/subscription"]},
   operations: {include: ["subscribe", "unsubscribe"]},
   resources: {include: ["knowledge/*"]}}
]

Grant handler operations, not tree:put to system namespaces. For load-bearing entity types (continuation, subscription, role assignment, compute subgraph), grant the handler's create operation — e.g., system/continuation:install instead of system/tree:put on system/continuation/suspended/*. Handler operations validate embedded capabilities at creation time; tree:put bypasses this validation. See §2.7.2 for the full rationale.

Level 0→1 is configuration-only — change the grant entries in grants.toml or the builder config. No new SDK operations needed.

Level 2 — Per-identity grant resolution. Different connecting identities get different grants. The SDK needs a grant policy mechanism:

set_grant_policy(policy: GrantPolicy)

GrantPolicy := {
  default: [GrantEntry]         ; Fallback for unknown identities
  rules: [{
    match:  IdentityPattern     ; Peer ID pattern or group
    grants: [GrantEntry]
  }]
}

Level 1→2 needs one new SDK operation. Same grant entry structure — the policy just selects which entries a given connection receives.

Level 3 — Runtime delegation. Application receives a grant, attenuates it for components or other peers. Uses delegate_grant, revoke_grant, inspect_grants from §11.2. Each child grant entry must be covered by a parent grant entry (V7 §5.6 — no escalation). Child inherits all parent excludes and may add more.

11.2B Rotation Re-issuance Helper

Long-lived caps held by third parties die on issuer rotation. This is a direct consequence of V7 chain validity composed with identity rotation: when a runtime peer is retired or rotated, the local peer→Op cap that authorized its outstanding issuances is revoked, and downstream caps lose their authorizing chain on the next is_revoked walk. Signatures are immutable; caps don't "follow" identity changes, and the protocol does not silently re-issue them.

The mitigation is the rotating peer's responsibility, not the consuming extension's. The rotating peer holds an outstanding-grants tree (system/capability/grants/...) listing the caps it has issued; on rotation, it iterates that tree, identifies grants that should survive, re-issues each from the new authority, and pushes the re-issued caps to consuming peers via the consuming extension's normal flow (re-subscribe, inbox push, etc.). Consuming extensions stay rotation-agnostic — they see a normal "new cap arrived" flow.

SDK helper:

rotation_reissue_outstanding_grants(rotated_peer: PeerID, new_authority: Grant) → [Grant]

Iterates the rotating peer's outstanding-grants tree, identifies grants that should survive rotation (filtered by deployment policy — long-TTL grants, subscription deliver_tokens, inbox dispatch caps, continuation roots), re-issues each from new_authority, and emits the re-issued caps for delivery to consuming peers via the appropriate flow.

Conformance. SHOULD be provided by SDK implementations targeting deployments with long-lived cross-peer flows. SDKs targeting short-TTL deployments (where natural renewal cycles cover rotation events) MAY omit it. Filter policy is implementation-defined; see GUIDE-IDENTITY for deployment-pattern guidance.

11.3 Handler Execution Models

Every handler in the entity system is a system/handler entity in the tree, dispatched by longest-prefix match (V7 §6.6), capability-checked on every request. What differs is where the handler's logic lives and how it executes. Three models exist, forming a progression from compiled infrastructure to transferable logic.

1. Precompiled system extensions.

Bootstrap handlers (system/tree, system/handler, system/type, system/protocol/connect) are compiled into the peer binary and pre-loaded during startup before the capability system exists (V7 §6.9). System extensions (revision, subscription, history, query, clock, etc.) are also precompiled: handler code is in the binary, registered in emit-pipeline order (SYSTEM-COMPOSITION §2.2) during peer initialization. All system infrastructure uses this model. The logic is compiled, not inspectable from within the system, not transferable between peers.

2. SDK-registered language-native handlers.

After peer creation, application code registers handlers at runtime via the SDK's register_handler primitive (§11.6). The handler manifest goes into the tree (interface entity + handler entity + grant), and the handler body is a language-native callable (Go closure, Rust Fn, Python async def) bound in the in-memory dispatch index. This is how applications extend the system without recompiling the peer. The trade-off: the callable is ephemeral. On restart, the tree entries survive but the dispatch index is empty — the application must re-register (§11.6.6). See §11.6 for the full registration contract.

3. Entity-native compute-backed handlers.

The handler manifest includes expression_path (V7 §3.7). Dispatch evaluates the compute expression at that tree path instead of calling compiled code (V7 §6.6). The handler body is a content-addressed compute expression entity — transferable, inspectable, auditable, and survives peer restart without re-registration. Registration uses the standard system/handler:register protocol operation; no SDK primitive is needed because the body is already in the tree. Requires the compute extension (EXTENSION-COMPUTE). See GUIDE-COMPUTE.md for expression language details, reactive subgraphs, TCO, and library-as-entities patterns.

Comparison:

PropertyPrecompiledSDK language-nativeEntity-native
RegistrationPeer startup (binary)register_handler (SDK §11.6)system/handler:register (protocol)
Body locationCompiled binaryIn-memory dispatch indexTree (expression entity)
TransferableNoNoYes (content-addressed)
InspectableNoNoYes (walk expression graph)
Hot-swappableNo (recompile)Close handle + re-registerReplace expression at tree path
Restart survivalYes (in binary)No (re-register on startup)Yes (expression in tree)
AuditableImplementation trustImplementation trustStatic analysis (compute install audit)
PerformanceNativeNativeInterpreted (~5x overhead)

Choosing a model. Precompiled for system infrastructure and extensions that need native performance. SDK language-native for application handlers that need compiled performance without recompiling the peer. Entity-native for handler logic that needs to be transferred between peers, audited by capability inspection, or updated without re-registration. compute/apply handler mode bridges entity-native expressions to language-native handlers when an entity-native handler needs native performance for a specific operation.

Handler-context chain-root primitive (SEC-3). For SDK language-native handlers that accept caller-provided capability references in their input and embed them in entities the handler creates (continuations, subscriptions, role assignments, compute installs, or any handler-created entity carrying an embedded cap), the handler context (ctx) MUST expose an in-chain authority check — ctx.identity_in_authority_chain(cap_hash) → bool or an equivalent named primitive. (The primitive's own name is the correct semantics: in the chain, not rooted at the caller — ENTITY-CORE-PROTOCOL.md §5.5. This sentence read "chain-root check" while naming identity_in_authority_chain in the same breath.) The handler uses it to validate that the EXECUTE author appears as a granter in the embedded cap's authority chain before persisting the entity. Without this primitive at the handler tier, the handler is forced to either (a) reconstruct the chain walk independently (fragile, error-prone) or (b) trust the caller (recreating Finding 3 at the application level — see GUIDE-CAPABILITIES §8.3). Entity-native handlers don't need the primitive directly: the compute install audit validates static-literal cap references at install time, and runtime-passed caps go through R1 at the underlying handler operation.

11.4 Handler Registration (Build Time)

Handlers register during peer build. The registration contract:

  1. Pattern — the path prefix this handler serves (e.g., local/files).
  2. Scope declaration — what tree paths and operations the handler needs (internal_scope). The builder generates a self-grant from this.
  3. Manifest — a system/handler entity stored at the pattern path. Includes handler name and operation specs.
  4. Interface — a system/handler/interface entity at system/handler/{pattern}. Lists operations with input/output types for discovery.
  5. Handle function — receives execution context (peer identity, caller capability, handler grant, scoped tree access, execute function) and request (operation, params, resource).

The builder registers the handler, stores the manifest, generates the grant, and creates the interface entity. After build, the handler participates in dispatch via longest-prefix matching (V7 §6.6).

This is L3 — needed for application developers building custom handlers, not for those using standard extensions.

11.5 Watch Implementation

§6 defines watch() as an L1 operation with implementation-defined backend. When the subscription extension is registered, the recommended implementation: create a deliver token → call subscribe with deliver_to pointing at local inbox → listen for inbox notifications → deliver events through platform-native mechanism. When subscription is not registered, the emit pathway event stream or polling are acceptable backends.

For direct control over cross-peer subscriptions, deliver tokens, and limits, applications use the subscription extension via execute("system/subscription", "subscribe", ...) — see SDK-EXTENSION-OPERATIONS.md §3.

11.6 Dynamic Handler Registration

The entity system supports two kinds of handler bodies:

This section specifies the SDK primitive for language-native handler registration.

SDKs that support runtime handler registration — registering handlers after create_peer() returns — MUST expose a register_handler primitive for the language-native case. Direct access to the underlying handler dispatch index MUST NOT be part of the SDK's public API surface. The dispatch index is an internal implementation detail; the SDK primitive is the only public mutation path.

Signature (illustrative, language-idiomatic):

register_handler(spec: HandlerSpec, body: HandlerBody) → Handle

  HandlerSpec := {
    pattern:        string           ; Bare pattern (e.g., "app/myapp/greeter").
                                     ; The SDK qualifies to /{peer_id}/{pattern} internally.
                                     ; A leading slash in the bare pattern is an error.
    name:           string           ; Display name for manifest + interface
    description:    string?          ; Human-readable, for discovery consumers
    operations:     [OperationSpec]  ; Operations this handler accepts
    internal_scope: [GrantEntry]?    ; Self-grant scope. Null = no outbound calls.
    types:          map<string, TypeDef>?
                                     ; Type definitions to install at system/type/*.
                                     ; For handlers that define custom operation types.
                                     ; Usually null.
  }

  HandlerBody: Language-native callable that receives HandlerContext,
               returns HandlerResult. See §11.6.3 for cross-language shapes.

  Handle: Opaque type whose close/drop/dispose unregisters both sides.
          See §11.6.2 for lifecycle.

  Errors:
    409  Pattern collision — a handler is already registered at this pattern.
    400  Invalid spec (empty pattern, empty operations list).
    500  Internal error (partial write failure after compensation).

Relationship to system/handler:register. The HandlerSpec fields map directly to the existing system/handler/register-request type (V7 §3.12): pattern and operations form the manifest; internal_scope maps to requested_scope; types maps to the types field. The tree entities this primitive writes are identical to the entities system/handler:register would write. The primitive adds one thing the protocol cannot specify: binding the language-native callable body.

Registration level progression. Currently, this primitive writes tree entities at L0 (direct store access, same as bootstrap). The architectural target is for the primitive to dispatch through system/handler:register (L1) for the declarative side, then bind the callable locally. The existing register-request / register-result types already support this. The progression:

11.6.1 What register_handler writes

The SDK constructs a system/handler/manifest from the HandlerSpec, then decomposes it into two stored entities per V7 §6.2. Registration produces four mutations, in this order:

  1. Tree: interface entity at /{pid}/system/handler/{bare_pattern}, type "system/handler/interface" (V7 §3.7). Contains pattern, name, and operations — the handler's public contract and single source of truth for its external description. Does NOT include max_scope or internal_scope. This is what discover_handlers() reads, system/query returns, and remote peers see. Written first because the handler entity references it by path.
  2. Tree: handler entity at /{pid}/{bare_pattern}, type "system/handler" (V7 §3.7). Contains interface path reference (pointing to step 1's entity), max_scope, and internal_scope. This is the dispatch target — what tree walk (V7 §6.6) finds. Security configuration lives here, not on the interface, so it is not exposed to remote peers through discovery.
  3. Tree: grant entity at /{pid}/system/capability/grants/{bare_pattern}, if internal_scope is non-null. Self-grant derived from the peer's root capability, attenuated to the declared scope. The tree binding — not the content-store put — is the declaration.
  4. Dispatch index: callable entry. For language-native handlers, the SDK registers a wrapper in the implementation's dispatch index that delegates to the caller's body. For entity-native handlers (compute-backed, with expression_path), no dispatch index entry is needed — dispatch evaluates the expression from the tree.

If types is provided, type definitions are additionally written at /{pid}/system/type/{type_name} for each entry, before step 1.

Ordering matters. Tree first, dispatch index second. If the dispatch index write fails after tree writes succeed, the SDK MUST compensate by removing the tree entries (§11.6.4). The reverse ordering creates a window where dispatch reaches a handler the tree doesn't declare.

Collision check. Before any writes, register_handler MUST check whether a handler is already registered at the pattern (either in the dispatch index or the tree). If so, return 409. Silent overwrite is not permitted. Replacement requires explicit close (via handle) followed by register_handler.

The authoritative pattern is the one passed in HandlerSpec.pattern, not any pattern declared inside the body's manifest or interface.

11.6.2 Handle lifecycle

register_handler returns a handle. The handle's close (or language-idiomatic equivalent) unregisters both sides:

  1. Dispatch index first — stop accepting dispatch immediately.
  2. Tree entries second — remove handler entity, interface entity, and grant binding. Type definitions installed via types are NOT removed — they have independent lifecycle and may be referenced by other handlers, queries, or remote peers.

Service-owning handlers insert a step before bothstop service → dispatch index → tree entries. See §11.6.9.

Close MUST be idempotent. Repeated close is a no-op. In languages where value types are copyable, the SDK MUST return a handle whose semantics are reference-based or MUST make close idempotent with an internal closed-flag.

The SDK MUST provide both:

The SDK MUST NOT rely on garbage collection or finalizers for correctness. GC-based cleanup MAY be provided as a safety net but MUST NOT be the only cleanup path.

11.6.3 Handler body contract

Dynamic handler bodies run under the same handler contract as bootstrap handlers. They receive a HandlerContext and return a HandlerResult. The SDK's job is to wrap language-native callables into that contract.

Body concurrency. The SDK does not guarantee serial invocation of handler bodies. Concurrent dispatches to the same handler pattern MAY invoke the body in parallel. Body implementers are responsible for their own synchronization. Single-threaded runtimes naturally serialize; this is acceptable but not a guarantee the spec makes.

Cancellation. Handler bodies MUST respect cancellation signals from their language-native runtime. SDKs MUST propagate cancellation from the dispatch-level context to the handler body's runtime primitive.

Internal scope. A dynamic handler registered without internal_scope (null) cannot call other handlers from its body. The SDK MUST NOT silently default to a wildcard grant — that is the capability equivalent of running as root. The caller declares intent; the SDK enforces it.

Cross-language body shapes:

LanguageBody shapeCapture semanticsCleanup idiom
RustFn(&HandlerContext) -> Future<Result> + Send + Sync + 'staticArc<T> captures; 'static requiredimpl Drop
Gofunc(context.Context, *HandlerContext) (HandlerResult, error)Closure captures by referenceClose() error + defer
TS/JSasync (ctx: HandlerContext) => HandlerResultClosure retains by defaultawait handle.close() or Symbol.asyncDispose
Pythonasync def handler(ctx: HandlerContext) -> HandlerResultClosure retains via scopingasync with or await handle.close()

11.6.4 Partial-failure compensation

If any write step in §11.6.1 fails, the SDK MUST compensate by removing all writes that succeeded before the failure. The SDK tracks writes-so-far and removes them in reverse order on failure.

tree write 1 (interface)  → OK     (tracked)
tree write 2 (handler)    → OK     (tracked)
tree write 3 (grant)      → FAIL
  → compensate: remove handler, remove interface
  → return error to caller
  → no handle returned — caller knows registration failed entirely

Compensation is best-effort — if a compensation removal itself fails, the SDK logs the orphaned entries and returns the original error. Orphaned tree entries without a dispatch index body are harmless (dispatch returns 404). Type definitions installed via types are NOT compensated — they have independent lifecycle (§11.6.2).

11.6.5 Dispatchable vs non-dispatchable: the litmus test

The requirement "if it's dispatchable, it must be tree-declared" has a precise boundary:

Can some piece of code — internal or external — cause this callback to fire by performing a dispatch to a path?

If yes: the callback is a handler in the protocol sense. It MUST be registered via register_handler and tree-declared.

If no: it is not a handler — SDK-internal callbacks, event-bridge listeners, channel receivers, in-process plumbing. These MUST NOT be registered in the dispatch index. They should be expressed as language-level constructs that are explicitly not reachable through dispatch.

No middle ground. No "registered in the dispatch index but hidden from the tree." That middle ground is the invariant violation this section exists to prevent.

11.6.6 Peer restart behavior

Dynamic handlers are ephemeral — their callable bodies exist only in memory. On peer restart with a persistent tree:

The recommended approach: applications re-register dynamic handlers on startup. register_handler replays the tree writes (idempotent if the entries already exist). The tree stays truthful as long as registration is deterministic from application state.

SDKs MUST NOT automatically re-register handlers from surviving tree entries — the callable body is not recoverable from the tree. (For entity-native handlers with expression_path, the body IS in the tree and dispatch works without re-registration.)

11.6.7 Runtime-instantiated handler placement

Handlers registered via register_handler can use any pattern the caller chooses — app/, domain-specific prefixes, or any path appropriate to the handler's purpose. No namespace restriction applies to application-owned dynamic handlers.

Handlers minted at runtime as system machinery (subscription delivery, continuation callbacks, etc.) live under one of two organizational patterns:

Both patterns coexist. Whichever is used, the layout MUST be explicitly enumerated in the owning normative spec — extension specs document the layout (and any caller-specified sub-namespace parameters) they use; specs describing particular system/runtime/ purposes enumerate sub-purposes.

Application code SHOULD NOT register handlers under system/runtime/ or directly under another extension's namespace.

Deprecation. The previous reservation system/sdk/{purpose}/{identifier} is deprecated. Existing impls using system/sdk/... continue to function during a deprecation window; new machinery uses the per-extension or system/runtime/ patterns above. (entity-core-go and entity-workbench-go never adopted system/sdk/...; entity-core-rust's entity-browser-rust uses it in entity-sdk/src/subscription.rs and migrates as part of proposals/implemented/PROPOSAL-OPERATIONAL-STATE-AND-SDK-CONVERGENCE.md.)

11.6.8 Open Questions (Deferred)

  1. Serial dispatch opt-in. §11.6.3 specifies that the SDK does not guarantee serial invocation. For subscription delivery handlers specifically, out-of-order events can produce incorrect state. A future serial: bool field on HandlerSpec (or equivalent) could let handlers opt into serialized dispatch. Deferred: the current non-guarantee is correct as the default.

  2. Owner attribution. Should tree entries record who registered the handler? Useful for plugin revocation. Not needed for the initial primitive.

  3. Atomic replacement. Is replace_handler(spec, body) needed? unregister then register is adequate for now.

  4. Handler status type. A system/handler/status entity (state: active/declared/disabled, boot_id, bound_at) would help with restart-recovery and posture B precision.

  5. Delivery-token tree binding. The subscription bridge mints a scoped capability token (delivery token) authorizing the subscription engine to dispatch receive onto the subscriber's inbox. This token is currently stored in the content store only — no tree binding. The same "floating entity" pattern that R fixes for handlers applies to these capabilities. Scope: either an addendum to §11.6, a subscription extension amendment, or a cross-cutting "SDK-minted capability lifecycle" proposal.

11.6.9 Service-Owning Handlers

Added 2026-07-29. §11.6 covers request/response dispatch: a handler is a body that answers when called. Some handlers additionally own a resource that lives between calls — a network listener, a background loop, a filesystem watcher. Nothing above accounts for one, so an implementation's only options are to spawn it inside the body (unstoppable, unowned) or to hardcode it into peer startup (a privileged built-in, which defeats the point of the handler abstraction).

A handler MAY declare itself service-owning. If it does, registration and close acquire two obligations.

1. Start. The peer invokes the handler's start step as part of registration — after the §11.6.1 tree writes succeed and before the pattern accepts dispatch. A start failure MUST be treated as a registration failure and MUST trigger §11.6.4 compensation. A handler MUST NOT be dispatchable with a failed service.

2. Stop. Close stops the owned service before unregistering dispatch, extending §11.6.2's ordering to three steps:

stop service → dispatch index → tree entries

Stop MUST be idempotent and MUST NOT be relied upon to run via GC or finalizers (§11.6.2's existing rule). Restart survival follows the existing model: an SDK language-native service-owning handler is re-registered — and so restarted — by the application on startup, exactly as §11.3 model 2 already requires for the body.

Entity-native (compute-backed) handlers MUST NOT be service-owning. An expression owning a socket breaks transferability, which is model 3's entire purpose.

Declaration is mandatory and is a tree entity. Spawning something in the body is not enough:

system/runtime/service-declaration := {
  fields: {
    handler_pattern: {type_ref: "system/tree/path"}
                ; the service-owning handler this declaration belongs to
    services:   {array_of: {type_ref: "system/runtime/owned-service"}}
                ; non-empty. One entry per owned service.
  }
}

system/runtime/owned-service := {
  fields: {
    kind:       {type_ref: "primitive/string"}   ; CLOSED enum
                ; "network-listener" — binds a socket, accepts connections
                ; "background-loop"  — timer/reaper/periodic task, no external surface
                ; "filesystem-watcher" — watches a path outside the entity tree
    exposure:   {type_ref: "primitive/string"}   ; CLOSED enum
                ; "peer-internal"      — not reachable from outside the peer process
                ; "entity-mediated"    — reachable, but every request is capability-checked
                ; "unmediated-public"  — reachable AND not mediated by the capability model
    descriptor: {map_of: {type_ref: "primitive/any"}, optional: true}
                ; free-form, kind-specific, diagnostic — nothing gates on it
  }
}

Stored at system/runtime/owned-services/{handler-pattern}, written as part of the §11.6.1 registration writes and removed with them on close. services is an array: a handler may own more than one service (a listener and its reaper is the common shape).

Why a separate entity and not a field on system/handler. Because of who can read it, not because the field would have been forbidden:

Discoverability is the point. An operator or auditing peer enumerates what is listening on this peer and which handler owns it by listing system/runtime/owned-services/*, with no out-of-band knowledge and no access to handler-private configuration.

MUST. A declaration with exposure: "unmediated-public" MUST carry the bind address and port in descriptor. An operator cannot assess exposure from "this handler owns a socket" alone.

MUST. A reader encountering an unrecognized kind or exposure value MUST NOT treat it as benign; it MUST surface it as unassessable. This is a deliberate departure from the MUST-ignore-unknowns rule, which is correct for wire extensibility — where an unknown field is something you did not need — and wrong here, where an unknown value is an exposure you cannot characterize. Silently ignoring it reports "no holes" for a peer that may be serving the open internet. Adding a value to either enum is a spec change.

The boundary rule — declared, not gated. A handler-owned service is frequently outside the capability model by construction: a public rendezvous service's entire job is to answer the open network with no grant, no handshake, and no entity encoding. That is a legitimate design, and it is why the declaration matters:

A handler-owned service is declared and auditable, never ambient. Declaring it does not capability-check its traffic; it makes the hole visible.

What this preserves. The entity system's guarantee is about entity operations, not about every byte a process touches. A handler-owned service is an admission that the peer process does something the entity model does not mediate. Making it declarable keeps that guarantee honest; leaving it ambient quietly weakens what "capability-checked" means.

Cross-impl surface. Three things MUST agree across implementations, because each is cross-peer observable or produces a divergent failure: the ordering (start after tree writes and before dispatch; stop before dispatch-unregister — a peer stopping in the wrong order can dispatch to a handler whose service is gone); the failed-start semantics (registration fails and compensates; never a dispatchable handler with a dead service); and the declaration shape above, which a remote peer or auditor reads. Language-idiomatic expression of start/stop is deliberately unpinned (a Rust impl Drop, a Go Close() error, a Python async with) — §11.6.2 sets that precedent.

Retrofitting existing precompiled engines onto this contract is out of scope. Prove the contract on a new extension first; retrofit as a follow-on if it holds.

11.7 Tree-Gated Dispatch (Migration Target)

The entity system's architectural target is tree-gated dispatch: the handler resolution step verifies that a system/handler/interface entity exists at /{pid}/system/handler/{bare_pattern} before dispatching to a handler in the dispatch index. If absent, dispatch returns status 503 with error code handler_undeclared.

This is distinct from 404 (no handler matched): 503 means the handler IS in the dispatch index but is NOT declared in the tree.

Implementations SHOULD support a peer configuration flag:

require_tree_declared_handlers: bool  (default: false)

When false: dispatch index only. When true: tree-gated dispatch.

This section is informative — it describes the architectural target, not a current requirement. The requirement is §11.6 (SDK-enforced paired writes).


12. Error Model

12.1 Status Codes

CodeMeaningWhen
200SuccessNormal completion
202AcceptedAsync delivery accepted; result will arrive via inbox
207Partial successPrimary operation succeeded; emit consumer(s) failed
303RedirectSubscription redirect to another peer
400Bad requestInvalid params, path, type, expression
403ForbiddenCapability grant does not authorize this operation
404Not foundNo binding at path, or no handler matched
409ConflictCAS failure, merge conflict
429Rate limitedPeer or handler capacity exceeded
500Internal errorUnrecoverable failure
501Not supportedHandler does not implement requested operation
503Handler undeclaredTree-gated dispatch (§11.6): handler in dispatch index but not declared in tree

12.2 Error Entity

system/protocol/error is a core-protocol type (ENTITY-CORE-PROTOCOL.md §3.4). It is reproduced here for reading convenience only — core is authoritative and this spec does not extend it:

system/protocol/error := {
  fields: {
    code:    {type_ref: "primitive/string"}                  ; programmatic identifier
    message: {type_ref: "primitive/string", optional: true}  ; human-readable detail
  }
}

Two fields are deliberately not on this entity, and an SDK MUST NOT add them:

Corrected 2026-07-31 (layering audit). This section previously showed status and details as fields of system/protocol/error and message as required — three deviations from the core type it was restating. It was documentation drift, not built reality: entity-core-go (PartialResultError, "mirrors system/protocol/error fields") and entity-core-py (messages.py, error results emitted as {code, message} with status on the response) both already match core. No implementation change follows from this correction; a restatement that drifts from its upstream is itself the defect, because an implementer building from the restatement produces an entity the rest of the ecosystem does not expect.

12.3 SDK Error Mapping

The SDK MUST preserve status codes. How they surface is language-idiomatic (Go error returns, Rust Result, Python exceptions). The SDK MUST NOT collapse all errors into a single generic type.

The SDK SHOULD distinguish: client errors (400, 404, 409 — caller can fix), authorization errors (403 — grant problem), and system errors (500, 501 — something broke).

12.4 Status 207 (Partial Success)

Primary operation succeeded. Emit consumer(s) failed. Response includes normal result. Application code SHOULD log consumer errors. Application code MUST NOT treat 207 as failure.

12.5 Registration Error Codes

Error codes introduced by register_handler (§11.6):

StatusCode stringMeaning
409pattern_collisionA handler is already registered at this pattern
400invalid_handler_specSpec is malformed (empty pattern, empty operations)
500partial_registration_failureCompensation succeeded but original write failed
503handler_undeclaredTree-gated dispatch (§11.6): handler in dispatch index but not declared in tree

These code strings MUST be consistent across SDK implementations.


13. SDK Patterns (Advisory)

Scoped handles, entity-backed state, type rendering, reactive cycle patterns, connection lifecycle, and multi-peer composition are documented in GUIDE-SDK-PATTERNS.md. These are advisory patterns — recommended ways to use the operations defined in this spec.


14. Cross-Language Contracts

14.1 Content Identity

Same entity MUST produce same content hash. Requires: ECF canonical encoding (ENTITY-CBOR-ENCODING.md) + SHA-256 with format code (V7 §1.2).

Use compare-types or validate-peer to verify.

14.2 Type Definitions

All implementations MUST register the same core type definitions at system/type/*.

14.3 Extension Composition

Same extensions → same emit pipeline ordering (SYSTEM-COMPOSITION.md §2.2).


15. Configuration Directory

The on-disk layout for keypairs, peer configurations, and (when the identity extension is registered) identity bundles.

15.1 V7-only mode (legacy / minimal)

For peers without the identity extension installed:

~/.entity/
├── identities/{name}/          Ed25519 keypairs
│   ├── public_key
│   └── private_key             (restricted permissions)
├── peers/{name}/
│   ├── keypair
│   ├── config.toml             (listen address, storage, extensions list)
│   └── grants.toml             (connection-time grants)

The flat identities/{name}/{public_key, private_key} form is legacy and load-bearing — V7-only peers continue to use this layout indefinitely. Absence of peers/{name}/identity.toml signals V7-only mode.

15.2 Identity-aware mode

For peers running the identity extension, the identities/{name}/ entry becomes a directory bundle. See SDK-IDENTITY-INFRASTRUCTURE.md §8.4 for the full layout including quorum-constituent custody state, controller/agent/identifier keypairs, identity metadata, and the per-peer identity.toml referencing the bundle.

Three modes coexist:

  1. V7-only (§15.1) — flat keypair files; no identity extension; absence of peers/{name}/identity.toml.
  2. Identity-aware single-identifier — bundle directory + peers/{name}/identity.toml. Default for identity-extension users.
  3. Multi-identity host — multiple peers/{name}/ directories on the same host, each referencing a different identity bundle. Per EXTENSION-IDENTITY.md §3.8, peer-configs MUST NOT share state across identities; structural separation enforces this.

15.3 Conformance

15.4 Migration

A V7-only peer becomes identity-aware via the BootstrapFromExistingKeypair helper (per SDK-IDENTITY-INFRASTRUCTURE.md §8.1): bundle is created in identities/{name}/, peers/{name}/identity.toml is added pointing at the bundle, the peer's keypair is unchanged (peer_id stable). No retroactive migration of contacts.


16. Conformance

16.1 MUST Implement

16.2 SHOULD Implement

16.3 Advisory (see guides/)

16.4 Implementation-Defined


17. Open Questions

  1. Batch operations. No put_many, get_many, remove_many. Sync workflows that merge hundreds of entities pay per-dispatch overhead for each one. Should the SDK spec batch operations, or is this an implementation optimization behind the existing API?

  2. Entity construction helpers. put(path, type, data) takes raw data. How does application code construct data that conforms to a type definition? A build_entity(type_name, fields) that validates against system/type/{type_name} would complement the type rendering pattern (see GUIDE-SDK-PATTERNS.md §3) — rendering is display, construction is input.

  3. Version negotiation. When two peers connect, what happens if they support different extension versions (e.g., revision v2.4 vs v2.1)? The handshake exchanges protocol versions but not extension versions. Should discover_handlers include version information?

  4. Dynamic handler registration. Resolved — see §11.6.


18. Relationship to Other Specs

SpecRelationship
ENTITY-CORE-PROTOCOL.mdSDK operations wrap protocol dispatch. §5 capability system authorizes every operation. §6 handler model is the dispatch foundation.
SYSTEM-COMPOSITION.mdSDK put triggers emit pathway. SDK watch observes Phase 2.
EXTENSION-QUERY.mdSDK query dispatches to query handler.
EXTENSION-SUBSCRIPTION.mdSDK watch uses subscription extension.
ENTITY-CBOR-ENCODING.mdEntity construction uses ECF for content hashing.

Addendum A: SDK Specification Notation

What the pseudocode in this document means

The code examples throughout this spec are illustrative, not prescriptive. They show the logical structure of operations — what goes in, what comes out, what the call looks like conceptually. They are not API definitions. Each language implementation translates these into its own idioms.

; This is a spec example:
peer.get("knowledge/articles/intro")

; A Go implementation might look like:
entity, err := executor.TreeGet("knowledge/articles/intro")

; A Rust implementation might look like:
let entity = peer_ctx.tree_get("knowledge/articles/intro");

; A Python implementation might look like:
entity = await peer.get("knowledge/articles/intro")

; A functional language might look like:
(peer-get peer "knowledge/articles/intro")

All of these implement the same operation: retrieve the entity at a path. The spec defines the semantics (what the operation does, what errors it returns, what guarantees it provides). The implementation defines the syntax.

Notation conventions used in this spec

NotationMeaning
operation(param: type) → ReturnTypeOperation signature — inputs and output
TypeName := { field: type ; comment }Result type definition — fields and their types
type?Optional (may be null/absent)
[Type]Array/list of Type
; commentInline comment (consistent with CBOR diagnostic notation)
peer.operation(...)Operation on a managed local peer (§2.8)
peer.store.operation(...)Level 0 direct store access (§2.7)
peer.execute(handler, op, params)Level 1/2 handler dispatch (§2.7)
entity(type, data)Construct an entity with the given type and data
{include: [...], exclude: [...]}Capability scope dimension (V7 §3.6)

What's normative vs what's guidance

Normative (must be consistent across implementations):

Guidance (language-idiomatic, expected to vary):

An implementation is conformant if the normative behavior matches. How it looks is up to the language.

Where to look for normative definitions

The SDK spec describes patterns of use. The normative foundations live in the specs below. When there's ambiguity in the SDK spec, these are authoritative:

ConcernNormative sourceWhat it defines
Entity structure, types, hashingENTITY-CORE-PROTOCOL.md §1-§2Entity := {type, data, content_hash}. Type definitions at system/type/*. Hash = SHA-256 with format code.
Canonical encoding (ECF)ENTITY-CBOR-ENCODING.mdCBOR canonical form. Same entity bytes → same hash in every language. L0 algorithm library — must be vendored per language, must produce identical output.
Capability systemENTITY-CORE-PROTOCOL.md §5Grant structure, four scope dimensions, chain verification, attenuation rules, revocation model.
Handler model and dispatchENTITY-CORE-PROTOCOL.md §6Handler registration, longest-prefix matching, dispatch chain, execution context, authority model.
Connection handshakeENTITY-CORE-PROTOCOL.md §4Hello/authenticate exchange, pre-authorization rules, initial capability delivery.
Wire formatENTITY-CORE-PROTOCOL.md §1.6Envelope structure, CBOR framing, length-prefixed messages.
Emit pathway and extension compositionSYSTEM-COMPOSITION.mdConsumer ordering, cascade depth, two-phase delivery (sync + async), convergence guarantees.
Tree operationsEXTENSION-TREE.mdSnapshot (trie), diff, merge, extract. View trees (capability-scoped projections).
Individual extensionsEXTENSION-*.md (in specs/extensions/)Each extension's handler operations, types, algorithms, and conformance requirements.
Cryptographic algorithmsENTITY-CORE-PROTOCOL.md §7, ENTITY-CBOR-ENCODING.mdEd25519 signatures, SHA-256, Base58 peer ID encoding. L0 algorithms — shared across all layers.

The SDK wraps these into an application-facing interface. The types referenced in SDK operation signatures (Entity, Hash, PeerID, GrantScope) are defined in the core protocol spec. The algorithms (hashing, signing, encoding) are in the L0 specs. The handler behavior behind each execute() call is in the extension specs.


19. Document History