Control-plane SDK
@hellmai/lumenflow-control-plane-sdk is the published package that defines the
orchestration state contract every LumenFlow surface speaks. The
package is intentionally small — it owns the state vocabulary, the
artifact document shapes, schema-versioned event vocabulary, and the parsers
that validate them.
When you read a status.json, receive an MCP coordination-tool payload,
or integrate a third-party launcher or remote control surface, the SDK is
what guarantees the shape.
What the SDK owns
Section titled “What the SDK owns”State + action constants
Section titled “State + action constants”These are the single source of truth for the state machine.
Internally, the software-delivery pack’s orchestrator module aliases the same values as
ORCHESTRATION_EXECUTION_STATES for intra-module readability, but that alias is not part of
the pack’s public initiatives entry point (or the deprecated @hellmai/lumenflow-initiatives
compatibility shell) — consumers outside the pack should import
ORCHESTRATION_STATUS_VALUES from @hellmai/lumenflow-control-plane-sdk directly.
Document types
Section titled “Document types”These are the shapes that plan.json, launch.json, and status.json
emit. See the artifact bundle page for
how each one is layered.
Runtime parsers
Section titled “Runtime parsers”The SDK ships validating parsers, not pass-through casts:
Legacy 'ready' is normalized to 'eligible' so older status files stay
readable.
Shared constants
Section titled “Shared constants”Used by the CLI monitor, MCP adapters, and any downstream tool that needs to find orchestration bundles on disk.
Campus routing & residency types
Section titled “Campus routing & residency types”The SDK is the .dev-side source of truth for the opaque, semantic routing
and residency labels that campus governance reasons about.
LumenFlow Cloud mirrors these with a Zod .strict() extension; cloud owns the
schema change and migration, while this package owns the type contract, so both
sides compile in lockstep.
Per the campus governance boundary’s vendor-name-free invariant, every label is opaque and semantic — it describes what a constraint means, never who provides the compute. No vendor brand string may appear in any value; vendor identity, where it must exist, lives in workspace-owned configuration mapped behind these labels.
| Type | Values (most → least restrictive) | Meaning |
|---|---|---|
DataEgressClass | campus_bound, campus_restricted, region_restricted, unrestricted | Where data is permitted to flow. The resolver branches on this class, never on a vendor name. |
CampusAffinity | required, preferred, none | How strongly work must be scheduled on a specific campus. A required affinity that cannot be met is refused, not silently downgraded. |
ResidencyClass | campus_local, region_local, multi_region, global | The geographic / jurisdictional scope within which data must remain. |
The _VALUES tuples are exported as const so cloud can build the mirrored Zod
enum directly from this source of truth, guaranteeing the two sides never drift.
Event schema catalog
Section titled “Event schema catalog”The JSON Schemas for these events are the same control-plane contract for local CLI views, self-hosted dashboards, third-party observability, and hosted consumers.
Validate event files locally with:
The validator rejects missing schema_version, unknown kind, and
schema-invalid payloads. The default command validates the committed valid
fixtures.
The measurement event family includes brief:token_reported records emitted
by wu:brief --report-tokens. Those records are appended to a
local brief-metrics.ndjson telemetry file for local inspection with
pnpm cost:summary --brief; any hosted or self-hosted dashboard consumes the
same schema rather than a dashboard-specific export.
Schema bundle for cross-language codegen
Section titled “Schema bundle for cross-language codegen”@hellmai/lumenflow-control-plane-sdk/schema-bundle publishes a versioned,
deterministic JSON-Schema bundle for the kernel/control-plane wire types
(signals, sessions, assignments, evidence envelopes, and the
halt/kill-intervention envelope), so an external client written in any
language can generate its own types instead of hand-copying Zod shapes.
The bundle is built from EVENT_SCHEMA_FAMILIES — the same catalog the
event schema section above documents — so it never
drifts from what validateControlPlaneEvent accepts. It does not pull in
the schemas of @hellmai/lumenflow-kernel or @hellmai/lumenflow-mcp: this SDK’s only runtime
dependency is zod, so each of those packages advertises its own single tool schemas to its own
consumer rather than feeding this bundle.
The halt/kill-intervention envelope (kind: 'control:halt_kill_intervention') mirrors the graceful-halt control
signal carried on the Connected Compute runtime-claim lifecycle response —
pending_control_action is additive-only, with 'kill' the sole recognised
value; a runner derives no capability expansion from it. Build determinism
is guaranteed: two calls to
buildControlPlaneSchemaBundle() produce byte-identical output.
The connected-compute client also parses the control plane’s per-assignment stop
record (additive, optional). pending_stop carries schema_version
(connected-compute-stop.v1), idempotency_key, workspace_id,
assignment_id, runtime_id, fencing_generation, requested_at, reason
(job_cancelled, operator_release or operator_stop) and acked_at (null on
delivery) on extend, receipt ack and runtime heartbeat results. A claim
response with kind: 'stop_control' is control only: claimWithHints returns
assignment: null and the records as stop_control, and it is never parsed as
an assignment. ackStop posts stop_ack (schema_version and
idempotency_key) to the existing ack route and returns the
{ schema_version, idempotency_key, acked_at } readback, which must name the
current schema_version and the key sent; it is separate from ack, so a
lifecycle wrapper that already acknowledged the receipt does not suppress it.
Every request carries X-LumenFlow-Control-Protocol: connected-compute-stop.v1
unless the client is built with controlProtocol: false (a probe, which must
not negotiate stop delivery). The current version is parsed strictly and a
malformed record parses to nothing; a record of another schema_version is
parsed by identity alone and flagged wellFormed: false, so a consumer can trip
on it and never acknowledge it. The control plane’s delivery is not live yet;
this is the client half of the contract.
CONTROL_PLANE_SCHEMA_BUNDLE_VERSION changes on every bundle-shape
change — MINOR for an additive change, MAJOR for a breaking one, PATCH for a
metadata-only edit.
Connected Compute assignment evidence
Section titled “Connected Compute assignment evidence”The @hellmai/lumenflow-control-plane-sdk/runtime-claim client requires a
lease_token and fencing_generation on every ToolDispatchEvidenceReceipt.
Pass the exact values from the claimed assignment: the lease token proves
ownership of that assignment, and the fencing generation rejects a stale
owner after takeover. The SDK sends both fields unchanged to the
assignment-scoped evidence route. The runtime bearer credential separately
authenticates the runner; it cannot replace the assignment lease token.
Runtime claim client (supported)
Section titled “Runtime claim client (supported)”@hellmai/lumenflow-control-plane-sdk/runtime-claim is a supported subpath.
Its claim, lease lifecycle and evidence types, and the ./cadence polling
types, are also exported from the package root, so import them from
@hellmai/lumenflow-control-plane-sdk without a deep path:
Supported means additive-only evolution within a major version: new optional fields and new error kinds can appear, and an existing field, route or error code is not removed or retyped without a major bump.
The optional confinement_admission object
Section titled “The optional confinement_admission object”A runner that holds an engine admission record sends one optional object beside its posture.
The posture enum is unchanged (ConfinementPosture is exactly 'linux-default' | 'stronger' | 'reduced', and
the pairing exchange’s effective_isolation accepts the same three values); the object is additive.
ConfinementAdmission has seven required fields: verdict (enforced | refused | waived), mechanism,
engine_id, catalogue_digest, admission_ref (both SHA-256 hex), admitted_at and expires_at (UTC
instants, expires_at after admitted_at). parseConfinementAdmission is strict: an extra key, a missing key,
a malformed value or an unknown verdict returns undefined, and the claim and heartbeat clients send the object
only when it parses. The object is absent when the runner has no admission record. A verdict never raises a
posture, and an expired record counts as absent. On the pairing exchange, send it through the typed
extensions input (extensions: { confinement_admission: admission }) of the generated runner-onboarding
client.
Cloud route clients
Section titled “Cloud route clients”An application that talks to LumenFlow Cloud reaches its routes through typed
clients in this package rather than hand-built URLs. Each client takes
{ baseUrl, credential: { token } } (or apiKey), returns a typed result for
every Cloud answer and network failure instead of throwing, sends the API key in
the Authorization header only, and strips it from every text it returns.
| Client | Cloud routes |
|---|---|
createEvidenceClient | POST/GET /api/v1/evidence (submit, list, ?verifyChain=1, ?getProof=<id>), POST /api/v1/evidence/anchor, GET /api/v1/evidence/signing-keys |
createCostsClient | GET /api/v1/costs |
createApprovalsClient | POST /api/v1/approvals/list (the API-key route) |
createOpenApiClient | GET /api/v1/openapi.json (public, the key is never sent) |
createReferencesClient | /api/v1/references; complete(referenceId, ownerId) sends the ownerId Cloud requires |
createAppConnectorsClient | /api/v1/app-connectors |
A failure is { ok: false, code, httpStatus, message, retryAfterSeconds?, errorCode?, requestId?, upstreamStatus?, upstreamCode?, limit?, limitValue?, scope?, resetsAt?, issues? }
with code one of invalid_request, unauthorized, forbidden, not_found, conflict (HTTP 409),
rate_limited, service_unavailable, server_error, unexpected_response,
unreachable or client_timeout. Input is validated before anything is sent, so
a refused request has httpStatus: null. errorCode is Cloud’s machine code from a
{ error: { code } } body (for example no_route), present only when the answer carried a
well-formed one. A size refusal (a 400, 413 or 429) also carries limit (messages,
message_chars, request_body_bytes, tokens_per_call or another registry limit) and limitValue,
the effective cap, which an operator can raise per workspace for the byte and token caps. A refusal
also carries scope (workspace, api_key, …) and resetsAt (an RFC 3339 instant) when the body
names a well-formed one; a malformed value is dropped, never passed on. Nothing is
retried unless you opt in (see Retry, pooling and conditional reads). The signing-keys and
openapi routes are public; the evidence client calls signing-keys without the key.
createCostsClient().query also groups spend by the reference an inference call carried:
groupBy: 'caller_reference' returns one row per reference, with the reference as the row key
(calls that carried none fall under (unknown)), and callerReference (sent as
caller_reference) narrows to one reference. It combines only with groupBy caller_reference
or day, never with wuId, agentId, model or sessionId; the client refuses that
combination before sending. The accepted groupBy values are checked against the pinned hosted
OpenAPI document.
The approvals client does not wrap GET /api/v1/approvals or /approvals/resolve:
those authenticate a signed-in workspace member, not an API key.
Retry, pooling and conditional reads
Section titled “Retry, pooling and conditional reads”Every route client, createAppConnectorsClient and createReferencesClient share one request path and
accept three options. All are off by default: with none set a call makes a single attempt, exactly as
before.
retrywaits with the same equal-jitter backoff the poll cadence uses, so callers that meet one 429 or 503 do not return together.Retry-Afteris honoured as a minimum wait; when the server asks for longer thanmaxDelayMsthe call stops and returns the failure withretryAfterSeconds. Only an idempotent request is retried: GET, HEAD, PUT and DELETE, or a POST or PATCH that carries anIdempotency-Keyheader. A governed refusal is never retried (a Cloud machine code such asbudget_exceededorresidency_violation, a sizelimit, aninsufficient_scope403), nor is any other 4xx, a streaming call, or the connectorsinvoke.maxAttemptscounts the first attempt (1 to 10); a malformed policy throws when the client is built.randomandsleepare injectable for tests.dispatcheris passed to every fetch the client makes (each retry and the signed reference upload included), so one pooled agent serves them all. The SDK adds no HTTP dependency.readCache(route clients and the connectors client) keeps a client’s GET answers that carry anETag, up tomaxEntries(default 64). InsidettlMsa repeat read sends nothing; after it, the GET sendsIf-None-Matchand a 304 is answered from the cache. Answers markedno-storeor without a validator are never kept, and a write through the same client clears the cache. Create the client for the reads you want cached, such as the provider catalogue. The cache never crosses a caller identity. Each entry is keyed on a keyed digest of the URL and every request header exceptIf-None-Match(a differentX-Tenant-Subject, key or any other header gets its own entry); the store holds digests, never a header value or the URL. An answer whoseVarynames a header the request did not send (orVary: *) is not kept (Accept-Encodingis ignored). WithoutfixedIdentity: truea request is cached only when every header it sends is public (accept,accept-language,content-type,user-agent,x-tenant-subject,x-workspace-id), so an authenticated request, or any request on a client given afetchordispatcheroption (even the global fetch) or whose fetch is not the runtime one the SDK captured at load, is not cached.fixedIdentity: truemeans: this client holds one credential, and all per-user identity is carried in headers the SDK client sets. The credential is bound on the first stored answer; a request with another credential is not cached. If afetchwrapper or dispatcher adds identity from ambient state (for example a request-scoped header), the cache cannot see it: do not combine that withfixedIdentity. Two cases no code can detect: a global undici interceptor installed withsetGlobalDispatcher, and a globalfetchpatched before the SDK module loads. Do not enablereadCachein a process that does either. Build one client per identity.maxTotalBytes(default 8 MiB, at most 64 MiB) andmaxStaleMsbound memory;Cache-Control: no-cacheormax-age=0answers are kept but revalidated on every read. Entries are served for at most the smaller ofttlMsand the server’smax-agewithout asking, so a revoked key can still be answered from the cache until then: keepttlMsshort for anything access-controlled. Hosted Cloud currently answers withVary: rsc, next-router-state-tree, ..., so a route that sends thatVarywith anETagis not cached.
Delivery outbox
Section titled “Delivery outbox”A durable outbox for anything an application must deliver to another system at
least once and in order. The SDK ships the port, a driver, three adapters and a
conformance kit; it adds no database driver (its only dependency is zod).
| Subpath | Provides |
|---|---|
@hellmai/lumenflow-control-plane-sdk/outbox | DeliveryOutbox port, deliverOutbox driver, OutboxSendError, parseRetryAfter, createInMemoryOutbox |
@hellmai/lumenflow-control-plane-sdk/outbox/file | createFileOutbox({ directory }): append-only log, fsync, crash safe |
@hellmai/lumenflow-control-plane-sdk/outbox/sql | createSqlOutbox({ client }) over an injected query interface; sqlOutboxSchema |
@hellmai/lumenflow-control-plane-sdk/outbox/conformance | runOutboxConformance(factory, it) and outboxConformanceCases |
The port: enqueue(item, { idempotencyKey, partition }),
lease(partition, limit, { leaseMs? }), ack(ids) and nack(ids, retryAt).
- Order. Items of one partition are leased in enqueue order. While any item
is leased, or the head waits for a
nackretry time, nothing behind it is handed out. Anackalso releases the items leased behind the nacked one. Partitions never block each other. - At least once. A lease expires after
leaseMs; an unacked item is leased again with a higherattempts. Make the receiver idempotent onidempotencyKey. - Idempotency. A key is accepted once; a repeat returns
{ duplicate: true }and the original id, even after the item was acked, untilidempotencyRetentionMs(default 7 days) has passed. - Payloads must be JSON-serialisable.
deliverOutbox is a timer-free pass: it drains each partition in order, nacks a
failed item with exponential backoff (jittered, raised to a Retry-After, capped
at backoff.maxMs) and stops that partition so order holds. An item that is
out of attempts or failed permanently goes to onExhausted (resolve to
dead-letter it); without the hook it is held and its partition stays blocked.
The caller schedules passes.
The file adapter serialises writers with a lock file, so several processes can
share one directory. The SQL adapter takes a client with one method,
transaction(run), whose tx has query(sql, params) and execute(sql, params)
(returning { changes }). It uses ? placeholders (placeholders: 'numbered'
rewrites them to $1), the SQLite and PostgreSQL common subset, and
sqlOutboxSchema() / ensureSchema() for the tables.
Run the same cases against your own adapter:
The cases cover per-partition order, head-of-line blocking, duplicate keys
(including concurrent enqueue and after ack), nack timing, redelivery after a
crash between lease and ack, restart durability and concurrent leases. The
adapter must read time from the injected clock, and reopen() returns a new
instance over the same storage.
Generated Cloud route clients
Section titled “Generated Cloud route clients”New Cloud route families are not hand-written. The SDK pins Cloud’s
openapi.json and generates a zod-validated client per family. Each
generated client calls the same transport as the clients above, so it never
throws, refuses bad input before any call, redacts the key, bounds reads and
surfaces Retry-After exactly as they do. The transport sends GET, POST,
PUT, PATCH and DELETE, and encodes path parameters with
encodeURIComponent.
Which operations form a family is set by tag, path prefix or exact METHOD /path; an operation
left to a hand-written client, such as streaming inference, is excluded.
Generation fails closed instead of guessing: it stops and names every mapped
operation that has no operationId or no 2xx JSON response schema, plus any
schema construct it cannot express. Response objects keep unknown properties;
request objects are closed at the top level (see “Forward compatibility” below).
Response string enums are open: the known values stay in the type
(Known | (string & {})), and a value Cloud adds later is accepted, so an additive
response enum value never breaks a pinned client. Your code that switches on a response
enum must handle the unknown case. Request enums stay closed.
Shipped families
Section titled “Shipped families”Each generated family is a package subpath export. Import the one you need; the root export is unchanged.
| Subpath | Client | Methods |
|---|---|---|
./cloud-routes/jobs | createJobsClient | listJobs, createJob, getJob, controlJob (POST /jobs/{id}/controls), recordJobEvents |
./cloud-routes/eval-runs | createEvalRunsClient | createEvalRun (post a scenario-evaluation report; 201 is a new run, 200 an idempotent replay of the same run_id), listEvalRuns (the runs, or with query.scenario_id one scenario’s history) |
./cloud-routes/lanes | createLanesClient | listWorkspaceLanes, setWorkspaceLanes (read and set a workspace’s lane WIP limits; the set replaces the whole list) |
./cloud-routes/jobs-stream | createJobsStreamClient | streamJob (server-sent events, hand-written) |
./cloud-routes/approvals | createApprovalsClient | listApprovals, createApproval, getApproval, actOnApproval, commentOnApproval, searchApprovals, requestApproval, resolveApproval |
./cloud-routes/agents | createAgentsClient | listAgents (typed response) |
./cloud-routes/workspaces | createWorkspacesClient | listWorkspaces (query.view: "provisioning" pages the org token’s workspaces), createWorkspace (the result carries the new workspace apiKey, shown once; headers.idempotencyKey makes a retry safe), archiveWorkspace, rotateWorkspaceApiKey, listAppKeys, createAppKey, revokeAppKey |
./cloud-routes/invites | createInvitesClient | createWorkspaceInvite (the result carries the invite token), acceptWorkspaceInvite |
./cloud-routes/model-config | createModelConfigClient | listModelConfigs, putModelConfig, deleteModelConfig, testModelConfig, discoverModelCatalog (bring-your-own-key model routes; the provider apiKey is write-only; testModelConfig also validates a config without storing it) |
./cloud-routes/model-config-admin | createModelConfigAdminClient | list, create, update and delete for the org routing configs (listOrgModelConfigs, createOrgModelConfig, updateOrgModelConfig, deleteOrgModelConfig) |
./cloud-routes/org-service-tokens | createOrgServiceTokensClient | listOrgServiceTokens, createOrgServiceToken (the result carries the token apiKey, shown once), revokeOrgServiceToken |
./cloud-routes/inference | createInferenceClient | createInference (one typed, non-streaming model call; headers.idempotencyKey makes a retry safe; request and response are Cloud’s schemas); listInferenceRoutes (the tiers the key can reach and its effective limits; interim name) |
./cloud-routes/runner-onboarding | createRunnerOnboardingClient | getRunnerOnboardingPolicy, putRunnerOnboardingPolicy, deleteRunnerOnboardingPolicy, confirmRunnerOnboardingPolicyChange, issueRunnerOnboardingGrant (the result carries the grant grant, shown once), listRunnerOnboardingGrants, revokeRunnerOnboardingGrant, createPairingCode (the result carries the one-time code), exchangePairingCode (the result carries runtime_credential, shown once) |
./cloud-routes/compute-onboarding | createComputeOnboardingClient | getComputePolicy, updateComputePolicy, listComputeSources, updateComputeSource, listEnrollments, createEnrollment (the result carries the runtime credential runtimeCredential, shown once), updateEnrollment (a key rotation answer carries runtimeCredential, shown once), deleteEnrollment, getManagedComputeEntitlements |
./cloud-routes/members | createMembersClient | listWorkspaceMembers, updateWorkspaceMemberRole, removeWorkspaceMember, listWorkspaceInvites, revokeWorkspaceInvite (createWorkspaceInvite stays in the invites client) |
./cloud-routes/limits | createLimitsClient | getWorkspaceInferenceLimits, getInferenceKeyLimits, getSubjectInferenceLimits, deleteSubjectInferenceLimits, getWorkspaceJobLimits, putWorkspaceInferenceLimits, putInferenceKeyLimits, putSubjectInferenceLimits, putWorkspaceJobLimits (all interim names) |
./cloud-routes/catalogue | createCatalogueClient | listConnectorCatalogue (the workspace connector catalogue; optional query.kind, query.availability and query.source) |
./cloud-routes/tool-connections | createToolConnectionsClient | listToolConnections (the tenant-bound connection list; optional query.connectorKey and query.status, and headers.tenantSubject sent as X-Tenant-Subject); createToolConnectionAttempt, getToolConnectionAttempt and deleteToolConnection (pre-release, see below) |
./cloud-routes/app-connectors | createAppConnectorsClient | putAppConnector, listAppConnectors, getAppConnector, deleteAppConnector, invokeAppConnector |
./cloud-routes/memory | createMemoryClient | listMemoryNodes, syncMemory (push, pull or bidirectional) |
The runner-onboarding family is generated from the pinned Cloud snapshot like the others;
every operationId is Cloud-published, so it has no interim overlay. Scope and credential rules:
the policy and grant routes need an owner or admin session, or a workspace key holding the opt-in
runners:manage scope; issueRunnerOnboardingGrant names a non-empty subset of the closed
runner:onboard.* scopes and a lifetime of 7 to 30 days, and a scope outside the published set is
refused before any call. exchangePairingCode is authenticated by the one-time code, and Cloud allows it anonymously or with the
grant that requested the code. The client reads that from the contract: build it with no credential
(createRunnerOnboardingClient({ baseUrl })) and the exchange sends no Authorization header, or build it
with the grant (lf_rog_..., with scheme: 'runnerOnboardingGrant'; scheme is required whenever a credential is supplied: platformAuth for a workspace key; a client with no credential needs none) and the exchange sends it. A client holding a workspace key sends no credential to the exchange, and a client holding a grant is refused locally on the platform-only operations; Cloud re-checks the grant against
the policy in force. Every other runner-onboarding operation needs a credential and, on a client built without
one, refuses locally before any request with an unauthorized failure that names the operation. The grant, the pairing code and the runtime_credential are
shown once: store them in a secret store, never in a log, a WU note or a command line. The client
scrubs the credential it was built with, and the pairing code you pass (Cloud marks it writeOnly),
from every failure and never logs.
exchangePairingCode returns the default exchange answer only: Cloud also offers a store-only
answer behind a format query parameter, which this client never requests and whose input has no
way to set. The answer carries runtime_credential and runtime_store (baseUrl, token,
lane), where token is the same credential again.
The compute-onboarding family covers the compute policy, the compute sources, the runtime
enrollments and the managed-compute entitlements of a workspace. A workspace key needs the read
scope for the reads and write for the writes (or an owner or admin session). GET /api/v1/runner/bootstrap is not generated: it answers JavaScript or shell source, not JSON.
Once-shown secrets
Section titled “Once-shown secrets”Cloud marks every string it returns exactly once in plaintext with x-sensitive: true (API keys,
the runtime credential, the onboarding grant, the pairing code, the invite token and its accept
link). Each generated operation whose answer carries one exports <Operation>SensitiveFields (for
example ExchangePairingCodeSensitiveFields), the paths of those values. The result still carries
the value, because you must store it, but redactSensitiveFields(result.data, paths)
(exported from the package root) returns a copy with each value replaced by <redacted>; log or
print only that copy. A top-level request field marked writeOnly or x-sensitive is removed from every
error the client returns; a marker anywhere else in the request body (under a union, in an array, nested, or on a property that is not a string) stops generation rather than going unredacted. When you refresh the pinned snapshot, generation stops on any
response string whose name looks secret (token, credential, secret, key, grant, password, code,
passphrase, bearer) and that is neither marked nor argued for in families.json
(sensitiveRatchet); it uses the same name rules and scan as Cloud’s own document-wide check.
The workspaces family also covers headless provisioning with an org service token that
holds workspaces:manage: listWorkspaces with query.view: "provisioning",
createWorkspace, archiveWorkspace and rotateWorkspaceApiKey (the body must name
scopes, drawn from read, write and model_config:write; an org scope is refused
before any call). The app-key routes (listAppKeys, createAppKey, revokeAppKey) need a
workspace owner or admin session or a workspace key holding write. createAppKey names a
non-empty subset of payments:checkout.create, entitlements:read,
app_connectors:invoke, tool_connections:read and tool_connections:connect, and its result carries the app key apiKey, shown once. The org
token and every key a request carries are scrubbed from errors and logs.
The connector catalogue and the tool-connections list both need a workspace key holding the
read scope; an inference-only key is refused (forbidden, HTTP 403), and app keys are not
accepted on the tool-connections route yet. listConnectorCatalogue returns the entries of the
workspace catalogue (built-in, operator-added and workspace-added, each tagged with its source
and a connect_mode) plus the install_kinds a workspace can add; query.kind,
query.availability and query.source narrow it, and a value outside the hosted set is refused
before any request. listToolConnections lists the workspace’s own connections when no
headers.tenantSubject is given, or one end user’s connections when it is; it never returns a
token, an external account id or an account handle. Response enums (status, source,
connect_mode) stay open, so a value added later still parses.
The connect flow is a pre-release contract and is not live until Cloud enables it; no connector
should be presented as connectable yet. createToolConnectionAttempt (path connectorKey; body
tenantSubject, returnUrl, optional appId and variant) answers HTTP 503
keyless_connect_unavailable until then. That is a typed failure (code: 'service_unavailable',
errorCode: 'keyless_connect_unavailable') and is never retried, even with retry opted in.
getToolConnectionAttempt polls an attempt (it has no live-mode check, so it says nothing about
whether connect is live) and deleteToolConnection removes a connection (body tenantSubject,
actorKind, actorRef; not gated). beginUrl and attemptId are single-use capabilities: open
beginUrl only in the person’s own browser, never store it, and pass a result through
redactSensitiveFields with CreateToolConnectionAttemptSensitiveFields or
GetToolConnectionAttemptSensitiveFields before logging.
The members operations need an org service token holding the opt-in members:manage scope, or a
signed-in workspace owner or admin; no caller can demote or remove the last owner. The limits
operations read the effective value of each self-service limit, the scope it comes from and its
ceiling, for the workspace, one inference key, one end-user subject, or the workspace’s job limits;
they need a workspace key holding read, or an owner or admin session. listInferenceRoutes takes
an inference-only key (inference:invoke) or the workspace key with read. The operation names for
the limits and inference routes are interim: Cloud publishes no operationId for them yet, and they
are replaced when it does. The four limit write operations (putWorkspaceInferenceLimits,
putInferenceKeyLimits, putSubjectInferenceLimits and putWorkspaceJobLimits) take a typed body
whose limits map must hold at least one entry (the contract’s minProperties). Response enums
stay open.
The inference, catalogue and memory routes take a workspace key. The generated
./cloud-routes/app-connectors client takes { baseUrl, apiKey } and covers
every Cloud route of the family. The hand-written createAppConnectorsClient
from the package root and ./app-connectors (put, list, remove, invoke,
with credential: { token }) keeps every public name and keeps working; a
compatibility test checks that both address the same routes.
Model routes and the provider key
Section titled “Model routes and the provider key”createModelConfigClient manages a workspace’s own model routes with a workspace
key that holds the opt-in model_config:write scope. The provider key is
write-only: it goes in putModelConfig or discoverModelCatalog requests, and
every response carries only maskedKey (the last four characters). The client
removes the provider key, and the workspace key, from every message, validation
issue and error code it returns, on the success, validation, transport and HTTP
4xx and 5xx paths alike. The generator reads writeOnly and x-sensitive fields from Cloud’s
request schemas to decide which values to remove, and stops if a writeOnly
field is nested where it cannot.
A call whose key lacks the scope answers 403 with reasonCode: insufficient_scope.
The result carries errorCode: 'insufficient_scope', requiredScope and
grantedScopes; isInsufficientScope(result) narrows it.
Scopes per operation: with a workspace key, listModelConfigs, putModelConfig,
deleteModelConfig, testModelConfig and discoverModelCatalog all require
model_config:write; a dashboard session needs no scope. Each listModelConfigs
row carries endpointRegion (eu_west, us_east, us_west, apac, or null):
the region declared when the route was saved, never verified.
testModelConfig has two forms. { body: { role } } tests a stored route.
A body carrying the connection fields (provider, model, apiKey, baseUrl,
endpointRegion, providerPreset) validates that config against the endpoint and
stores nothing; the apiKey in it is write-only and removed from every result like
the one in putModelConfig. The response always carries role (the role sent,
otherwise probe). A config that fails validation answers 400.
requestId is Cloud’s top-level request_id, kept only when it is a short token. The code
provider_rate_limited (the upstream provider answered 429 for this call; Cloud made one attempt
and did not fail over) is a refusal the client never retries, even with retry opted in. On the
streamed inference client it arrives as the terminal error event and becomes
{ code: 'rate_limited', errorCode: 'provider_rate_limited' } with retryAfterSeconds from
retry_after_seconds (an integer from 1 to 3600; anything else is dropped, not coerced); the same
code after model output stays provider_error with no hint.
Upstream status and code on a refusal
Section titled “Upstream status and code on a refusal”When Cloud refuses a call because the upstream provider refused (for example a bring-your-own-key
route that ran out of credit), the outer answer is a Cloud error such as HTTP 502 provider_error
and the provider’s own status is nested in error.details. A failure now carries what was
observed, as two optional fields next to the unchanged httpStatus, errorCode and code:
upstreamStatusis the provider’s HTTP status. It is set only whenerror.details.upstream_statusis an integer from 400 to 599. When the value is absent, a 2xx, a string, a fraction or out of range, the field is absent. It is never taken fromhttpStatus: an outer 502 says nothing about the provider.upstreamCodeis one ofbad_request,auth_failed,permission_denied,not_found,rate_limited,overloaded,request_too_large,provider_internalorunknown(exported asUPSTREAM_CODES). Beside anupstreamStatusit is always present, and a value outside that set becomesunknown. Without a status, a set member other thanunknownis kept and anything else is dropped.
The provider’s message and every other details field are never forwarded, so neither a failure, a
streamed event nor a log line can carry provider text. The streamed inference client applies the same
rule to the terminal error event (the outer status there is 200), and the agent runtime Cloud
provider exposes the two fields as upstream_status and upstream_code in the turn error details.
An older consumer that ignores the fields behaves as before.
When the agent runtime host loop ends because a provider turn failed, the loop result error and the
failed turn report’s failure carry the same two fields, upstream_status and upstream_code, each only
when valid (the SDK validator is applied again at that point). Nothing else from the failure details is copied
into the loop result. Without a valid observation the result is unchanged, so branch on
result.error.upstream_status to tell a deterministic refusal (for example 402) from a transient failure.
upstreamObservationOf is exported from the package root for the same validation in your own code.
The fields exist so your own retry logic can stop retrying a deterministic refusal. The SDK’s own
opt-in retry never retries an answer whose observed upstreamStatus is 400, 401, 402, 403, 404 or
422; every other case, including an observed 408, 429 or 5xx, keeps the existing rule. UPSTREAM_CODES
and the UpstreamCode type are exported from the package root.
Both testModelConfig and discoverModelCatalog are rate limited for key callers
(429 rate_limited). The result has code: 'rate_limited', httpStatus: 429 and
retryAfterSeconds taken from the Retry-After header; nothing is retried by the
client unless you opt in to retry.
Prompt-caching prices are declared on the same request. declaredPrices takes inputPerMillion, outputPerMillion, cachedInputPerMillion (the cached-read rate) and cacheWritePerMillion (the cache-write rate), each 0 to 10000 USD per million tokens. Without cacheWritePerMillion the hosted side bills cache writes at the input rate, so a bring-your-own-key receipt understates cost. provider: 'anthropic' takes an explicit baseUrl (a Messages-protocol endpoint) and cacheDialect: 'anthropic-ephemeral'. The request is still strict: an unknown field, at the top level or inside declaredPrices, is refused locally before any call.
The org variant (/orgs/{orgId}/model-config) is a separate
model-config-admin family: it is addressed by organisation rather than by a
workspace key’s own workspace, and they carry routing policy (role, billing mode, fallback chain)
with no provider key, so they stay off the workspace client.
Tenant subject on jobs
Section titled “Tenant subject on jobs”A key scoped to a tenant names that tenant on every job read. The jobs client
takes it as headers.tenantSubject, sent as X-Tenant-Subject, on createJob,
getJob and listJobs; query.tenant_subject is the same value for a caller
that cannot set a header. The job status stream takes tenantSubject and sends
the header, or the query parameter with tenantSubjectVia: 'query'.
Job create idempotency, typed job answers and lanes
Section titled “Job create idempotency, typed job answers and lanes”createJob takes headers.idempotencyKey (1 to 255 printable ASCII characters), sent as
Idempotency-Key. With a key, a retry of the same request is safe, and the three answers are
distinct typed results: 202 is a new job (ok: true, httpStatus: 202), 200 is a replay of
the same key and body (ok: true, httpStatus: 200, the existing job), and 409 is the same
key with a different body (ok: false, code: 'conflict', httpStatus: 409). Without a key every
call creates a new job. classifyCreateJobResult (package root) names the outcome as
created, replayed, conflict or failed.
The create, list and get answers are typed from the hosted contract: create returns
{ job_id, status }, listJobs returns { jobs } of job summaries, and getJob returns the
status snapshot with its result and result_envelope. Status values are open, so a value Cloud
adds later is accepted.
Typed approval decisions and evaluation reports
Section titled “Typed approval decisions and evaluation reports”resolveApproval (./cloud-routes/approvals) takes a typed body: approval_id, decision
(approved, rejected or expired) and an optional reason. Any other field, an empty id or an
unknown decision is refused before a call, and the answer is the approval record (status values
are open). Cloud attributes the decision to the authenticated workspace member; the route takes no
end-user subject.
createEvalRunsClient posts a continuous-scenario evaluation report (run_id is the idempotency
key) and reads runs back with an optional limit (1 to 200) or one scenario’s pass history with
scenario_id. The report body is stored verbatim; the scenario results are typed.
The job reads (listJobs, getJob) carry the tenant subject as shown above; the pinned hosted
contract declares it on both. Cloud publishes no agent-task snapshot (steps, pending
approval, cost, stop reason); getJob returns the typed job status snapshot.
Secrets returned once
Section titled “Secrets returned once”Every operation that returns a secret Cloud shows only once (createWorkspace,
rotateWorkspaceApiKey, createAppKey, createOrgServiceToken,
createWorkspaceInvite with its accept link, createPairingCode,
issueRunnerOnboardingGrant, exchangePairingCode, createEnrollment and a key
rotation through updateEnrollment) exposes it in the result so you can store it;
no client logs it, and it never appears in a failure. See “Once-shown secrets” above for
redactSensitiveFields.
Which Cloud contract a client targets
Section titled “Which Cloud contract a client targets”The package root exports CLOUD_OPENAPI_CONTRACT: { sha256, infoVersion, syncedAt, families }, the lock the generated clients were built from. Compare
sha256 with the Cloud document you run against to see whether the shipped
clients match it. The test suite asserts that the constant equals the lock, and
the generator refuses to run when the lock and families.json list different
families.
Forward compatibility: new Cloud fields need no SDK release
Section titled “Forward compatibility: new Cloud fields need no SDK release”A field Cloud publishes after the pinned contract is reachable without waiting for an SDK release:
- Known request fields stay typed and validated.
- Every operation with a closed-object request body (and a body-less
GETwith a query) accepts one extra input property,extensions: Record<string, unknown>. Its entries are merged into the wire body (or query), soclient.putWorkspaceJobLimits({ path, body, extensions: { newField: 1 } })sendsnewFieldnext to the known fields. Values must be JSON-serialisable; a query extension must be a string, number or boolean. - Extension names match
^[A-Za-z_][A-Za-z0-9_.-]{0,63}$, and values must be plain JSON (depth at most 32, at most 10,000 nodes, 1 MiB of text; no class instances, accessors or cycles);__proto__is refused at any depth. Extensions go to the body or query only, never to a header. Do not send a secret throughextensions: only the typed write-only fields are scrubbed from answers. path,queryandheadersinputs are strict too, so a misspelt header name is refused instead of being silently dropped. Four bodies (createWorkspaceInvite,updateWorkspaceMemberRole,createAppKey,syncMemory) now refuse an extra key they used to drop.- Any other unknown top-level key is refused locally with an
invalid_requestfailure (no request is sent), so a misspelt field is still caught. A body that is already an open record takes any key and needs no extension. - An extension key that collides with a known field is refused locally the same way, with an issue
at
extensions.<key>. Set the known field itself. - Responses keep properties the pin does not name, at every depth, and response enums stay open. As before, the credential and any write-only request value are removed from every string in the answer.
Do not base authentication, routing or tenancy decisions on fields the pin does not name: only the typed, pinned fields are validated.
Once Cloud’s contract is re-pinned, the field becomes a typed property and the extension entry becomes a collision error, so move it into the typed body then.
Pending families
Section titled “Pending families”The generated clients cover the routes in Cloud’s published OpenAPI document. Route families that document does not describe are neither generated nor exported. The hand-written createApprovalsClient exported from
the package root is a different client; the generated one lives only on its
subpath.
Cloud’s document does not yet describe most routes, so those families carry an
interim overlay: the engine names the operations and, where it knows them, types
the request and response (the workspaces, invites, org-service-tokens,
app-connectors, catalogue and memory families; inference uses Cloud’s own
schemas); otherwise request bodies are accepted as any JSON object and
responses are returned unvalidated.
Tenant-scoped fields (tenant_subject, end_user_subject, actor_kind as an
opaque string, actor_ref) therefore pass through in the body today. Once
Cloud publishes them, regenerating the clients turns them into typed input and response schemas.
Key verification outcomes
Section titled “Key verification outcomes”verifyControlPlaneApiKey posts the key once and classifies the answer. It throws a ControlPlaneKeyVerificationError
whose code is one of:
code | When |
|---|---|
invalid_key | HTTP 401 with exactly {"error":"Invalid API key"}, or HTTP 401/403 with {"error":{"code":"invalid_api_key"}}. |
denied_unattributed | Any other HTTP 401 or 403 (other JSON, HTML, text, empty, oversize). The request was denied before the key could be confirmed. |
service_unavailable | HTTP 503. reason is maintenance only for {"error":{"code":"maintenance"}} (with an optional clamped retryAfterMs), else unavailable. |
endpoint_unreachable | Network error, timeout or any other non-success status. |
malformed_response | HTTP 200 without a usable identity body. |
Check service status before replacing a key after denied_unattributed or service_unavailable: neither says anything
about the key. Maintenance is never inferred from a 403, a header or body text. No outcome retries, returns an identity or
changes configuration. Messages never carry the key, a body excerpt or a header value; a maintenance message may show the
clamped retry hint in whole seconds (and says “Retry later.” for less than a second), never the raw header text. One
overall deadline (timeoutMs) covers the headers and the body read: a body that stalls after a 401/403 is
denied_unattributed, after a 503 service_unavailable with reason unavailable, and after a 200 endpoint_unreachable.
A 200 body that also carries an error is malformed_response, never an identity. The recognized forms are
exported as CONTROL_PLANE_AUTH_DENIAL_CONTRACT, CONTROL_PLANE_MAINTENANCE_CONTRACT and
CONTROL_PLANE_SERVICE_UNAVAILABLE_REASON.
Trace correlation (W3C trace context)
Section titled “Trace correlation (W3C trace context)”Every Cloud route client (the references, app-connectors, evidence, costs, approvals, openapi,
stream and generated clients) accepts an optional W3C trace context, so a consumer’s trace can be
linked to the exact Cloud calls it caused. Supply it per client with the traceContext option, or
per call as the trailing { traceContext } argument (a stream client takes it in its input object).
A per-call context replaces the client’s wholesale. Both are plain strings, with no tracing library
required:
The shared transport validates the value against the W3C grammar and sends traceparent (and
tracestate when given) unchanged on every request, including the retries of one call. An invalid
value (upper-case hex, version ff, an all-zero trace or parent id, a malformed or over-long
tracestate of more than 512 characters or 32 members) is refused locally with invalid_request
and nothing is sent; the refusal text names the field, never the value. Without a supplied context
no trace header is sent: the SDK never invents an id. The signed upload target of the references
client never receives one. The opt-in read cache ignores trace headers in its key, so a new trace id
neither splits the cache nor lets two tenants share an entry; a cache hit sends no request and so
carries no trace to Cloud.
Why the SDK exists
Section titled “Why the SDK exists”Before the SDK existed, each LumenFlow package carried its own orchestration
types. The CLI collapsed rich states to in_progress. MCP ran a second
classifier that silently drifted from the initiatives reconciler.
Remote parity was aspirational — every surface paraphrased the state
vocabulary slightly differently.
The SDK fixes that by being the only source of:
- The 13-value orchestration state vocabulary.
- The 5-value next-safe-action vocabulary (
launch_wu,finish_wu,recover_wu,relaunch_wu,wait). - The snake_case JSON schema every persisted artifact uses.
- The schema-versioned event catalog for control-plane measurement and routing.
- The parsers that enforce that schema at every boundary.
The Software Delivery pack imports from the SDK. @hellmai/lumenflow-cli imports from the SDK.
@hellmai/lumenflow-mcp imports from the SDK and from
@hellmai/lumenflow-packs-software-delivery/initiatives so it shares the canonical reconciler. Remote
control surfaces import from the SDK too. One contract, many consumers.
Local vs remote parity
Section titled “Local vs remote parity”The same OrchestrationStatusDocument that your local CLI writes to
status.json is the same contract a remote control surface can expose over
HTTP or another transport. That means:
- A WU you start locally can hand off to another surface without reshaping the state.
- An agent that reads MCP coordination tools today can talk to a remote orchestrator tomorrow tomorrow without code changes.
- Third-party launchers that implement the SDK contract get to participate in the same state machine — not their own dialect of it.
This is the principle of “one initiative state model, many launchers.”
Event versioning policy
Section titled “Event versioning policy”Events use kind as the discriminant and schema_version as the compatibility
switch. Additive payload fields are compatible. Changing the required shape of
an existing kind needs a version bump or a parallel compatibility path. New
measurement emitters must add schema coverage before they emit measurement,
routing, skill-hint, or prompt-optimizer events.
Adaptive poll cadence (./cadence)
Section titled “Adaptive poll cadence (./cadence)”@hellmai/lumenflow-control-plane-sdk/cadence is the shared, pure adaptive
backoff primitive for anything that polls the control plane — the Connected
Compute runner claim loop, the control-plane sidecar tick, and the surfaces
control-plane event subscriber. It replaces flat setInterval polling with
equal-jitter exponential backoff per outcome class, a startup spread that
de-synchronises a freshly-installed fleet, and precedence for server-sent
cadence hints.
The primitive is pure: no timers, no fetch, no process access, and
randomness is injected (the only default implementation reads
node:crypto, never Math.random). It is the caller’s job to classify each
poll result into work | idle | transient | permanent and to carry any
server-sent cadence hint from the response into the next PollOutcome.
Hint precedence
Section titled “Hint precedence”Every caller honours the same precedence, highest first:
Retry-After(retryAfterMsonPollOutcome) — delta-seconds or an HTTP-date, parsed byparseRetryAfterMs. Sent on 429, 503, and on any permanent 403/401 the server chooses to pace.X-LumenFlow-Poll-After-Ms(pollAfterMsonPollOutcome; the header name is exported asPOLL_AFTER_HEADER), ornext_heartbeat_mson a heartbeat response. This is the fleet-wide lever: the server can slow every client without a release.- Local adaptive default — the equal-jitter backoff computed from the outcome kind and the caller’s configured caps.
A hint is always clamped to [floorMs, ceilingMs] and jittered by
hintJitterFraction (default 10 percent) before use. Absence of every hint
means local defaults; no server-side change is required for an existing
client to keep working, and no client-side change is required for the server
to stay correct without ever sending a hint.
Adoption: the HTTP surface’s control-plane sync
Section titled “Adoption: the HTTP surface’s control-plane sync”@hellmai/lumenflow-surfaces/http’s control-plane event subscriber
(wrapping the in-process event stream) and the detached
sync sidecar wu:claim spawns both replaced their flat setInterval sync
cycle with two independent PollCadence instances driven by a single
self-rescheduling timer, sharing one scheduling core so the two mounts cannot
drift apart:
- Heartbeat:
baseMs60,000 (the documented server contract), floor 30,000, every cap (idle/transient/permanent) and the hint-clamp ceiling 240,000 — a successful heartbeat iswork(steady state a ~60s-centred jittered band, sincepollAfterMsis always passed asnext_heartbeat_ms ?? pollAfterMs ?? 60_000, so the delay always runs through the hint-jitter path); a failure istransientand never exceeds the 240s stall-detector ceiling. The heartbeat is never gated behind the policy pull. - Policies:
baseMsis the caller’s configuredpollIntervalMs(controlPlaneSyncIntervalMs), idle cap 300,000. When the port exposespullPoliciesConditional, an unchanged pull isidle(backs off); a changed pull isworkwithpollAfterMs ?? baseMs(resets to a jittered band around base and re-evaluates event forwarding). Absent that method, everypullPoliciesresult is treated asworkthe same way, so an older sync-port implementation degrades to “poll at a jittered band around base forever” rather than breaking. - Both cadences wait an independent startup spread before their first poll.
- Attempt bookkeeping (the backoff counter) and delay computation are
independent:
outcome.kindalone governs the attempt counter, so a server hint riding aworkoutcome still resets backoff instead of leaving it stuck at whatever it had climbed to (see “Hint precedence” above).
Permanent code set
Section titled “Permanent code set”A permanent outcome — no backoff relief without operator or server action —
is: any 401, or a claim 403 with code capability_mismatch,
session_not_found, runtime_session_mismatch, compute_class_mismatch,
confinement_posture_required, capability_not_approved, or
source_policy_denied. Adding a code to this set is a contract change on
both sides, not decided by a single client’s own
classification table.
Sidecar cadence
Section titled “Sidecar cadence”The control-plane sidecar (spawned by
wu:claim and agent:session and stopped by wu:done) drives two
independent createPollCadence instances instead of the historical flat
setInterval(30_000) tick:
- Heartbeat — base 60s (the documented contract), floor 30s, every cap
(idle/transient/permanent) and the ceiling 240s: the server’s stall
detector releases claims after 5 minutes with no heartbeat, so liveness
must never wait longer. A successful heartbeat, and a heartbeat carrying a
next_heartbeat_ms/poll-after hint, both stay jittered — never a flat deterministic wait, which would re-synchronise a fleet on the heartbeat route. A failed heartbeat backs off through thetransientclass. - Policies — base from
LUMENFLOW_SIDECAR_INTERVAL_MS(default 30s), idle cap 5 minutes. Each pull is conditional (If-None-Match, falling back to the lastdesired_state.revisionas an ETag); a 304 or an unchanged revision isidle(equal-jitter backoff to the 5-minute cap), a changed body iswork(jittered reset to base), andCache-Control: max-ageis honoured as a floor under either.
Each tick runs whichever of the two is due; heartbeat-only side effects
(NDJSON telemetry sync, memory/signal drain, kernel-event outbox replay)
ride the heartbeat cadence exactly as before. The last-tick state file carries
next_heartbeat_at and next_policy_pull_at alongside the existing
heartbeat_ok/policy_pull_ok fields, and pnpm compute:doctor prints them
as part of the sidecar-sync check’s message.
Sidecar start and memory/signal sync health
Section titled “Sidecar start and memory/signal sync health”A spawned pid is not reported as a running sidecar. Once its config and
credential resolve and its first authenticate round trip completes, the runner
writes a started record in the workspace state. wu:claim and
agent:session wait up to 10 seconds for that record. Then they print one of
three results:
Control-plane sync sidecar started (pid=…)when the record names the spawned pid.- A warning that the sidecar has not confirmed its start.
- A warning that it exited before starting. The cause is a missing runner, an
invalid
control_planeblock, or no credential.
The first heartbeat comes later, because it is spread over up to four minutes.
The memory/signal route keeps its watermark, cursors and health in its own sync state file:
- Atomic writes. The file is written to a temp file, then renamed into
place. On Windows the rename is retried while another process holds the
file. A zero-byte, truncated or non-object file reads as empty state, the
next sync rewrites it, and a
control_plane_sync_state_unreadableline goes toflow.log. - Budget. Each round trip has a budget, 2000 ms by default. Set
software_delivery.telemetry.cloud_sync.remote_timeout_msto change it. Signals are pushed 100 per batch, and the watermark moves only after a batch is stored. A call over budget, or a failure the HTTP port reports, defers the rest of the work to the next tick. Nothing is dropped. - No echo. Cloud returns pushed signals in the workspace’s own bucket. The
pull leg skips any id this workspace wrote or already ingested, so a local
signal never comes back as a
[REMOTE-SIGNAL].
pnpm lumenflow:doctor reports the sidecar check red in four cases:
- The control plane is configured but the sidecar has never run.
- The sidecar started more than five minutes ago and has never ticked.
- The last tick failed to heartbeat.
- The memory/signal sync’s own last error is set, or the sync state file is
unreadable. The message names the failing leg and its error. Each failure is
also written to
flow.logascontrol_plane_sync_failed.
Gate-run documents and canonical JSON
Section titled “Gate-run documents and canonical JSON”The SDK publishes the documents a host and a gates engine exchange for one programmatic run, plus the RFC 8785 (JSON Canonicalization Scheme) implementation every digest in that design uses.
@hellmai/lumenflow-control-plane-sdk/canonical-jsonis stable. It exportscanonicalizeJson,canonicalJsonBytes,canonicalJsonSha256and the pinned snapshot manifest encoding (encodeSnapshotManifest,snapshotContentId). Non-JSON values, non-finite numbers and lone surrogates are refused with aCanonicalJsonErrorthat never contains the value.@hellmai/lumenflow-control-plane-sdk/canonical-json/conformanceis stable. It exports the shared RFC 8785 known-answer corpus:DOCUMENT_VECTORS, the 24 finiteAPPENDIX_B_NUMBER_VECTORS,NON_FINITE_BIT_PATTERNSandNEGATIVE_VECTORS(a sparse array, accessors and Proxies included). Any package that canonicalises through./canonical-jsoncan run it against its own entry point.@hellmai/lumenflow-control-plane-sdk/experimental/gate-runis unstable until the shapes are frozen. It exportslumenflow.gate-run-request/1,gate-run-result/1,gate-exec-request/1andgate-exec-result/1as zod schemas, types andparse*/safeParse*helpers.
The request and the two executor documents refuse any unknown field. The executor request keeps the policy-declared command separate from what the engine adds: injected_flags and injected_files are placed by the layout trusted policy declares per command (files_separator and files_position, both part of command_id), never go through a shell, and are valid only with an argv command. A flag value uses a small safe character set, and a path flag must point inside the gate output directory. On Windows there is no cmd.exe route: argv[0] must be an absolute path to node.exe followed by a .js, .cjs or .mjs script, and a shell or interpreter is refused as argv[0] on every platform. One executor request is one command-line chunk within the declared platform’s budget. The request in particular has
no field for an outcome, a record, a MAC, a key or an identity the engine computes, so a caller
cannot assert a pass; the master key is passed outside the document. The result is validated
strictly when produced, and read with a must-ignore view: unknown fields are dropped, an unknown gate
state reads as failed, an unknown reuse reason reads as miss, and an unknown overall outcome
refuses the document. Parse errors report fixed reasons and schema paths and never echo input. The
request digest is a binding between a result and the request it names, not authentication: a
result’s authenticity comes from the dedicated result channel.
Related
Section titled “Related”- State machine — the state vocabulary the SDK defines.
- Artifact bundle — the document types the SDK validates.
- Events contract — pack-level event authoring rules.
- Capacity + integrity — the detectors that populate the SDK’s integrity fields.
- Campus Pack — consumes the routing & residency labels for campus-governed compute resolution.
- Campus Governance Boundary — the vendor-name-free invariant these labels enforce.