Skip to content

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.

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.

import {
  ConnectedComputeAssignmentV1Schema,
  ConnectedComputeResultV1Schema,
  connectedComputeResultMatchesAssignmentFencingV1,
} from '@hellmai/lumenflow-control-plane-sdk';

const assignment = ConnectedComputeAssignmentV1Schema.parse(rawAssignment);

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.

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.

import {
  evaluateNeverSendGuardV1,
  assertNeverSendGuardV1,
  NEVER_SEND_REASON_CODE,
} from '@hellmai/lumenflow-control-plane-sdk';

const verdict = evaluateNeverSendGuardV1(assignment);
if (verdict.verdict === 'rejected') {
  // verdict.forbidden_class, verdict.reason_code, verdict.path
}

assertNeverSendGuardV1(assignment); // throws NeverSendGuardViolationError on rejection

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 classReason codeDetects
Shell commandNEVER_SEND_SHELL_COMMANDsh -c, cmd /c, powershell -command, or a leading #! shebang
Executable pathNEVER_SEND_EXECUTABLE_PATHA Windows .exe/.bat/.cmd/.ps1/.dll path, or a POSIX /bin//sbin path or .sh/.exe/.dylib/.so file
Private URLNEVER_SEND_PRIVATE_URLA URL whose hostname resolves to loopback, RFC1918 private space, link-local, or .local/.internal
Raw credentialNEVER_SEND_RAW_CREDENTIALPEM 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 codeNEVER_SEND_UNBOUNDED_CODEA multi-line blob containing a recognisable function/class/import construct
Environment contentsNEVER_SEND_ENVIRONMENT_CONTENTSThree 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.

compute:connect, compute:run, and compute:doctor are stable. Connect defaults to local mode; no remote credential or network call is required:

pnpm compute:connect --workspace-root /absolute/path/to/claimed-worktree
pnpm compute:doctor
pnpm compute:run --assignment-file /absolute/path/to/assignment.json --max-iterations 1

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.

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.

