Reference
Environment protocol
This page states the environment protocol’s rules: what every peer sends, accepts and answers. It is written for anyone who implements a provider controller, a relay in front of the daemon, or a client. The provider guide explains how to build a provider; this page is the contract that guide refers to.
The wire types live in the environment protocol crate. Its doc comments use wire names and state the per-field rules. Where this page and the crate differ, the crate is authoritative.
Planes and versions
The protocol has three planes. Each runs over its own WebSocket connection.
| Plane | Client (dials) | Server | Version number | Purpose |
|---|---|---|---|---|
| Data | The Lightspeed runtime | lightspeed-envd in the environment |
ENVD_PROTOCOL_VERSION |
Filesystem, transfer, process, and job operations in one environment. |
| Controller | The Lightspeed runtime | A provider controller | CONTROLLER_PROTOCOL_VERSION |
Create, observe, power, and close the provider’s targets. |
| Registration | lightspeed-envd |
The Lightspeed environment gateway | ENVD_PROTOCOL_VERSION |
Admit an outbound daemon and ask it for reverse-dialed data streams. |
Data and registration currently use 5; the controller uses 4. They are
matched exactly. A peer that states any other number is refused with
versionMismatch. There are no version
ranges and no negotiation. A change to a plane’s wire shape, or to the meaning
of an existing field or method, needs a new number for that plane. The data
plane and the registration channel share one number, so a data-plane change
also changes the number in the gateway’s challenge, which is what prompts a
registered daemon to upgrade.
release/metadata.env records the same numbers as
LIGHTSPEED_ENVD_PROTOCOL_VERSION and LIGHTSPEED_CONTROLLER_PROTOCOL_VERSION.
scripts/release/verify-metadata.sh checks them against the crate.
The controller plane does not depend on data-plane types. A provider that changes only its controller does not force a new daemon, and a daemon change does not force a provider redeploy unless the provider bakes the daemon into its images.
Contract documents
The method manifest
lists every method with its plane, direction, family, params and response
types, and the facts servers act on: the capability a method needs, whether
it counts as use of the environment, and whether $/cancelRequest aborts it.
A method exists exactly when it has a manifest entry.
Generated from the manifest and committed under
crates/environment-protocol/contract/:
| File | Contents |
|---|---|
data.openrpc.json, controller.openrpc.json, registration.openrpc.json |
One OpenRPC document per plane: every method, its params, result, and summary. |
data.schema.json, controller.schema.json, registration.schema.json |
JSON Schema bundles of every type the plane uses. |
The controller documents also name two types that no method carries as a
whole: x-lightspeed-data-route, the data route refusal body with its hold
and Retry-After bounds, and x-lightspeed-idle-policy, the idle policy
providers run.
Each method also has a fixture under
fixtures/<plane>/, named
after the method with . for /, such as fixtures/data/process.read.json.
It holds a valid params object, a valid response, and a typed error. The crate’s
tests decode each fixture strictly and check that the committed contract
matches the current types.
Envelope
Every plane uses one JSON-RPC 2.0 envelope:
- Each WebSocket text frame holds exactly one JSON-RPC message. Binary
frames are not part of any plane: a server answers one with
invalidRequestand a null id, and a client closes the connection. jsonrpcmust be"2.0". An unknown member of the envelope isinvalidRequest.- Params are passed by name, as an object. Absent params mean
{}. Params that are not an object areinvalidParams. - A request has an integer or string
id. A notification has noidand is never answered, not even with an error. A malformed frame that names a stringmethodand has noidis treated as a malformed notification: it is dropped without an answer. - A batch (a JSON array) is answered with one
invalidRequesterror and a null id. - A frame that is not JSON is answered with
parseErrorand a null id. - The connection stays open after every framing error.
On the data and controller planes, the server never sends a request or a notification. A client that receives one treats it as a protocol violation and closes the connection. The registration channel carries requests and notifications in both directions; see Registration channel.
Handshake
The data and controller planes each begin with initialize. The server
enforces these rules per connection:
- Before a successful
initialize, any other request is answered withnotInitialized, and notifications are dropped. - An
initializewhoseprotocolVersiondiffers from the server’s number is answered withversionMismatch. Itsexpectedfield is the server’s number andreceivedis the number the client stated. The connection stays open and uninitialized; the client closes it. - A second
initializeafter a successful one isinvalidRequest. - The response names its
plane. A client checksplaneandprotocolVersionbefore it uses the connection, so a client that reached the wrong socket fails at the handshake.
clientInfo and serverInfo describe the peer’s build: a name and
optional version, gitSha, and target, each 1 to 256 bytes without
control characters. They are descriptive only. Nothing selects, gates, or
authenticates on them.
Data initialize also carries platform: the remote os, pathConvention,
verified shell catalog and optional default shell. Shell entries identify a
kind, absolute executable path and observed version. An absent catalog/default
does not authorize a client to guess a shell. Windows defaults to verified
PowerShell 7, otherwise Windows PowerShell; Git Bash requires explicit selection.
Clients validate these facts against cwd/home and execution capabilities before
using the connection.
The daemon probes installed shells once at startup, recording only successful,
bounded version probes. Windows probes PowerShell 7 on PATH and in its standard
installation directory, and Windows PowerShell in the system directory. Git
Bash requires an adjacent verified Git for Windows installation in a standard
system or per-user installation directory; a PATH bash.exe is not accepted.
Restart the daemon after installing a shell to refresh the catalog.
Model-facing exec_command and Bash accept an optional shell enum:
bash, powerShell, windowsPowerShell, or gitBash. Omission uses the
discovered default. exec_command preserves Unix Bash’s login default;
PowerShell and Git Bash default to no profiles. Explicit login: true enables
profiles. PowerShell pipe requests use -NonInteractive; terminal requests
omit it. Direct process and job argv bypass these shell adapters.
The active environment’s model context includes its OS, native cwd/home, shell
paths and versions, and relevant execution capabilities. Model file paths and
cwd overrides are resolved using the remote path convention, independently of
the controller OS. Windows accepts native drive paths or relative paths; the
wire still uses canonical C:/... spelling. VFS paths retain their separate
logical POSIX grammar.
Decoding
Every params, response, and message type on the three planes decodes
strictly: an unknown member is refused, and defaults exist only for
documented optional knobs. An omitted protocolVersion is never assumed.
The frozen bootstrap surface is the exception. It is the gateway’s first
frame (the registration/challenge notification), the deployment’s daemon
discovery document, and the JSON that lightspeed-envd --print-build
prints. These only gain fields, and they are decoded tolerantly, so a daemon
of any version can read them.
Encoding conventions
| Topic | Rule |
|---|---|
| Methods | domain/verb[Object] in lowerCamel, singular domains, no plane prefix. |
| Fields | camelCase. Lists are plural nouns; counts end in Count, sizes in Bytes, durations in Ms, instants in AtMs, ids in Id. Payload bytes are always data. |
| Optionality | No null except JSON-RPC’s own id. Absent options and empty collections are omitted. Flags are booleans that default to false and are omitted when false. |
| Numbers | Byte counts, offsets, and durations are unsigned 64-bit; counts, indices, and depths unsigned 32-bit; instants are signed 64-bit milliseconds since the Unix epoch. Every value stays below 2^53. |
| Unions | Data unions carry a kind tag. |
| Ids | Lightspeed-assigned ids follow the general grammar ^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$. Provider-assigned ids (targetId, templateId) are opaque: 1 to 255 bytes without control characters. |
| Bytes | Payloads are padded standard base64. Fixed-size cryptographic values are lowercase hex. Content digests are sha256:<64 hex>. Opaque fingerprints carry their own prefix, such as scan:v1:. |
| Paths | EnvironmentPath: absolute, normalized, at most 4096 bytes; . and .. segments are refused, not rewritten. Tree entries use relative TreePath values; the selected root itself is .. |
| Text | Descriptive text such as a target message is 1 to 1024 bytes, without control characters other than line feed and tab. URLs are at most 2048 bytes. |
Errors
An error is a JSON-RPC error object. Its code is fixed by its kind, and its
data carries the kind and its typed fields:
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32004, "message": "unknown process id: p1", "data": {"kind": "notFound", "reason": "expired"}}}data.kind is authoritative. Receivers branch on it, never on the code or
the message. A message is at most 4096 bytes and is never parsed.
Retryability is a property of the kind and is never sent. The same error
object appears inside results that report per-entry failures, such as
job/read entries, fs/scan roots, and a failed transfer’s status.
| Kind | Code | Retryable | Typed fields | Use |
|---|---|---|---|---|
parseError |
-32700 | No | The frame is not JSON. | |
invalidRequest |
-32600 | No | A malformed envelope, a batch, a binary frame, a repeated initialize. |
|
methodNotFound |
-32601 | No | No such method on this plane. | |
invalidParams |
-32602 | No | field, ceiling |
Params do not decode, a value is out of range, or a value exceeds an advertised ceiling. field is the wire path of the offending member, such as jobs[0].dependsOn. |
internal |
-32603 | No | A defect inside the server. | |
notInitialized |
-32002 | No | A request before initialize. |
|
permissionDenied |
-32003 | No | reason |
Confinement, operating-system permissions, or registration credentials. |
notFound |
-32004 | No | reason |
An unknown handle, path, target, or job. |
versionMismatch |
-32005 | No | expected, received |
The stated protocol version differs. |
capabilityUnavailable |
-32006 | No | capability |
The endpoint lacks a capability family or fact the request needs. |
failedPrecondition |
-32007 | No | reason (required) |
The current state forbids the request. |
busy |
-32008 | Yes | reason, retryAfterMs |
A concurrency, capacity, or rate limit. |
alreadyExists |
-32009 | No | The thing to create already exists. | |
payloadTooLarge |
-32013 | No | limitBytes, limitEntries |
A message, payload, or result exceeds its limit. |
deadlineExceeded |
-32015 | No | A server-side duration bound ran out. | |
expired |
-32016 | No | A credential or token is past its validity. | |
aborted |
-32017 | Yes | reason |
Cancelled, or the input changed underneath the operation. |
processFailed |
-32018 | No | A process could not be started or supervised. | |
unavailable |
-32019 | Yes | retryAfterMs |
A dependency of the server is temporarily unreachable. |
Reasons by kind:
| Kind | Reasons |
|---|---|
permissionDenied |
outsideRoot, daemonState, filePermission, invalidSignature, invalidRegistrationKey, registrationKeyRevoked |
notFound |
expired (the handle was evicted), daemonRestarted (the handle belongs to an earlier daemon instance), unknownDaemon |
capabilityUnavailable |
filesystem, filesystemWrite, process, processPty, job, power, ingress |
failedPrecondition |
inputClosed, ifMatchFailed, createCondition, notEmpty, isDirectory, notDirectory, notRegularFile, symlinkLoop, symlinkRejected, filesystemRoot, crossDevice, noSpace, jobIdInUse, operationIdInUse, transferPhase, transferOffset, digestMismatch, environmentClosed, identityInUse, ownerMismatch, templateDeprecated, ingressUnsupported, targetNotActive |
busy |
requestLimit, transferCapacity, rateLimited, capacityExhausted |
aborted |
cancelled, sourceChanged |
An endpoint that cannot honour a parameter refuses the request with
capabilityUnavailable. It never ignores the parameter.
Liveness
A peer that is waiting for a response sends a WebSocket ping at least every 20 seconds while any request it sent is unanswered. It may also ping while idle. The daemon, providers, and the gateway answer pings and never originate them on data or controller connections. A server answers pings even while it is busy writing responses.
Intermediaries, such as a gateway route, a provider relay, or an edge proxy, forward ping and pong frames, count every frame as activity, and never close a connection for idleness sooner than 60 seconds. Request deadlines, not pings, detect a server that has stopped answering.
The registration channel has its own heartbeat; see Registration channel.
Limits
The protocol has three kinds of limit:
- Protocol constants. Every message, and therefore every frame, is at
most 16 MiB (16,777,216 bytes) on every hop. Each reader and writer
configures that limit explicitly, with the frame limit equal to the
message limit. A sender whose request would exceed it fails locally
instead of sending. A server whose response would exceed it answers
payloadTooLargefor the same id instead. - Endpoint ceilings. The daemon advertises its operational ceilings in
the data-plane
initializeresponse aslimits. A request above a ceiling isinvalidParamsnaming the field and the ceiling. Nothing is clamped. - Caller quotas. Each request states its own bounds as required fields,
such as
maxBytes,waitMs, or alimitsobject.
Response sizes are bounded by construction. A process/read page or one
job/read response holds at most 4096 chunks plus gaps. Search and glob
matches, and the entries, skips, and content of all roots of one scan, stop
at a 12 MiB result budget. A scan root or transfer lists at most 100 skipped
entries and counts the rest. A leftover-process report lists at most 64
processes, each command cut to 1024 bytes.
Pipelining and cancellation
Clients may pipeline requests. Responses arrive in any order and are matched
by id. Servers accept at least 16 requests in flight per connection; the
daemon advertises its own number as limits.maxConcurrentRequests. Beyond
it, a server answers busy with reason requestLimit at once.
On the data plane, the client notification $/cancelRequest {id} aborts a
pending request. The manifest marks which methods it stops: process/read,
job/read, fs/searchText, fs/globFiles, and fs/scan. They stop
waiting or walking and answer aborted with reason cancelled; an aborted
process/read never advances the output cursor. Every other method ignores
the notification and runs to a consistent state. An unknown or finished id is
ignored. The controller plane has no cancellation.
When a data connection closes, the daemon cancels that connection’s pending requests as if each had been cancelled, and drops their replies.
Controller plane
A provider controller manages targets: machines, containers, or other units of compute. The runtime is the client. Calls go one way only: the runtime calls the provider, and the provider never calls Lightspeed.
The provider owns everything that happens to a running target: its power, its idle policy, its ingress, its placement, and any pools or snapshots behind it. Lightspeed decides only two things, that an environment is created and that it is closed, and it retries both until the provider accepts them. Lightspeed records what the provider reports for display; it stores no power or ingress intent and never reverts a change the provider made.
Transport and credential
The controller is a WebSocket endpoint at the URL the operator registers in
Lightspeed: ws://, wss://, or unix:///absolute/path. Any path is
allowed.
The credential is optional. When the provider is registered with one, every
connection to the controller and to its data routes carries it as
Authorization: Bearer <credential>; the provider compares it in constant
time and answers HTTP 401 before the upgrade when it is missing or wrong.
When the provider is registered without one, the runtime sends no
Authorization header. Run a provider without a credential only where
nothing but Lightspeed can reach its listener.
initialize
The params carry protocolVersion and clientInfo. The response carries:
| Field | Meaning |
|---|---|
protocolVersion |
The controller’s number. |
plane |
Always controller. |
serverInfo |
The controller’s build. |
capabilities |
Optional methods: power serves target/setPower, and ingress serves ingress/ensure and ingress/remove. template/list, target/create, target/get, and target/close are mandatory. |
{ "protocolVersion": 4, "plane": "controller", "serverInfo": {"name": "lightspeed-provider-incus", "version": "0.5.0"}, "capabilities": {"power": true, "ingress": true}}The runtime reads capabilities from the live handshake of each
connection and never stores it. It refuses an API request for a power or
ingress change up front when the provider does not advertise the method.
A controller that receives an optional method it does not serve answers
capabilityUnavailable.
Methods
| Method | Params | Semantics | Errors |
|---|---|---|---|
template/list |
universeId |
The templates the universe may create targets from. A provider may offer each universe a different set. | unavailable |
target/create |
owner, templateId, displayName?, idle? |
Idempotent per owner. If the provider already holds a target for the owner, answer its observation and apply no creation policy (deprecation or image checks). Otherwise apply policy, start creation, and answer promptly with phase: provisioning. Handing out a target from a warm pool counts as a create. idle replaces the template’s default idle policy for this target. |
failedPrecondition (templateDeprecated, ownerMismatch), notFound (template), invalidParams (including an idle that does not validate), unavailable |
target/get |
target |
A pure read: it never creates, starts, or changes anything. Resolves by targetId when given, otherwise by owner. |
notFound, failedPrecondition (ownerMismatch), unavailable |
target/setPower |
target, power |
Needs power. Move the target to one of its template’s powerStates. A target already there, or moving there, is observed without action. Answer promptly with the transitional observation. The provider stays the owner of power afterwards. |
capabilityUnavailable, notFound, invalidParams (a state outside powerStates), failedPrecondition (targetNotActive), unavailable |
target/close |
target |
Destroy everything the owner’s target holds, public ingress included: gracefully within the provider’s own bound, then forced. Answer closing while it runs, closed once nothing remains, and closed without targetId when the provider holds nothing for the owner. Idempotent. |
unavailable |
ingress/ensure |
target |
Needs ingress. Route the template’s public ingress to the target. Idempotent. |
capabilityUnavailable, notFound, failedPrecondition (ingressUnsupported) |
ingress/remove |
target |
Needs ingress. Stop routing public ingress to a target that stays. Idempotent. |
capabilityUnavailable, notFound |
Every target and ingress method answers {"target": <observation>}.
Ownership
A target belongs to exactly one owner, the Lightspeed environment it serves:
{"universeId": "u1", "environmentId": "env_1"}A provider holds at most one target per owner. Every target method names the
owner in a target reference, {owner, targetId?}. Without targetId, the
provider resolves the owner’s target itself; this is how the runtime finds
and closes a target whose id it never learned. A target that belongs to
another owner is refused with failedPrecondition (ownerMismatch).
Knowing a provider’s target id is never enough to reach a target. An
environment id is never reused, so a closed environment’s owner cannot
reach a later environment’s target.
Record ownership durably enough to survive the provider’s own restarts and an ambiguous create, where the target was created but the response was lost. The Incus provider uses deterministic names and ownership metadata on its native resources; another provider may use its own store.
Bounded calls
Every method answers within a bounded time, a few seconds. Slow work, such as
an image pull, a boot, or a graceful shutdown, continues in the background,
and the runtime follows it with target/get. An asynchronous provider may
answer target/create before it has assigned an id; the observation then
omits targetId, and the runtime records the id from the first observation
that carries one.
Observation
{ "targetId": "ls-3f2a", "owner": {"universeId": "u1", "environmentId": "env_1"}, "phase": "active", "power": "paused", "ingress": {"endpoint": "https://acorn.env.example.com"}}| Field | Meaning |
|---|---|
targetId |
The provider’s id. Absent only while provisioning before an id exists, and in a closed observation of an owner with nothing. |
owner |
Always echoed from the request. |
phase |
provisioning, active, closing, closed, failed (broken beyond the provider’s own recovery), or unknown (the provider cannot tell). |
power |
Present exactly when phase is active: running, paused (execution halted, memory retained), suspended (execution state persisted, compute released), or stopped (powered off, disks retained). |
message |
Why the target is failed or unknown, or what a transition waits for. |
ingress |
Realized public ingress, an http or https URL. Absent when disabled. |
Report what the infrastructure shows, not what a caller asked for. An observation says nothing about whether the daemon answers: the runtime proves that itself by dialing the data route.
Templates
A template has a templateId, a non-empty displayName, and these
optional fields:
| Field | Meaning |
|---|---|
description |
A short description for people choosing a template. |
powerStates |
The power states target/setPower can move this template’s targets to. Absent or empty when the provider offers no power control beyond create and close. |
publicIngress |
Targets of this template can serve public ingress. |
idle |
The idle policy a target gets when target/create names none. Absent when the provider applies none by default. |
deprecated |
The template creates no new targets; existing targets keep working. |
resources |
vcpus, memoryBytes, and diskBytes in provider-neutral units, each omitted when the provider does not fix it. |
metadata |
Provider display facts, such as an image fingerprint. |
Idle policy
An idle policy names when a provider powers down or closes an idle target:
{"pauseAfterMs": 600000, "stopAfterMs": 3600000, "closeAfterMs": 86400000}pauseAfterMs, suspendAfterMs, stopAfterMs, and closeAfterMs are each
optional, but a policy sets at least one. Every set threshold is positive,
and the set thresholds are non-decreasing in the order pause, suspend, stop,
close. A policy that breaks these rules is invalidParams.
The policy belongs to the provider. A template names a default, and
target/create may replace it for one target; the runtime validates a
policy and passes it through without interpreting it. The provider runs the
clock. For a running target it reads the daemon’s env/getIdle over its own
channel to the guest and applies the most escalated stage whose threshold
idleForMs has reached and whose power state the template supports; close
always qualifies. The protocol crate’s
IdlePolicy::due_action
implements that rule. Running processes, running jobs, and leftover process
groups hold idleForMs at zero, so they keep the target awake.
Data routes
A provider serves the daemon’s data plane for each target on a data route:
the provider’s data route base followed by
/{universeId}/{environmentId}/{targetId}, each segment percent-encoded
(everything except RFC 3986 unreserved characters). The runtime dials it
with the bearer credential when one is configured. The provider serves the
data plane unchanged on that route, under the liveness rule and the message
limit above, and refuses a route whose owner does not own the target.
The data route base is a ws:// or wss:// URL with a lowercase scheme, a
host and optional port, an optional path that does not end with /, and no
user information, query, or fragment. The operator may set it when
registering the provider. Without one, the runtime derives it from the
controller URL: the controller’s scheme, host, and port followed by
/routes, so wss://provider.example.com/control gives
wss://provider.example.com/routes. A unix:// controller has no derivable
base, so its registration must name one. A configured base for a controller
reached over wss (or https) must itself use wss, so the credential
never travels in cleartext; registration refuses a downgrade.
A dial of a powered-down target’s data route wakes the target. This is the
only wake signal: Lightspeed has no separate wake call. The provider may
hold the dial for at most 5 seconds (MAX_DATA_ROUTE_HOLD_MS) while it
resumes the target. If the daemon answers in time, the route upgrades and
relays as usual. Otherwise the provider refuses the upgrade with a JSON
body, Content-Type: application/json, tagged by state:
| Status | Body | Meaning |
|---|---|---|
| 503 | {"state": "waking"} |
The target is resuming from a powered-down state. |
| 503 | {"state": "booting"} |
The target runs, but the daemon does not answer yet. |
| 410 | {"state": "gone"} |
The target no longer exists. |
| 409 | {"state": "failed", "message": "..."} |
The target is broken beyond the provider’s recovery. |
Every 503 carries Retry-After in whole seconds, from 1 to 60
(RETRY_AFTER_MAX_SECS); receivers clamp a larger value. A 503 may succeed
on a later dial; 410 and 409 do not. The body is an HTTP refusal, not a
JSON-RPC message. The controller schema bundle names it as
x-lightspeed-data-route, together with both bounds.
The environment gateway’s worker route uses the same body. It adds three
states of its own and forwards a provider’s refusal unchanged, status and
Retry-After included:
| Status | Body | Meaning |
|---|---|---|
| 503 | {"state": "provisioning"} |
The environment is still provisioning. |
| 503 | {"state": "disconnected"} |
The registered daemon is not connected. |
| 410 | {"state": "away"} |
The registered daemon has been away longer than it is waited for. |
Provider obligations
The provider owns its targets’ power, idle policy, and ingress. Every provider:
- makes
target/createandtarget/closeidempotent per owner; - wakes a powered-down target when its data route is dialed, holding the upgrade at most 5 seconds before it answers a typed refusal;
- decides idleness from the daemon’s
env/getIdle, so running processes, jobs, and leftover process groups keep the target awake; - never powers a target down while it relays a data connection to it;
- reports
closed, ornotFoundfromtarget/get, for a target it removed on its own, such as by a lifetime limit or an idlecloseAfterMs; - never snapshots or clones a daemon state directory that holds a daemon identity.
How the runtime drives a controller
Providers can rely on this behaviour:
- Until it has recorded a target id, the runtime sends
target/create, with the samedisplayNameandidleon every attempt. Afterwards it observes withtarget/getand never sends create again. phase: provisioningis checked again after 2 seconds.phase: activeleads the runtime to run the data-plane handshake on the target’s route. This handshake is the only readiness proof: success opens the environment and records the daemon’s build. A daemon that speaks another protocol version fails the environment, naming both versions. A 503 refusal is retried after itsRetry-After;goneandfailedrefusals fail the environment.phase: failedfails the environment with itsmessage.closingorclosedbefore the environment opened fails it too.phase: unknownis retried with backoff.- Retryable kinds,
internal, transport failures, timeouts, and failed handshakes are retried with backoff: 2 seconds, doubling per attempt, at most 5 minutes. Other refusals,notFoundincluded, fail a provisioning environment at once, so reserve them for requests that can never succeed. An environment that has not opened within 30 minutes of entering provisioning fails with the last recorded problem. - Every 5 minutes, the runtime reads each open environment’s target with
target/getand records the observation for display.phase: failedfails the environment.closed, ornotFound, closes it with the reasonproviderEnded. This sweep never changes power. - Close sends
target/closeby owner, with the target id when one is recorded, until the provider answersclosedornotFound.closingis checked again after 2 seconds, and errors are retried with backoff. Close has no deadline, and the runtime never removes ingress before it. target/setPowerand the ingress methods are sent only when a user asks for a change, synchronously, with a 30-second deadline, and only when the controller advertises them. No loop calls them, and nothing retries them.- Connect and
initializeeach have a 10-second deadline, and the runtime’s other controller calls a 120-second deadline. The runtime keeps at most eight calls in flight per provider, over one connection.
Data plane
The daemon, lightspeed-envd, is the data server on every provider. The
data plane is specified for that daemon; a provider routes to it rather than
reimplementing it.
initialize
The response carries protocolVersion, plane: data, serverInfo, and:
| Field | Meaning |
|---|---|
daemonInstanceId |
Random per daemon start. A different value than on an earlier connection means the daemon restarted and the earlier instance’s process handles are gone. |
capabilities |
Capability families (below). |
limits |
The daemon’s ceilings: maxConcurrentRequests, maxWaitMs, and per-family process, job, and filesystem limits. |
defaultCwd |
The daemon’s working directory, the default cwd of commands. |
homeDirectory |
The home directory of the user commands run as, when it lies inside the filesystem root. |
A present filesystem capability covers fs/*, including search, glob and
scan. filesystem.transfer separately enables transfer/*; an omitted flag
means transfers are unavailable. process covers process/*; job covers
job/*. filesystem.write is false on a daemon started read-only, and
process.pty says whether process/start accepts pty. A
request that needs an absent family or fact is capabilityUnavailable before
any side effect.
Methods
| Method | Counts as use | Abortable | Needs | Purpose |
|---|---|---|---|---|
initialize |
No | Handshake. | ||
env/getIdle |
No | idleForMs since the last use, 0 while any process root or job runs or any leftover process group has live members, and diagnostic counts. |
||
$/cancelRequest |
No | Notification: abort a pending abortable request. | ||
fs/readFile |
Yes | filesystem |
Read at most maxBytes of a regular file from offset. |
|
fs/writeFile |
Yes | filesystem.write |
Write a file atomically, with create, ifMatch, and executable; answers the new version. |
|
fs/createDirectory |
Yes | filesystem.write |
Create a directory, optionally with its parents. | |
fs/getMetadata |
Yes | filesystem |
Describe one entry, optionally following a symlink. | |
fs/readDirectory |
Yes | filesystem |
List one page of a directory, sorted by name, with an opaque cursor. | |
fs/remove |
Yes | filesystem.write |
Remove a file, symlink, or directory without following links. | |
fs/rename |
Yes | filesystem.write |
Rename atomically, without replacing an existing destination unless overwrite is set. |
|
fs/searchText |
Yes | Yes | filesystem |
Search file contents below a root with a regular expression. |
fs/globFiles |
Yes | Yes | filesystem |
List non-directory entries below a root that match patterns. |
fs/scan |
No | Yes | filesystem |
Observe small trees below several roots, each reported on its own. |
transfer/begin, transfer/advance, transfer/getStatus, transfer/readInventory, transfer/append, transfer/listMissing, transfer/read, transfer/write, transfer/commit, transfer/abort |
Yes | filesystem.transfer (writes also filesystem.write) |
One capture, materialize, or file write as many bounded calls. | |
process/start |
Yes | process |
Start a process under a caller-chosen processId. |
|
process/read |
Yes | Yes | process |
Wait for and read a page of output and the process state. |
process/write |
Yes | process |
Queue bytes for a pipe or PTY input. | |
process/terminate |
Yes | process |
Interrupt, terminate, or kill the owned group or job using the advertised mechanisms. | |
job/start |
Yes | job |
Start jobs, or replay an identical earlier start. | |
job/read |
Yes | Yes | job |
Read jobs’ summaries and output pages. |
job/cancel |
Yes | job |
Cancel jobs and optionally their dependents. |
Methods that count as use reset the environment’s idle clock. Handshakes,
idle reports, cancellations, and scans do not, so discovery and a
provider’s idle policy can look at an environment without keeping it awake.
The idle clock also restarts when the last running process, job, or
leftover process group ends: a service that a command started and left
running keeps idleForMs at zero like running work.
Paths and confinement
A wire path is absolute, in the environment’s own filesystem namespace: the
one its processes see. The daemon’s filesystem root is not a chroot. With the
root narrowed to /workspace, /workspace/src names that directory and
/etc lies outside the root.
One rule applies to every method and to every command’s cwd:
- A path that is neither the root nor below it is
permissionDenied(outsideRoot). - The daemon resolves the remaining segments one at a time beneath the root. A symlink is followed only while its target stays inside the root; a link that leaves it is refused the same way.
- No method addresses the daemon’s state directory. A path that names it or
lies inside it, and a remove, rename, write, or capture whose subject
contains it, is
permissionDenied(daemonState). Walks prune it.
Paths are UTF-8. POSIX spellings remain unchanged; Windows drive paths use an
uppercase drive and / separators (C:/work). Native conversion takes the
remote convention explicitly and renders Windows paths with backslashes.
UNC, drive-relative and device namespaces, alternate streams, reserved device
names and trailing dots/spaces are unsupported. Windows names also exclude
control characters and Win32-reserved punctuation. Path equality and prefixes
are lexical, not filesystem identity or authorization; confinement needs native
handles and identity checks. Entries that cannot be represented are skipped
and counted. Relative transfer paths retain their grammar but must be validated
against the destination convention before mutation. VFS paths are unchanged.
The native Windows backend opens path components relative to directory handles,
retains ancestors against rename and junction replacement, and excludes daemon
state by volume/file identity. Writes stage privately and preserve the target’s
DACL when replacing an existing file. New files retain the destination’s inherited
DACL. Same-directory rename is supported; cross-directory rename and
executable: true are capabilityUnavailable. Executable metadata is false on
Windows. Publication is atomic, but directory crash-durability is not promised.
Windows advertises filesystem.transfer: true. Captures, publication to a new
tree path, and atomic file writes are supported. Replacing an existing
materialized tree is capabilityUnavailable; the daemon never emulates an
atomic tree replacement by deleting the destination first.
Pattern grammar and walks
fs/searchText, fs/globFiles, fs/scan, and transfer captures share one
filter: include and exclude patterns, ignoreCase, includeHidden,
includeIgnored, and maxDepth. Patterns match paths relative to the walk’s
root, gitignore-style: * and ? match within one segment, ** spans
segments, [...] is a character class, and {a,b} an alternation. A pattern
without / matches the entry’s name at any depth; a leading / anchors it
to the root. An empty include selects everything, and an exclude that
matches a directory prunes it. The root’s direct children are depth 1.
By default, walks respect .gitignore, .ignore, and git excludes, and
skip names that start with .. includeIgnored and includeHidden opt out.
.git, .hg, .svn, .jj, and the daemon state directory are always
pruned. Entries a walk cannot read or represent are skipped and counted; they
never end the walk. A file larger than the remaining byte budget is skipped
and counted too.
Process output
Process and job capabilities advertise tree (posixProcessGroup or
windowsJobObject) and termination (gracefulThenKill or immediateKill).
Immediate termination has zero grace. Processes additionally advertise separate
interrupt.pipe and interrupt.pty flags; unsupported interruption is
capabilityUnavailable, never a substitute forced kill.
A Windows ownership unit is a non-breakaway Job Object attached before the
child runs. Normal process root exit keeps descendants; job root exit sweeps
them. Timeout, cancellation, environment close and daemon death terminate the
owned tree. The daemon keeps non-inheritable kill-on-close handles while any
owned descendants survive, independently of retained output. Restart never
adopts raw Windows PIDs. POSIX group and restart behavior is unchanged.
Windows returns native exit codes through the signed i32 code field, preserving
all 32 bits; it never represents Windows termination as a Unix signal.
Native pipe execution preserves bytes and supports closing stdin. ConPTY uses
UTF-8 terminal streams and supports Ctrl-C; pipe interruption is unavailable.
Closing ConPTY input with close: true is capabilityUnavailable, because
closing the pseudoconsole would terminate attached processes rather than supply
EOF. Terminal dimensions are set at start; the backend supports resize, but the
protocol currently has no resize method. Direct Windows execution requires an
.exe; scripts and batch files require an explicit interpreter.
A process has one combined output stream: stdout, stderr, or PTY output
interleaved in arrival order. Positions are byte offsets into it.
process/read returns a page of chunks, the next offset after the page,
the end offset of everything produced so far, and gaps for ranges it
cannot return (overflow when the retention cap dropped them, expired when
they were released after delivery). A page cut at maxBytes loses nothing.
A read starts at after, at end - tailBytes, or at the daemon-owned
cursor when it names neither. A cursor read advances the cursor only after
the reply was written to the socket, so a lost connection never consumes
output. Reads at an explicit after or tailBytes never move the cursor,
so a second reader can follow without consuming. An optional ack advances
the cursor to an offset the caller has already processed, which gives a
caller that tracks positions exactly-once delivery. returnOn chooses when a
wait ends:
anyOutput, window (enough bytes for maxBytes), or exit. Every mode
returns when waitMs elapses.
state is running or exited. exited means the root process exited and
its output has settled. An exit is {kind: code, code} or
{kind: signal, signal: "SIGSEGV", coreDumped}, with signals named because
their numbers differ by platform. stoppedBy (timeout, terminate,
kill) is set when the daemon stopped a running root. A process that leaves
descendants running reports them as leftovers with disposition kept;
processes never sweep their group on a natural exit.
An unknown handle is notFound. An evicted handle is notFound with reason
expired, and a handle from before a daemon restart is notFound with
reason daemonRestarted.
Jobs
A job’s identity is its jobId together with the requestId of the
job/start that created it. A start that repeats an existing job’s
requestId and spec replays it; any other reuse of the id is
failedPrecondition (jobIdInUse). Statuses are queued, running,
succeeded, failed, timedOut, cancelled, dependencyFailed, and
interrupted. Dependencies name a job id or a name within the same request.
Jobs that share a queueKey run one at a time, in start order. At job
completion, the daemon sweeps the job’s owned group or Job Object and reports
what it stopped as leftovers with disposition swept.
job/read takes a list of entries, each reading one job from after or the
last tailBytes; a job may appear twice to read its head and tail in one
call. The request’s maxBytes is a total budget across entries. An unknown
job is answered with a per-entry notFound error.
Files and transfers
fs/writeFile stages the new content next to the final target and renames
it into place, so readers see the old or the new file and never a partial
one. An in-root symlink at the path is followed to its final target. An
existing target keeps its mode. ifMatch makes the write conditional on the
version a read returned.
A transfer moves a whole file or tree in bounded calls under a
caller-chosen operationId. Retrying a call with the same input is safe. A
capture reads a tree out of the environment; a materialize replaces a
destination tree atomically at commit; a file write replaces one file with
fs/writeFile semantics. Content moves by sha256 digest, so content
already present is not sent again. The first failure moves the operation to
failed; later calls answer that error and nothing is committed. Finished
operations keep a receipt for the advertised retention period. The
transfer module
documents the phases.
Windows validates incoming inventory names against its native path rules and
rejects case-colliding paths, including collisions across inventory pages.
Parents must precede children with the same spelling. Rejected pages leave the
inventory unchanged. Unix executable bits cannot be represented on Windows:
executable: true in a write selection or inventory is capabilityUnavailable.
VFS tree paths keep their portable grammar; these checks occur when publishing
into Windows.
Windows stages the selected root beside its destination and retains ancestor
handles through publication. Staging is private to the execution account and
SYSTEM. Materialized trees retain that protected ACL; file writes restore the
existing target’s DACL or, for a new file, its inherited DACL. File writes
recheck create and ifMatch at commit. Existing-tree materialization is
refused at begin, or at commit if a destination appeared during the operation.
Transfer journals tag native path encoding: Unix bytes or Windows UTF-16LE. Legacy untagged records are Unix records. Foreign or undecodable records are quarantined, never interpreted as native cleanup paths. A daemon restart fails interrupted operations and retries staging/spool cleanup; a sharing violation retains the cleanup record even after its receipt expires. A restart during commit reports an uncertain outcome because publication may already have occurred. Completed captures retain their inventories with their receipts. Windows flushes journal files before write-through replacement; this recovery contract covers daemon restart and does not promise directory durability after power loss.
Registration channel
An outbound daemon dials
wss://<gateway>[/prefix]/environment-gateway/connect and keeps that control
connection open. The daemon is the JSON-RPC client and the gateway the
server; both directions carry requests and notifications.
gateway -> daemon notification registration/challenge {protocolVersion, nonce} always firstdaemon -> gateway request registration/register {protocolVersion, daemonPublicKey, signature, registrationKey?, displayName?, metadata?, implementation}gateway -> daemon response {receipt, heartbeatIntervalMs} or an errorgateway -> daemon request registration/openStream {token, stream} any number of timesdaemon -> gateway response {} once the stream socket is upgraded, or unavailabledaemon -> gateway notification registration/goodbye {reason: shutdown} before a deliberate stopgateway -> daemon notification registration/closed {reason, message?} superseded or environmentClosedRules:
- The challenge is frozen. A daemon reads its
protocolVersionbefore decoding anything else, and a mismatch leads to an upgrade or a refusal, never to a decode loop. - The daemon signs
"lightspeed-envd-registration/v2" ‖ nonce ‖ sha256(origin)with its Ed25519 key, whereoriginis the canonical WebSocket origin of the gateway URL it dialed. The gateway accepts a signature over any of its configured registration origins: the canonical origin of its public base URL, plus any further registration URLs the operator lists, such as a cluster-internal service address. Every other origin is refused, so a gateway cannot relay another gateway’s challenge. - The registration key is sent only until the daemon’s first admission.
displayNameandmetadataare read only then. - Before admission the gateway accepts only
registration/register. Out of bounds build facts, display names, or metadata refuse admission withinvalidRequest; other undecodable params areinvalidParams. - The receipt names
environmentId,daemonId,controlConnectionId,identityMode(persistentorephemeral), andadmission(createdorreconnected). registration/goodbyefrom a persistent identity marks its environment disconnected; the environment stays open and waits for the daemon to return. From an ephemeral identity, it closes the environment.- For each
registration/openStream, the daemon dials the stream URL derived from its configured connect URL, replacing the/environment-gateway/connectsuffix with/environment-gateway/data, with the one-time token as a bearer header, and serves the data plane on that socket. The gateway never names a URL. - The gateway pings every
heartbeatIntervalMs. Either side treats three intervals without any frame as a dead connection. - The newest connection for a daemon identity wins. The gateway sends
registration/closedwith reasonsupersededto the older one before closing it. - When the environment closes while its daemon is connected, the gateway
sends
registration/closedwith reasonenvironmentClosed. A closed identity that registers again is refused withfailedPrecondition(environmentClosed). Either way the identity is spent: the daemon exits or parks, as configured, and never reconnects.
The daemon’s exit status says why it stopped: 0 after a deliberate shutdown,
1 for an unexpected failure, 2 for a local configuration or receipt-file
error, 3 for a terminal
registration refusal or an unsupported gateway protocol, 4 when its
environment was closed, and 5 when another process with the same identity
superseded it. A daemon configured to park on close instead stops
registering and listening, waits for SIGTERM or SIGINT, and then exits
with 0. Retryable refusals, disconnects, and heartbeat timeouts never
end the process.
Conformance
Two suites check an implementation against this contract without provisioning anything else:
- The controller suite,
environment_client::conformance::controllerbehind the client crate’sconformancefeature, drives any controller URL with one template, and with the credential when the provider uses one. It checks the handshake rules, the credential refusal, per-owner create idempotency, puretarget/get, owner fencing, close by owner and its repetition, strict decoding of every observation, andcapabilityUnavailablefor methods the controller does not advertise. It creates and closes one target. - The data-route helpers,
environment_client::conformance::data_route, check that a refused data route answers a typed body with the status it names, a 503 withRetry-After, within the hold bound. - The data suite,
tools::environment_protocol::conformance, drives a data endpoint and checks file writes and reads, confinement, walks, scans, process output and exits, and job identity. When transfers are advertised it also checks capture, chunked file publication, stale-version rejection at commit, and receipt replay. It works in a fresh directory and removes it afterwards.