| title | Sandbox node protocol |
|---|
The sandbox node protocol connects Core to a Docker or microsandbox host, which runs Provider operations against its local provider and reports readiness, host measurements and the deployment generations it holds.
| Caller | Core, the only lifecycle owner; the node never retries a mutation or schedules work |
| Implementations | The sandbox node program with the Docker and microsandbox Providers |
| Code | services/core/internal/sandbox/node (wire.go, generation_wire.go) |
| Transport | One WebSocket, GET /api/v1/sandbox-node/connect; the HTTP routes a node uses to enroll and read its configuration are in the machine connection API |
| Version rule | Exact match on node.ProtocolVersion, see Frames and version |
Node-local storage, generation leases and recovery are not part of the wire protocol; the Node installer owns them.
| Party | Owns |
|---|---|
| Core | Node identity and presence, the owner epoch, the target generation, every request and its sequence, and every retention decision |
| Node | Its local provider, the generations it prepares and serves, health and host measurements, and the execution of each request against the provider |
Every frame is one JSON text message whose version equals node.ProtocolVersion; both peers reject any other version, and there is no fallback decoder. Member names are exact and unique: unknown members, case aliases, duplicates and unexpected nulls are rejected. Control frames (hello, welcome, heartbeat, heartbeat_ack, retention, retention_ack) are at most 32 KiB; request and response frames at most 72 MiB. An invalid frame closes the connection.
sequenceDiagram
participant N as Node
participant C as Core
N->>C: dial /sandbox-node/connect with Bearer credential
Note over C: 401 rejected credential (permanent), 409 duplicate connection
N->>C: hello (identity, health, generation observations)
C->>N: welcome (connection_id, owner_epoch, deployment)
loop every 10 s
N->>C: heartbeat (connection_id, owner_epoch, health)
C->>N: heartbeat_ack (deployment)
end
C->>N: request (id, sequence, operation)
N->>C: response (id, result or error_code)
- The node dials
/api/v1/sandbox-node/connect?node_id=<uuid>on its stored Core origin (wssforhttps) with its node credential as a Bearer header. Core answers 401 to a rejected credential, which the node treats as permanent; any other failure, including a 403 from a proxy, is retried with bounded backoff. Core refuses a second connection for a node identity while one is opening, live or closing, with 409. - Within 15 seconds the node sends
hellowith its identity (node_id,installation_id,provider,backend_fingerprint, the enrolleddeployment_generationandspecification_digest,max_active,max_retained), and its first health report. Core closes the connection unless the identity matches the authenticated node. - Core records the node's presence, then replies
welcomewith a newconnection_id, the currentowner_epochanddeployment: the targetgeneration, itsspecification_digestand a nullableserving_generation. The node stores a higher owner epoch and refuses a lower one. - Every 10 seconds the node sends
heartbeatwithconnection_id,owner_epochand health. On each heartbeat Core authenticates the node credential again and checks the owner epoch, records the health and repliesheartbeat_ackwithdeployment. Either peer closes the connection after 35 seconds without a frame.
Core counts a node as online while it is connected under the current owner epoch and its last heartbeat is less than 45 seconds old. A heartbeat establishes provider readiness and the last host measurements, never Session activity. Timeouts and limits lists every timer.
Health carries observed_at, active_operations (at most 32) and the host measurements that the Runtime telemetry API reports. The enrollment request carries the same measurements that the node takes for health, cpu_count, effective_cpu_cores, total_memory_bytes and available_disk_bytes, each omitted when the node cannot measure it and validated like a health report. Core derives from them every limit the enrollment token leaves unset, as Recommended capacity defines. A node reports readiness per generation, as described below. A diagnostic in a generation observation that is not a declared class makes the frame invalid. The nodes guide lists the classes and their causes.
Core sends request frames with:
| Field | Meaning |
|---|---|
id |
New UUID per request |
sequence |
Increases by exactly one per request on this connection |
connection_id, owner_epoch |
The values from welcome |
deployment_generation |
The generation the allocation belongs to, separate from its compute generation |
operation |
One of the operations below |
timeout_ms |
Remaining budget, 1 to 120000 |
reference |
The exact (tenant_id, environment_id, allocation_id) |
Each operation carries its own arguments and returns the following result on success:
operation |
Provider method | Arguments | Successful result |
|---|---|---|---|
create |
Create |
bootstrap |
info |
info |
GetInfo |
None | info |
renew |
Renew |
None | info |
kill |
Kill |
None | None |
command |
RunCommand |
command |
command |
observe |
Observe |
observation |
sample |
initial |
Initial |
None | compute |
new_compute |
NewCompute |
Positive compute generation and optional snapshot |
compute |
compute |
GetCompute |
compute |
state |
kill_compute |
KillCompute |
compute |
None |
resume_compute |
ResumeCompute |
compute |
state |
command_compute |
RunCommandCompute |
compute and command |
command |
suspend |
Suspend |
suspend |
state |
resume |
Resume |
resume |
state |
delete_snapshot |
DeleteSnapshot |
snapshot |
None |
restore_preparation |
RestorePreparation |
key |
restored |
save_preparation |
SavePreparation |
key |
None |
A request whose connection_id, owner_epoch or sequence does not match closes the connection. A malformed request gets an invalid response. The node runs the request on the provider of the named deployment_generation and answers unconfirmed when it cannot. Core sends create and a resume that is not observe-only only to a generation that is ready on that node, and keeps at most 32 requests pending per connection.
The budget is relative: the node anchors timeout_ms to its own clock on receipt and consumes it while the request waits in its queue, so the hosts' clocks need not agree. Core still bounds its own wait. A full node queue closes the connection.
The response frame carries id and connection_id. A successful response carries the result named in the operation table, with no result field for kill, kill_compute, delete_snapshot or save_preparation. A failed response carries an error_code:
error_code |
Meaning |
|---|---|
invalid, ownership, exists, not_found |
ErrInvalid, ErrOwnership, ErrExists, ErrNotFound |
command_unconfirmed |
ErrCommandUnconfirmed |
checkpoint_unusable |
ErrCheckpointUnusable, a resume outcome |
observation_unavailable, runtime_not_running |
The observation outcomes |
unsupported |
The operation is declared unsupported; see below |
unconfirmed, or any other value |
The outcome is unknown |
A failed response carries no result, except an info that is an exact-reference CreateSettled receipt: a confirmed native Create that failed a later check can still prove that the attempt settled. A timeout, a lost response or a disconnect is unavailable or uncertain, never evidence of absence, and Core never replays a mutation after one; it observes the original operation instead. The Sandbox Provider protocol defines each outcome.
Node startup and generation loading validate complete Provider operation declarations before accepting work, and the Core proxy uses the same registered declaration, so an unsupported operation rejects before node resolution or native I/O. The operation contract owns the inventory. An unsupported response carries an unsupported object with the exact method operation and an authored safe reason; the proxy checks both against the request. Missing, malformed or mismatched evidence is an unconfirmed result, never proof that a mutation was rejected. Unsupported stays distinct from observation unavailability and unknown compute or command results, and it neither settles resource ownership nor authorizes a replay.
Create bootstrap and Resume requests may carry an optional workspace filesystem binding. The node validates its tenant and Environment against the allocation reference, its immutable ObjectID, and its attachment configuration ID against the supplied configuration before forwarding it. The filesystem resolver validates adapter-native ownership. A Provider whose policy declares workspace storage rejects a missing binding; a Provider without it rejects a present one. Neither falls back. Error responses may retain a Resume target only when its nonempty native ID, name, generation and snapshot provenance match the requested target; this is cleanup evidence, never successful restore.
The node prepares the target generation Core announces in welcome and heartbeat_ack and keeps serving its durable serving generation while it does; target preparation is independent of the serving provider's readiness.
The node's hello and heartbeats carry at most eight generation observations. Each names a positive signed-64-bit generation, its lowercase SHA-256 specification digest, a ready, preparing or failed state and an optional fixed diagnostic. The target and serving generations come first; other records rotate fairly. Eight bounds one message, not the number of generations a node may keep. An omitted observation never authorizes deletion or implies absence. A ready checkpoint-capable generation includes checkpoint with nonempty opaque artifact_domain and execution_class tokens, each at most 256 bytes. Unsupported providers omit it; preparing or failed generations never advertise it. Core persists the declaration with the authenticated connection and generation, and uses it only while that generation is ready on the current online connection. The Provider contract defines archive and execution compatibility.
stateDiagram-v2
[*] --> preparing
preparing --> ready
preparing --> failed
failed --> preparing: repair
ready --> [*]: dropped
failed --> [*]: dropped
A generation is the target, the serving generation, both or neither. The node reports each held generation as preparing, ready or failed; a dropped generation is never prepared or used again. Local collection of a dropped generation is described in the Node installer.
Retention uses its own bounded exchange: the node asks and Core decides. The node's retention request names at most eight local (generation, specification_digest) references, a UUID, a sequence that increases by one, the current connection_id and the owner epoch. Core's retention_ack must match the complete pending request, entry order and identity included, and give an explicit boolean keep for every entry. One exchange is pending per connection, and a disconnect discards it. An unsolicited, replayed, stale, partial or mixed acknowledgement deletes nothing. Retention traffic never uses the Provider request queue.
sequenceDiagram
participant N as Node
participant C as Core
N->>C: retention (id, sequence, up to 8 references)
C->>N: retention_ack (keep for every entry, same order)
Note over C,N: One exchange is pending per connection
The readiness classes, one exported error and one code each, are authored in services/core/internal/sandbox/node_diagnostic.go. The shared services/core/internal/sandbox/testdata/node-diagnostics.json fixture checks the Go mapping, OpenAPI source annotations and generated enums, and the TypeScript client declaration. Web uses the client normalizer and checks localized messages for every declared code. Update these projections with a code change; clients read an unknown code as provider_unavailable.
Preparation diagnostics keep fixed typed causes. Provider, ownership, cancellation and unclassified failures keep their typed code or provider_unavailable. No raw provider text crosses the protocol.
| Value | Applies to | On expiry or excess |
|---|---|---|
| 15 s | hello after the connection opens |
The connection closes |
| 10 s | Heartbeat interval; also the write deadline of each frame | A failed write ends the connection |
| 35 s | Any frame on an open connection | Either peer closes the connection |
| 45 s | Age of the last heartbeat for an online node | Core counts the node as offline |
| 1 to 120000 ms | Request timeout_ms |
The request fails as unavailable or uncertain |
| 32 | Pending requests per connection; active_operations in health |
A full node queue closes the connection |
| 32 KiB | Control frames | The connection closes |
| 72 MiB | request and response frames |
The connection closes |
| 8 | Generation observations per message; references per retention request |
An omitted observation implies nothing |
The Go tests in services/core/internal/sandbox/node cover frames, connection ordering and fencing, request budgets, generation control and Provider operations. make check-core runs them. The Python node installer tests in deploy/node cover the node side of generation preparation and collection.