The number of custody slots a process may admit resolves in this order:

  1. LUMENFLOW_WORKER_CUSTODY_SLOTS in the environment (for a host operator, for example on a build runner’s service unit or container).
  2. software_delivery.worker_custody.slots in workspace.yaml, set with pnpm config:set --key software_delivery.worker_custody.slots --value 8.
  3. A capacity-aware default, derived from measurement: memory (the container limit from cgroup v2 memory.max or v1 memory.limit_in_bytes when 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/tmp is 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/tmp is 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.

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.

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.

Connected Compute credentials no longer belong in process arguments. Put the runtime credential in an uppercase environment variable and pass only its name:

export LUMENFLOW_CONNECTED_COMPUTE_TOKEN='<runtime-token>'
pnpm compute:connect \
  --base-url https://cloud.example.com \
  --token-env LUMENFLOW_CONNECTED_COMPUTE_TOKEN

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.

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:

export RUNNER_PAIRING_CODE='<one-time-code>'
pnpm compute:connect \
  --base-url https://cloud.example.com \
  --workspace-id <workspace-id> \
  --pairing-code-env RUNNER_PAIRING_CODE
  • --base-url is 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-env cannot be combined with --token-env.
  • The exchange is anonymous (the code authenticates it) and goes through the generated runner-onboarding client. The minted credential is written to runtime.json in 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.

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.

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.

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_exec assignments 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_generation rather 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-only stop_control claim 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 (for agent:launch it also halts the worker, as a kill does), then sends a stop acknowledgement (stop_ack on 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 in runtime.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 header X-LumenFlow-Control-Protocol: connected-compute-stop.v1 on 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:

pnpm config:set --key agent_runtime.connected_compute.launch --json-value \
  '{"adapters":["embedded"],"max_turns":12,"max_attempts":3,"max_wall_clock_ms":3600000,"standing_publication":"grant"}'
  • adapters is the allowlist of adapter selectors. embedded runs LumenFlow’s own governed agent loop (the direct worker mode); cli runs a runner-configured external agent CLI (see below).
  • max_turns, max_tool_calls, max_attempts and max_wall_clock_ms are local ceilings.
  • standing_publication: grant is 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:

pnpm config:set --key agent_runtime.connected_compute.launch.cli --json-value \
  '{"executable":"/usr/local/bin/agent","args":["-p"],"credential_env":["AGENT_API_KEY"],"no_progress_ms":600000}'
  • executable is an absolute path and args its fixed argv; the child starts with shell: false in the claimed worktree, in its own process group. The prompt is the local wu:brief, delivered on stdin.
  • The child environment is the fixed allow-list plus the named credential_env variables, 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:prep and 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 a sandbox_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.

  1. Checks the allowlist and the host confinement posture, then reads the WU from origin/main.
  2. Claims a ready WU with wu: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.
  3. 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 exact wu:done and 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.
  4. Reports complete only after canonical done is confirmed on origin/main, with actualCostUsd (or null when 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.

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_approval on completion, failed on failure) and a human-readable summary of what ran (truncated to 4000 characters).
  • outputs, an opaque object that always names the wu_id the assignment worked and an initiative_id when 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.

{
  "repository_snapshot": {
    "url": "https://snapshots.example.com/acme-widgets-3f2a.tar.gz",
    "sha256": "<64 lowercase hex characters>",
    "size_bytes": 1048576,
    "format": "tar.gz"
  }
}

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 https URLs are accepted (plain http is accepted for loopback hosts only), with no embedded credentials and no redirects followed. size_bytes is checked against the runner’s archive cap (256 MiB by default) before the request, against content-length on the response, and again while streaming. A body that outgrows size_bytes aborts the transfer.
  • Verification before extraction. The received size must equal size_bytes and the SHA-256 must equal sha256. 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, .git entries 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_snapshot carries baseline_commit, patch_sha256, patch_bytes, files_changed and a test_report (lumenflow.test_report/v1, recording the sealed gate run that authorised completion). artifact_refs lists code.patch and code.test_report entries with content-addressed sha256: URIs. The patch text itself is inline (patch, patch_inline: true) while it fits the result envelope’s 64 KiB outputs bound; a larger patch is reported by digest with patch_inline: false and a local copy in the runner’s artifacts directory for the assignment.
ReasonMeaning
snapshot-descriptor-invalidA field is missing, malformed or the format is not tar.gz.
snapshot-url-refusedNot https/loopback, credentials in the URL, or a redirect was returned.
snapshot-download-failedNon-success status, unexpected content-encoding, or a transport error.
snapshot-size-cap-exceededThe declared size, content-length or the streamed body exceeded the cap.
snapshot-size-mismatchThe received size differs from size_bytes.
snapshot-sha256-mismatchThe archive digest differs from sha256.
snapshot-malformed-archiveBad gzip, header checksum or truncated archive.
snapshot-path-traversal, snapshot-absolute-path, snapshot-invalid-pathAn entry path leaves the tree or is not a plain relative path.
snapshot-link-escape, snapshot-symlink-traversal, snapshot-symlink-unsupportedA link points outside the tree, an entry passes through a symlink, or the host cannot create symlinks.
snapshot-device-entry, snapshot-unsupported-entry-typeA device, fifo or other special entry.
snapshot-git-metadata-entry, snapshot-reserved-path, snapshot-duplicate-entryAn entry would write .git, overwrite existing workspace state, or repeat a path.
snapshot-entry-limit-exceeded, snapshot-extracted-size-exceededEntry-count or extracted-byte caps were exceeded.
snapshot-target-not-emptyThe workspace already holds a different repository or snapshot.
snapshot-baseline-failed, snapshot-patch-failed, snapshot-extract-failedA 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:

import { runMicrovmAgentCode } from '@hellmai/lumenflow-cli/microvm-agent-code';

const result = await runMicrovmAgentCode({
  assignment, // an agent:code assignment carrying repository_snapshot
  workspaceRoot: '/work', // the VM's workspace directory
  model: { profile: 'primary', url: 'https://provider.example.com/v1/chat' },
  allowedCommands: ['node', 'git'], // bare names code:run_command may run
  jobAllowlist: ['registry.example.com:443'], // exact host:port pairs, per job
  emitter: (event) => forward(event), // receives every sandbox:violation
});
// result.kind: 'completed' | 'refused' | 'failed'

What the call does, in order, and what it refuses:

  1. Attested microVM posture. The VM must carry a host attestation for this exact assignment_id. A host with no attestation returns refused / microvm-host-not-attested; an invalid, expired or wrong-assignment attestation returns refused / microvm-posture-refused. Nothing falls back to another mechanism.
  2. Snapshot. The assignment’s repository_snapshot is verified, extracted and committed as the baseline (see above). An assignment without one is refused.
  3. Egress proxy. The job’s exact host:port allowlist (the argument, or assignment_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 one sandbox:violation event (network_egress_blocked, with host, port and reason) to emitter and in result.violations. Code run by code:run_command finds the proxy socket at LUMENFLOW_EGRESS_PROXY_SOCKET.
  4. Governed loop. runGovernedAgentLoop runs 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 nested bwrap is skipped, because the VM is the boundary and a guest has no user namespaces. code:run_command runs 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.
  5. Result. On completed, result.resultOutputs.repository_snapshot carries the patch (or its digest) and the test report, and result.artifactRefs lists the code.patch and code.test_report artifacts, exactly as above. The test report records source: "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.

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:

OutcomeTriggerBehaviour
workAn assignment is delivered (200)The only outcome that resets the backoff; re-polls immediately
idleNo eligible work (204, or 404 with cloud code no_eligible_work)Exponential backoff, capped at 5 minutes
transient5xx, a transport error, a timeout, route-not-exposed, admission denial, or insufficient creditExponential backoff, capped at 5 minutes
permanent401, 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.

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 valuePostureEngine id (mechanism)Sent by the local runner
linux-defaultenforcedlumenflow.bwrapYes. It is the only value the runner produces today.
strongerenforcedlumenflow.microvmNo. The attested microVM runner is a separate path.
reducedwaived if the operation ran, else refusednoneNo. 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.

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.

{
  "verdict": "enforced",
  "mechanism": "lumenflow.bwrap",
  "engine_id": "lumenflow.bwrap",
  "catalogue_digest": "<sha256 hex of the conformance catalogue the claim binds>",
  "admission_ref": "<sha256 hex id of the host-signed claim>",
  "admitted_at": "2026-10-25T10:00:00.000Z",
  "expires_at": "2026-10-26T10:00:00.000Z"
}
  • 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. enforced is a verified claim on a host whose kernel layer is enforced. refused is 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. waived is a posture the operator waived. The expires_at is the claim’s notAfter; 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_at has 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_isolation and confinement_posture from 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, and expires_at is after admitted_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 every wu:claim) is telemetry’s actual job path — it must survive. Its main() 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 main lumenflow 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 reports state: 'healthy-with-skips' distinct from plain 'healthy', so a consumer that checks state (not message text) can still tell the two apart — the same passed: true plus a distinct state idiom the runner status already uses for backoff/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.

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.

  • 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.