Connected Compute contracts
@hellmai/lumenflow-control-plane-sdk’s connected-compute/ module
generalises the private-MCP assignment and typed-result
wire contract to every Connected Compute assignment, and adds
a runner-side content-safety guard that contract never had.
Assignment and result contract
Section titled “Assignment and result contract”Every assignment and result shares a correlation envelope —
workspace_id, assignment_id, invocation_id, connector_binding_id,
connector_class, manifest_revision/manifest_digest,
idempotency_key, fencing_generation, and receipt_id — plus an
opaque, bounded, connector-specific payload.
connector_class is the open extension point that makes the envelope
connector-agnostic — 'private_mcp' is the only class implemented today;
it’s a bounded label rather than a fixed enum so a future
connector class doesn’t require re-versioning this schema.
A typed result carries a terminal status (succeeded / failed),
structured_result or a typed error, artifact_references and
evidence_references, usage and cost, and — on failure — an
error_class/retryable pair drawn from the
contract spine’s error taxonomy.
connectedComputeResultMatchesAssignmentFencingV1 proves a result was
minted under the same ownership epoch the runner was issued; a caller must
reject a result whose fencing generation doesn’t match rather than
resuming from it.
This is a generalisation, not a fork: the private-MCP shape fits inside this
envelope unmodified.
connectedComputeResultV1FromPrivateMcpResultCommitV1 projects a private
MCP result commit onto the generalised contract.
Never-send guard
Section titled “Never-send guard”The dossier states Cloud must never send, inside a Connected Compute assignment payload: shell commands, executable paths, private URLs, raw credentials, unbounded code, or environment contents. The never-send guard is the runner-side content check that enforces exactly that — layered on top of, not instead of, the shape validation above.
The guard walks every string leaf of the payload and fails closed — its
only two outcomes are allowed and rejected; there is no “uncertain,
allow” branch. It detects the canonical shape of each of the six
forbidden classes by signature (fixed patterns and structural checks),
not by exhaustive classification:
| Forbidden class | Reason code | Detects |
|---|---|---|
| Shell command | NEVER_SEND_SHELL_COMMAND | sh -c, cmd /c, powershell -command, or a leading #! shebang |
| Executable path | NEVER_SEND_EXECUTABLE_PATH | A Windows .exe/.bat/.cmd/.ps1/.dll path, or a POSIX /bin//sbin path or .sh/.exe/.dylib/.so file |
| Private URL | NEVER_SEND_PRIVATE_URL | A URL whose hostname resolves to loopback, RFC1918 private space, link-local, or .local/.internal |
| Raw credential | NEVER_SEND_RAW_CREDENTIAL | PEM private-key material, a cloud access or secret key, a JWT, a URL password, a vendor API or chat bot key, or a bearer token of 20 or more characters |
| Unbounded code | NEVER_SEND_UNBOUNDED_CODE | A multi-line blob containing a recognisable function/class/import construct |
| Environment contents | NEVER_SEND_ENVIRONMENT_CONTENTS | Three or more consecutive KEY=value lines |
Detector order is fixed: the guard reports the first forbidden class it finds, so a payload matching more than one class is always reported under the same reason code.
Stable local execution
Section titled “Stable local execution”compute:connect, compute:run, and compute:doctor are stable. Connect defaults
to local mode; no remote credential or network call is required:
The workspace must have workspace.yaml and an active WU claim in the exact
checkout. The assignment is a regular, non-symlink JSON file up to 1 MiB:
assignment_id, wu_id, nullable job_id, lane, required_capabilities,
confinement_posture, and payload containing the ask, steps, connections,
rules, approvals, proof, assignment_payload, and trigger_context records.
For a preplanned read, assignment_payload.local_tool_calls contains
{"tool_name":"fs:read","input":{"path":"README.md"}}. Trusted WU code_paths,
workspace/lane scopes, and physical confinement govern execution. Current local
tools require Linux, Bubblewrap on PATH with descriptor-bind support, and user
namespaces; unsupported hosts report them
unavailable and retain configuration, doctor and idle polling. There is no
reduced-posture fallback for local mutation. The file
cannot supply lease identity or grant tool authority. Without --assignment-file,
the local runner polls healthy idle.
Connect stores mode: "local", the canonical workspace root, and contract
version 1.0.0 in private runtime.json. Supervisors pin this version. Doctor
checks the same local host without probing a cloud endpoint. An incompatible
configuration fails checks and needs reconnecting with the matching runner.
--experimental is accepted as a deprecated compatibility flag with the same
behavior.
Leases expire 60 seconds after their latest committed claim or fenced heartbeat.
Renewal preserves the generation; expiry takeover increments it and rejects
old-owner calls. Tools produce canonical receipts and the existing RunAggregate
atomically stores terminal evidence/outbox; restart drains it without re-executing
work. Pass --control-file /absolute/path/to/control.json to observe
{"assignment_id":"local-read-1","pending_control_action":"kill"} during lease
renewal: an in-flight tool finishes, the next call is refused, and terminal
failure carries its receipt. Event stores and lease timers close on exit.
Runtime shutdown and failed startup
Section titled “Runtime shutdown and failed startup”Shutdown stops new execution before waiting for admitted operations. If a cleanup step fails, the runtime stays closed and retains the same owner’s unfinished cleanup for retry. Successfully completed shutdown steps are not repeated.
Code assignments, preplanned local tools and playbook jobs retain cleanup ownership even when startup fails before returning a binding. They retry that cleanup before reporting terminal failure; a failed retry remains a visible error with its original cleanup owner. A final-close failure also preserves the binding for retry. These guarantees apply while the owning process is alive.
For supported Linux Bubblewrap workers, a separate bounded custody path tracks sandbox resources. It admits a bounded number of bindings per operating-system principal (see Worker custody pool size), with software limits of 128 MiB, 20,000 files, 64 packages, and depth 24 per binding. If native cleanup evidence is missing or uncertain, the slot stays occupied and later work is refused with a recovery-required result; while the original owner remains live, cleanup retries stay with that owner. Time passing or a process disappearing does not reclaim the slot. These limits are software allocation bounds, not disk quotas. No command or flag changes are needed.
Before releasing a slot, the original custodian checks every entry in its sealed worker and proxy registration list, including consumers never launched or with an uncertain launch. Each needs authentic native completion status, matching resource identity, and an accounted disposition; an observer’s report alone is insufficient. This keeps the software-ceiling and supported-Linux limitations stated above.
When the run that owns a slot is gone (its host process has exited and no guard or recorded native child of any registered consumer is still alive), the custodian no longer waits for completion proof that cannot arrive. It releases its handles and exits, leaving the sealed slot counted. Exiting never deletes anything and is not a reclaim: there is still no automatic reclaim of an occupied slot.
Worker custody pool size
Section titled “Worker custody pool size”The number of custody slots a process may admit resolves in this order:
LUMENFLOW_WORKER_CUSTODY_SLOTSin the environment (for a host operator, for example on a build runner’s service unit or container).software_delivery.worker_custody.slotsinworkspace.yaml, set withpnpm config:set --key software_delivery.worker_custody.slots --value 8.- A capacity-aware default, derived from measurement: memory (the container
limit from cgroup v2
memory.maxor v1memory.limit_in_byteswhen one is set, otherwise total memory) divided by 4 GiB, further limited by the custody filesystem size divided by one slot’s budget when/var/tmpis a tmpfs, never below 2 and never above 32. A slot’s budget is the verified artifact tree size plus 7 MiB of custody metadata. The tree size is the largest slot tree measured so far; until one has been measured, the 128 MiB staging ceiling is used. Where/var/tmpis not a tmpfs, and on macOS and Windows (which have no cgroup), the default uses memory only.
The resolution reports each default input and where it came from
(formatWorkerCustodyResolution), for example memory=8589934592 (cgroup v2 memory.max) tmpfs=268435456 (/var/tmp tmpfs size) tree=16000000 (...) metadata=7340032. A 256 MiB tmpfs with a 16 MB tree budgets about 23 MiB per
slot, so it admits more than 2 slots when memory allows.
A configured value must be a whole number from 1 to 32. Anything else refuses
with worker_custody_config_invalid before any slot is allocated. A 62 GiB
host without a tmpfs /var/tmp and without a container limit defaults to 15
slots; in a container limited to 8 GiB the same host defaults to 2.
There is one custody pool per user per /var/tmp, so one per container. The
maximum of 32 slots applies to each pool and is not configurable; to run more
concurrent bindings than one pool holds, run more containers, each with its own
pool and its own tmpfs.
The size only bounds admission. Slots are the fixed directories slot0 to
slot31 under the custody pool, each created exclusively, so a process sized
N only ever creates slot0 to slot(N-1). Two processes configured with
different sizes together hold at most the larger size, and a slot a live owner
holds is never stolen or reset at any size. When every slot a process may use
is held, it gets a typed busy error, ArtifactResourcePoolBusyError with the
code resource_pool_busy. It is a subclass of the existing recovery error and
keeps its message, which names the count (both for two slots, all N
otherwise). Match on the error code, not the message text. Its poolRoot and
per-slot diagnostics (open or closed, intent-record count, control-socket
entry present) are advisory hints only; no decision is made from them.
To wait instead of failing at once, pass slotWait: { timeoutMs, signal } to
createVerifiedWorkerRuntimeArtifacts (timeoutMs is a whole number from 0 to
1,800,000). It retries the same admission until another run releases a slot through
its normal cleanup, rejects with the busy error when timeoutMs elapses, and
rejects with the signal’s reason when aborted. Waiters in one process are
served first come, first served: only the oldest polls (25 ms growing to
250 ms) and the rest wait their turn, so a later caller never overtakes an
earlier one, and a caller whose deadline passes leaves the queue without
delaying those behind it.
A waiter never steals, resets or reclaims a slot: there is still no automatic
reclaim of an occupied slot.
Worker custody socket path
Section titled “Worker custody socket path”A Unix socket path longer than 108 bytes cannot be bound, and the kernel reports a bare EINVAL or
ENAMETOOLONG. Production custody is not exposed: it uses the fixed pool
/var/tmp/lumenflow-worker-custody-<uid>, whose control.sock path is well under the limit whatever
TMPDIR is. The exposure is a custody pool created under a deep temporary directory (the test and
conformance pools), and the egress proxy socket that runConfinedCommand and the subprocess network
confinement place under TMPDIR.
The custodian binds control.sock through the slot directory’s open descriptor, and each guard connects through that same directory after
checking it is still the slot’s inode. The socket keeps mode 0600, is owned by the user who owns the
slot, and the slot’s capture and collect checks and teardown are unchanged. If the custodian cannot
create the socket, the host’s start-up error says so (custody control socket cannot be created,
naming the 108-byte limit when that is the cause) instead of reporting only that the custodian IPC
disconnected.
The proxy socket directory is placed under TMPDIR when <dir>/p.sock fits, and otherwise under
a fixed per-user root, /tmp/lumenflow-sockets-<uid>. That root is checked before use (its parent is
root-owned and sticky, and it is your own private directory); if the check fails, or neither location
fits, or a custody slot’s socket directory is too deep, the launch refuses with
socket.scratch-root-unverified or socket.path-too-long, naming the cause and the 108-byte limit.
state:cleanup --scratch removes a crashed run’s leftovers from that root, but only your own private
entries whose owning process has ended. Gate checks that run tsx (the CLI docs drift check and the
parity suites in wu:prep) use the same root: under a deep TMPDIR, where tsx’s own socket path would
be too long, they get a short temporary directory there, so wu:prep no longer fails with
listen EINVAL.
Worker custody deadline
Section titled “Worker custody deadline”The host waits for each worker custodian acknowledgement, and the private custodian entry waits
for its guard configuration, launch grant, native version check and hello, within one bounded
budget: software_delivery.worker_custody.deadline_ms in workspace.yaml, set with
pnpm config:set --key software_delivery.worker_custody.deadline_ms --value 45000. It defaults
to 30000 (30 seconds), and must be a whole number of milliseconds from 5000 to 120000; anything
else refuses with worker_custody_config_invalid before any slot is allocated. There is no
environment variable and the budget is not scaled by load. If the budget expires, admission
still closes and the counted resources are retained, exactly as before; raise the value if a
heavily loaded host reports custodian acknowledgement is unknown.
A custodian that never answers keeps its slot until the waits end. Closing a run waits for the finish acknowledgement, the custodian exit and the guard exits in turn, each bounded by the budget, so the worst case is about three budgets: roughly 90 seconds at the default and 360 seconds at the maximum, against about 15 seconds with the former fixed waits. A healthy custodian is not affected. Lower the value if you prefer a faster refusal; 5000 restores the former behaviour.
Worker artifact staging accepts only regular files with a single link. If a
workspace dependency tree shares a filesystem with its package-manager store,
install it with copy imports (for pnpm,
pnpm_config_package_import_method=copy) so store hardlinks do not cross the
artifact boundary.
Optional HTTP runner enrolment in v7
Section titled “Optional HTTP runner enrolment in v7”Connected Compute credentials no longer belong in process arguments. Put the runtime credential in an uppercase environment variable and pass only its name:
The v6-era --token <runtime-token> form is rejected in v7 so the secret does
not enter shell history or process listings. Remote endpoints must use HTTPS
and must not include username/password URL userinfo. Plain HTTP remains
available only for explicit localhost, IPv4 127/8, or [::1] development
endpoints.
Join Cloud with a pairing code
Section titled “Join Cloud with a pairing code”A runner joins Cloud through the governed CLI, with no hand-written HTTP. The workspace owner mints a one-time pairing code; the runner exports it in an environment variable and names that variable:
--base-urlis required by design. The endpoint that receives the code and later the credential is always the one you typed; it is never read from an environment variable, and the stored endpoint is that same URL, not any URL in Cloud’s answer. The same HTTPS (or explicit loopback HTTP) rules apply.- The code is read only from the named variable. A code on the command line (
--pairing-code,--code) is refused without being echoed, an unset or empty variable is refused, and--pairing-code-envcannot be combined with--token-env. - The exchange is anonymous (the code authenticates it) and goes through the generated
runner-onboardingclient. The minted credential is written toruntime.jsonin the runner directory, atomically with user-only permissions (mode 0600 on Linux and macOS, a user-only ACL on Windows): it is staged in a fresh user-only directory beside the target and renamed into place. The write is refused when the runner directory belongs to another user (compared by SID on Windows), and every such check, including a symlink at that path, runs before the code is sent so a failing check never spends the code. The variable is removed from the process environment as soon as it is read, and neither the code nor the credential is ever printed. - Cloud text is never printed. Cloud’s refusals map to fixed, actionable text: an expired or already-used code (ask the owner for a new one), an onboarding policy that is missing or does not allow this platform, a different workspace, the active-runner limit, rate limiting, and an unreachable or failing Cloud. A policy refusal leaves the code unconsumed.
Runner onboarding client
Section titled “Runner onboarding client”A consumer that onboards runners through Cloud uses the generated
@hellmai/lumenflow-control-plane-sdk/cloud-routes/runner-onboarding client instead of raw HTTP: the
onboarding policy (read, write, confirm, remove), onboarding grants (issue, list, revoke), pairing
codes (createPairingCode) and the pairing exchange (exchangePairingCode) that trades a one-time
code for the runner credential. The exchange is the only place a runner credential is minted, and the
credential is shown once; pass it to the runner through an environment variable named by
--token-env (see above), never an argument. Details, scopes and the routes that still lack an
operationId are in Control plane SDK.
Sealed host lifecycle in v7
Section titled “Sealed host lifecycle in v7”Code assignments do not choose the command that decides gate completion. The
real compute-run CLI entrypoint captures its own installed wu-prep sibling
before accepting an assignment, then passes a sealed host closure through the
runner. The assignment workspace is only the command cwd: its package.json,
installed packages, bin metadata, scripts, and payload paths cannot replace the
host lifecycle entrypoint. Missing host authority or a failed gate refuses
immediate completion.
For code assignments, the host records the gate result in the assignment’s durable Agent Runtime session. A verified nonzero gate result can trigger an authenticated follow-up in that same session, with at most three accepted gate attempts. Missing or uncertain gate evidence remains a typed wait; it cannot authorize a retry or successful completion. Provider request, tool action, and optional USD budgets remain cumulative across resume and gate follow-up.
The session starts or resumes against the same immutable RunAggregate
task/run/session identity, and cold resume reuses accepted provider and tool
results. An operation with an unknown external outcome returns
reconciliation_required and waits. Only trusted host evidence bound to the
operation ID and input fingerprint may resolve the wait, either with the exact
completed output or with definitive proof of nonexecution that the old executor
cannot still complete. Model narration and continuation text cannot resolve it
or authorize another dispatch. Approved actions still pass through the kernel’s
authenticated approval path. These rules prevent blind replay; they do not
guarantee exactly-once effects in an arbitrary external service.
The optional non-negative agent_runtime.connected_compute.cost_limit_usd sets
the assignment’s USD ceiling. Before dispatch, the host must reserve a trusted
upper bound for each operation. The adapter computes it from the trusted
per-model prices in agent_runtime.models.<profile> (input bytes times the
input price, plus max_output_tokens times the output price); an assignment
payload cannot supply or change a price. A dispatch under the remaining bound
proceeds and one over it waits budget_exhausted with no provider request. A
profile without prices, or without an enforced output cap, waits with
cost_bound_unknown. The reservation is checkpointed and survives a cold
restart. Actual cost is provider usage (cached tokens included) times the
configured price, and unknown (null), never zero, when the price or usage
is missing. An Agent Runtime host-policy
halt is checkpointed and sticky across resume; model or continuation text cannot clear it or
authorize another turn. This assignment integration does not claim outer
initiative-supervisor orchestration. Linux worker resource custody follows the
bounded path described above; it does not establish recovery for every crash
interleaving.
Externally posted playbook jobs
Section titled “Externally posted playbook jobs”A Connected Compute assignment can also carry a kind (an event or playbook
name you choose, such as release-notes or incident-response) and params
(an opaque object of your own shape — commonly entity_id, event_type,
repo_url, codePaths, and a correlation object you want echoed back)
instead of a preplanned tool-call list. When it does, your runner executes
that playbook in the named repository under full LumenFlow governance: it
resolves (cloning or fast-forwarding, never a shell command from the
payload) a checkout for repo_url inside your runner’s own allowed
workspace roots, creates and claims a Work Unit there, and runs the same
governed agent loop and quality gates every code assignment runs, with a
mission prompt telling the agent which playbook to execute and for which
entity.
repo_url names a repository, never a filesystem path: only https://,
ssh://, and scp-like user@host:path remotes are accepted, and every
other form (including a file:// path, which would otherwise let a job
reach any local path your runner can read) is refused before the runner
touches disk or git. Reusing an existing checkout is also checked against
that checkout’s own origin remote, so two different repo_urls can never
collide onto the same reused directory. params.codePaths is required (a
non-empty array of paths relative to the repository root); there is no safe
“whole checkout” default, so a job posted without it fails closed rather
than running unscoped. A wu_id that collides with a Work Unit already on
disk is refused rather than overwritten, unless it is this runner’s own
prior write for the same assignment (a retried assignment is then reused,
not blocked).
The result you get back carries outputs.wu_id (and outputs.initiative_id
when the playbook implied a companion initiative), so you can look up exactly
what the runner did and where. Nothing is pushed anywhere on your behalf —
the checkout, the Work Unit record, and the branch all stay local to your
runner’s machine until you choose to publish them.
Local runner: reduced-posture approval and control/kill halt
Section titled “Local runner: reduced-posture approval and control/kill halt”The customer-machine runner applies two
additional governance rules on top of the contract above when executing a
preplanned local:tool_exec assignment:
- Reduced-posture mutation approval. For preplanned
local:tool_execassignments on a non-sandboxed (reduced) confinement posture, a mutation tool call (fs:write) only dispatches if the assignment’s own sealed Approvals section explicitly approved it. That approval value is remote input sealed into the envelope by the cloud claim response; the runner does not locally verify it. A missing or unset approval fails closed; read-only calls are never gated. This gate does not cover the code-class agent path. - Control/kill graceful halt. A pending control/kill action rides the
runner’s existing lease-renewal call as one additive field — no new
route, no change to claim semantics. The runner lets an in-flight tool
call finish, refuses to start the next one, and reports a terminal
failure carrying the assignment’s existing
fencing_generationrather than a false success. A halted assignment cannot be resumed by a stale or superseded runner process; resuming requires a fresh claim. - Per-assignment stop record. The runner also understands a control
plane stop record (
pending_stop) on receipt-ack, lease-renewal and heartbeat responses, and a control-onlystop_controlclaim response after a reconnect. When the record names the assignment and fence of a lease this process registered, the runner trips its governed stop first and synchronously (foragent:launchit also halts the worker, as a kill does), then sends a stop acknowledgement (stop_ackon the existing ack route) only after the trip is durable. If the local event store is blocked, no acknowledgement is sent and the redelivered record joins the same hold. The runner acknowledges only when it knows both its own workspace and runtime ids, which pairing now keeps inruntime.json; a runner paired before that still trips the stop but never acknowledges it. A record for another assignment or fence, or a malformed one, changes nothing. A record naming another workspace or runtime is ignored only when the runner knows its own ids and the record carries them; a runner that does not know them still trips the stop, checking assignment and fence alone, and never acknowledges it. A record with an unknown version or reason trips the stop but is not acknowledged. After a runner restart the registered leases are gone, so such a record is ignored and the lease expiry stays the backstop. The runner sends the opt-in headerX-LumenFlow-Control-Protocol: connected-compute-stop.v1on every request except its connect and doctor probe claims. The control plane’s delivery of these records is not live yet, so treat this as the runner half of the contract only.
Cloud-dispatched worker launch (agent:launch)
Section titled “Cloud-dispatched worker launch (agent:launch)”A runner can accept a ready Work Unit that Cloud dispatches and run it end to end under
normal local LumenFlow governance. Cloud only dispatches; claim, gates, review, publication
and wu:done all stay on the runner, and agent logins, provider keys and the LumenFlow licence
never leave it.
Enable it locally. The runner advertises agent:launch only when its own workspace config
allowlists it. Nothing Cloud sends can turn it on:
adaptersis the allowlist of adapter selectors.embeddedruns LumenFlow’s own governed agent loop (the direct worker mode);cliruns a runner-configured external agent CLI (see below).max_turns,max_tool_calls,max_attemptsandmax_wall_clock_msare local ceilings.standing_publication: grantis a standing local publication policy. Without it, a finished worker reports a typed publication wait and publishes nothing; Cloud can display that wait but never grants it.
The cli adapter. With cli allowlisted, the runner launches an external agent CLI in its
non-interactive print or exec mode. The launch.cli block fixes everything that runs; a Cloud
payload can select the adapter and nothing else:
executableis an absolute path andargsits fixed argv; the child starts withshell: falsein the claimed worktree, in its own process group. The prompt is the localwu:brief, delivered on stdin.- The child environment is the fixed allow-list plus the named
credential_envvariables, and nothing else. Values stay on the runner; the session, receipt and acceptance variables never reach it. - The agent only edits and commits. The host runs
wu:prepand publication, and a child exit never reports complete: a clean exit with no commit, a dirty worktree or a failed prep is a failure, and without a standing publication policy the run reports a typed publication wait. - Wall-clock (
max_wall_clock_ms, or the payload’s tighter value),no_progress_ms(any output counts as progress) and a Cloud kill each stop the agent and its whole process group. - On Linux the child runs under
wu-sandbox --box."sandbox":"none"is an explicit local opt-in that needs asandbox_opt_in_reason; it is written to the dispatch state as evidence before the unboxed child starts. Where the box is unavailable the assignment is released, not failed.
The payload is strict. An agent:launch assignment carries the WU id, an adapter selector
and optional numeric caps (max_turns, max_tool_calls, max_attempts, max_wall_clock_ms).
Any executable, argv, env, path or prompt field anywhere in the payload, and any unknown key,
fails the assignment before anything runs. The prompt is always the local wu:brief. Effective
caps are min(local, payload): a payload can only tighten a local ceiling.
What the runner does.
- Checks the allowlist and the host confinement posture, then reads the WU from
origin/main. - Claims a
readyWU withwu:claim --dispatch-key <assignment id>. A redelivered assignment adopts only its own claim (the dispatch key and worker id must both match) and never creates a second one; a WU claimed for another dispatch, or by hand, is released. - Authors the handoff from the claim and the full
wu:brief, stores it with private permissions, and runs the governed worker (the direct worker mode) as a child of the claimed worktree’s own LumenFlow install, so the worker resolves the worktree’s dependency graph. The runner keeps publication: the standing local policy, the exactwu:doneand the done check all run in the runner, never in the child. The lease keepalive covers the whole run. A kill control action or the wall-clock cap stops the worker and its whole process group. - Reports
completeonly after canonical done is confirmed onorigin/main, withactualCostUsd(ornullwhen unknown). A typed wait or an unknown outcome sends no terminal verb, so the lease lapses and a redelivery resumes.
A local refusal is a release, not a fail: a refused claim, busy custody slots, a WU that is not
ready, or a host that cannot confine a worker all requeue the assignment without a strike. Only a
payload or allowlist refusal, a worker failure, a kill or the wall-clock cap is a fail.
Cloud contract parity
Section titled “Cloud contract parity”Capabilities advertised. The runner advertises local tool execution
under two strings: the colon form local:tool_exec (the authorization gate
for local execution) and the hyphenated local-tool-exec (what an external
caller puts in an assignment’s required_capabilities to route local-tool
work here). Both are required on the wire and are not interchangeable, so
the runner always sends both together, and treats either spelling on an
assignment as a local-tool-exec assignment. GET /api/v1/sessions shows a
runner’s registered session with the exact set it last advertised; session
registration at /api/v1/sessions is opt-in for HTTP compute run (pass --register-session), because an unsolicited
registration can currently shadow the runner’s own session on the control
plane and make later claims fail. The local runner preserves the capabilities
its caller supplies and adds this local-tool pair when its local runtime is
available. It does not infer agent:code from local execution being enabled;
that capability must be requested explicitly.
Keepalive. A long-running assignment keeps its lease alive for the WHOLE execution, not only while one governed tool call is in flight: a background check every 30 seconds calls the lease-extend endpoint once less than half the lease’s TTL remains, and adopts the returned ownership generation and expiry for its own next call. If an extend response comes back terminal — the lease was reaped and re-queued to another runner, or a newer owner already took over — the runner aborts the assignment and reports a failure; it never reports success for work it no longer owns.
Result envelope a caller receives. Every completion or failure the runner reports now carries a typed, bounded envelope alongside the terminal call:
status(succeeded/needs_approvalon completion,failedon failure) and a human-readablesummaryof what ran (truncated to 4000 characters).outputs, an opaque object that always names thewu_idthe assignment worked and aninitiative_idwhen the assignment named one (bounded to 64 KiB serialized).evidence_refs, the evidence-receipt ids the run ingested while executing (bounded to 100 entries).correlation, echoed back unchanged from whatever correlation object the original assignment carried, when it carried one (bounded to 64 KiB).- On failure,
error: { reason_code, message }naming why.
An oversized value degrades to a small marker rather than being sent partially or dropped silently, so a runaway output never prevents a run from reporting its outcome. A caller that needs a stronger read on ownership handoff should treat a run that never reports back within its lease TTL the same way it already treats a crashed runner today — this envelope is additional detail on an outcome the lease/fencing contract already governs, not a replacement for it.
Repository snapshot for agent:code assignments
Section titled “Repository snapshot for agent:code assignments”An agent:code assignment may carry one optional, additive field. Assignments
without it behave exactly as before.
Before any model turn the runner downloads the archive into the assignment workspace, verifies it, extracts it, and commits a git baseline. The agent then edits that tree, and on completion the runner returns the patch and a test report as result artifacts. Every check fails closed: the assignment ends with the named reason below and the workspace keeps only what was already there.
- Transfer. Only
httpsURLs are accepted (plainhttpis accepted for loopback hosts only), with no embedded credentials and no redirects followed.size_bytesis checked against the runner’s archive cap (256 MiB by default) before the request, againstcontent-lengthon the response, and again while streaming. A body that outgrowssize_bytesaborts the transfer. - Verification before extraction. The received size must equal
size_bytesand the SHA-256 must equalsha256. Nothing is extracted until both match. - Safe extraction. The archive is untrusted even when its digest matches.
Extraction refuses path traversal, absolute and drive-letter paths, symlinks
or hardlinks that point outside the tree, writes through a symlink, device,
fifo and other special entries,
.gitentries at any depth, entries that would overwrite files already in the workspace, duplicate entries, more entries than the entry cap (50,000), more extracted bytes than the byte cap (1 GiB total, 256 MiB per file), and a gzip stream that decompresses past those caps. Hardlinks are written as independent copies and setuid, setgid and sticky bits are dropped. - Baseline. The runner creates the repository itself (git is run with no system or global configuration, no hooks and no signing) and commits the extracted tree as the baseline. A resumed run reuses the baseline of the same archive rather than downloading again.
- Returned artifacts. The result
outputs.repository_snapshotcarriesbaseline_commit,patch_sha256,patch_bytes,files_changedand atest_report(lumenflow.test_report/v1, recording the sealed gate run that authorised completion).artifact_refslistscode.patchandcode.test_reportentries with content-addressedsha256:URIs. The patch text itself is inline (patch,patch_inline: true) while it fits the result envelope’s 64 KiBoutputsbound; a larger patch is reported by digest withpatch_inline: falseand a local copy in the runner’s artifacts directory for the assignment.
| Reason | Meaning |
|---|---|
snapshot-descriptor-invalid | A field is missing, malformed or the format is not tar.gz. |
snapshot-url-refused | Not https/loopback, credentials in the URL, or a redirect was returned. |
snapshot-download-failed | Non-success status, unexpected content-encoding, or a transport error. |
snapshot-size-cap-exceeded | The declared size, content-length or the streamed body exceeded the cap. |
snapshot-size-mismatch | The received size differs from size_bytes. |
snapshot-sha256-mismatch | The archive digest differs from sha256. |
snapshot-malformed-archive | Bad gzip, header checksum or truncated archive. |
snapshot-path-traversal, snapshot-absolute-path, snapshot-invalid-path | An entry path leaves the tree or is not a plain relative path. |
snapshot-link-escape, snapshot-symlink-traversal, snapshot-symlink-unsupported | A link points outside the tree, an entry passes through a symlink, or the host cannot create symlinks. |
snapshot-device-entry, snapshot-unsupported-entry-type | A device, fifo or other special entry. |
snapshot-git-metadata-entry, snapshot-reserved-path, snapshot-duplicate-entry | An entry would write .git, overwrite existing workspace state, or repeat a path. |
snapshot-entry-limit-exceeded, snapshot-extracted-size-exceeded | Entry-count or extracted-byte caps were exceeded. |
snapshot-target-not-empty | The workspace already holds a different repository or snapshot. |
snapshot-baseline-failed, snapshot-patch-failed, snapshot-extract-failed | A local git or filesystem step failed. |
Hosted agent:code in a single-use microVM (control-plane integration)
Section titled “Hosted agent:code in a single-use microVM (control-plane integration)”A hosted control plane that boots one single-use microVM per job can run a code assignment end to end with one library call from inside that VM. The call is a package export, not a command:
What the call does, in order, and what it refuses:
- Attested microVM posture. The VM must carry
a host attestation for this exact
assignment_id. A host with no attestation returnsrefused/microvm-host-not-attested; an invalid, expired or wrong-assignment attestation returnsrefused/microvm-posture-refused. Nothing falls back to another mechanism. - Snapshot. The assignment’s
repository_snapshotis verified, extracted and committed as the baseline (see above). An assignment without one isrefused. - Egress proxy. The job’s exact
host:portallowlist (the argument, orassignment_payload.egress_allowlist) is validated all or nothing and handed to the egress proxy. The VM’s guest firewall drops everything except the proxy; the proxy admits only the allowlisted pairs at vetted public addresses. Each denial raises exactly onesandbox:violationevent (network_egress_blocked, with host, port and reason) toemitterand inresult.violations. Code run bycode:run_commandfinds the proxy socket atLUMENFLOW_EGRESS_PROXY_SOCKET. - Governed loop.
runGovernedAgentLoopruns over exactly the code-work catalog (fs:read,fs:list,fs:search,fs:edit,fs:write,code:run_command). Scope, policy and evidence apply as everywhere else; only nestedbwrapis skipped, because the VM is the boundary and a guest has no user namespaces.code:run_commandruns argv only, never a shell, with the command-name allowlist, a fixed working directory, a scrubbed environment, a timeout and an output cap. File scope is the snapshot’s own top-level paths. - Result. On
completed,result.resultOutputs.repository_snapshotcarries the patch (or its digest) and the test report, andresult.artifactRefslists thecode.patchandcode.test_reportartifacts, exactly as above. The test report recordssource: "agent-loop"for this entry.
The call returns typed refused or failed results for expected problems rather
than throwing, and always stops the proxy, closes the runtime and removes its
scratch space. The workspace directory holds workspace.yaml (the runner image
ships it, or the call writes a minimal one) and the runner’s state;
both stay out of the patch.
Adaptive claim cadence
Section titled “Adaptive claim cadence”compute:run’s claim loop backs off adaptively instead of polling at a flat
interval regardless of outcome. --poll-interval sets only the base
cadence; every claim result classifies into one of four outcome classes, and
each class backs off on its own schedule:
| Outcome | Trigger | Behaviour |
|---|---|---|
work | An assignment is delivered (200) | The only outcome that resets the backoff; re-polls immediately |
idle | No eligible work (204, or 404 with cloud code no_eligible_work) | Exponential backoff, capped at 5 minutes |
transient | 5xx, a transport error, a timeout, route-not-exposed, admission denial, or insufficient credit | Exponential backoff, capped at 5 minutes |
permanent | 401, or a 403 in the frozen code set (for example capability_mismatch) | Converges to and stays in a 30-to-60-minute band; never exits |
idle and transient share one attempt counter and back off on the same
growing schedule: a 204/no_eligible_work response that follows a run of
transient failures continues that backoff from the current attempt instead
of resetting to base. This is deliberate — if recovery reset every client
to base cadence, a fleet-wide outage’s end would storm the control plane in
the same second it comes back up. Only a real assignment (work) resets the
schedule.
A startup delay, uniform in [0, 4 * base), runs before the first claim so a
newly installed or just-restarted fleet does not synchronise its request
times against the control plane.
The server can override the local schedule. A Retry-After header (on a
429, or a paced 403/401) always wins, jittered by 10 percent and clamped to
one hour; an X-LumenFlow-Poll-After-Ms header on a 200/204 wins over the
local idle schedule next. Neither hint is required: a client that never
receives one runs the local adaptive default unchanged.
A permanent claim failure degrades the runner instead of crash-looping
it. Under a restarting supervisor (the unit compute:service installs uses
Restart=on-failure), exiting on an unrecoverable
403 just produces a fast crash-restart loop — worse than staying up at a
slow, bounded poll rate. Instead, the runner writes an operator-visible
runner-status.json (state polling, backoff, or degraded; the cloud
code and message; consecutive_failures; next_poll_at) to the runner
directory on every claim-loop state change, mode 0600, atomic replace,
never containing the credential.
Confinement posture wire values
Section titled “Confinement posture wire values”The confinement_posture the runner sends and the assignment carries keeps its three accepted values
(linux-default, stronger, reduced), and the pairing exchange’s effective_isolation keeps the same
three. None is removed or renamed. The one addition is the optional confinement_admission object, described
below. Their meaning in the closed posture
vocabulary (enforced, refused, waived) is:
| Wire value | Posture | Engine id (mechanism) | Sent by the local runner |
|---|---|---|---|
linux-default | enforced | lumenflow.bwrap | Yes. It is the only value the runner produces today. |
stronger | enforced | lumenflow.microvm | No. The attested microVM runner is a separate path. |
reduced | waived if the operation ran, else refused | none | No. A mutating tool call under it needs the assignment approval. |
linux-default describes the host’s kernel layer. It does not describe how each allowlisted call is confined:
fs:read and fs:write run in-process. The gate is unchanged: Linux with bwrap found advertises
linux-default and runs, even where the kernel layer reports a refusal (for example a bwrap older than
0.8.0). compute:doctor shows the kernel layer’s typed reason and notes that the capability is still
advertised. compute:doctor also prints the full posture (engine, admission verdict, receipt
identity) in its Sandbox Posture section, which matches lumenflow:doctor and the kernel’s launch
receipt. That richer report stays on the host; what the runner sends on the wire is limited to the posture
value and the optional object below, and the runner’s messages are the same text as before. The section is
display-only and changes no exit code or ok.
The section also reports a launch user who is in a root-equivalent group. Membership of docker, lxd or disk
already gives root without a password (start a container that mounts the host root, or open the raw block device),
so it bounds every confinement guarantee on the host. For each such group, lumenflow:doctor prints a finding that
names the group and the remedy: run agents as a user outside it, or use a rootless container runtime. It prints
nothing when the user is in none. sudo, wheel and admin are not counted: they need a password, so membership
alone is not root. A host that grants passwordless sudo adds those groups with root_equivalent_groups in the
root-owned engines.yaml host trust file. The groups are read from the host group database for the process uid, never from an environment variable. On macOS, Windows or an unreadable database
the line says unknown instead of staying silent, because the host was not examined. wu:sandbox --admit records
the same memberships in the signed claim as the root_equivalent_groups condition. It is recorded and never gated,
and a claim signed before the condition existed still verifies.
The optional confinement_admission object
Section titled “The optional confinement_admission object”When the host holds an engine admission record, the runner sends one extra object beside the posture, on the
pairing exchange (beside effective_isolation), on the claim and on the heartbeat (beside
confinement_posture). It is a generic control-plane contract: it names no consumer, and the posture enums above
are untouched.
- Absent without a record. The key is omitted unless a host-signed engine claim verified for the kernel layer. A host with no engine admission claim, a claim that does not verify, or a kernel running inside a box sends nothing. A host with a verified kernel claim sends the object. A host that has never been admitted sends none.
- Verdicts.
enforcedis a verified claim on a host whose kernel layer is enforced.refusedis a claim that verified but does not bind this launch (for example the engine runs from an untrusted path), or a posture the host refused.waivedis a posture the operator waived. Theexpires_atis the claim’snotAfter; a claim lives at most 24 hours, and a receiver should treat an expired record as absent. - Read afresh, and expiry is enforced by the sender. The runner derives the record anew for every claim and every
heartbeat that states the posture, rather than once at startup. A record whose
expires_athas passed is not sent (it counts as absent), and a verdict that changed since the runner started (for example a claim that no longer binds the host’s artifacts) is the one sent. The record never feeds the posture value. - A verdict never raises a posture. The runner computes
effective_isolationandconfinement_posturefrom the host’s launch preconditions alone and reads the admission record afterwards; the object is only attached. A receiver must not raise a posture from it either. - Strict shape. All seven fields are required and no other key is allowed: the digests are 64 lowercase hex
characters, the engine ids are
vendor.engine, the instants are UTC, andexpires_atis afteradmitted_at. A malformed object is dropped, never sent half formed.
The SDK types are ConfinementAdmission and parseConfinementAdmission in
@hellmai/lumenflow-control-plane-sdk/runtime-claim. The pairing exchange carries the object through the typed
extensions input (extensions: { confinement_admission }).
The sidecar, doctor, and a pre-trust_tier workspace pack
Section titled “The sidecar, doctor, and a pre-trust_tier workspace pack”A workspace pack manifest that predates trust_tier — the shape a pre-rc.1
consumer workspace ships — still cannot have its in-process telemetry
mapper imported (see Pack Contract v2’s
trust-tier table, for the guard that never relaxes). Two paths care
about that refusal
differently:
- The control-plane sidecar (spawned
detached with
stdio: 'ignore'by everywu:claim) is telemetry’s actual job path — it must survive. Itsmain()skips the untrusted source instead of throwing, then records the skip to its last-tick file (skipped_telemetry_mappers) before entering its normal poll loop, so the skip is visible even though the process has no console. compute:doctor’s telemetry-sync check (and the mainlumenflow doctor, which shares it) is a readiness signal, not a telemetry enforcement point. It reports the skip as a warning — naming the skipped source, from its own direct check AND from whatever the sidecar’s last-tick file already recorded — and exits with its other checks unaffected, instead of either a fatal error or a silent green. It is also STRUCTURED, not just a message string: a skipped source reportsstate: 'healthy-with-skips'distinct from plain'healthy', so a consumer that checksstate(notmessagetext) can still tell the two apart — the samepassed: trueplus a distinctstateidiom the runner status already uses forbackoff/stale.
Telemetry-is-the-gate paths — the wu:done telemetry gate — keep the
strict, fail-closed behavior: they reject on the same condition instead of
skipping it.
Connect reachability probe
Section titled “Connect reachability probe”lumenflow compute connect checks that the control plane is reachable before
it stores a runtime credential. The check claims under a session id derived
from that credential (a SHA-256 prefix). The same credential keeps one id; a
second credential in the same workspace gets a different id, so the probe does
not trip runtime ownership held for the first credential. The id is not the
credential itself.
Related
Section titled “Related”- Contract spine — the shared error taxonomy Connected Compute results classify against.
- Runner deployment — the container and Kubernetes deployment modes that run this contract’s assignments.