Skip to content

Web server job API ​

This document specifies the web application's server-side job API. It specifies the HTTP endpoints through which a supervisor creates, observes, cancels, and deletes a job -- an ordinary exchange or a zero-setup exchange -- that the console server drives as an alcove CLI subprocess, and the two typed job-create intents the client submits and the validation that makes both injection-closed. It also specifies the SFTP connection (authored in-app through a separate endpoint) and its validation, the secrets-mount browse contract, the on-disk workdir layout and modes, and the SSE event relay and its full-history replay. It further specifies the exit-code reconciliation and cancellation escalation, the environment variables that gate and configure the feature, the memory-only job lifetime, and the two artifacts a console exchange hands the operator to carry away -- the recurring-run hand-off this API serves, and the partner accept kit it does not. It is the spec-tier complement to the operator-facing overview in CONSOLE.md, which says what the feature is for and how to turn it on; this document says how each request, file, and event is constructed. It consumes -- and re-validates at the trust boundary -- the CLI's fd-3 event stream specified in CLI_EVENTS.md; it does not respecify that stream's construction (see there). It does not cover the exchange protocol that produces the events (see PROTOCOL.md) or the display-sanitization escape format the fields reuse (see DISPLAY_ESCAPE.md). Intended readers are implementors writing a supervisor against this API and security auditors.

The job API exists for the console-appliance deployment: a container serving one party, inside that party's trust boundary, that drives the party's own alcove exchange runs without the operator invoking the CLI by hand. It is not a shared rendezvous between parties; the trust invariant it rests on and what would violate it are in SECURITY_DESIGN.md. The API is dark unless the deployment enables it (see Feature gate), and its whole design -- server-composed CLI inputs, memory-only state, no per-request authentication -- is calibrated to that single-operator posture; the deployment reaches it only from the operator's own machine by publishing the container port to host loopback. The console facilitates a single exchange at a time, and this API holds at most one (see Job lifetime and orphan handling).

Feature gate ​

The API is dark by default. Enablement requires two conditions together: a console deployment build (VITE_DEPLOYMENT_PROFILE=console, the one signal read the same way server-side as the client reads it) and a data root configured (JOB_DATA_ROOT non-empty). Every endpoint resolves this feature gate uniformly, before the job id is even parsed and before any filesystem access or subprocess spawn: if either condition is unmet -- a hosted build (any non-console profile, unset included) or no data root -- the endpoint answers 404 and consults nothing further. On a console deployment where the gate is unmet, the endpoint is not reached at all: the console server's entry refuses it ahead of its routes (see The /api namespace's refusal), so the API's presence is not observable to a caller. That refusal reads this same enablement, so the two are one control applied at two altitudes rather than two independent ones -- a mis-set profile darkens or lights both together. Gating on the deployment profile is the app-layer defense-in-depth safety check that keeps the hosted build from ever serving the routes even when built with JOB_DATA_ROOT set. There is no per-request authentication: the deployment publishes the container port to host loopback (see the reachability rule below), so its only reachable caller is the local operator.

Every job response has Cache-Control: no-store and no CORS headers -- the API is same-origin appliance-local, so a cross-origin caller is never granted access. These are additive to the defense-in-depth response headers the console server already applies globally (see SECURITY_DESIGN.md).

The /api namespace's refusal ​

No request reaches a route under /api unless the deployment profile serves its path.

  • Hosted profile: no server, so no route. The hosted app is a static site (npm run build -w apps/web), and the build writes no file under /api, so a static host answers every path there with the root document, as it answers any unknown path. The peer-coordination broker is a service of its own (packages/peerjs-broker), which a page reaches at the deployment's signaling endpoint rather than under the site's /api.
  • Console profile: only the job API's paths, and only while the job API is enabled. The console server's entry (createConsoleHandler, apps/web/server/console/app.ts) decides this ahead of its job route table with the refusal (withApiGuard, apps/web/src/utils/apiNamespace.ts).

The console entry answers every other path there with the feature gate's own empty 404 -- Cache-Control: no-store, no content type, no body -- before any route runs.

What that settles:

  • On the console server there is no framework router behind the refusal. Its route table matches a job route's path only as written -- no trailing or doubled slash, no case-varied or percent-encoded segment -- and answers every other path under /api, a declared path reached by a method it declares no handler for, a TRACE request, and an absolute-form request target with the same empty 404. A probe therefore reads one answer under /api whether the refusal or the route table gave it.
  • No job route module runs. The request is answered without the router matching it, so no handler, its feature gate included, is entered.
  • Response timing does not separate a declared route from an unknown path, because the discarded document render that separated them is not performed.

Neither deployment serves a PeerJS route or a signaling WebSocket. The static site has no server to attach one. On the console server the broker's own paths (/api/peerjs and those under it) answer the refusal like any other /api path, and the server attaches no signaling upgrade listener, so a signaling upgrade is answered with the refusal's 404, never a 101. A WebRTC party reaches the shared or an operator-provisioned peer-coordination server instead.

How a path is decided: over every spelling of the request path -- as written, case-folded, dot-resolved, and repeatedly percent-decoded -- refusing when any of them lands under /api while the job API is not enabled. The refusal is therefore wider than the router's own resolution and never narrower, and over-refusal is its safe direction. With the job API enabled the console refusal passes the namespace to the route table, which matches a job route only as written. A path still decoding after four rounds is refused on being under /api at all. The Request the entry reads has already had a literal .. or %2e%2e segment resolved by the URL parsing layer before the guard runs, so the guard's own dot resolution is a second layer that matters only for a spelling the parser leaves alone, a double-encoded %252e%252e segment among them.

Three checks hold it, none standing in for another. scripts/api-namespace-routes.test.mjs holds the generated route tree (src/routeTree.gen.ts) to serving no path under /api, and the public asset tree, which the hosted build copies into the static site, to holding nothing there. It reads the router's own account of the paths it serves rather than mapping a route file's name to a path itself, so a name whose served path differs from how it is written -- a flat api.telemetry.ts, a _-prefixed pathless layout, a parenthesized group -- is read as the generator serves it. Job handlers live only in the console server, never in that tree: scripts/job-route-gate.test.mjs holds every handler under apps/web/server/console/routes/ to calling the job gate first and returning its refusal. apps/web/test/integration/apiNamespace.test.ts drives the request forms above against the built console server under both Accept values, with the job API enabled: the spellings that server matches as written answer the job route and every other row the one refusal, so a spelling it stops matching, or starts matching, fails there; that matrix, rather than an assertion per shape, is what catches a framework version that adds a shape. Two dot-segment targets are written on the wire over a raw socket rather than driven through fetch, which resolves a dot segment against the base URL before the request leaves: sent verbatim, /api/peerjs/../jobs/slot and its %2e%2e form answer the job route. The same suite drives the broker's paths and the signaling upgrade against the console server, with and without a foreign Origin and Host, and it refuses every one. Against the hosted static site it holds that the build writes no top-level api entry and that every GET and HEAD row, and each verbatim target, answers the root document through the static-host harness. apps/web/test/unit/utils/apiNamespace.test.ts pins that nothing downstream of the refusal runs.

What the refusal leaves ​

  • The job routes are the console server's alone. They are modules of that server (apps/web/server/console/routes/), served from its route table (apps/web/server/console/routeTable.ts), and the hosted router's tree (src/routeTree.gen.ts) names none of them, so the hosted static site holds no job route.
  • On the console server, /api stays distinguishable from the rest of the site. An unknown page renders the app's document; an /api path answers empty.
  • The two layers fail together. The refusal and the per-route feature gate read the same enablement, so a mis-set profile moves both. Neither is independent depth over the other.
  • Timing is leveled in process only. It was measured single-client over loopback against the built server, where the refused paths sit within the run-to-run spread of each other. Behavior under load, behind the load balancer, and on the dev server is not measured.
  • On the hosted static site, what answers under /api is the host's. The build writes nothing there, which is what the checks above hold; a host-side rule that routes /api elsewhere is outside them.
  • The console profile itself is enumerable. With the API enabled the refusal covers only a path that is still decoding at the round bound; every other /api path is answered by the route table as it otherwise would be. That is accepted under the single-operator trust boundary (SECURITY_DESIGN.md).
  • Overlong UTF-8 and mixed-encoding spellings are not measured. The spellings the matrix drives through fetch are case-varied, percent-encoded, and encoded-space forms, and the dot-segment targets it drives over a raw socket are the two above. How the entry and the router each read a path spelled with an overlong UTF-8 sequence, or with more than one encoding layered, is not measured here.
  • A front end may rewrite the path before the entry reads it. The refusal decides over the request target the entry receives. A proxy in front of the console server, and an HTTP/2 client's :path pseudo-header, can normalize or rewrite a target on the way in, so what the entry reads is not always what the client wrote.

Browser-CSRF gate ​

The shared route gate applies two complementary browser defenses to every route, after the feature gate's 404 (so a disabled API stays a uniform 404, its presence unobservable) and before any filesystem access or spawn. Each refuses with an empty-bodied 403, matching the gate's 404.

  • Loopback Host-allowlist. The request's Host hostname -- port-stripped, IPv6 brackets removed, lowercased -- must be a loopback literal (127.0.0.1, localhost, or ::1) or an entry in the JOB_ALLOWED_HOSTS environment variable (comma-separated hostnames, each trimmed and lowercased, empties dropped); any other value is refused, as is an absent or unparseable Host (fail closed). The match is on the hostname only, so any published-port remapping (-p 127.0.0.1:8080:3000) passes. 0.0.0.0 is never a loopback literal. A rejection is logged server-side naming the Host and pointing at JOB_ALLOWED_HOSTS, so a misconfigured operator gets a self-service diagnosis rather than a silent break. The check keys on the Host header, never the socket peer address (under Docker Desktop the peer is the gateway, not loopback).
  • Origin / Sec-Fetch-Site check. In order: if Sec-Fetch-Site is present and is neither same-origin nor none, the request is refused; else if Origin is present and it is not an http or https origin whose hostname and port equal the Host header's, it is refused; if neither header is present the request is allowed. The scheme is not compared, because the server cannot tell a plain http request from one a TLS front terminated, and one host and port serve one scheme. A Host naming no port stands for the default port of the Origin's scheme, so Host: localhost matches http://localhost and https://localhost but not https://localhost:8443.

The Origin/Sec-Fetch-Site check closes plain cross-origin CSRF: a page the operator visits while the console runs can issue a CORS "simple" request (no preflight) to a job route and drive a side effect -- e.g. make the console connect out to an attacker-chosen host via POST /api/jobs/sftp/probe. Browsers send Origin on state-changing requests and Sec-Fetch-Site on every fetch, and page JavaScript cannot set either (both are forbidden header names), so a visited page cannot forge its way past the check; the console's own UI fetches relative same-origin URLs and passes unchanged; a non-browser client on loopback (curl, the operator's CLI) sends neither header and is allowed. The check is on the request's origin metadata, not its body, so it is content-type-agnostic and covers every route uniformly regardless of body shape.

The loopback Host-allowlist closes DNS rebinding, a standard technique the Origin/Sec-Fetch-Site check alone does not stop. A page at http://attacker.example whose name the attacker rebinds to 127.0.0.1 reaches the API with Host and Origin both naming attacker.example -- genuinely same-origin, so the Origin check passes -- and, being same-origin, the page can then read the response (job results, keys, the SFTP projection), not merely drive a side effect. Requiring the Host to be a loopback name refuses it. This is what makes the no-CORS "cross-origin caller is never granted access" posture above actually hold: without the loopback-Host requirement, rebinding would render an attacker page same-origin and CORS-exempt. JOB_ALLOWED_HOSTS is the deliberate-exposure override for an operator who fronts the console behind a reverse proxy or reaches it by a LAN name -- deployment paths marked unsupported / an explicit choice in CONSOLE.md.

The console's client files take the Host-allowlist too. A request no job route answers is served from the built client (createStaticFileHandler, apps/web/server/console/staticFiles.ts), which applies the same loopback Host-allowlist to a GET or HEAD outside /api before it reads the path, with the same empty 403 and the same server-side log line, whether or not the job API is enabled; every other request keeps the one 404 refusal above. A page reached by a rebound name is therefore never served the console client, rather than served it and refused at its first job request. The Origin/Sec-Fetch-Site check does not apply there: a page load is a navigation, which the operator's own address bar makes as well, and the files are the same client the hosted build publishes. In development (npm run dev:console) the client is served through Vite's middleware instead, which this check does not reach. apps/web/test/unit/consoleServer/staticFiles.test.ts drives a foreign, a loopback and a JOB_ALLOWED_HOSTS name against it.

"Every endpoint" is a check rather than a claim. The feature gate and both browser defenses reach a request only through the shared route gate, so an endpoint that omits the call is exposed on all three counts while each endpoint's own test still passes. scripts/job-route-gate.test.mjs (run by npm run test:scripts, a CI static check) walks the route directory rather than a maintained list, enumerates each module's inline handlers map per HTTP method rather than per module -- a module-level reading would pass one that gates GET and misses DELETE -- and fails on a handler that never calls the gate or calls it somewhere other than its first statement. There is no allowance list. Its reach is syntactic and its header states the limits exactly: a handler written in any shape other than inline in that map is reported as unread rather than passed over, as is a module in that directory with no such map. The gate is matched by imported name and specifier rather than resolved. The check also decides whether a handler acts on that outcome. A handler that binds the call to a name in its first statement and tests that name's discriminant against the outcome's two kinds in the next statement is read. That holds for either polarity, with the refusing side exiting by return or throw, including a success-first guard clause whose fall-through is treated as the implicit refusal. One that discards the call, tests late or not at all, or lets a refusing branch fall through is named. Any other shape (a destructured binding, a helper predicate, a switch, a ternary) joins the unreadable report. It still does not decide that the refusing branch returns the gate's own response specifically, or that a branch exits by anything other than a syntactic return or throw -- the per-endpoint tests are what cover those. A second check, scripts/api-namespace-allowlist.test.mjs, reads the generated route tree for a different claim: that every path the router serves under /api is accounted for by the namespace refusal above, allowlisted or gated. One decides which routes a deployment serves at all, the other whether each handler gates; neither stands in for the other.

Endpoints ​

MethodPathSuccessNotes
POST/api/jobs201 { "id": "<uuid>" }Create and start a job from a JSON intent -- an exchange intent (the default) or a zeroSetup intent, selected by the top-level mode field (see The job-create intent). 413 (empty body) when the body exceeds the size cap (see Size caps). 400 on unparseable body, intent that fails schema validation, an sftp intent with no connection authored, a webrtc intent other than a run of the opened configuration with no coordination server authored and none named, a signing.identityLocation naming nothing in the secrets mount, an inputFile whose name resolves to no admissible regular file in the work-input directory, a filedrop intent where the rendezvous directory is unset or unusable (the same fault the rendezvous report states), or a filedrop intent on a split-provisioned console that does not set retainFiles (empty body, each resolved before any workdir exists). 500 (empty body) when creating the workdir or writing an input into it fails -- the manager has already removed what it made, and no internal detail crosses the boundary, exactly as for the probe below. 500 { "reason": "working-folder-unwritable" } when the data root refuses the write of a webrtc run's alcove.yaml and .alcove.key, leaving neither behind (see The working folder's configuration and key file). 400 { "reason": "signing-identity-in-rendezvous" } for a filedrop intent whose rendezvous directory holds this party's signing identity (see Refusing a run that would publish the signing identity), 400 { "reason": "sftp-fingerprint-list" } for an sftp zeroSetup intent whose authored connection pins more than one host-key fingerprint, 400 { "reason": "sftp-url-directory" } for one whose authored remote directory has no sftp:// URL form (both: see Zero-setup invocation), 400 { "reason": "mounted-key-file-absent" } or { "reason": "mounted-key-file-invalid" } for a run of the opened configuration with no usable key file beside it (see The key file beside an opened configuration), and 400 { "reason": "mounted-signing-paths-unconverted" } for a signed run of an opened configuration stating a signing identity path of its own that the operator did not convert (see Converting an opened configuration), and 400 { "reason": "mounted-signing-identity-mismatch" } for a signed run in place of a file signing with another identity file than the console's (see Running an opened webrtc configuration in place), and 400 { "reason": "sftp-credential-console-file" } for an sftp intent whose authored connection names one of the console's own files, or the signing identity the run would sign with, as a credential (see The authored SFTP connection), and 400 { "reason": "working-folder-configuration-present" } or { "reason": "working-folder-console-exchange" } for a webrtc intent while the data root holds an alcove.yaml or .alcove.key the run would write when it starts and does not run over (see The working folder's configuration and key file), and 400 { "reason": "coordination-server-changed" } for a webrtc intent other than a run of the opened configuration whose signalingServer is not the authored coordination server (see The coordination server), and 400 { "reason": "opened-configuration-not-runnable" } for a webrtc run of the opened configuration whose data root alcove.yaml is not one the console runs in place (see Running an opened webrtc configuration in place) -- the create rejections with a body -- and 400 { "error": "<field>: <reason>" } for a schema-valid intent core refuses while the CLI configuration or the hand-off is composed from it (an sftp run on a split connection without retainFiles, or a composed document past core's node budget), the field naming the composed configuration's and the reason core's own fixed text, so the operator sees which rule the combination broke. 409 { "id": "<uuid>" } -- the id (only) of the exchange occupying the single slot -- when an exchange is already active (see Job lifetime and orphan handling). 503 (empty body) once the server has begun shutting down.
GET/api/jobs/:jobId200 status JSON404 on malformed, unknown, or already-deleted id.
GET/api/jobs/slot200 { "occupied": <bool>, "id"? }Report whether the single exchange slot is occupied, and by which job: { "occupied": false } free, else { "occupied": true, "id": "<uuid>" }. The lobby's recovery panel reads it when the browser holds no attachment; it discloses exactly what a busy POST /api/jobs 409 does and no job list (see The single-slot occupancy probe).
DELETE/api/jobs/:jobId204Kills a still-running child, marks the exchange deleted, removes the workdir. Also removes an on-disk workdir named by a valid id but orphaned by a server restart. 404 when neither the active exchange nor such a workdir matches.
GET/api/jobs/:jobId/events200 text/event-streamSSE event relay with full-history replay. 404 on unknown id.
POST/api/jobs/:jobId/cancel202Request cancellation; idempotent (202 even if already terminal). 404 on unknown id.
POST/api/jobs/:jobId/apply-terms200 { "status" }Apply the partner's changed terms a run of the opened configuration stopped on to the mounted alcove.yaml, by running alcove apply (see Applying a partner's terms change). No body. 404 on unknown id and on a job with no proposal to apply; 409 (empty body) while an apply runs or the run's child has not exited; 503 (empty body) once the server has begun shutting down.
GET/api/jobs/:jobId/result200 text/csvThe matched-result CSV, only after the job succeeded. 404 otherwise.
GET/api/jobs/:jobId/folder200 { "live", "results", "record", "sharedSecret", "receipt", "log", "input" }Which of a run's files its workdir holds, for the active exchange and for a workdir a restart left behind under a valid id (see The GET /api/jobs/:jobId/folder response). 404 when no such workdir exists.
GET/api/jobs/:jobId/record200 application/jsonThe self-attested exchange record, once the run has settled and the pair is on disk -- a run that disclosed and then terminated included (see The GET /api/jobs/:jobId/record and /api/jobs/:jobId/keys responses). 404 otherwise.
GET/api/jobs/:jobId/keys200 application/jsonThe private verification keys paired with the record, under that same gate. 404 otherwise.
GET/api/jobs/:jobId/receipt200 application/jsonThe signed receipt a certificate-mode run wrote, at any point in the run's life (see The GET /api/jobs/:jobId/receipt response). 404 for a run that signed nothing, or before the file exists.
GET/api/jobs/:jobId/log200 text/plainThe diagnostic log a diagnosticRun intent captured, at any point in the run's life (see The GET /api/jobs/:jobId/log response). 404 for a run that captured none, or before the CLI opened the file.
GET/api/jobs/:jobId/handoff200 hand-off JSONThe recurring-run hand-off: the portable, secret-free template plus its metadata (see The recurring-run hand-off). 404 on malformed, unknown, or already-deleted id.
GET/api/jobs/sftp200 { "configured": <bool>, ... }The authored SFTP connection as a credential-free projection: { "configured": false } when none, else { "configured": true, "host", "port"?, "path"? or "inboundPath"/"outboundPath", "credentialWarnings": [ ... ], "zeroSetupRefusal"? }. credentialWarnings is the non-blocking credential-containment warnings; zeroSetupRefusal is the token a zeroSetup create would refuse the connection with. See The authored SFTP connection.
PUT/api/jobs/sftp200 credential-free projectionAuthor the SFTP connection from a file-reference credential body (see Authoring the SFTP connection). 400 (body naming a field path, never a value) on a body that fails hard validation. 413 when the body exceeds its size cap.
DELETE/api/jobs/sftp204Forget the in-app authored connection and its credential warnings (idempotent).
GET/api/jobs/webrtc200 { "configured": <bool>, ... }The coordination server a webrtc job dials: { "configured": false } when none, else { "configured": true, "host", "port"?, "path", "secure", "webAppOrigin"?, "invitationRelay"?, "ownRelay"?, "warnings": [ ... ] } (see The coordination server).
PUT/api/jobs/webrtc200 the same bodyAuthor the coordination server from { "address", "invitationRelay"?, "ownRelay"? }. 400 { "error" } on a refused address, 502 { "error" } when the web app it names cannot be read, 409 (empty body) when a later PUT or DELETE took effect while that read ran, 413 when the body exceeds its size cap.
DELETE/api/jobs/webrtc204Forget the authored coordination server (idempotent).
POST/api/jobs/sftp/probe200 typed envelopeRead the host-key fingerprint a server at { "host", "port"? } presents, for the operator to compare against the published value (see Probing the server host key). 400 (field-path body) on a bad body, 409 (empty body) when a probe is already running; 503 (empty body) once the server has begun shutting down.
GET/api/jobs/relay-check200 typed envelopeThe hosts a relay check of the working folder's alcove.yaml and .alcove.key would contact and those it would not, read from the file without contacting any: { "status": "available", "configurationDigest", "statement" }, or { "status": "unavailable", "reason" } (see Checking the relay). 500 (empty body) on an unexpected fault.
POST/api/jobs/relay-check200 typed envelopeRun the CLI's doctor relay --json over that pair while alcove.yaml is still the file { "configurationDigest" } names (see Checking the relay). 400 (empty body) on a bad body, 413 above its size cap, 409 (empty body) when a check is already running, 409 { "reason": "exchange-running" } while an exchange's child is starting or running, 503 (empty body) once the server has begun shutting down, 500 (empty body) on an unexpected fault.
POST/api/jobs/signing/fingerprint200 typed envelopeReturn this party's signing identity's fingerprint, optionally exporting the public certificate: create-or-reuse at the console's default location in the mounted working directory, READ ONLY at an identityLocation the operator picked (see The signing identity). 400 (field-path body) on a bad body or a location naming nothing in the secrets mount, 409 (empty body) when a request is already running; 503 (empty body) once the server has begun shutting down. 200 { "status": "syncing" } when creating it would write the key into a folder the exchange occupying the slot is syncing (see Refusing a run that would publish the signing identity); 200 { "status": "absent" } when a picked location holds no identity.
GET/api/jobs/rendezvous200 { "configured": <bool>, ... }Whether a filedrop exchange can run here and how an invitation minted here may name the shared folder: { "configured": false } when no rendezvous mount resolves, else { "configured": true, "sharesDataRoot": <bool>, "sharesDataRootUncertain": <bool>, "locator"?, "folderName"? } (see The filedrop rendezvous report).
GET/api/jobs/inputs200 { "configured", "readable", "files" }No request fields. Lists the admissible regular files in the resolved work-input directory (JOB_INPUT_DIR, falling back to JOB_DATA_ROOT; see Environment variables): each entry's name, sizeBytes, and modifiedAt (integer epoch ms), sorted by name. Files that are not inputs are left out: alcove.yaml, alcove.yaml.previous, and alcove-certificate.json; any name in the exchange's protocol filename grammar, which a shared-folder exchange on a one-folder console writes beside the inputs; and any file whose realpath is a credential or signing-identity file the authored SFTP connection or the opened configuration references (a relative reference taken against the data root). configured: false with an empty list when neither variable resolves a directory; readable: false with an empty list when the resolved directory cannot be enumerated (a mis-mount), distinct from an empty-but-readable one. Every reachable state answers 200; the route has no refusal of its own beyond the shared route gate.
GET/api/jobs/inputs/profile200 profile JSONQuery parameter name (required): a single admissible path segment (the same shape rule the listing above applies), resolved against the work-input directory; absent or inadmissible is a 404. Query parameter delimiter (optional): the field delimiter the pass reads the file by, held to the create intent's own csvDelimiter grade -- absent reads commas, and a value the grade refuses is a bare 400 rather than a read by a delimiter nobody chose. The columns this reports become the party's linkage terms, so it is the same choice the composed config states. The body is name, sizeBytes, modifiedAt, rowCount, columns, dateInputFormats -- { column, format } pairs in column order, one for each column whose values the date-format inference infers a format for, so the console parses whichever column the operator binds as the date of birth with that column's own format -- and columnSamples -- one entry per column, every column in the header, each with the first 5 (PREVIEW_SAMPLE_SIZE) non-empty values found for that column scanning rows from the top of the file, held verbatim as the input's own raw cell text: profiling a file discloses sampled real content across every column, not only its shape. 404 also when the work-input directory does not resolve or the name resolves to no regular file. 400 { "error": "<code>" } (too_large, not_a_csv, or parse_failed) when the streaming parse cannot profile the file on one of those closed grounds, or a bare 400 on any other profiling fault; the code is a closed set that never includes the underlying path or a cell's bytes.
POST/api/jobs/inputs/samples200 { "files": [ { "name", "written" }, ... ] }No body. Writes the two synthetic sample CSVs (alcove-sample-inviter.csv, alcove-sample-partner.csv) into the work-input directory, each created exclusively: a file or link already at the name is left untouched and reported written: false. 409 { "error": "unwritable" } when a file cannot be created for any other reason (a read-only input mount), naming no path or OS error. 404 when the work-input directory does not resolve.
POST/api/jobs/inputs/coverage200 { "rates": [ ... ] }Body { "name", "standardization", "csvDelimiter"? }, read under a 1 MiB cap (MAX_COVERAGE_BODY_BYTES): name is 1-255 characters and must resolve to an admissible regular file in the work-input directory; csvDelimiter takes the create intent's own grade, so the sweep reads the file the way the run will and an absent one reads commas; standardization is core's StandardizationSchema plus this route's own bounds (the transformation and step counts, output/input length, and a per-step compiled-regex source-length cap for the functions that compile one, since RE2JS's compile cost lands on this event loop even though its execution is linear-time). Sweeps the named input's per-field non-empty coverage under that standardization in a single streaming pass. 413 when the body exceeds the cap. 400 on unparseable JSON, a schema violation (an unknown field included, since the schema is .strict(), or an over-length compiled pattern), or any other sweep fault. 404 when the work-input directory does not resolve or name is inadmissible or unknown. 499 when the client disconnects or supersedes the sweep before it completes (request.signal aborts the in-progress pass; no client reads this response).
GET/api/jobs/mounts/secrets/entries200 { "configured", "readable", "entries" }List the mounted secrets directory the operator browses for a credential file, and for the signing identity's location (see The secrets mount and browsing).
GET/api/jobs/mounts/folder/entries200 { "configured": true, "readable", "entries" }List the working folder (JOB_DATA_ROOT), which the credential picker browses on a console with no secrets directory (see Browsing the working folder for a credential).
GET/api/jobs/config200 { "configured", "present", "document"?, "carriedThrough", "warnings", "signingPathSettings"?, "folderPathSettings"?, "relayRegistrarNamed"?, "iceProvisionNamed"?, "keyFileFault"?, "inPlaceSigningIdentity"? }The command-line configuration mounted at <dataRoot>/alcove.yaml, projected to the settings the authoring forms edit, and kept as opened for the run that follows. present: false when the mount holds none. 400 { "error" } -- naming settings as the FILE spells them, never a value -- for a document off the shared schema, one on a channel outside the console's allowlist, or one stating a shared secret (see Loading a configuration from the mount).
PUT/api/jobs/config200 { "written": true }Write the settings the authoring steps edit back into the mounted alcove.yaml, for a configuration on a channel the console does not conduct, keeping its connection and every setting no step edits from the file itself (see Saving an opened configuration back). 400 { "error" } on a body the schema refuses, a mount holding no such configuration, settings that do not validate once merged, or a file the console could not write. 413 when the body exceeds its size cap.

Job id and the traversal guard ​

The job id is a server-generated v4 UUID; the client never supplies it. Every id-bearing endpoint validates the parameter against the exact canonical v4 UUID pattern before any filesystem use, and a value that is not a canonical v4 UUID (a traversal payload, an absolute path, an empty string) is 404 without touching disk. Resolving a workdir applies a second, defense-in-depth check: the id is joined to the resolved data root and the result confirmed to stay strictly under <dataRoot>/, so even a validated id that resolved outside the root is refused. An unknown-but-well-formed id (never created, already deleted, or belonging to an exchange the server forgot on restart) is 404 identically, so id validity is not distinguishable from job existence.

The GET /api/jobs/:jobId status body ​

json
{
  "id": "<uuid>",
  "status": "running" | "succeeded" | "failed" | "cancelled",
  "terminal": { "outcome": "succeeded" | "failed" | "cancelled" | "completedWithPersistenceLoss",
                "exitCode": <int|null>, "signal": "<sig|null>" } | null,
  "terminalEmitted": <bool>,
  "eventCount": <int>,
  "resultAvailable": <bool>,
  "recordAvailable": <bool>,
  "recordCreatedAt": "<iso-8601>",  // present only when recordAvailable is true
  "recordOutcome": "completed" | "receipt-swap-terminated",  // same
  "recordCertificateMismatchObserved": <bool>,  // same
  "recordUnavailableReason": "not-settled" | "no-record" | "undescribable-record",  // present only when recordAvailable is false
  "logRequested": <bool>,
  "logAvailable": <bool>,
  "receiptRequested": <bool>,
  "receiptAvailable": <bool>,
  "transportTeardownOverran": <bool>
}

terminal is null until the child exits; resultAvailable is true exactly when status is succeeded.

recordAvailable is true only when the run has settled (its child has exited, so terminal is non-null), both the record and its verification-keys file are on disk, and the record validates into a createdAt, a recognized outcome, and the certificateMismatchObserved marker beside it; the record pair is offered all-or-nothing. Three properties fix that gate.

  • It turns on the record's existence, not on the run having succeeded. A record is owed from the moment this party's payload crosses, so a run that disclosed and then terminated writes one to the same destination a completed run's takes, and it is precisely the disclosure-accounting artifact that run's operator needs. A failure before that point owes no record and writes none, so it reports recordAvailable: false on the file's own absence rather than on a status test standing in for it.
  • Settling is a separate claim from the file being there, and both are required. The CLI writes the pair near the end of a run, so a mid-run read could take a half-written state for the run's answer; a complete pair sitting in a running job's workdir does not open the routes. Settled is the child's exit, not its terminal event: a failing run emits its error event, and status turns failed, before the child has exited.
  • A record that has no recognized outcome, or no certificateMismatchObserved beside it, is treated as unavailable. Every record this console's own CLI writes states both -- the record format has each on every record -- so a file without one is not a record the console can describe, and describing it is required, per recordOutcome and recordCertificateMismatchObserved below. Unavailable is not absent, though, and the body says which: such a file is reported undescribable-record per recordUnavailableReason below.

recordUnavailableReason says which negative a withheld pair is, present exactly when recordAvailable is false. The three are separate answers because what a client may safely do with them differs:

  • no-record -- nothing is at the record path. This is the console's definitive denial, and the only one that licenses a console control to destroy the run's workdir without asking first.
  • undescribable-record -- a record file is in the workdir and the console cannot offer it: its outcome is not one this server bundle knows (a data root a differently-versioned CLI wrote), it does not parse into a createdAt, an outcome, and a certificateMismatchObserved at all, or the verification-keys half beside it is missing so the pair cannot be served whole. A record of a disclosure is on disk, so a client neither offers it nor treats it as an absence.
  • not-settled -- the run's child has not exited, and the CLI writes the pair near the end of it. A client that has seen the run's terminal event asks again until the reason changes; it is not an absence.

The field refines the boolean rather than replacing it: recordAvailable stays the single gate the download routes share, so an undescribable record is withheld there too (the console does not serve a pair it cannot read whole) and the client's own version-skew guard remains the one below on recordOutcome. What the field removes is a client having to read one negative as two things: a body denying availability without naming a reason is a console that predates the field, and is treated as the plain denial it was.

A client's classification still guards only the body it receives, not what is on disk: the reason is the console's reading of the workdir, and a record file that counts as absent to it -- unreadable to the server process, say -- is reported absent.

recordCreatedAt is the record's own timestamp and recordOutcome its own outcome, both present exactly when recordAvailable is true. A client derives the download filenames from the timestamp, matching the in-browser exchange path, and reads the outcome to say what it is offering. A terminated record's commitments re-supply from a result file that run never wrote, so the keys beside it have nothing to open (EXCHANGE_RECORD.md) -- a client that offered the two alike would make a claim the record does not.

recordCertificateMismatchObserved states what the run observed about the partner's certificate, present exactly when recordAvailable is true as the two above are. It is true only on a record whose run held the certificate the partner presented and found it was not the pinned identity, and false on every other record, a termination the run cannot attribute to the certificate included (EXCHANGE_RECORD.md). A client states it as that finding rather than as the reason the run ended, which is what recordOutcome says: the marker puts who received the disclosure in doubt whatever the outcome beside it. It adds nothing to what this body discloses -- no certificate, no fingerprint, and no party the body does not already name.

What a client may read from the field's absence on a body asserting recordAvailable: true is one thing: a console that predates the field, on the reading recordUnavailableReason takes above. It is not a record stating no mismatch, and that is why the marker is written on every available record rather than only where it is true -- a present-only-when-true field would leave a client unable to tell a record that observed no mismatch from a console that does not report one. In both cases the client has nothing to say about the partner's certificate, and in neither may it state a check that passed off a field that said nothing.

Because the CLI's record write is non-fatal (a disk failure after a successful exchange is warned, not thrown), a job can be resultAvailable: true with recordAvailable: false.

logRequested is true exactly when this job's intent asked for a diagnostic run, and logAvailable is true exactly when it did and the log file is on disk. Both are read off the log path the server set at job creation from that intent, never from anything the reading request holds. Neither is gated on status: the run a diagnostic log exists for is the one that misbehaved, so a still-running or failed job reports the log as soon as the CLI has opened it. A run that did not ask for one has no log path at all, so no file planted at that name in its workdir can make logAvailable true.

receiptRequested and receiptAvailable are the same pair for the signed receipt, read off the receipt path the server set at job creation from the intent's signing mode. A run whose intent asked for no signed receipt has no receipt path at all, so no file planted at that name in its workdir can make receiptAvailable true.

The pair is what a client watching for a log reads: logAvailable: false alone does not say whether the file is still coming, and logRequested: false is what lets a client stop asking after one request instead of polling an ordinary run for its whole duration.

An ask that yields neither field -- a non-200, a body that is not this status body, or a request that never completed -- is not an answer about the log at all, and a client does not read it as "not yet". A job the server forgot across a restart, or a route that keeps failing, answers that way for as long as the client keeps asking. A client bounds how many such asks it makes in a row -- the console's seat spends a few seconds on them, not the run -- and tells its operator the server stopped answering for that run, rather than watching silently for a log that will never be reported. An answered ask ends the run of them, whatever it answered.

transportTeardownOverran is true when the run reported that its transport close did not finish and may have left this party's protocol files in the shared exchange directory. It is false until the child's exit is reconciled -- terminal present -- because the report reaches the console only then, and a client reads the two fields together for that reason: false beside a null terminal says nothing has been reported yet, not that the close finished. Which run it is stated for, and what a client does with it, are in The transport-teardown notice.

status and terminal.outcome answer different questions and are read together. status is the artifact promise, set by the terminal event the CLI emitted, and it is what the result route gates on (the record and keys routes read it only for the settled-versus-running distinction above). terminal.outcome is the run classification, set by the child's exit (see Exit-code reconciliation). They diverge on exactly one condition: completedWithPersistenceLoss, the CLI's persistence-loss exit (CLI_EVENTS.md) -- the exchange completed and a local write did not. A run that took one and still emitted its result terminal is succeeded with terminal.outcome: "completedWithPersistenceLoss", and its artifacts are served: the exchange happened and the result file is on disk. The warning events that preceded the terminal name what was lost; the outcome is what tells a supervisor -- or the console seat -- that the run must not be repeated, because repeating it would re-send this party's data for an exchange that already happened.

The GET /api/jobs/:jobId/result response ​

Served only when status === "succeeded" and the output file exists and is readable; any other case (unfinished, failed, cancelled, or missing file) is 404 rather than leaking whether an unfinished job exists. The gate is on status alone, never on terminal.outcome, which is what makes a persistence loss servable rather than withheld: such a run completed its exchange and usually wrote its result file, and the loss is an audit artifact or a configuration record beside it. The one persistence loss that is the result file itself arrives as the CLI's own output error terminal, so its status is failed and this route 404s -- decided by the terminal event the CLI actually emitted, not by the exit code both cases share. The body is this run's result file inside its workdir, located by the run's stamp (Workdir layout) -- never a client-named path. Headers: Content-Type: text/csv; charset=utf-8, Content-Disposition: attachment; filename="alcove-results-<time>.csv" (the result file's own name in the workdir), and X-Content-Type-Options: nosniff, plus the no-store discipline.

The GET /api/jobs/:jobId/folder response ​

Every field is a boolean: live is true for the exchange the slot holds and false for a workdir a restart left behind; sharedSecret, log, and input state whether .alcove.key, run.log, and input.csv are present; results and receipt whether any alcove-results-<time>.csv or alcove-receipt-<time>.json is, and record whether any file of a record pair or its agreed-terms file is, whichever run wrote it, since a discard deletes them all (see Workdir layout). Presence is an lstat or the directory listing, so a file the server cannot read still counts. The body states no file content, size, or path. The slot's own id while it is starting, or deleted with its child still exiting, is 404, as is a leaf that is not a real directory: the containment and real-directory guards are the disk-only DELETE arm's. The console reads it to offer a left-behind workdir to keep or discard, and to list in each discard confirm what the discard deletes.

The GET /api/jobs/:jobId/record and /api/jobs/:jobId/keys responses ​

Both are served under the status route's own recordAvailable rule above -- the run settled, both files (alcove-record-<time>.json, alcove-record-<time>.keys.json, under the run's stamp) readable, and the record validating into a createdAt, a recognized outcome, and its certificateMismatchObserved marker -- and 404 otherwise, an undescribable-record included. The console withholds a pair it cannot read whole rather than serving material it cannot describe, and the status body's reason is what tells the client the file is nonetheless there. One gate for the two routes and the status field means a client that offers a download whenever recordAvailable is true never links a 404, and the pair is never split. Unlike the result response this is not gated on status === "succeeded": a run that disclosed and then terminated exits non-zero and still owes the record of that disclosure, so gating on success would withhold the accounting artifact from the run whose operator most needs it -- while the console's own recovery controls remove the workdir it sits in.

The bodies are this run's record and keys files inside its workdir, located by the run's stamp, never a client-named path. Headers: Content-Type: application/json; charset=utf-8, a fixed Content-Disposition: attachment; filename="alcove-record.json" / "alcove-record.keys.json" (a server-side fallback; the browser's save name is set by its download control and includes the record's timestamp), and X-Content-Type-Options: nosniff, plus the no-store discipline. The verification keys are private material -- a salt plus the record's commitment can open a committed value -- so /keys is gated and no-store identically to /record and /result; see EXCHANGE_RECORD.md.

What the keys are usable for is the client's to state, not this route's to withhold. A terminated run wrote no result file and all three of the record's commitments re-supply from one, so nothing can be opened against that run's keys; the file is still the operator's own material, written beside the record it pairs with, so it is served and recordOutcome is what lets the surface say so.

The GET /api/jobs/:jobId/receipt response ​

Served when the run asked for a signed receipt (signing.mode: certificate) and alcove-receipt-<time>.json exists in the workdir under the run's stamp; 404 otherwise, including for every run that signed nothing. The body is that file, never a client-named path. Headers: Content-Type: application/json; charset=utf-8, a fixed Content-Disposition: attachment; filename="alcove-receipt.json" (a server-side fallback; the browser's save name is set by its download control and includes the run's stamp), and X-Content-Type-Options: nosniff, plus the no-store discipline.

Unlike the record and keys routes, this one is not gated on status === "succeeded". The receipt is written from the mutually-verifiable facts once the signature swap completes, independently of the local record build and of the run's eventual exit code -- a persistence-loss exit is a completed exchange whose receipt may be precisely the artifact that survived. Gating on success would withhold the one third-party-verifiable artifact of the run most in need of one. What the receipt does and does not reveal to a holder is in EXCHANGE_RECORD.md.

A client reads the receiptRequested/receiptAvailable pair independently of how the run ended, not off a successful terminal: the run whose result file was the persistence loss reports its output error terminal and status: "failed", and it is exactly that run whose receipt may be the only third-party-verifiable artifact left. It asks once the run has settled, which is a different condition -- the file appears at the signature swap, so an earlier ask would read a receipt not yet written as one the run does not have, while a settled run's receipt is written or never will be.

A client offers the download only when receiptAvailable on the status route is true, so it never links a 404, and it names the file on the record downloads' stamped convention -- alcove-receipt-<time>.json, the CLI's own default receipt name off the createdAt the record downloads are stamped from. Where the same status body reports no record there is no createdAt to stamp from, and the job id stands in: the receipt outlives the record's build (the reason this route is not success-gated), so the name falls back rather than the download being withheld. The two status fields are read together and separately from availability: receiptRequested true with receiptAvailable false is a run that asked for a receipt and has none, which a client states rather than rendering as no control at all, while a body with neither as true answers nothing about a receipt and leaves the client with nothing to say.

The recurring-run hand-off ​

GET /api/jobs/:jobId/handoff returns the portable material an operator carries from a prototyped console exchange to a scheduled command-line alcove run. It is composed at job creation from that run's intent and resources and held on the in-memory record, so it is served for the whole lifetime of a live record, and the panel is available over that whole lifetime too -- collapsed while the run is in flight, expanded once it completes. The endpoint keys its 404 on job existence, not status: a malformed, unknown, or already-deleted id is 404 identically to the other id-bearing routes. It is JSON with the no-store discipline. The body is:

json
{
  "mode": "exchange" | "zeroSetup",
  "channel": "sftp" | "filedrop" | "webrtc",
  "usedKeyFile": <bool>,
  "keyFileBesideConfiguration": <bool>,
  "credentialPasted": <bool>,
  "usedSigningIdentity": <bool>,
  "signingSettingsToSet": [ "linkage_terms.identity" | "signing.identity_file", ... ],  // present only when non-empty
  "pathsAsRead": { "credential": <bool>, "sharedDirectory": <bool>, "signing": <bool> },
  "bindPaths": [ { "path": "<absolute path>", "readOnly": <bool> }, ... ],
  "template": { "kind": "config", "yaml": "<alcove.yaml text>",
               "argv": [ "<token>", ... ] }
             | { "kind": "workingFolder", "argv": [ "<token>", ... ] }
             | { "kind": "command", "argv": [ "<token>", ... ] }
}

usedKeyFile is true for the exchange mode (a .alcove.key the operator must copy), false for the zero-setup mode (which has no shared secret). keyFileBesideConfiguration is true for a run of the opened configuration and for a webrtc run, whose key file is the one beside <dataRoot>/alcove.yaml rather than one in the job's workdir, so the panel points the operator at the file the run rotated; it is false for every other run. credentialPasted is true only for an sftp run whose credential was a pasted, materialized value (the panel then tells the operator to save it to a file); it is always false on the filedrop channel. usedSigningIdentity is true for a certificate-mode exchange, and drives the panel's carry-the-key caveat: the signing key is a file rather than a setting, and the scheduled run must load the same one -- a fresh alcove fingerprint on the scheduling machine mints a different key with a different fingerprint, which the partner's pin then rejects. signingSettingsToSet names, as the file spells them, the settings the template's certificate-mode signing block lacks of the two Alcove refuses such a block without before it exchanges anything: a party name in linkage_terms.identity (core's assertCertificateModeNamesLocalParty) and signing.identity_file (the exchange command's pre-flight). An unconverted opened document's block, stated as read for a run that signed nothing, can lack either; the panel then warns, naming each setting to set or signing.mode: none, rather than the create refusing a run that is itself sound. pathsAsRead states, per kind of machine-specific path, whether the template holds the opened configuration's own value rather than a placeholder: credential when every sftp credential @path the template states is the file's (false on filedrop, and false while any credential field is still a placeholder), sharedDirectory when the filedrop folder -- or both folders of a split pair -- is the file's (false on sftp), and signing when the template's signing block states an identity_file other than the placeholder. It is computed from the template itself, so a path the opened file itself states as a placeholder counts as a placeholder. All three are false for a zero-setup run, a converted configuration, and an exchange authored on the console. The panel reads it to word each path caveat: a path held as read is named as the one in the configuration the operator opened, to check on the scheduling machine; a placeholder is named as one to set; and the pasted-credential caveat says to save the credential at the path the configuration names rather than to point the path at a new file. A body missing the field, or holding anything but the three booleans, is malformed and shows no panel. The template is discriminated on kind: an exchange run yields a config (the alcove.yaml document, recomposed through the same functions the live run used, beside the argv tokens of the alcove exchange INPUT OUTPUT command that runs it), a zero-setup run a command (the argv tokens of the alcove URL INPUT OUTPUT form). A webrtc run yields a workingFolder instead of a config: the argv tokens of the alcove exchange INPUT OUTPUT command alone, run on the data root's alcove.yaml and .alcove.key as they stand (see The working folder's configuration and key file), which hold the rotated secret and any partner pin a first contact recorded, so no recomposed document is offered without them. Every command ends on the same --log-file=exchange.log INPUT OUTPUT: the log file a scheduled run appends to in the folder it runs in, the input as the name of the file the run picked from the work-input directory (input.csv for an uploaded one, and ./NAME for a name starting with -, so the CLI reads it as a file rather than a flag), and the output as the folder the command runs in (./), where the CLI writes each run's result under its own stamp. A body whose argv is not alcove, a command or URL, and the two positionals is malformed and shows no panel.

bindPaths lists each absolute path the template names outside the folder the command runs in -- each sftp credential @path (read-only), each filedrop folder, and the signing identity file (read-only) -- once each; a relative path resolves under that folder and is not listed. A workingFolder template states no path, and lists the signing identity placeholder /path/to/your/signing-identity.json where the run signed, as the path the panel has the operator set signing.identity_file to. A container run of the published image reads each only where it is mounted at the same path. A body missing the field, or holding an entry that is not an absolute path and a boolean, is malformed and shows no panel.

The panel builds the commands it shows from argv, bindPaths, and this build's image reference; the lines it composes are described in CONSOLE.md.

Limit: % and ! in the Task Scheduler line. The line's action runs the command under cmd.exe, which expands %NAME% even inside the double quotes the panel wraps a token in, and !NAME! where delayed expansion is on. The panel neither escapes nor refuses either character, so a token the operator chose, such as the identity or the input file's name, that holds %NAME% for a variable defined on the scheduling machine reaches Alcove with that variable's value in its place.

The config text is written by core's serializeExchangeDocument -- the writer the CLI's own saveConfig calls -- over a document parseExchangeSpec has validated, so the template is the file Alcove would write for those settings, guidance comments included, and a document that fails validation is refused here rather than at the operator's first scheduled run. What that writer does and does not preserve is in EXCHANGE_FILE.md.

Where the run was composed from a configuration the operator opened off the mount -- the intent's mountedConfigurationOpened, which the browser sets from the load's own state (see Loading a configuration from the mount) -- the template merges the top-level keys of that document, as the open read it, OUTSIDE the blocks the console's composition can write with the run's own composed document. A block the composition can write -- the connection, the linkage terms, the signing block, the authentication block holding the max-age policy -- comes from the composition, present or absent as the run composed it, except that an unconverted document's signing block is put back as read (below): a converted signing block does not survive a run whose intent turned signing off, and the document's authentication block does not survive a run whose intent states no tokenMaxAgeDays, since the composition's absence of the block is itself the operator's edit. What survives beside it is the settings the console composes no key for, which the load names for the operator, and an inline credential value the opened file states never reaches the template; a credential it states as an @path follows the conversion rule below, like the other paths. Without the flag nothing opened is merged, so an exchange the operator authored here takes nothing from a configuration sitting in the folder that they never opened; a mount holding no configuration, one the console refused, or one on a channel the console does not conduct contributes nothing for the same reason, and the template is the composition alone.

The paths in those blocks depend on whether the operator converted the document (the intent's mountedConfigurationConverted; see Converting an opened configuration):

  • Not converted. The template states the paths the document read, since the file is the operator's own: a filedrop document's connection.path, or its inbound_path/outbound_path pair, where the run kept the filedrop channel; an sftp document's credential @path, field by field (password, private_key, private_key_passphrase), where the run kept the sftp channel and stated that field as an @path -- an inline credential value stays the composed placeholder; and, for a run that signs nothing, the document's signing block exactly as read -- mode, identity_file, partner_fingerprint -- since the run's mode is that run's choice and the file is the operator's. A certificate run, which the create admits only for a document stating no identity_file, gets the certificate block the save below writes: the run's partner pin and the placeholder identity.
  • Converted. The template states the console's own paths as the placeholders the invariants below name, exactly as for an exchange authored here.

The merge base is never a read of the mount at run start. The server keeps the bytes GET /api/jobs/config read, in memory, until the next open; at run start it compares them with the file the mount holds now. The run takes the open it uses when its create claims the slot, before any write, so an open landing while the run is starting affects only a later run. Where the two differ -- the file edited, removed, or unreadable -- the run still starts and composes from the document as opened, and a relayOpenedConfigurationChanged warning names alcove.yaml and tells the operator to open it again to run what it holds now. A run flagged as opened on a server that holds no open, such as one restarted since, raises the same warning and merges nothing. The run starts rather than being refused because the file is the operator's own on their own machine, and what runs is the document they reviewed.

Two invariants hold by construction and are pinned by tests:

  • No secret. The response never includes the shared secret, the key-file body, or an inline credential value. The exchange config holds the credential only as an @path reference (the secret rides the key file, which never crosses this API), and the zero-setup command has no secret at all.
  • No container path. The response never includes a container-internal path. The credential @path (sftp) is replaced with the fixed placeholder @/path/to/your/credential-file (a private-key passphrase with @/path/to/your/passphrase-file), the filedrop rendezvous directory with /path/to/your/shared-directory in a config or file:///path/to/your/shared-directory in a command (on a split rendezvous, the inbound directory with /path/to/your/inbound-directory or file:///path/to/your/inbound-directory and the outbound one with /path/to/your/outbound-directory in a config and on --outbound-path), and signing.identity_file with /path/to/your/signing-identity.json. An unconverted opened document's own folder, sftp credential, and signing identity paths are the operator's, read from their file, and are stated in place of the placeholders above; an inline credential value is never among them, since it never reaches the template. The portable values -- host, port, username, the remote SFTP working directory, the host-key fingerprint pin, the linkage terms, and the run's tuning options -- are emitted verbatim as they ran; only the machine-specific local paths become placeholders.

The tuning options transfer across in whichever form the mode uses: the exchange template's options block is the one the live run composed, and the zero-setup template's flags are built by the same builder the live argv used. A run that kept its exchange files therefore graduates to a scheduled run that keeps them too -- which matters because the trio is a bilateral agreement, so a scheduled run that silently dropped it would stall against a partner still retaining. The same holds for the connection tuning: a run prototyped against a slow peer or a session-capping server graduates to a scheduled run with the same polling, timeouts, retry budget, and session mode, rather than one that quietly reverts to the defaults. The signing block transfers across the same way -- the mode and the pinned partner fingerprint are portable and emitted verbatim. Each scheduled run writes its receipt into its output folder under the run's own time stamp, so a schedule accumulates one receipt per run.

The partner accept kit ​

The accept kit is the console's second graduation artifact: a plaintext, printable, ASCII instruction sheet composed in the browser at invitation-mint time -- from the values captured there and the build's own release version, the named values below and nothing else -- and written to the operator's disk through the same client-side blob download as the exchange file. It is specified here beside the hand-off because the two artifacts share the invariants below and must not drift, not because the job API serves it: no endpoint returns it. What the sheet is for, what it walks the partner through, and which channels get one are in CONSOLE.md.

Its contents are fixed text apart from two kinds of interpolated value:

  • The rendezvous locator minted into that invitation -- the sftp host, optional port, and either the shared remote directory or the split inbound/outbound pair; or, on filedrop, the shared folder's name or a split rendezvous's two folder names -- printed back for the partner to check. It is taken from the same values that built the invitation's connectionEndpoint, so the sheet can never name a rendezvous the token does not hold. On the filedrop channel it prints the shared folder's name only where the console can name it (folderName in The filedrop rendezvous report); where it cannot, the sheet omits the name entirely and says so, rather than printing the container mount point the folder was bound at as though it were the folder's name. The sheet therefore prints a subset of what the token holds, never a different value. What it asks for is a check and not a match: the same shared folder can have a different name on each side -- a share root mapped to a drive letter is the ordinary case. So the sheet asks the partner to check the name against the folder they were told to use rather than asserting their own name for it will be the same. The step that repoints the partner's own config at their copy of the folder follows the same branch: named, it says accepting wrote the inviter's name for the folder; unnamed, it calls what accepting wrote a placeholder, because what the token held there was the console's mount point. Either way it directs the same replacement, so the sheet never tells the partner a value is a name neither side chose.

    A split rendezvous -- on either channel -- names its two directories by what the reader does with each ("You write to" the inviter's inbound, "You read from" the inviter's outbound) rather than by "inbound"/"outbound", which the endpoint states from the inviter's side and which the partner's own tool mirrors. The filedrop split keeps its shape independently of its names: a console that cannot name either folder still describes a two-folder rendezvous, since falling back to the single-shared-folder body would send the partner to set up the wrong thing. That body directs a two-mount run (inbound_path/outbound_path against two fixed container mount points), and its network-drive branch hands the reader to the same PowerShell launchers the single-folder branch does, since they provision a pair as well as one shared folder. What the branch adds is which folder the launcher asks for first and the guidance that keeps the pair inside one exchange folder: the launcher reaches two folders on one share through a single mount over the folder that holds them both, so folders further apart leave it reaching everything between them.

  • The release version of the build that composed it, which decides two things together: the image tag every docker run line includes, and the release page the filedrop launcher branch links. A kit minted on a released console image names ghcr.io/georgetown-mdi/alcove:X.Y.Z in every command and links that release's own page, so the partner runs the version that minted their invitation and takes the launchers from the release that built it. The image build reads the version from apps/cli/package.json, the canonical release version (see RELEASES.md), and bakes it into the client bundle as VITE_ALCOVE_VERSION; the kit therefore names whatever version the manifest held at image build. The published image's own tag comes independently from the pushed git tag, and on the release path their agreement is checked rather than left to the release process's step ordering. The publish job refuses a tag whose version disagrees with the manifest, naming both values, before anything is built or pushed, and the image build itself refuses a manifest read that yields no version rather than baking an empty one into the bundle.

    A value is interpolated only in the release shape -- X.Y.Z with semver's optional prerelease and build suffixes, excluding the 0.0.0 marker held by the manifests that have no release version. Anything else, including an absent value, names the floating latest tag a release publishes alongside X.Y.Z and links the releases index, so a build that passes no version -- the continuously deployed hosted deployment, or any web build outside the image build -- prints a working command rather than an empty, partial, or placeholder tag. That gate is also what holds the sheet's ASCII contract for this value: unlike the rendezvous locator, it is never filtered, because no value outside the shape reaches the sheet at all.

    Two limits hold as measured. The tag check binds a tag push and nothing else, and the image build takes whatever version the CLI manifest holds, which between releases is the last published one. An image built from a non-release tree therefore names that release rather than the build that minted the invitation, and a release-branch tree built after the version bump but before the tag is published names a release that does not exist yet. And the gate holds the value to release shape, not to Docker's tag grammar: a semver suffix -- which the release process never sets, publishing bare X.Y.Z only (see RELEASES.md) -- would render into the tag verbatim.

The exchange's own settings reach the sheet as selectors, never as values, which is what keeps the count at two. The ones the partner must be told about are the bilateral, non-negotiated file-sync settings the run uses -- retain mode and the lockless rendezvous -- because their own run must use the same ones or the two sides stop at rendezvous (see FILE_SYNC.md). Retain mode additionally leaves every protocol file in the rendezvous location afterwards, which is the partner's to know before they join. The kit takes each as a boolean, resolved as the run resolved it (so retain mode's implication of the lockless rendezvous is already folded in), and each picks between fixed paragraphs and a fixed flag on the exchange command the sheet prints: --retain-files under retain mode, --lockless-rendezvous where the rendezvous setting resolved on with retain mode off. One flag rather than the trio, and never both, because the CLI resolves what retain mode implies, so the sheet cannot state a rule the CLI does not enforce. Rendering the option block instead would make a third value representable and void the property above, so any later setting the partner must know about is disclosed the same way. With both settings at their defaults the sheet is byte-identical to one composed with no such setting at all, and under retain mode it is byte-identical however the rendezvous setting resolved.

What the retain paragraphs state, in the channel's own terms and without reprinting the locator, is that the exchange keeps every file it writes as a permanent transcript. They also state where those files persist -- the directory the two parties meet in on the SFTP server the sheet has already named, or the shared folder, or, on a split rendezvous of either channel, both directories the sheet named above -- and that nothing removes them, so both sides must start from an empty one. They state that the message bodies stay ciphertext there while the rendezvous control files do not, so a later reader of that location learns that an exchange happened, when, how many messages each side sent and how large they were, the name each side ran under, and the settings each side announced. And they state that the agreement is bilateral, the flag on the printed command being the partner's half of it. The filedrop launcher branch hands the partner to the console's accept flow rather than to the sheet's commands, so on that branch the disclosure says to choose to keep every exchange file in the console instead of naming the flag, and both routes off the sheet include the setting.

The lockless rendezvous leaves nothing behind, so it contributes no section of its own. The whole of what it has to state is the agreement, and the sheet states it where the reader meets the flag -- that the setting is their half of one the inviting side runs, and that dropping it stops the two of them with an error when they meet. The launcher branch is covered the same way as retain mode's, naming the console control ("Lockless rendezvous") in place of the flag, so both routes off the sheet include this setting too.

Four invariants hold by construction -- nothing else is representable as an input -- and each is pinned by a test:

  • No secret. The sheet has no shared secret, key-file body, or credential value. The credential an sftp partner needs is theirs, not the inviter's: the sheet names the placeholder username acceptance seeds (see EXCHANGE_FILE.md) and the @path convention for pointing at a credential file, and states that the invitation has no credential by construction.
  • No invitation token. The sheet shows the fixed placeholder PASTE_YOUR_INVITATION where the invitation string goes, never the encoded token or its deep link. The invitation travels on its own confidential channel; the sheet is safe to pass around and to print.
  • No container path. The only container paths on the sheet are the fixed mount points of the commands the partner will run (/work, /sync -- or /sync-in and /sync-out for a split rendezvous -- and /run/secrets); no path inside the inviter's console appears.
  • No inviter-machine path. Nothing from the inviting operator's filesystem appears beyond the rendezvous locator the invitation already holds -- on the filedrop channel that is a single path segment naming the shared folder, never the console's resolved absolute path. The mount path does not cross the job API at all (see The filedrop rendezvous report), so no composition step in the browser has one to leak.

Every docker run line mounts the working folder as -v "${PWD}:/work", one form for every shell the sheet addresses: it reaches docker as one argument in Windows PowerShell 5.1, bash, and POSIX sh. Limits: from a network-share location, Windows PowerShell 5.1's $PWD is provider-qualified (Microsoft.PowerShell.Core\FileSystem::\\server\share\...), which is not a path docker can mount, so the sheet's commands run from a folder on a local drive; measured in CI on Windows PowerShell 5.1.

The single-slot occupancy probe ​

GET /api/jobs/slot reports whether the console's single exchange slot is occupied, and by which job: { "occupied": false } when free, else { "occupied": true, "id": "<uuid>" } naming the occupying exchange. Occupied is exactly the state in which a create is refused with a 409 (see Job lifetime and orphan handling), so this probe and the busy create disclose the same occupant and cannot drift.

The console lobby's recovery panel reads it when the browser holds no stored attachment: a browser that never started the exchange -- a second machine, or one whose recovery record was lost -- can then still see the slot is taken and re-attach to the running exchange or discard it, closing the gap the attachment-only recovery left. It is gated identically to the rest of the job API -- the feature gate, the loopback Host-allowlist, and the browser-CSRF check all apply, and a disabled API is 404 -- so it adds no new perimeter. It discloses exactly what a busy POST /api/jobs 409 already does (occupied plus the occupant's id, a non-secret v4 UUID reachable only over the loopback-local origin) and nothing more: it is not a job listing (GET /api/jobs stays absent), has no run detail, and adds no destructive endpoint -- clearing the slot rides the existing per-id DELETE /api/jobs/:jobId through the panel's Discard. The static slot segment cannot be captured as a job id: ids are validated as canonical v4 UUIDs before any use, which slot is not.

The filedrop rendezvous report ​

GET /api/jobs/rendezvous reports whether a filedrop exchange can run on this console, over one shared folder or a split inbound/outbound pair, and what an invitation minted here may say about each folder. The body is { "configured": false } when no rendezvous mount resolves (neither JOB_RENDEZVOUS_DIR nor JOB_DATA_ROOT is set), or { "configured": false, "problem": "..." } when the mounts resolve but cannot run an exchange as provisioned, else { "configured": true, "sharesDataRoot": <bool>, "sharesDataRootUncertain": <bool>, "split"?, "locator"?, "folderName"?, "outboundLocator"?, "outboundFolderName"? }:

  • locator -- the advisory locator the invitation's filedrop endpoint holds: the shared folder's name where the console has one, else the resolved mount's own last path segment. Always a single path segment, never the resolved mount path. A mint needs one whether or not the folder can be named, because core's endpoint schema requires a filedrop directory. Absent only for a mount with no last segment at all, which the browser client treats as unavailable.

  • folderName -- the shared folder's own name (see JOB_RENDEZVOUS_NAME under Environment variables), present only where the console can name it. It is the only one of the two that may be shown to anyone as the shared folder's name: the inviting console's confirm line and the accept kit take this field and say nothing where it is absent, so neither presents a mount point the operator did not choose as a folder the partner could recognize. A console accepting an invitation holds to the same rule from the other side: an invitation's locator is the inviter's folder name where their console had one and their launcher's mount segment where it did not, and the accepting console cannot tell those apart, so its confirm line renders no part of the locator at all. It names the directory mounted on the accepting console instead -- this field, from that console's own report -- and asks the operator to check it with their partner rather than to match a name. Presence: the launchers emit JOB_RENDEZVOUS_NAME unconditionally, an empty value where no name is derivable, and an empty value resolves to no name rather than the mount segment. That emptiness path rests on the engine passing --env NAME= through as set-but-empty, and an engine that dropped the empty value instead would leave the console on the mount segment. Measured 2026-10-08 on a real Docker engine, a set-but-empty --env JOB_RENDEZVOUS_NAME= reaches the container as an empty-valued environment variable rather than being dropped, so the emptiness path holds beyond the launcher tests' stub engine.

  • split -- present and true exactly when JOB_RENDEZVOUS_OUTBOUND_DIR names an outbound mount, so this console rendezvouses over a pair. outboundLocator and outboundFolderName are that leg's counterparts to the two fields above, under the same rules, and locator/folderName then describe the inbound leg alone. A split body always includes outboundLocator: an invitation's split endpoint needs a locator per leg, so the browser client reads a split body without one as unavailable, exactly as it reads a configured body with no locator at all.

  • sharesDataRoot -- whether a rendezvous leg holds the data root, so the folder the partner syncs into is also the folder this party's signing key, config, input, and results live in. Always present on a configured: true body, and a bare boolean: it is the single-mount layout as a fact, never the path that decided it. True on the JOB_DATA_ROOT fallback and for any leg an operator pointed at the data root or at a folder containing it; false where the rendezvous has a mount of its own. The test is directional -- a leg mounted inside the data root does not hold it, since the sync reaches that subfolder rather than the key beside it -- and runs over each leg both as configured and as its realpath, on the terms the split pair's containment refusal above uses. Where the paths leave the layout open it then compares filesystem identity: the leg's (st_dev, st_ino) pair against that of the data root and of every ancestor of it, up to the filesystem root. That is what extends the verdict to aliasing no path expresses -- one host directory bind-mounted at two container paths (-v /host/alcove:/data beside -v /host/alcove:/mnt/share) presents two distinct real paths for the one directory a partner's sync writes into while stat identity joins them. Measured 2026-08-26 under Docker, the two aliased mounts kept distinct real paths, reported one (st_dev, st_ino), and a write through one alias read back through the other. That measured shape is what the walk is built for; the fail direction is benign, since an engine that resolved both container paths to a single real path instead would have the lexical comparison decide the layout, leaving the identity walk a no-op and the advisory raised either way. The identity comparison keeps the same direction the path comparison has, since it is the data root's ancestors that are walked and not its contents. A leg or a data root whose real path cannot be read counts as holding, as does a directory the console cannot stat: the aliasing that would join them is what could not be resolved, and this field decides whether the console raises an advisory and which copy it raises, never whether a run is refused, so what cannot be ruled out is reported. The browser reads an absent or non-boolean field as true for the same reason. What stays invisible is aliasing that neither realpath nor the identity of the data root's own ancestor chain can see -- a leg bound onto a directory whose contents reach the data root by a route that chain does not pass through -- so the field is a best-effort answer about the layout, not a proof of separation. The separate mount is what makes the layouts different, not this report. It has two consumers. The shared-folder notice beside the shared-directory transport is raised where the field is true, hedged or not, and states what the partner's sync copies out of the folder and the flags for a mount of its own. The console's receipts card WITHHOLDS its advisory about the shared mount where this field positively says the rendezvous has a mount of its own: no folder the partner syncs holds the key there, so the warning channel keeps its meaning for the layouts where the hazard is live. Where the field positively establishes the shared layout the card states the refusal in force there and the one path it reads (see Refusing a run that would publish the signing identity); on a hedged or unanswered report it states what the pre-run check can miss instead, since that is where the check can be fooled. The card's other word about the signing key -- that it is written into the mounted folder and how to look after it -- is true on every layout and is shown on every layout.

  • sharesDataRootUncertain -- true when a sharesDataRoot: true verdict was defaulted because a real path or a directory in the comparison could not be read, false when the comparison established it. Always present on a configured: true body, a bare boolean like sharesDataRoot, and true only alongside sharesDataRoot: true. It selects between the card's two copies above: the browser treats the shared layout as established only when sharesDataRoot is true and this field is false, and reads an absent or non-boolean value as uncertain.

  • problem -- why a filedrop exchange cannot run as this console is provisioned, in the operator's own terms and naming the environment variable to set. It accompanies configured: false, because the console cannot run one; the surfaces that report the unavailable state relay it in place of their generic "nothing is mounted" copy, so an operator who mounted two folders is not told to mount a first. The faults it reports are the ones a split pair can be provisioned into and no exchange could survive. They are two legs that are the same directory or nested one inside the other (this party would read its own writes as the partner's), and two legs whose derived locators resolve alike (core refuses such an endpoint at the mint, with nothing an operator can act on). They are also a leg the console can put no locator on at all (an invitation minted here would hold none for it). They are also an outbound mount with no inbound one of its own (the inbound leg's JOB_DATA_ROOT fallback does not provision the inbound half of a split, so JOB_RENDEZVOUS_OUTBOUND_DIR set while JOB_RENDEZVOUS_DIR is unset is half a pair whatever the data root resolves to).

What the first of those faults -- two legs that are the same directory or nested one inside the other -- resolves, and what it does not:

  • It compares each leg both as configured (lexically resolved) and as its realpath, in every pairing of the two forms, so an outbound leg symlinked onto or into the inbound one is refused exactly as a lexically nested one is -- the same refusal, in the same words. The inbound leg is partner-written, so a pair provisioned as two folders that silently resolves onto one is outside the layout latitude the console leaves the operator, however the collapse is spelled.
  • A leg that does not exist yet is resolved through its nearest existing ancestor with the missing tail re-appended, so a mount not created until the first exchange is still compared through a symlinked parent. A component that does not exist cannot be the symlink that joins the legs; its parent can.
  • A leg whose real path cannot be read (a permission or I/O failure on the way to the mount, as opposed to a component that is simply absent) narrows the comparison back to the configured paths and warns at startup naming the variables that could not be resolved. It never becomes a refusal of its own: a filesystem that cannot answer is not evidence the legs are one directory, and the lexical verdict still stands on its own.
  • It does not follow a dangling symlink (there is no directory at the far end for an exchange to write into), and it resolves each leg once, when the provisioning is read at startup, so a symlink created or repointed while the console runs is not seen. The exchange's own entry guard remains the safety check.

The resolved mount paths are not in the body -- neither leg's, and not inside problem, which names variables rather than paths. Nothing in the browser needs them, and leaving them server-side is what makes the "No inviter-machine path" invariant above a property of the API boundary rather than of every consumer remembering to reduce a path before minting.

The GET /api/jobs/:jobId/log response ​

Served when the job exists, its intent asked for a diagnostic run, and the log file is readable; 404 in every other case, including a run that captured no log. Unlike the result response this one is not gated on status === "succeeded", nor even on the run having settled: the run a diagnostic log exists for is the one that failed or stalled, and withholding the log until success -- or until the stall ends -- would withhold it exactly when it is wanted.

The body is run.log inside the job's own workdir. The path is composed from the workdir and that fixed name and then confirmed to resolve strictly under the workdir (resolveWorkdirFile), so no operator-typed or intent-supplied value reaches it -- the intent's control is a bare boolean, and the client never names a file.

Headers: Content-Type: text/plain; charset=utf-8, Content-Disposition: attachment; filename="alcove-run-<id>.log", and X-Content-Type-Options: nosniff, plus the no-store discipline. It is served as an attachment rather than inline for two reasons: a debug-level log is private material -- it can contain partner identity, linkage keys, and data categories, which is why the CLI creates the file owner-only -- and it holds text the partner and the transport chose, which an attachment keeps off the console's own page.

The job-create intent ​

POST /api/jobs accepts a JSON body validated against a strict schema, discriminated first on mode -- "exchange" or "zeroSetup" -- and, within each mode, on channel. The discriminant is optional on the exchange arm alone: a body that omits mode defaults to "exchange", while a zero-setup body must name itself explicitly, so an omitted mode is never routed there. Both modes are injection-closed by construction, on identical grounds: no field on either becomes a filesystem path, a host, or a credential reference, a zero-setup field that reaches the argv does so only as a bare flag or a schema-bounded value in a single --flag=value token (see Zero-setup invocation), and every leaf arm is .strict(), so a smuggled connection, server, or remote key fails the parse regardless of mode. On the sftp arm of either mode, connection material is drawn exclusively from the authored server-side connection (see The authored SFTP connection), and on the webrtc arm of the exchange mode from the authored coordination server (see The coordination server), or for a run of the opened configuration from the mounted alcove.yaml itself; the client contributes no connection field on either mode. POST stays closed to connection material even where the connection is operator-authored in-app: authored material flows only through PUT /api/jobs/sftp (a separate endpoint whose body is validated by the same connection chain) and PUT /api/jobs/webrtc, never a field on either mode's intent. Every arm of both modes additionally has exactly one input source -- inline inputCsv content or a mounted inputFile reference -- enforced by a union-level refine after each arm's own strict parse, so a body naming both or neither fails identically regardless of mode.

The exchange intent ​

An exchange intent (mode: "exchange", or mode omitted) drives alcove exchange against a composed config and key file: the linkage terms and the shared secret are pre-agreed and stated on the intent itself, so the exchange runs against a partner already holding the same secret.

FieldType / validationWhy it cannot inject
modeoptional literal "exchange"Defaults to "exchange" when omitted (see above); an explicit "exchange" is also accepted.
channel"filedrop", "sftp", or "webrtc"The closed discriminant. A filedrop exchange has no host and no credential field at all, so the connection block the server composes holds nothing injectable. An sftp exchange draws every piece of connection material from the operator-authored connection; the sftp arm has no connection field at all (the console runs exactly one authored connection). A webrtc exchange dials the operator-authored coordination server; its arm has no connection field either, only signalingServer, which is compared with that server and composed nowhere. Any other value is rejected as unknown. A remote field on any arm is rejected as an unknown key by the strict parse.
linkageTermscore's LinkageTermsSchemaBounded partner-authored vocabulary (field names, key elements, transforms). It has no filesystem path, host, or command field, so a hostile value cannot escape into argv or the filesystem. Its identity is optional to core, and required by this surface only under signing.mode: "certificate" (see the signing row).
sharedSecretbase64url 32-byte pattern (/^[A-Za-z0-9_-]{42}[AEIMQUYcgkosw048]$/); required, except absent exactly when mountedConfigurationOpened is trueCredential material matching the CLI key-file shape. It is written into a fixed-name key file, never used as a path or argv fragment; a malformed secret is rejected here rather than crashing the child at load. A union-level refine refuses an intent stating both a secret and mountedConfigurationOpened: true, or neither: that run uses the key file beside the opened configuration (see The key file beside an opened configuration).
inputCsvnon-empty string, length-cappedcontent written to a fixed, server-chosen filename in the workdir. The client never names a file. The cap is anchored to the browser intake's own 200 MiB file-size gate, so a CSV that passed that gate is never rejected here (see Size caps). The alternative to inputFile; exactly one of the two is set.
inputFile{ "name": "<segment>" }A reference to a file in the mounted work-input directory (JOB_INPUT_DIR, see Environment variables), not content: name is a single admissible path segment (the same shape rule the work-input listing applies) and is resolved against the mounted directory at create time -- a name that resolves to no regular file is refused before any workdir exists. The CLI reads the file in place; nothing is copied into the workdir. The alternative to inputCsv; exactly one of the two is set.
metadataoptional; core's MetadataSchemaThe operator's per-party column metadata (each column's name, semantic type, role, and payload flag). Structured data written into the config as YAML values, never an argv fragment, path, host, or credential. Included so the CLI honors the operator's disclosure edits (which columns are sent vs ignored) instead of inferring metadata from the column names.
standardizationoptional; core's StandardizationSchemaThe operator's per-party standardization pipeline (per-field transform steps). Structured data written into the config as YAML values, never an argv fragment, path, host, or credential.
expectedPartnerDeduplicateoptional booleanThe acceptor's terms-side commitment: the deduplicate the accepted invitation declared for the inviting party's own side. A schema boolean -- never a path, host, credential, or argv fragment. false is a real declaration and is forwarded verbatim; only an omitted field binds nothing. Set on the acceptor path only; the inviter omits it.
side"inviter" or "acceptor"; optional, except required on the webrtc armWhich side of the partnership the submitting party runs. A closed enum, never a path, host, credential, or argv fragment. On the webrtc arm it is composed as the connection's role, the rendezvous end the run registers as; on the other arms no composition rule reads it, and it reaches no CLI argument and no composed config key. The console's server-job driver config requires it.
mountedConfigurationOpenedoptional boolean (default false)Whether this run's settings came from the configuration the operator opened off the mount. A bare boolean selecting a composition rule rather than contributing a value: it decides whether the run reads its shared secret from the key file beside the opened configuration rather than from sharedSecret, and whether the recurring-run hand-off merges the opened document into its template (see The recurring-run hand-off), and the browser sets it from the load's own state and nothing else. It reaches no CLI argument and no composed config key, and is never a path, host, credential, or argv fragment. On the webrtc arm, true runs the data root's own alcove.yaml and .alcove.key in place rather than writing a pair, over the coordination server the file names (see Running an opened webrtc configuration in place).
signalingServeroptional { "host", "port"?, "path", "relay"? }, strict, on the webrtc arm only; host non-empty, host and path at most 2048 code units, port an integer from 1 to 65535, relay the invitationRelay shape belowThe coordination server the run's invitation names, as the invitation names it, with no scheme, and the relay it names. The create refuses it unless it is the authored server's host, port and path and, for an acceptor's run, its invitationRelay, or, for an inviter's run, the turn urls of its ownRelay (see The coordination server). It reaches no composed config key and no CLI argument: the connection's coordination server and relay urls are still drawn from the authored server alone. A run of the opened configuration does not read it.
allowedRelaysoptional array of strings, on the webrtc arm only, each graded and normalized by core's connection.allowed_relays entry rule (AllowedRelaySchema); an empty array admitted; refused beside mountedConfigurationOpened: trueThe relays the run may use, as the console's accept review commits them. Each entry is a host with an optional port from 1 to 65535, with no scheme, path, query, user information or whitespace, written lowercase with no trailing dot. It is composed as the connection's allowed_relays and nothing else, never a path, credential or argv fragment, and it only narrows the TURN urls the run lists: no host it names is contacted for being named. A run of the opened configuration states its own connection.allowed_relays, so a create naming both fails validation. The array has no count or length cap of its own, as on the command line; the body cap and core's node budget for the composed document bound it.
mountedConfigurationConvertedoptional boolean (default false)Whether the operator converted the opened configuration to the console's own folders and signing identity. Read only beside mountedConfigurationOpened: true. A bare boolean selecting a composition rule: it decides whether the recurring-run hand-off states the console's placeholders or the paths the opened document read, and whether a certificate run of a document stating a signing identity path is refused (see Converting an opened configuration). It reaches no CLI argument and no composed config key, and is never a path.
optionstuning subset (below)Every field is a number, boolean, closed enum, or a bounded single-label peerId -- none can hold a path, host, credential, or command.
eventStreamoptional boolean (default true)Whether --event-stream is passed.
diagnosticRunoptional boolean (default false)Run at the CLI's debug log level with its sub-library verbosity, capturing the whole log to run.log in the workdir (see Subprocess invocation). A bare boolean that selects fixed flags: it names no path, and the log's location is the server's.
sweepExchangeFilesoptional boolean (default false)Pass --sweep-exchange-files, the CLI's own pre-rendezvous recovery of a directory a crashed prior run left protocol files in. What counts as a protocol file, and whether a retain-mode transcript may be deleted, are the CLI's decisions entirely; the escalation past that guard (--force-retain-sweep) is not representable on this surface.
signingoptional { "mode", "partnerFingerprint"?, "identityLocation"? }, strictThe receipt-signing choice. mode is "none" or "certificate" -- an allowlist of the two modes an exchange honors, matching core's own assertSigningModeImplemented, so session-derived (and any mode later added to the format but not yet implemented) is refused here rather than accepted into a job whose child then exits 64 on the very config it exists to run. partnerFingerprint is admitted only under certificate and is optional there: an absent pin is the first authenticated contact the spawned child adopts the partner's presented certificate on, and the console does not hold the operator to an out-of-band value they may not have yet. A pin obtained that way is the stronger of the two anchors a pin can have (see SECURITY_DESIGN.md), so the card warns and guides toward it rather than refusing the run -- and the fingerprint a first contact records is reported back on the job's event stream (see Warning sources on the job stream). When present it must be a canonical 43-character unpadded base64url SHA-256 digest -- a public digest of a public certificate, never a credential, and no separator, path, or flag-shaped value fits the shape. certificate also requires a non-blank linkageTerms.identity, the one cross-field rule of this surface that spans two blocks (matching core's assertCertificateModeNamesLocalParty): a certificate is trusted by the identity its holder used in the agreed terms, so an unnamed party's job is refused for the same reason and at the same point, with the issue on linkageTerms.identity rather than on the signing block. identityLocation is admitted only under certificate too -- a run that signs nothing loads no identity -- and is a mount locator the server resolves, never a path (see Where the identity is: the console's option). The path field of the CLI's signing block is not representable: identity_file is supplied by the server at composition (see Composed CLI configuration), and an opened document's own is stated only in its hand-off (see Converting an opened configuration).
csvDelimiteroptional string, graded by core's own delimiter ruleThe field delimiter the CLI reads this party's own input by and writes its own result with -- core's local csv_delimiter. The value is resolved by core's own rule for the words a party may write (tab, \t, detect) and then graded by core's accepted set: a single character that is a tab or a printable ASCII character other than the double quote, or the reserved word detect. So the boundary admits exactly what a hand-authored configuration and the --csv-delimiter flag admit, and refuses the rest in core's own sentence, prefixed with the field name and holding the refused value's SHAPE rather than its bytes. The field is bounded at 32 code units (MAX_CSV_DELIMITER_LENGTH) ahead of that resolution -- above every spelling it may state, detect being the longest, with room for the whitespace the resolution trims -- so an unbounded value is refused on its length rather than resolved and graded. One YAML scalar, never a path, host, credential, or argv fragment. Local to this party: it governs only how this party's own file is read and its own result written, and nothing about it crosses to the partner. An omitted field composes no key, so an older client's intent runs at the comma default.
includeMatchingKeyoptional booleanWhether this party's result file ends with the matched_on_key column -- core's local include_matching_key. A schema boolean, never a path, host, credential, or argv fragment. Local to this party: nothing about it crosses to the partner. The composed config's parse refuses true beside terms that do not admit it -- a count-only or single-pass exchange, or one whose output gives this party no result.
retentionDispositionoptional non-empty string, capped at core's MAX_TEXT_LENGTH (1024), no control characters beyond tab/LF/CRThis party's own retention/disposition note, written into the composed config as a YAML value and from there into this party's exchange record -- never swapped with the partner, cross-validated, or folded into the agreed-terms hash. The cap is the record schema's own ceiling, so a note that passes here cannot pass config validation only to fail the record build. The note is composed verbatim, so it is held to a control-character rule of its own: the C0 and C1 ranges and DEL are refused apart from tab, LF, and CR -- the whitespace a multi-line note legitimately holds -- since a NUL or an ESC would compose into the YAML and land in the record. That rule is this field's own, not one the surface applies uniformly: every other bounded string here is held to a shape rule drawn for its own sink (peerId's single-label pattern, inputFile.name's path-segment rule) rather than to a common control-character policy. Never a path, host, credential, or argv fragment.
tokenMaxAgeDaysoptional integer, 1 to core's MAX_TOKEN_MAX_AGE_DAYS (36500)This party's maximum-age policy for the shared secret, composed as the config's authentication.token_max_age_days (see the composed config). A bounded number: never a path, host, credential, or argv fragment, and never the secret or its expiry, which the key file holds.

Both channel arms are .strict(): an unknown key (a smuggled path, host, server block, or @path credential reference) fails validation, and each arm admits only its own fields. The options subset is the tuning settings only -- pollIntervalMs, peerTimeoutMs, inactivityTimeoutMs, serverConnectTimeoutMs, maxReconnectAttempts (0..604800), timestampInFilename, locklessRendezvous, peerId, retainFiles, unexpectedFiles (error/warn/ignore), and writeWithoutRenaming -- plus connectionPerPoll on the sftp arm alone. The webrtc arm admits only peerTimeoutMs, inactivityTimeoutMs, serverConnectTimeoutMs, and maxReconnectAttempts: a webrtc run polls no folder and holds no file-sync session. The path and directory fields of the CLI's file-sync options are intentionally not exposed: the server owns every directory.

connectionPerPoll is admitted on the sftp arm only, mirroring where the CLI applies it: the ephemeral-session mode dials a real SFTP session, and a filedrop client holds none, so the filedrop arm's strict parse refuses the key rather than accepting a value the run cannot honour.

pollIntervalMs has no floor beyond core's own positive-integer rule, on either channel. An sftp poll is a directory listing against a remote server, so a sub-second interval can trip that server's anti-flood protection -- but the interval is the operator's own choice about a connection they authored on their own console, and the CLI warns about it rather than refusing it. The boundary therefore matches the CLI: the value is admitted and the authoring surface raises the anti-flood advisory at the threshold core exports as LOW_POLLING_FREQUENCY_WARN_MS (1000 ms), where the operator can still act on it. A demo against a controlled server legitimately polls sub-second, and a floor here would make the advisory unreachable on the one channel it is about.

peerId is the subset's one free-text field, and it is bounded rather than passed through. It becomes the filename prefix of every file this party writes into the shared rendezvous directory, so this boundary admits only a single label: at most 64 characters, matching /^[A-Za-z0-9](?:[A-Za-z0-9 _-]*[A-Za-z0-9])?$/ -- ASCII letters, digits, spaces, -, and _, beginning and ending with a letter or digit. That refuses the separators, dot runs, and leading dash a filename component must never contain, and it is stricter than core's own field (which admits any non-empty string, because a CLI operator authors their own configuration by hand and their filename-safety note is advisory there).

On the two exchange arms, peerId may instead hold core's per-run fields (FILE_SYNC.md): the console's name control takes none, so such a value reaches an intent only from a mounted configuration, and the composed run writes it back as written. It is admitted only when it parses under core's grammar, holds at least one field, and every expansion is a label the rule above admits; the fields fill in digits and hex of fixed widths, so one expansion decides it. A literal % (%%) therefore never passes. The zero-setup arms admit only the plain label.

Every rule about the option values is core's, not this boundary's: the validated block is re-parsed through core's file-sync options schema, and its issues are raised as this parse's issues. So peer_id requires timestamp_in_filename: true, peer_id may not be the reserved temp, and retain_files requires both timestamp_in_filename: true and lockless_rendezvous: true -- each refused at create time, in core's own wording, rather than at config composition or at rendezvous. Retain mode's implication (that stating retain_files alone turns on the other two) is resolved by the same core helper the CLI's --retain-files uses, applied by the authoring client before it POSTs; a body stating retain_files alone, with neither implication, is a 400 rather than a silently corrected intent.

The metadata and standardization fields are validated structured data -- core's MetadataSchema and StandardizationSchema, respectively -- and have no injectable field. Core bounds each column name and each standardization output and input, and closes role/type to enums; the arrays, the free-text description, and that same output/input pair are additionally bounded web-side at this boundary (see Size caps). A standardization step's params is a Record<string, unknown>, unbounded by nature and left uncapped at the field level -- the boundary body cap is its safety check. The linear-time regex-dialect gate (docs/spec/PROTOCOL.md, "Transform regular-expression dialect") -- which caps and dialect-checks transform-pattern sources -- applies to the negotiated linkageTerms transforms, not to the standardization pipeline's raw-pattern steps. That is a compile/size cost, not a ReDoS hole: every standardization raw-pattern step still compiles and runs under core's linear-time RE2 engine (RE2JS), so it cannot backtrack catastrophically. It remains a resource bound, not an injection escape: no metadata or standardization value becomes an argv fragment, a path, a host, or a credential.

The zero-setup intent ​

A zero-setup intent (mode: "zeroSetup", required and literal) drives the CLI's zero-setup command instead of alcove exchange: both parties run the CLI against the same server they agreed on out of band, and the CLI infers linkage terms, metadata, and standardization from each party's own input file rather than either party pre-authoring them for both. It therefore has none of the exchange mode's sharedSecret, linkageTerms, metadata, standardization, or expectedPartnerDeduplicate fields, and no connection field at all: on the sftp arm connection material is drawn exclusively from the effective server-side connection, exactly as the exchange arm; the filedrop arm's connection is the configured rendezvous directory, again exactly as the exchange arm.

FieldType / validationWhy it cannot inject
modeliteral "zeroSetup"Required and literal -- a zero-setup intent names itself; a body that omits mode is routed to the exchange arm instead, never here.
channel"filedrop" or "sftp"The closed discriminant, identical in meaning to the exchange arm's.
inputCsv / inputFileas the exchange modecontent or a mounted-file reference, exactly as the exchange mode's fields of the same name; exactly one of the two is set.
optionstuning subset, narrowed to what an argv can holdThe exchange mode's fields less unexpectedFiles, with the same peerId label bound and no per-run fields in it, the same core-sourced cross-field rules, the same absence of a poll floor, and the same sftp-only connectionPerPoll. Every admitted field reaches the run as a flag -- see below.
eventStreamoptional boolean (default true)As the exchange mode.
diagnosticRun / sweepExchangeFilesoptional booleans (default false)The per-run controls, identical in meaning and effect to the exchange mode's fields of the same name.
linkageStrategyoptional "cascade" or "single-pass"A closed enum forwarded to the CLI's --linkage-strategy; never a path, host, or credential.
deduplicateoptional booleanThis party's own side of the matching cardinality, forwarded to the CLI's --deduplicate as a bare flag when true and as no token at all when false or absent, which select the same side. A schema boolean, so it is never a path, host, or credential, and it needs no cap. It is not the exchange mode's expectedPartnerDeduplicate, which binds the PARTNER's presented value against an accepted invitation: a zero-setup run holds no invitation to bind one to, and the strict parse refuses that field on both arms.
identityoptional string, 1 to 1024 characters, no leading -, no control or text-direction character, no private key materialA bounded operator label forwarded to the CLI's --identity (the party's name/org/contact string). Free text rather than a closed enum, so it takes the four shape rules the shared label contract states: a leading - is forbidden so a flag-shaped value cannot masquerade as a CLI flag (the driver also emits it as a single --identity=<value> token, which parses a --leading value verbatim regardless); every C0 control, DEL, and C1 control is refused -- no tab/LF/CR exception, unlike the retention note's rule, because the label is single-line and rides the run into this party's disclosure record; the nine bidirectional embedding, override, and isolate characters of Unicode UAX #9 are refused, the class core refuses in the linkage-terms identity this label becomes, since a layout scope opened in the label reorders the copy the disclosure record is read beside; and a PEM or OpenSSH private-key block is refused, the third rule core holds that same field to, so a label cannot bind a certificate no linkage terms document could then name. The implicit marks U+200E, U+200F, and U+061C stay admitted. Letters outside ASCII are untouched.
csvDelimiteroptional string, graded by core's own delimiter ruleThe field delimiter the CLI reads this party's own input by and writes its own result with, forwarded to --csv-delimiter as a single --csv-delimiter=<value> token. Graded exactly as the exchange mode's field of the same name -- resolved by core's own rule for the words a party may write (tab, \t, detect), then held to core's accepted set, and bounded at 32 code units (MAX_CSV_DELIMITER_LENGTH) ahead of that resolution -- so this arm and the exchange arm admit and refuse the same values in the same sentence. A single character or the reserved word detect is never a path, host, credential, or flag-shaped value, and the single-token form parses a - value verbatim regardless. Local to this party: it governs only how this party's own file is read and its own result written, and nothing about it crosses to the partner. Absent, no flag is emitted and the run reads and writes commas.

Both channel arms are .strict(), so no sharedSecret, linkageTerms, metadata, standardization, expectedPartnerDeduplicate, or any other unmodeled key -- including a connection/server/remote key -- survives validation, and the exactly-one-input-source rule holds exactly as in the exchange mode. Because a zero-setup run has no shared secret, it writes no key file and composes no config document at all: its connection reaches the CLI as argv instead -- see Composed CLI configuration and Subprocess invocation.

Emit or refuse. A zero-setup run composes no configuration document, so its options reach the child only as flags. Every field the zero-setup arms admit has one, and every value the flag it rides would itself refuse is refused by the schema rather than accepted and dropped -- a job created on a value its own child exits 64 on would occupy the console's single run slot to produce nothing:

  • Emitted (see Zero-setup invocation): retainFiles, locklessRendezvous, timestampInFilename, peerId, pollIntervalMs, peerTimeoutMs, inactivityTimeoutMs, serverConnectTimeoutMs, maxReconnectAttempts, writeWithoutRenaming, and -- on the sftp arm -- connectionPerPoll.
  • Refused: unexpectedFiles, a configuration-only key with no CLI flag at all, so the strict parse rejects it as an unknown key on both arms; connectionPerPoll on the filedrop arm, exactly as in the exchange mode.
  • Refused as unrepresentable: a peerTimeoutMs, inactivityTimeoutMs, or serverConnectTimeoutMs that is not a whole number of seconds when expressed in milliseconds. Those three ride the CLI's coarse duration flags, whose grammar takes a second-or-coarser unit and rejects a millisecond one, so a sub-second remainder has no faithful flag form and is a 400 rather than a value silently rounded into one the client did not send. pollIntervalMs has no such rule: its flag accepts a millisecond suffix, so any admitted value is preserved exactly.
  • Refused as out of range: a peerTimeoutMs, inactivityTimeoutMs, or serverConnectTimeoutMs above MAX_TIMEOUT_SECONDS (604800 s, seven days), the sanity ceiling --peer-timeout, --inactivity-timeout, and --connection-timeout are capped at, or a maxReconnectAttempts above MAX_RECONNECT_ATTEMPTS (604800), the ceiling --max-reconnect-attempts is capped at. Both constants live in packages/core/src/config/connection.ts -- core is the one module both the CLI's parse guard and this boundary import, so neither pair can drift. Past either ceiling the CLI exits 64 before any side effect, so an over-ceiling value is a 400 here instead of a job created to spawn a command line that refuses it. pollIntervalMs has no ceiling, exactly as --polling-frequency has none: a long interval is merely slow.

Size caps ​

The intent is operator-authored, never partner-supplied, and the API is feature-gated and reached only from the operator's own machine, so the worst case an oversized intent reaches is a single operator exhausting their own console's memory -- not a remote surface. Two defense-in-depth layers bound it anyway, so neither the request nor a persisted artifact can grow without limit:

  • Boundary body cap. POST /api/jobs reads its body under a hard byte cap (424 MiB -- twice the inputCsv cap plus 24 MiB, so it moves with that cap), streamed off the request and counted chunk by chunk; the read aborts the moment the running total exceeds the cap, and Content-Length is never trusted (it can be absent or understated on a chunked request). An oversized body is a 413 before the body is fully buffered or any schema parse runs. The cap sits well above the JSON-encoded size of a realistic schema-valid intent -- real CSV text barely grows under JSON string escaping -- so a legitimate intent reaches a clean schema error rather than a boundary 413. It is not sized to clear a pathological payload built from control characters that each escape to a 6-byte \uXXXX sequence (not valid CSV), nor an unbounded standardization params; bounding those here is exactly the memory guard's job. It applies identically to both modes. Within the cap the accumulated bytes reach the shared bounded-JSON chokepoint (parseBoundedJson, see PARSED_INPUT_BOUNDS.md) undecoded, so its UTF-8-fatal decode applies: a body holding an invalid byte is a 400, never a value parsed with U+FFFD substituted for it. Every route reading a body under a cap -- each 413 in the table above -- shares that read. The server gives such a body time to arrive in proportion to the create cap rather than the 15-second signaling bound (see SIGNALING_SERVER_BOUNDS.md).
  • Schema caps. The intent schema bounds inputCsv (anchored to the browser intake's 200 MiB file gate), the metadata array and each description, and the standardization array, each transformation's steps, and its output/input. The bounds are generous -- far above any legitimate intent -- and apply to both channel arms. They are enforced web-side at this boundary rather than in core's shared schemas, so they do not tighten core's partner-facing validation. The inputCsv cap applies identically to a zero-setup intent's inputCsv field; the metadata and standardization caps are exchange-mode only, since a zero-setup intent has none of those fields.

The browser's read of the console's ANSWERS is bounded the same way. Every job-API client reads a response body under a per-endpoint byte cap and parses the accumulated bytes through parseBoundedJson (readBoundedJson / readJsonOrNull in apps/web/src/psi/jobClient/jobApiBody.ts, over core's shared read, readBoundedJsonBody in packages/core/src/utils/boundedJsonBody.ts), so an answer that is not the one the endpoint returns -- a proxy's error page, a truncated stream, a console at a different version -- costs a bounded read and the client's own malformed-body state rather than an unbounded buffer in the operator's tab. Two of the four caps follow from what the body holds: the fixed-shape status bodies (16 KiB, every field a boolean, number, closed-set member, id, fingerprint, or one bounded path or message) and the SFTP connection projection (64 KiB, matching the authoring body's own cap, which is what decides how long the projected fields can be). The hand-off cap (32 MiB) follows the create intent's own caps, since the template recomposes the intent's blocks: the metadata columns and their descriptions and the standardization outputs and inputs are each count- and length-capped at the create boundary, and the margin above their sum holds the rest of the document. A standardization step's function name and params record are the one part no field-level cap bounds, so a template inflated through them exceeds the cap and reads as no hand-off. Only the listing cap (8 MiB) follows no code-level bound -- the operator's own mount contents, CSV header, and configuration file decide the listing, profile, coverage, and mounted-configuration bodies -- so it is headroom over any answer this console plausibly returns rather than a derived bound. Response.json() is banned in apps/web/src and apps/web/server (no-restricted-properties, beside the raw-JSON.parse ban) so a new client cannot read one unbounded. The other two whole-body reads, .text() and .arrayBuffer(), are banned over the same trees under the same message by a no-restricted-syntax selector, since a property ban cannot state them: a locally chosen File declares both methods too, and the app's own file intake must keep reading one. The selector tells the two apart by the receiver's name, admitting file and any name ending in File; a fetched body reached under any other name is refused, and a File reached through a computed index takes an eslint-disable with its reason.

expectedPartnerDeduplicate needs no cap: it is a schema boolean, and its present-vs-absent distinction is preserved end to end; see the composed-config note below.

Composed CLI configuration ​

This section covers the exchange mode only: a zero-setup intent composes no config document and no key file at all, so a zero-setup job's workdir has neither -- see Subprocess invocation for how its connection and tuning reach the CLI instead, as argv.

From a validated filedrop intent the server composes the CLI config document (snake_case YAML the CLI loads verbatim) through core's mintExchangeFile, so the assembled spec is validated by the CLI's own schema before it is written. The composition is fixed:

  • The connection is a credential-free filedrop locator whose one path field is set to the resolved rendezvous mount (JOB_RENDEZVOUS_DIR, or JOB_DATA_ROOT when it is unset) -- server-side environment configuration, never a client value. By core's ExchangeFileInput typing no credential is representable in a filedrop connection. On a split-provisioned console (JOB_RENDEZVOUS_OUTBOUND_DIR set) the connection instead holds inbound_path (the inbound mount) and outbound_path (the outbound one), never the single path beside them; mintExchangeFile validates the pair through core's own directory-mode rules, so the pair-set-together, must-differ, and retain-mode preconditions are stated once, in core, rather than restated here. Because every filedrop exchange on such a console holds the pair, the retain-mode precondition is a precondition of running one at all: the console states it at each authoring gate in its own words, and POST /api/jobs refuses a filedrop intent whose options.retain_files is not true with a 400 before any workdir exists, rather than letting core's refusal be reported as a compose failure.
  • The intent's tokenMaxAgeDays, when present, is attached as the config's authentication.token_max_age_days, the only key the composed authentication block holds; an omitted field composes no block. The shared secret and its expires ride the key file and are not representable here. The CLI reads the policy as a command-line run of the same file does: a successful exchange writes the rotated secret with expires that many days out, and a later run refuses the secret once that instant has passed. The field itself is specified in EXCHANGE_REFERENCE.md.
  • The intent's linkageTerms reach the document only after core's schema validation.
  • The intent's metadata and standardization, when present, are attached as the config's metadata and standardization blocks (omitted when absent). Including them is what makes the operator's data-prep edits authoritative on this path: the CLI's prepareForExchange uses the composed metadata instead of falling back to inferMetadata, which sends no column, and so would not send a column the operator marked sent.
  • The intent's expectedPartnerDeduplicate, when present, is attached as the config's expected_partner_deduplicate (a false is attached verbatim; only an omitted field is left off). Including it is what makes the acceptance's terms binding survive to the run: the console conducts the exchange at a separate CLI invocation holding no invitation, so without the field the CLI would hold the inviter's presented deduplicate to nothing and an inviter that declared false could present true -- widening what this party's own records disclose. The inviter path omits it. The field itself is specified in EXCHANGE_FILE.md.
  • The tuning options, if any were set, are narrowed to the CLI's file-sync options and attached; when none were set the block is omitted entirely.
  • The signing block is attached only for signing.mode: "certificate". mode: "none" -- and an intent stating no choice at all -- composes no block, which is the absent block the CLI already treats as "sign nothing", so an operator who authors no receipt composes a config with no signing key at all. When attached, the block holds the intent's mode and, when the intent states one, its partner_fingerprint verbatim -- an absent pin composes a block without the key, the first authenticated contact described under The exchange intent -- plus one server-chosen path the intent cannot name: identity_file, the long-lived identity resolved from the intent's identityLocation or, with none, the default in the mounted data root. The CLI writes the receipt as alcove-receipt-<time>.json in the job's workdir (see Workdir layout). A certificate intent reaching composition with no resolved identity path is a hard error, never a silently unsigned run.
  • The intent's csvDelimiter, when present, is attached as the config's csv_delimiter. Core's exchange-spec schema resolves and grades it on the way in, so the composed document holds the same value a hand-authored configuration would, and an omitted field composes no key -- the run then reads and writes commas. The field itself is specified in EXCHANGE_REFERENCE.md.
  • The intent's includeMatchingKey, when present, is attached as the config's include_matching_key, and core's exchange-spec schema refuses true on terms that give this party no matched key to name. The field itself is specified in EXCHANGE_REFERENCE.md.
  • The intent's retentionDisposition, when present, is attached as the config's retention_disposition verbatim. It is per-party and local: the CLI writes it into this party's own exchange record and it is never swapped with the partner, cross-validated, or folded into the agreed-terms hash. The field itself is specified in EXCHANGE_REFERENCE.md.

An sftp intent is composed differently. mintExchangeFile's input type cannot represent a credential -- an invariant shared with the browser's exchange-file minting that must not be widened -- so the server assembles the exchange spec directly. The connection's server block is the authored entry verbatim (its @path credential references land in the YAML as references, resolved only by the CLI child), and the client's linkageTerms, metadata, standardization, expectedPartnerDeduplicate, retentionDisposition, includeOwnColumns, includeMatchingKey, csvDelimiter, and tuning options attach exactly as on the filedrop path. The signing block is composed by the same rule from the same server-chosen paths. The assembled spec is validated through core's exchange-spec schema before serialization, with only the schema's own fields reaching the YAML. The intent contributes nothing to the connection. The authentication block is composed by the same rule.

A webrtc intent is assembled the same way as an sftp one. The connection is { channel: webrtc, server, role }: server is the authored coordination server's host, port (omitted for the scheme's default), path, and secure: false for a ws: server (omitted for wss:, the CLI's default), and role is the intent's side. An acceptor's run states the authored server's invitationRelay, where it has one, as invitation_relay, as alcove accept writes it, and no relay of its own. An inviter's run states the authored server's ownRelay, where it has one: each of its turn urls as a turn entry holding only url, whose credential the CLI mints from the shared secret on each run, its websocket url as websocket_relay, and its relayRegistrar as relay_registrar. No stun or ice_provision key is composed, nor invitation_relay for an inviter's run; the CLI's built-in STUN default still applies. The intent's allowedRelays, where it states them, are composed as allowed_relays, an empty list included. The rest of the document attaches as on the sftp path, with the four webrtc options above.

The key file body is {"sharedSecret":"<value>"} with no expires stamped, so a server-driven job has no invitation-token lifetime of its own. Under a tokenMaxAgeDays policy the CLI stamps one when it writes the rotated secret back.

The working folder's configuration and key file ​

A webrtc job writes its configuration and key file into the data root, as alcove.yaml and .alcove.key where alcove invite writes them, rather than into its workdir, and spawns the CLI with --config-file and --key-file naming those two files. The CLI rotates the shared secret in the key file in place at the terms exchange and records the partner's pin in the configuration, as it does for every alcove exchange run. The configuration is serialized by core's serializeExchangeDocument, the writer alcove invite uses. The workdir holds no copy of either file.

  • The create writes both files, never over anything. Each is created mode 0600 with an exclusive create, so a name holding anything, a link included, is not written through. Where either name is taken, the create is 400 and nothing is written: { "reason": "working-folder-console-exchange" } where the pair is the one the manager recorded (below) for another invitation, { "reason": "working-folder-configuration-present" } otherwise. A data root that refuses the write is 500 { "reason": "working-folder-unwritable" }, and neither file is left behind: a file whose write fails after its exclusive create is removed, and so is a configuration written before a failed key write.
  • The manager records the pair by content. It keeps, in memory, a digest of the invitation's original shared secret and sha256 digests of both files' bytes: as written at create, and as they are when the child of each job over the pair exits. A restart forgets the record.
  • A retry of the invitation runs over the pair only while its content matches. A webrtc create finding either name taken runs over the pair when its secret's digest is the recorded invitation's, the configuration's bytes match the digest recorded last, and the key file's bytes match the digest recorded last or the file holds the invitation's original secret. Each file is read as a regular file under a size bound without following a link. Anything else refuses as above, so a pair swapped into the folder is never run over and keeps its bytes, and after a restart every retry refuses. One limit is accepted: a file swapped in while a job over the pair runs is recorded when that job's child exits, since the manager cannot tell it from the CLI's own rotation, so a later retry runs over it.
  • A retry writes its allowed relays into the configuration first. Once the slot is claimed, a create that runs over the pair sets the configuration's connection.allowed_relays to the intent's allowedRelays, removing the key where the intent states none, before its child is spawned. A configuration already stating that list is not written. Otherwise only that key is edited, every other line keeping its bytes where the YAML writer allows, and the file is replaced by a new mode 0600 file renamed over its name. The manager records the new bytes as the configuration's latest. A configuration that cannot be edited or replaced is 500 { "reason": "working-folder-unwritable" }.
  • Deleting a job removes only a pair it left unchanged. Once the deleted job's child has exited, the manager removes both files where their bytes still match the digests recorded at create, which holds only before the CLI rotated the key. Otherwise it removes nothing and logs that the files were left. A create that fails before its child is spawned removes the pair it wrote on the same rule.

Running an opened webrtc configuration in place ​

A webrtc run of the opened configuration (mountedConfigurationOpened: true) runs the data root's own alcove.yaml and .alcove.key, as alcove exchange runs them in that folder, rather than writing a pair: the browser first saves the operator's edits into that file (Saving an opened configuration back), then creates the run.

  • Checked before anything is written. Before the slot is claimed, the server reads alcove.yaml again through the load's own read and refusals. It runs the file only where that is a webrtc document stating no connection.ice_provision, its other relay settings included. Anything else -- no file, one the load refuses, another channel, ice_provision -- is 400 { "reason": "opened-configuration-not-runnable" } with nothing written. The key file is then checked as below.
  • Run as it stands. The child's --config-file and --key-file name those two files. The CLI reads the role, the coordination server, and every other setting from the file, rotates the key in place, and records a first contact's pin in the configuration. Nothing is composed for the run. The relay entries run as the file states them, a static credential the operator wrote included; the run mints a credential for any url-only entry, and the console itself writes only url-only entries. The authored coordination server is not read, and none need be authored: the run dials the server the file names, which the load discloses as signalingServer for the browser to show.
  • A signed run signs with the console's identity or does not start. The run signs with the signing.identity_file its file names, and no conversion changes the file. The browser withholds a signed run of a file naming another identity file, from the load's inPlaceSigningIdentity (The body), and a signed save of a file naming none writes the console's (Saving an opened configuration back). The server holds the rule as its own check: a run of a file signing in certificate mode, whatever the intent's signing.mode, with an identity file other than the one the intent's signing.identityLocation resolves to is refused before the slot is claimed, mountedConfigurationConverted or not, with 400 { "reason": "mounted-signing-identity-mismatch" } and nothing written. For an intent that requests no signed receipt, the console's identity is its default identity in the working folder, so an in-place file signing with an identity kept in the secrets mount is refused for that intent even though the review step shows it as one of the console's own; the console's own start saves the file first, so it never sends that request. The comparison is of real paths: each side resolves through its links where a file or its folder is there, and is otherwise taken as written. A relative or ~ path is refused.
  • A start-mode provisioning block is called as on the command line. A file whose connection.server.provision block is in start mode makes the CLI call that provisioning endpoint before the exchange, as alcove exchange does (EXCHANGE_REFERENCE.md).
  • A partner terms change is applied where it was written. The CLI writes the proposal beside the data root's alcove.yaml, and applying it reads the run's configuration and the proposal there, holding the file to the bytes it had when the run was created rather than when it was opened.
  • Never removed. The manager records no pair for such a run, so deleting the job leaves both files whatever they hold, and a later webrtc create that writes a pair is refused while they are there, as above. A retry runs the file again as the last run left it.

The key file beside an opened configuration ​

A run of the opened configuration (mountedConfigurationOpened: true) writes no key file. It continues the exchange the configuration set up, under <dataRoot>/.alcove.key, so the partner is sent no invitation:

  • Checked before anything is written. After the busy check and before the slot is claimed, the server opens the file (non-blocking, one descriptor, a regular file only, read bounded to 10,000 bytes), parses it through the sensitive-JSON chokepoint, and holds it to the CLI's key-file shape: a canonical sharedSecret and an optional ISO 8601 expires, other keys stripped rather than refused, as the CLI's own reader does. No file there is 400 { "reason": "mounted-key-file-absent" }; one that cannot be opened or read or fails that shape is 400 { "reason": "mounted-key-file-invalid" }. Neither body nor any log states anything read from the file, and no workdir is created.
  • Handed to the CLI by path. The child's --key-file is that file. The CLI reads the secret, rotates it at the terms exchange, and writes the rotated one back to the same file, exactly as a command-line run in that folder does, so the file the run leaves is the one the next run -- console or command line -- needs.
  • Never on this API. The secret is not on the intent, the hand-off, the status body, or the event stream; the browser never holds it.

The authored SFTP connection ​

An sftp job's connection material -- for either mode -- never comes from the create intent; the operator authors it in-console through PUT /api/jobs/sftp (Authoring the SFTP connection). The console is a single-owner prototyping tool -- the person who runs the container, authors the connection, and runs the exchange is the same person -- so there is no deploy-time provisioning step and no server file to pin in advance. The console conducts one exchange and is not a store of named connections, so there is exactly one effective outbound SFTP connection at a time, held in a single in-memory slot. It is never persisted, so a restart forgets it, and it is cleared only by DELETE /api/jobs/sftp or replaced by a later PUT. Deleting an exchange (DELETE /api/jobs/:jobId) keeps it, since a retry deletes the failed exchange before it creates the next, so within one tab the connection shown is the one held. The server holds one connection for the console, so another tab that clears or authors one changes what the next create runs over, and no create-time comparison refuses that.

The authored connection resolves to an internal server block (host, port, username, a remote directory as either path or the inbound_path/outbound_path pair, one primary credential @path, an optional private_key_passphrase @path, keyboard_interactive, and host_key_fingerprint) validated by these rules, each critical:

  • Strict field allowlist. The block admits exactly host, port, username, path, inbound_path, outbound_path, password, private_key, private_key_passphrase, keyboard_interactive, and host_key_fingerprint; any other key is a validation error. This is stricter than the CLI's connection schema, which is non-strict and admits blocks the console must never hold: provision (whose auth block holds inline HTTP credentials and drives pre-connect egress) and the detected-but-rejected certificate/known_hosts. Strictness also turns a typo into a validation error instead of a silently dropped field.
  • One remote-directory form, chosen by core. The remote directory is either the single shared path or the split inbound_path/outbound_path pair -- a server with distinct drop and pickup folders. Which combinations are coherent is not restated here: the composition check below runs core's own directory-mode rules, so path alongside the pair, half a pair, and two paths that resolve to one directory are each rejected rather than reconciled.
  • Mandatory literal pin. host_key_fingerprint is required (a string or a list) and every element must be a literal OpenSSH SHA256 fingerprint; an @path reference is rejected. The job child is non-interactive (stdin ignored), so first-use trust can never happen there; requiring the pin makes every console SFTP connection host-key-pinned, verified before authentication. A host-key rotation is staged by listing the old and new fingerprints together.
  • Credential must be an existing @path (hard); containment is a warning. password, private_key, and private_key_passphrase, when present, must be @path references to an absolute path, and the referenced file must exist at validation time -- each a hard error. The server reads at most 16 KiB of every referenced credential file, at authoring and again at each SFTP job create, only to parse it as JSON and recognise a signing identity document (see the next bullet); it discards those bytes, and nothing read is logged or returned. Inline values are rejected, so no secret is held in a composed job file, and none is kept in server memory beyond that transient read, except through the pasted-credential fallback described in Materializing a pasted credential; the CLI child resolves the reference at config load. A reference that resolves inside the data root or -- when configured distinctly -- the rendezvous directory is a non-blocking warning, not a rejection: the console is a single-owner prototyping tool, so referencing a credential in the operator's one mounted folder is guided-against, not forbidden. The containment is checked on both the lexically resolved path and its realpath (so a symlink cannot resolve into an excluded directory undetected), producing at most one warning per credential field. Each warning names the field and the directory only -- never the reference, resolved path, or secret -- and points at the fix: mount a separate read-only secrets directory (JOB_SECRETS_DIR) and reference the file there. The warnings ride the connection's projection so both the PUT response and a later GET (a console reload) include them.
  • The console's own files are refused (hard). A credential reference -- typed, picked, or the passphrase -- that names one of the console's own files is a 400 whatever form it arrived in: a top-level console name in the data root or the secrets mount (the configuration alcove.yaml and its saved copy, the signing identity .alcove-signing-identity.json and its certificate, the key file .alcove.key, or a job working directory), checked on the lexically resolved path and its realpath -- or any file, under any name or link, whose content is a signing identity document (a JSON object with version, a privateKey holding d, and a certificate, matched on those keys). None of them is ever a credential, so this refusal costs the operator no valid choice, unlike the containment warning above. The message says which file it is and to choose the file holding the SFTP credential instead, and names no path. POST /api/jobs re-checks the saved credentials at each sftp create, adding the signing identity that run would sign with, and answers 400 { "reason": "sftp-credential-console-file" } (see Endpoints).
  • Composition check. The block is additionally parsed through core's connection schema as {channel: "sftp", server: <block>}, so the CLI's cross-field refines (one primary auth method, a passphrase requires a key, keyboard-interactive requires a password, canonical fingerprint form, and the directory-mode rules above) hold at authoring time rather than first inside a job. A split block is composed with the retain-mode option block, because core's remaining directory rule -- a split requires retain_files -- is a rule over the exchange's tuning options, which a connection does not hold and this endpoint cannot know. That precondition is enforced everywhere the connection and the options are known together, since the two are authored separately and either can change after the other. The console's authoring form states it while the operator is still at the controls, and its create and continue gates re-ask it before an invitation is minted or a run is started, so a retain toggle flipped after the connection was authored blocks there rather than reaching the job. POST /api/jobs composes the whole spec through core and so refuses a split job created without retain mode, and a zero-setup run meets the CLI's own --outbound-path guard.

The connection is frozen once authored: changing the host or pin means re-authoring (PUT again) or clearing it. The referenced credential files are live: the job's CLI child reads them when it loads its configuration, not the server when the connection is authored, so a secret rotated in place after authoring is the one the run uses, without re-authoring. apps/web/test/interop/consoleSftpCredentialRotation.test.ts drives the real CLI through the job manager, exchange and zero-setup alike: an emptied credential refuses at configuration load and a filled one passes it, so a change made after authoring is what the run reads.

GET /api/jobs/sftp ​

Returns the authored connection as an explicitly mapped, credential-free projection: { "configured": false } when none is authored, else { "configured": true, "host": "...", "port"?: <int>, "path"?: "...", "inboundPath"?: "...", "outboundPath"?: "...", "credentialWarnings": [ "..." ], "zeroSetupRefusal"?: "..." } and nothing else -- no username, no credential references (which would reveal the secret-mount filesystem layout), no fingerprint. GET /api/jobs/config discloses the username and the host-key fingerprint of a connection it reads out of the MOUNTED configuration, and this endpoint still does not: the two answer different questions. This one reports the connection the console itself holds, which the browser needs only to know it exists and where it points; that one pre-fills an authoring form whose username and fingerprint fields the operator is about to edit. Neither discloses a credential reference. The remote directory appears in whichever single form the connection holds; the browser reads a half pair, or a pair beside path, as no connection at all rather than half a layout. credentialWarnings holds the non-blocking credential-containment warnings the credential rule above produces (empty when every credential resolves outside the excluded directories), so a console reload re-surfaces them. zeroSetupRefusal, present only when a zeroSetup create would refuse this connection, is that create's own token -- sftp-url-directory or sftp-fingerprint-list (see Zero-setup invocation) -- computed by the same check the create runs, so the quick-exchange step refuses the connection before a run instead of after; an exchange create does not refuse over it, and the browser ignores a token it does not know. An enabled API with no connection serves 200 { "configured": false }, the same shape family and gate as GET /api/jobs/rendezvous (a 404 there means the API is disabled). The console web build uses this to gate the run-SFTP-here behavior and to author an invitation's sftp endpoint from the locator. The static sftp segment cannot be captured as a job id: ids are validated as canonical v4 UUIDs before any use, which sftp is not.

Authoring the SFTP connection ​

PUT /api/jobs/sftp authors the connection from a JSON body with a file-reference credential or a pasted value the server materializes to a file (see Materializing a pasted credential). The body is read under a small byte cap (65536) -- a pasted password or SSH private key rides it but is far under the cap -- so an oversized body is a 413 before any parse. Fields:

FieldType / validation
hostnon-empty string (required)
portinteger 0..65535 (optional)
usernamenon-empty string (optional)
pathnon-empty string (optional) -- the remote shared directory
inboundPath / outboundPathnon-empty strings (optional, set together) -- the split remote directories, this party reading the peer's files in the inbound one and writing its own to the outbound one. Mutually exclusive with path; the two must differ
hostKeyFingerprinta literal OpenSSH SHA256 fingerprint or a non-empty list of them (required); an @path reference is rejected
credentiala credential in one of three forms (all tagged with credType: "password" | "private_key", which maps the resolved reference to the password or private_key field): a typed { "kind": "ref", "ref": "@/absolute/path", "credType" } -- the documented exception for a credential outside any listable mount -- a mount locator `{ "kind": "mountRef", "mount": "secrets"
privateKeyPassphrasean @path reference (optional)
keyboardInteractiveboolean (optional)

The body is strict: an unknown key (a provision, certificate, known_hosts, or a bare inline credential field) fails validation, with one spelling this rule does not report -- a key literally spelled __proto__ is neither rejected as unrecognized nor copied into the job (measured against the route: 200, no prototype pollution). A mountRef credential is resolved server-side against the mount its id names -- secrets the resolved JOB_SECRETS_DIR, folder the data root -- via the same mount-file resolver the browse listing uses (resolveMountFile) and rewritten to @<realpath>, so no container-absolute path ever transits the browser. A secrets locator on a console with no secrets mount, an unknown id, or a subPath naming no readable regular file (or one escaping the mount) is a 400 naming the credential or the mount -- never a path. A raw credential is materialized to a server-owned file whose @path replaces it (below). The resolved (materialized, typed, or picker-resolved) @path credential and the passphrase are then folded into a server block and run through the validation chain of The authored SFTP connection -- the strict field allowlist, mandatory literal fingerprint, credential-must-be-an-existing-@path, and the core-schema compose -- so every source is held to identical rules, containment warning included. A hard-validation failure is a 400 whose body is { "error": "<field path>: <reason>" } for a schema issue, or { "error": "<field path> must ..." } from one of the hand-written checks, naming a field path only -- never a submitted value, credential reference, or secret. An unmodeled key crosses as the fixed reason unrecognized key rather than the caller's own spelling of it, taken from the same formatter the probe and fingerprint routes use (see Probing the server host key). A malformed raw credential is rejected on shape (a non-string or empty value) before any write, and its 400 does not echo the value. Where the probe and fingerprint bodies report their first issue, this one reports every issue at fault, joined by ; , each rooted at the sub-object it was raised against. The roots are connection or connection.credential for the field- and credential-shape checks, and a doubled server.server root -- e.g. server.server.keyboardInteractive: ... -- for an issue the core-schema compose raises against the composed block, since that block's own issue path already begins with server before the formatter prepends its root a second time. The response length is therefore proportional to the issue count and bounded only by the request-body cap (MAX_SFTP_AUTHOR_BODY_BYTES) -- a pathological body measured near 25x amplification -- which is acceptable on the loopback single-operator surface but is not a bounded contract. On success the response is the authored connection's credential-free projection, including credentialWarnings (the same body GET returns).

Materializing a pasted credential ​

The { "kind": "raw" } credential is the de-emphasized fallback for a secret that exists nowhere on the console as a file. It widens the credential threat model -- a pasted value transits the loopback API and is briefly held in server memory -- but only within the single-party-appliance trust boundary: a loopback-only browser on the operator's own machine, where the value crossing same-origin loopback and living momentarily in memory is on-host. The at-rest and partner-facing boundaries stay closed:

  • Materialize once, then rewrite. The server writes value once to a server-owned file under a fixed, container-internal scratch directory (/run/alcove/sftp-credentials), then rewrites the credential to { "kind": "ref", "ref": "@<that file>" } and runs the same credential checks the file-reference forms run. The scratch directory is asserted outside the data root and rendezvous directory at boot (below), so a materialized paste never raises a containment warning. Server memory holds value only transiently -- between request parse and the file write -- and never logs it, places it in argv or the child environment, or returns it in any response, projection, or SSE event; a JS string cannot be zeroized, so this is best-effort minimization rather than a guaranteed wipe. The composed alcove.yaml holds the resulting @path, never the value.
  • The scratch directory. It is not JOB_DATA_ROOT and not the resolved JOB_RENDEZVOUS_DIR, and needs no extra operator mount, so a paste works with only the data root mounted. Its path defaults to a fixed container-internal location and is relocatable server-side with JOB_SFTP_CREDENTIAL_DIR (the image ships that default directory owned by the unprivileged account it runs as and uses it; a deployment under another account, or the test harness, points it at another writable, non-partner-syncable directory, since the default's parent /run is root-owned and the directory cannot be created at boot). It is created mode 0700; each secret is written under a server-generated name (a v4 UUID) at mode 0600, chmod-enforced after the write. A pasted secret exists at rest only as this file, and only as long as the connection it belongs to: it is deleted on the connection's DELETE/reset and on a re-author that replaces it, and deleting an exchange (DELETE /api/jobs/:jobId) leaves it in place with the connection. Unlike a workdir, it does not linger until deleted -- see the boot sweep below. A raw credential on a console with no scratch directory (the API disabled, or the boot setup skipped) is refused rather than composed inline.
  • Boot sweep and containment assertion. At server startup, when the job API is enabled, the currently-configured scratch directory is swept clean (covering a secret orphaned by a restart -- an SSH credential must not inherit the workdir's persistence). It is asserted to resolve strictly outside the data root, the rendezvous directory, the secrets mount, and the work-input directory, on both the lexical resolve and the realpath and in both nesting directions (a sweep of an excluded directory would destroy operator or partner data). The "no pasted secret lingers across a restart" guarantee therefore holds only while the job API stays enabled and JOB_SFTP_CREDENTIAL_DIR is unchanged between runs -- a since-removed override location is not the one swept. The containment check runs on the realpath before the directory is created or re-moded, so a symlinked scratch path resolving into an excluded mount is refused before any mkdir/chmod can touch that mount. A directory that fails the assertion refuses the boot, fail-closed, matching the console's posture. So does one that can be neither created nor swept, and that refusal names JOB_SFTP_CREDENTIAL_DIR alongside the (server-side, non-secret) path and the errno. The console run as an account other than the one the image built the default directory for reaches this with the default, where every operator mount is excluded and the variable is the only place left to point it.
  • A tmpfs for the scratch directory. A tmpfs keeps pasted secrets off disk entirely and is preferred; the boot sweep is the fallback when the container does not arrange one. The boot creates the scratch directory 0700 and chmods it, so that directory has to be one the unprivileged runtime account owns, and a tmpfs arrives owned by root unless its mount options say otherwise. Mounted straight onto the scratch path, the chmod fails EPERM; mounted at the parent (/run/alcove) with no options, creating the scratch directory inside it fails EACCES; either refuses the boot. Mount the tmpfs at the scratch path, or at the parent so the server creates the scratch directory inside it, with uid/gid options naming the runtime account (uid 1000 in the shipped image). Measured 2026-10-08 with the arm64 image on Docker Desktop (tmpfs ownership is engine-specific): --tmpfs /run/alcove/sftp-credentials and --tmpfs /run/alcove each exited 1 with the refusal naming JOB_SFTP_CREDENTIAL_DIR, the path and the errno, while uid=1000,gid=1000 at either path, and mode=1777 at the parent, booted with the scratch directory node:node 700. An override moves what that guidance applies to along with the directory. A deployment naming an account with --user points JOB_SFTP_CREDENTIAL_DIR at a directory that account can create -- /tmp/alcove-sftp-credentials is what the published POSIX launcher passes. A tmpfs at that path needs uid/gid naming the account the container was given rather than uid 1000. A tmpfs at its parent, /tmp, needs no options on Docker Desktop, which mounts it root-owned 1777, so the account creates the scratch directory inside it. Measured 2026-10-09 with the arm64 image on Docker Desktop (macOS), run as --user 501:20: --tmpfs /tmp/alcove-sftp-credentials with no options, and with uid=1000,gid=1000, each exited 1 with the refusal "the pasted-credential scratch directory /tmp/alcove-sftp-credentials could not be created (EPERM); set JOB_SFTP_CREDENTIAL_DIR to a directory the account this server runs as can create, outside every mounted folder", while uid=501,gid=20 at that path, and --tmpfs /tmp with no options, with uid=501,gid=20, or with mode=1777, each booted with the scratch directory 501:20 700. Another engine may mount a tmpfs at /tmp with other ownership, which this measurement does not cover; uid/gid naming the account sets that ownership explicitly. The image arranges a tmpfs at no path, the default's included, so an override left without one is on whatever filesystem the engine gives the container for its own writes, and the boot sweep is the fallback exactly as it is at the default.

If any validation step after the write rejects the entry (a bad host or fingerprint), the just-written file is deleted before the 400 returns, so a rejected paste leaves nothing at rest.

The coordination server ​

A webrtc job dials the coordination server the operator authors through PUT /api/jobs/webrtc; the create intent holds no connection field, and names in signalingServer the server its invitation names. A create whose signalingServer is absent or differs from the authored server's host, port or path is 400 { "reason": "coordination-server-changed" }, as is one naming a server while none is authored; one naming none while none is authored is the empty 400. An omitted port is compared as the authored scheme's default, and a path as if it ended in /. An acceptor's relay must equal the authored invitationRelay, both absent included, and an inviter's must equal the locator the console's invitation names for the authored ownRelay (below), or be absent where none is authored; otherwise the create is the same 400. The scheme is not compared, since the invitation does not state it. The server is held in memory, a restart forgets it, and a job captures it when it is created. Like the SFTP connection, it is kept when an exchange is deleted, which a retry does before it creates the next. None of this applies to a run of the opened configuration, which dials the server its file names (Running an opened webrtc configuration in place).

The body is { "address": "<text>", "invitationRelay"?: { "turn"?: [ "<url>" ], "stun"?: [ "<url>" ] }, "ownRelay"?: { "turn"?: [ "<url>" ], "websocket"?: "<url>", "relayRegistrar"?: { "url", "exchangeId" } } }, strict, read under a 16 KiB cap (413 above it), with address at most 2048 code units (core's MAX_SIGNALING_SERVER_URL_LENGTH). invitationRelay is the relay an accepted invitation names, which the console's accept sends with that invitation's server: a strict object naming at least one list and no other key, a credential included, each list of 1 to 8 urls (core's MAX_RELAY_LOCATOR_URLS), each url at most 1024 code units and held to core's TurnUrlSchema or StunUrlSchema.

ownRelay is the relay the operator sets for the console's own invitations:

  • Its shape is url-only. A strict object holding turn, a list of 1 to 8 url strings (core's MAX_RELAY_LOCATOR_URLS), each at most 1024 code units and held to core's TurnUrlSchema, websocket, one url string held to core's webSocketRelayUrlSchema, the rule of connection.websocket_relay, or both, and optionally relayRegistrar, held to core's RelayRegistrarSchema. No other key is admitted, a credential included, and an entry is a url, never an object, so no TURN username or credential is representable.
  • A registrar needs a TURN url. A relayRegistrar beside no turn list is refused, as core's schema refuses a connection.relay_registrar beside no url-only turn entry, so a WebSocket relay alone is set with no registrar.
  • The invitation names it as alcove invite names a configuration's own relay. The locator holds the turn urls in order, the websocket url, and beside that url, as websocketExchangeId, the relayRegistrar's exchangeId, which the relay holds the key under (ownRelayInvitationLocator in apps/web/src/jobContract/signalingServer.ts). The registrar's address is this party's own and is not named.
  • A refusal names the rule, not the value. A refused ownRelay is a 400 { "error" } stating the first rule it breaks in core's words, which repeat no part of a url.
  • A PUT states the whole setting. A PUT without invitationRelay or ownRelay leaves the server with neither, so the console's accept, which sends only the invitation's relay, drops an ownRelay set before it.

The address is trimmed and read as a URL:

  • A user name or password is refused for every scheme, with a message that does not repeat the address.
  • A ws: or wss: address is held to core's publishedSignalingServerURL, the rule the web app's build applies to the coordination server it publishes at /alcove.json: a host, an optional port and path, no query, fragment, or percent-escape, and no host or path a dial would read as another server's. The path gains a trailing /. Port 0 is refused, since the CLI cannot dial it.
  • An http: or https: address is a web app's: it must end at its host and port. The server reads the app's /alcove.json through core's resolveWebAppSignalingServer, the read alcove invite makes given a web app's address: no redirect followed, a 4 KiB body cap, a 15 s timeout, and a published server whose scheme matches the address's (wss: for https:, ws: for http:). An app that answers with no usable document is a 400; one that cannot be reached, answers 408, 429, or 5xx, or does not answer in time is a 502. Its origin is reported as webAppOrigin.
  • Any other scheme, or text that is not a URL, is refused.

Each refusal is { "error": "<message>" }: one sentence saying what was wrong with the address and one saying what to type instead.

A ws: server is admitted with a warning in warnings, since its signaling, which includes the rendezvous ids and both parties' network addresses, is not encrypted; it is the operator's choice, and what a server on the console's own machine uses. A refused address leaves the authored server unchanged. Because the read of a web app takes time, each PUT and DELETE is counted: a PUT whose read finishes after a later PUT or DELETE took effect is a 409 and changes nothing, so the later request decides the setting. A refused PUT takes no effect, so it makes no earlier PUT a 409.

Probing the server host key ​

POST /api/jobs/sftp/probe reads the host-key fingerprint an SFTP server presents, so the console can offer it beside the paste field for the operator to compare against the value the server operator published. It authors nothing: it never touches the authored connection, records nothing, and returns the observation for the caller to forget. The manager spawns the CLI's probe-host-key --json subcommand (the same binary the exchange runs; the server cannot probe SSH in-process -- see SFTP_CONNECTION.md) under the shared no-shell spawn discipline, caps and parses its stdout, and discards its stderr.

The child's budget fits inside the server's: a 10 s connect timeout plus the child's own 2 s bounded read of the peer's first bytes, against a 15 s SIGTERM watchdog. Crossing the watchdog would flip the typed result from unreachable to timeout and lose the diagnosis with it, so the ordering is held by an arithmetic check over the three constants rather than by this sentence. A host that accepts the connection and then answers nothing therefore reports unreachable at about the connect timeout rather than timeout: the CLI dials once and is not re-dialed (see Reading a host key with probe-host-key), so the child exits on its own budget before the watchdog reaches it.

The SSRF bounds are the module contract, pinned by tests:

  • Request holds host + port only. The body is .strictObject({ host, port? }) -- host a non-empty string, port a 0..65535 int -- so no username, path, or credential field is representable; an unmodeled key is a 400. host additionally passes the shared bare-host predicate (no scheme, userinfo, path, or whitespace), the same rule PUT /api/jobs/sftp applies, so it cannot smuggle a URL. The body is read under a tight cap (4 KiB, 413 above it).
  • Response holds fingerprint + key type only. A completed attempt is always a 200 with a discriminated body: { "status": "ok", "fingerprint": "SHA256:...", "keyType": "..." } on success, or { "status": "unreachable" | "timeout" | "error" } for a probe that ran but yielded no key. The fingerprint is re-validated against HOST_KEY_FINGERPRINT_REGEX, and the key type is length- and charset-capped (server-controlled bytes). No stderr, latency-beyond-category, saved-hosts list, or re-probe-on-change is exposed, and there is no credentialed "test connection". An unreachable may additionally include the peer-answer diagnosis below.
  • A diagnosed unreachable names what answered. The console's guided audience is the likeliest to sit behind an intercepting middlebox, where a bare unreachable sends an operator to check an address that is right. On the exit-69 path the CLI's --json form emits its own diagnosis line on stdout (see CLI.md), and this endpoint states it structurally: { "status": "unreachable", "peerAnswer": "closedUnanswered" }, or { "status": "unreachable", "peerAnswer": "nonSsh", "peerAnswerShape": "http" | "tls-alert" | "unrecognized", "peerAnswerExcerpt": "<escaped>" }. The route is structural rather than a stderr forward for the same reason stderr is discarded at all: what crosses is a closed vocabulary plus one field of the peer's own bytes. That field is re-validated at the trust boundary -- the discriminant and shape against closed sets, the excerpt escaped through the display escape and capped at 512 characters (four times the CLI's 128-byte excerpt bound, since the escape expands a non-printable byte to \xHH) -- so nothing past that boundary holds a raw control byte or an unbounded span. A line outside that vocabulary degrades to the bare unreachable rather than to an error, which is also what a CLI without the line produces.
  • Single-flight and stateless. One probe child runs at a time; a concurrent request is a 409 (empty body). The flag is claimed synchronously, independent of the exchange slot. The probe reconciles the child's exit -- CLI exit 69 to unreachable, a watchdog kill (SIGTERM at ~15 s, SIGKILL after a grace) to timeout, any other non-zero or malformed line to error -- and forgets it.

Non-2xx is reserved for HTTP-level conditions only: 400 for a bad body, 409 for a probe in flight, 404 when the feature gate is off, and 500 for an unexpected internal fault. A probe that ran and failed is a 200 category, never a 502. A 400 message names a field path and a shape reason and nothing else: neither a submitted value nor the caller's own spelling of an unmodeled key, which the schema's own rejection message would otherwise quote. Every message a route derives from A schema issue -- this one, POST /api/jobs/signing/fingerprint, PUT /api/jobs/sftp, and a POST /api/jobs core refuses at composition -- composes it through the same formatter module, so none can come to echo more than another. A handful of hand-written fixed strings (a bare-host or literal-fingerprint check, a credential-kind fallthrough, an @-file or missing-file credential error, the connection- and server-mapping-shape checks, and the probe route's own inline bare-host rejection) are composed at their own throw sites instead -- caller-byte-free as measured, but not covered by the module's rule.

Checking the relay ​

/api/jobs/relay-check runs the CLI's doctor relay over the working folder's alcove.yaml and .alcove.key, the pair a webrtc run of this console runs over, so the operator learns whether the TURN relay a run uses gives a relay address from this container, and whether each WebSocket relay a run dials admits it. The console offers it on the inviter's screens; the endpoint itself reads whichever webrtc configuration the working folder holds. The console writes nothing and records nothing; it returns the verdict for the caller to show and forget.

GET reads the pair and contacts nothing. It loads alcove.yaml through the schema the CLI loads it with, and answers unavailable with a closed reason where there is nothing to check: no-configuration, configuration-refused, not-webrtc, ice-provision, no-relay (no TURN url and no WebSocket relay), relays-left-out (every relay url is left out by connection.allowed_relays), or key-file (no usable key file beside it, as The key file beside an opened configuration states). Otherwise it answers available with the hosts a check contacts and those it does not, composed by core's relayCheckPlan and relayHostStatement -- the functions the CLI prints its own statement with -- so the hosts the operator is shown before the spawn are the hosts the CLI's plan of the same file contacts. configurationDigest is the SHA-256 of the file's text as read, decoded as UTF-8: the text the offered hosts come from.

POST takes .strictObject({ configurationDigest }) under a 1 KiB body cap, and re-reads the pair first. Where the file's digest is not the one given, it answers { "status": "configuration-changed" } and spawns nothing, so a check runs only over the configuration whose hosts the operator was shown; where the pair is now unavailable, it answers { "status": "unavailable", "reason" }. Otherwise the manager spawns doctor relay --json --config-file=<data root>/alcove.yaml --key-file=<data root>/.alcove.key under the shared no-shell spawn discipline, with the data root as its working directory, caps its stdout at 64 Ki UTF-16 code units, and discards its stderr, where the CLI writes its own statement and its human lines.

  • The exit is reconciled to a closed set. Exit 0 with an ok verdict and exit 78 with a fix_and_retry verdict are { "status": "verdict", "overall", "checks" }; exit 64 is { "status": "refused" }, the CLI's refusal of the configuration before it contacted anything; a watchdog kill is { "status": "timeout" }; any other exit, a verdict whose overall is not its exit's, an overflowed read, or a line this boundary does not accept is { "status": "error" }.
  • The watchdog sits above the check's own bounds. The child's budget is the check's 30-second request bound plus the invitation relay's 3-second enrollment wait; the watchdog sends SIGTERM at 45 s and SIGKILL after a 5 s grace, so a check that runs to its own bound reports its verdict rather than timeout. The ordering is held by an arithmetic check over the constants.
  • Each check crosses from closed fields. A check's id is enrollment, turn_<n> or websocket_<n>, its status, transport and outcome members of their closed sets, its close_code an integer, its lapses_at a UTC timestamp, and the checks' statuses agree with overall; a verdict breaking any of these is an error. Each check crosses as { "id", "status", "headline" }, with the CLI's meaning and action where the verdict holds them. The headline is composed by core's relayCheckHeadline from the check's closed fields, the function the CLI prints its own summary line with (see What the verdict withholds), so a refused enrollment's headline holds no registrar answer. A url or registrar can come from the partner's invitation, so the headline, meaning, action, and every host and line of the GET statement are escaped and bounded by sanitizeForDisplay at this boundary and rendered by the UI as given.
  • One check at a time, and never beside a run. One check child runs at a time: a concurrent request is a 409 (empty body), the flag claimed synchronously and independent of the slot. A request while the slot's exchange child is starting or running is a 409 { "reason": "exchange-running" }, so a check is not run over the pair a running exchange uses.

Stated limits ​

  • A run started during a check is not refused. The check writes nothing in the working folder, so an exchange created while a check runs starts as it would otherwise; the two contact the relay at once. At a WebSocket relay the check's sockets hold this side's role while it runs, so the run's own socket is refused as role taken and dials again, as its relay client does on that refusal (WEBSOCKET_RELAY.md).
  • The offer before a run follows the TURN urls. The console offers the check before a run only under a configuration that lists a TURN url, so a configuration whose only relay is a WebSocket relay is offered it after a failed attempt alone.
  • A configuration with no role is offered the check. A configuration that lists a WebSocket relay and sets no role is offered the check, and starting it answers refused, as a run of that file is refused.
  • The digest is compared before the spawn, not bound to it. The child reads alcove.yaml by path after its digest matched, so an edit made between the two is checked without the operator having seen its hosts; only the operator writes the working folder.

The signing identity ​

POST /api/jobs/signing/fingerprint returns this party's long-lived signing identity's fingerprint, so the operator can share it out-of-band before a signed exchange. It is anchored here, at an explicit operator action, rather than at run time for the reason the CLI anchors it at alcove fingerprint: the partner must pin the fingerprint before the exchange, so the identity has to exist before the run rather than during it.

Where the identity is: the console's option ​

The identity's location is a console option, composed into the CLI's own signing.identity_file, so a graduated command-line run loads the configuration unchanged.

  • Default: the mounted working directory. With no location configured, the identity is <dataRoot>/.alcove-signing-identity.json (see Workdir layout), CREATED there on demand by this endpoint. This is the location every other row about the default describes, and it is unchanged by the option's existence.
  • Configured: a locator under the secrets mount. signing.identityLocation, on the job-create intent and on this endpoint's body alike, is { "mount": "secrets", "subPath": [ "<segment>", ... ] } -- the same locator shape a credential's { "kind": "mountRef" } takes (see Authoring the SFTP connection). The server resolves it against JOB_SECRETS_DIR, so the browser never sends or learns a container-absolute path.
  • Resolution is the secrets browse's, with the file allowed not to exist. Every segment passes the browse's single-segment shape rule, and the PARENT directory is confined to the mount lexically and then by realpath, exactly as GET /api/jobs/mounts/secrets/entries confines a listing. The FINAL segment takes the same realpath confinement whenever it names something, so a symlink there is confined by its target: one pointing out of the mount does not resolve, and one pointing inside resolves to the file it names, which is what the composed signing.identity_file holds and whose directory the publish refusal compares. Only a final segment that names nothing -- absent, or a link with nothing at its end -- keeps the resolved parent plus the name, so a path naming no file still resolves: whether an identity is there is an ANSWER this endpoint gives, not a precondition of resolving. A location that does not resolve -- no secrets mount configured, an inadmissible segment, a parent outside the mount, or a final segment whose target is outside it -- is a 400: POST /api/jobs/signing/fingerprint names the field and a shape reason, and POST /api/jobs answers with an empty body (see Endpoints).
  • A configured location is READ; only the default is created. With a location configured and no identity at it, the endpoint answers { "status": "absent" } and spawns no child. What is there is read at the END of the path, following a symlink to its target, so a link with nothing at its end is absent rather than a name the create-or-reuse child would create through. The console therefore writes nothing into the secrets mount on any path but one: not the certificate export (which is always <dataRoot>/alcove-certificate.json), and not the child's working directory, which is pinned to the data root whatever the identity's location. The one path that writes is the identity itself, and only through a window: the presence check and the child's load are two steps, so an identity REMOVED between them is created by the child at the operator's own path. Writing nothing else is what lets the operator mount the identity's directory read-only, which is the posture DEPLOYMENT.md recommends for the CLI; a read-only mount closes that one window, and it is the recommended layout.
  • signing.identityLocation is admissible only under mode: "certificate". A run that signs nothing loads no identity, so a location beside it would name a file nothing reads -- and exactly one path then answers which identity an unsigned run could publish (see Refusing a run that would publish the signing identity).

The manager spawns the CLI's fingerprint subcommand (the same binary the exchange runs) under the shared no-shell spawn discipline, caps and parses its stdout, and discards its stderr -- which names filesystem paths inside the container. Whether the identity already existed is read from the file's presence before the spawn, not from the child's own "Created"/"Loaded" banner, which is on that discarded stream.

The child's working directory is pinned to the mounted data root, never inherited from the server process. The argv has no --config-file, and with it omitted the CLI reads its default ./alcove.yaml for identity hints -- resolved against the directory the child runs in, so the pin is what decides which document that is. It makes the mount the only place a hint config can come from: an alcove.yaml in the server's own working directory is never the one read (the flags override every hint it could supply, but a malformed one is a usage error the child exits 64 on). The data root is created if missing, since a spawn cannot start in a directory that does not exist.

The bounds mirror the host-key probe's:

  • Request holds a label, a boolean, and a mount locator only. The body is .strictObject({ identity, exportCertificate?, identityLocation? }) -- identity the operator's own linkage_terms.identity, held to the same shared label contract the zero-setup intent's identity is (the cap, no leading -, no control or text-direction character, no private key material), exportCertificate a bare toggle, identityLocation the mount locator above. The three content rules are critical on this route rather than defensive: what the label is bound into is a long-lived certificate the partner pins and displays, and rebinding it costs an alcove fingerprint --force and a re-pin by every partner, so the input rule is where a stray byte, a layout scope that would reorder the copy the identity is displayed beside, or a pasted key that no terms document may hold, is caught. Both server surfaces refuse the same values through the same two patterns and the same key detector, so neither can drift from the browser guard that mirrors them, and a certificate cannot bind a label the linkage terms would refuse. No path field is representable -- a locator is a mount id and segments the server resolves, never a path -- and an unmodeled key is a 400 whose message takes the same field-path-and-reason shape the probe's does. The body is read under a tight cap (4 KiB, 413 above it).

  • Response holds a fingerprint, a flag, file names, and the bound party name only. A completed attempt is always a 200 with a discriminated body: { "status": "ok", "fingerprint": "<43 chars>", "created": <bool>, "identityFileName": "...", "certificateFileName"?, "boundIdentity"? }, or { "status": "refused" | "syncing" | "absent" | "timeout" | "error" }. The fingerprint is re-validated against core's canonical FINGERPRINT_REGEX on both sides of the boundary. The two names are file names, never paths, so no container location crosses: identityFileName is the console's own constant at the default location, and the picked subPath's LAST SEGMENT at a configured one -- a value the browse already admitted as a single segment, echoed rather than composed.

  • boundIdentity is the party name the identity is bound to. Read from the CERTIFICATE half of the document after the child runs, on the terms the CLI's own certificate-only load reads it: the version literal around the certificate is checked, the certificate's key encoding and self-signature are validated, and the private key beside it is never imported. It is what lets the console compare the identity a run would sign under with the linkage_terms.identity that run states, and refuse the launch before it -- the label THIS request sent binds nothing on a reuse, so it answers nothing about a key that already existed. The field is ABSENT where no name could be read (no file, an unreadable one, an unrecognized format, or a certificate that does not validate), which is nothing to compare rather than agreement: the run's own refusal at identity load stays the authority on whether a diverging run may proceed. It is free text out of the operator's own mount rather than a value this route's schema admitted, so the browser bounds it by the terms identity cap and escapes it where it shows it.

  • refused is the CLI's exit 64, reported as one category. The request cannot be its cause: the body schema requires a non-empty identity (an absent one is a 400 before any child runs) and every path is server-composed. What remains are conditions in the mounted working directory -- an identity file that cannot be read or parsed, a first-time exclusive create that kept losing a create/delete race, a failed certificate-export write, or a malformed alcove.yaml in that same folder, which the pinned working directory makes the only config the child can read. The discarded stderr is the only place the CLI says which. So the boundary reports the class rather than a cause it cannot observe, and the console's copy points at the mount and at running alcove fingerprint there for the reason.

    Not every member of that class is the operator's own doing. JOB_RENDEZVOUS_DIR falls back to the data root (see CONSOLE.md), so on a single-mount console running a filedrop exchange the pinned working directory is the partner-synced folder, and an alcove.yaml a partner writes there deterministically refuses identity creation. The scope of impact stops at that denial: the argv passes --identity-file and --identity explicitly, and both beat any hint the config could supply, so a planted document cannot redirect where the key is written or which identity a new key binds. The console's copy therefore names the file as a cause and tells the operator to read it rather than assume their own error.

  • Single-flight. One fingerprint child runs at a time; a concurrent request is a 409 (empty body). The flag is claimed synchronously, independent of the exchange slot. Single-flight matters more here than for the probe: two concurrent runs would both create-or-load the same file, and the CLI's first-time create is exclusive precisely so a race cannot silently discard a generated key.

  • absent is a picked location holding no identity. No child spawns and nothing is written. A link with nothing at its end holds none either. It is reachable only with identityLocation set: the default location is created on demand, so nothing there is a create rather than an absence. The operator's remedy is alcove fingerprint --identity-file against that path, or picking the file that holds their identity.

  • syncing is a create the exchange slot forbids. Single-flight is against other fingerprint requests only, so nothing else stops a create landing in a folder a running exchange is syncing to the partner. A create is answered { "status": "syncing" } -- no child spawns and no key is written -- while a filedrop exchange occupies the exchange slot and a rendezvous leg holds the directory the key would be written into. Only the create is: a fingerprint READ of an identity already there is served, since that key is on disk whatever the request does, and a request naming an identityLocation is always a read. See Refusing a run that would publish the signing identity.

  • No regeneration. There is no --force on this surface and no body field that reaches one. A re-key mints a new key with a new fingerprint and invalidates every pin a partner holds, so it is a coordinated action that stays on the command line, where the flag names what it does. This refuses the operator nothing: alcove fingerprint --force is theirs to run against the same mounted file whenever they mean to. The console states that, rather than offering the button.

  • The export never overwrites the identity, and never lands beside it. --export-certificate writes the public certificate (never the private key) to a second fixed name in the mounted DATA ROOT -- always there, whatever the identity's location, since that is the one directory the console writes to and a configured identity's directory may be read-only. The CLI refuses an export path that resolves to the identity file itself, and the console holds the same rule as its own check before it spawns, so the two server-composed names can never collide silently.

Refusing a run that would publish the signing identity ​

The rendezvous directory is partner-writable and kept in step with the partner's copy by the operator's own sync tool, continuously and independently of any exchange (see CONSOLE.md). A signing identity inside it is therefore a private key the partner can read from the moment it is written, run or no run -- and whoever holds it signs receipts under this party's identity, with every partner, not only the one the folder is shared with.

POST /api/jobs REFUSES a filedrop run on that layout rather than warning about it. What the refusal stops is the one thing the console would otherwise do knowing it publishes the key: start an exchange whose own protocol step copies the folder's contents to the partner. It does not contain the disclosure, which the sync already made, and it is not a control against the partner. The console admits every other layout, and the operator's own choice governs everywhere else, but a disclosed long-lived key is not recoverable by any later choice, so the console does not take that step itself.

The refusal fires when both hold, checked at create time before the exchange slot is claimed and before any workdir exists:

  • An identity file is present at one of the two paths the console knows: the identity this run LOADS (the configured location, or the default when it names none), and the default in the data root, which a run that configured a location of its own leaves behind rather than moves. Presence, not readability: a file the console's own uid cannot open is a file the partner's sync copies, so it counts. A run with none there publishes no key whatever it signs: the child is pointed at that path explicitly and LOADS the identity from it, refusing a certificate run when nothing is there (resolveSigningPersist in apps/cli/src/commands/exchange.ts), so no run creates the key it would then sync. The layout alone is not the refusal.
  • A rendezvous leg IS the directory holding it, or HOLDS that directory. The comparison is directional: a leg mounted INSIDE that directory, or beside it, leaves the key out of what the run syncs and is admitted. Every leg is compared -- the shared mount of a single-mount console, both legs of a split one -- since either is partner-synced. Pointing the identity's location at a folder outside every leg is therefore a remedy the console offers in place of a mount change, and pointing it INTO one is refused the same way the default location is. The directory compared is the one the key sits in: a configured location resolves through its links, so a link in the secrets mount to a key in a synced folder meets the same refusal as picking that key directly, and a link to a key outside the secrets mount does not resolve at all.

Creating the identity is refused on no LAYOUT. The rendezvous falls back to the data root, so on a single-mount console every create lands in a directory a leg holds; refusing there would take signed exchanges off that console on every channel, while only a filedrop run publishes the folder. POST /api/jobs/signing/fingerprint therefore creates on demand wherever the mounts point, and the run that would publish the key is the one refused. The create-time gates read the DEFAULT location, which is the only path a create lands at.

One moment is refused rather than one layout: a create made while a filedrop exchange occupies the exchange slot and a rendezvous leg holds the directory the key would be written into. That run's own check ran before the key existed and cannot run again, so nothing else keeps the console from writing a private key into a folder an exchange is syncing as it goes. The endpoint answers { "status": "syncing" }, and the console tells the operator to create the identity once that run finishes or to give the synced folder a mount of its own. A read of an identity already there is served: the key is on disk whatever the request does.

The comparison is the one the rendezvous report's sharesDataRoot uses (see The filedrop rendezvous report): each directory as configured, as the real path symlinks resolve it to, and by filesystem identity along the ancestor chain, which catches one host directory bind-mounted at two container paths. It is read per run rather than at boot, since the identity is created on demand between one run and the next.

A hold the comparison could not establish -- a path component the console cannot read, or aliasing outside the ancestor chain -- ADMITS the run. A refusal is owed a positive finding, and the console states that limit beside its signing control instead. A folder mounted twice under two names, outside that chain, stays undetectable. One unresolvable case still refuses: a leg spelled exactly as the identity's directory is that directory, whatever the spelling resolves to, so nothing about it is left open by a component the console could not read.

The rejection is one of the POST /api/jobs answers with a body: 400 { "reason": "signing-identity-in-rendezvous" }. The token is an enumerated word and the whole body -- no path, no mount name, no message -- because the refusal is about the console's mounts, which the browser never learns, and the copy the operator reads is composed in the browser from the token. A token an older browser does not know reads as no token and falls back to that bundle's generic refusal copy.

Stated limits ​

The refusal covers one run on one layout, and the layout is one the operator can be in without ever starting a run. Five limits follow, accepted rather than closed -- the first three under the deployment's trust in an authenticated partner under a signed agreement, and the last two as windows the console's own checks leave open:

  • The folder syncs at all times, not only during a run. The operator's sync tool keeps a configured rendezvous in step continuously, so a key written into it is disclosed from that moment whether or not an exchange is ever started. The refusal signals the layout; it does not prevent the disclosure. Keeping a key in a folder they have chosen to sync is the operator's own choice, and the console's posture there is the standing one -- warn and guide, not block: the receipts card names the hazard on that layout and points at the remedy, a rendezvous mount of its own.
  • The probe reads a name, not a key. It is a presence probe on one fixed file name in the mounted data root, which on the refusing layout is a folder the partner writes into. A file a partner plants at that name refuses a run that would have published nothing, and an identity the operator renamed is not seen, so a run that would publish it is admitted. The file's contents are not read, and neither its mode nor its ownership changes the answer.
  • A document at that path is taken as this party's identity. It is what POST /api/jobs/signing/fingerprint reports the fingerprint of, and what a run on any channel signs with. That endpoint also reports the party name the document is bound to, so a substitute bound to another name is stated before the launch (see The signing identity); a document bound to the agreed label diverges from nothing, and the fingerprint the operator shares is then the substitute's. A partner able to write into the synced folder could therefore substitute one.
  • The two refusals exclude each other in one direction only. A create of the identity is answered syncing while a filedrop exchange occupies the slot, and a run is refused while the identity file is present -- but the run's probe reads presence at create time and nothing else. A filedrop run created after a fingerprint child spawned and before that child wrote the file is admitted, over a window of one child process start, and the key then lands in the folder that run syncs.
  • A picked location nested in a rendezvous leg takes the write the read leaves open. The syncing check applies at the default location only: it fires where nothing is at the path, and a picked location is read only where something is, so applying it there would refuse nothing. Where the operator nests JOB_SECRETS_DIR inside a rendezvous leg, an identity removed between the presence check and the child's load (see Where the identity is: the console's option) is therefore created into a folder a live filedrop run is syncing. Mounting the secrets directory read-only is what closes it, and is the posture recommended for a directory holding key material.

The remedy for all five is keeping the identity outside every folder a partner syncs -- the identity's location is a console option, so that is a pick in the secrets browse rather than a mount change (see console-signing-identity-custody.md).

The secrets mount and browsing ​

JOB_SECRETS_DIR names an operator-mounted directory the console browses to pick a connection's credential file, and to pick where this party's signing identity is kept (see The signing identity). Unlike JOB_INPUT_DIR and JOB_RENDEZVOUS_DIR, it has no JOB_DATA_ROOT fallback: when it is unset the secrets mount is unavailable and a secrets locator is refused, so the data root, which every job writes into, is never treated as a secrets directory. The credential picker then browses the working folder through a listing of its own, under the containment warning (see Browsing the working folder for a credential). It is server-side configuration, never a browser-sent path.

Browsing does not widen the credential-reference rule: a typed { "kind": "ref" } reference a PUT holds is validated only against the data-root and rendezvous exclusions (above), not required to lie under the secrets mount. The mount is the browse surface; it is not an allowlist that authorizes a path. A { "kind": "mountRef" } locator (see Authoring the SFTP connection) confines only in the tightening direction: it is resolved to a realpath under the mount before it becomes a reference, so a picker selection cannot escape the mount, yet the resolved path still runs the same data-root/rendezvous exclusion checks a typed reference does. So the mount confines a browsed pick without authorizing one.

The console WRITES nothing into the mount on any path but one. A credential is referenced in place and read by the CLI child; a picked signing identity is read rather than created, except an identity removed between the presence check and the child's load, which that child creates at the picked path (see The signing identity); the certificate export goes to the data root; and the fingerprint child's working directory is the data root whatever the identity's location, so no alcove.yaml in the mount can become the child's config. The mount may therefore be read-only, which closes that one case and is the posture recommended for a directory holding key material.

GET /api/jobs/mounts/secrets/entries ​

Lists one directory of the secrets mount. subPath is a repeated query parameter -- one value per path segment (?subPath=.ssh&subPath=keys), never a single slash-joined string -- so a / inside a value can never compose a traversal. Each segment is admitted by a single-segment shape rule identical to the work-input name rule except that a leading dot is allowed, so .ssh and other dotfiles that hold SSH key material are navigable; the separator, ./.., control-character, and 1..255 length checks are shared with the input rule and cannot drift from it. The resolved directory is confined to the mount twice -- lexically (the resolve + startsWith(root + sep) idiom) and then by realpath, so a symlink cannot escape the mount. No file bytes are ever read; an entry's kind comes from stat only.

The body mirrors the input listing's shape family:

  • { "configured": false, "readable": true, "entries": [] } when JOB_SECRETS_DIR is unset (the mount is unavailable).
  • { "configured": true, "readable": true, "entries": [ { "name": "...", "kind": "dir" | "file" }, ... ] } for a readable directory, entries sorted by name and filtered to admissible names.
  • { "configured": true, "readable": false, "entries": [] } when the subpath is inadmissible, escapes the mount (lexically or by realpath), or cannot be enumerated -- reported as a bare boolean, so a mis-mount or a traversal attempt reveals neither the errno nor the absolute path.

At the top of the mount the listing leaves out the console's own names, the set the working-folder listing below leaves out, so the credential picker does not offer one. The signing-identity picker asks with purpose=signing-identity and gets them, since an identity file is what it is looking for.

Browsing the working folder for a credential ​

On a console with no secrets mount the credential picker browses the working folder instead, through GET /api/jobs/mounts/folder/entries: the same subPath contract, confinement, and body as the secrets listing above, with the data root as the mount and configured always true. A file picked there is sent as a { "kind": "mountRef", "mount": "folder" } locator. This widens no rule: the data root stays outside the secrets mount (which keeps its no-fallback rule, so a secrets locator is still refused on a console without one), and a credential resolved in the data root takes the same non-blocking containment warning a typed reference into it does. The console's own files in the working folder (the key file, the configuration and its saved copy, the signing identity and its certificate, and the job working directories) are left out of this listing and refused as a credential. The two sample input CSVs the console writes on the data root when no input directory is set are inputs the operator picks, not console-owned files, and stay listed. The signing-identity picker does not browse the working folder.

Loading a configuration from the mount ​

GET /api/jobs/config reads the command-line configuration at <dataRoot>/alcove.yaml -- the file an alcove invite --save run wrote, or one the operator authored by hand -- so the console's authoring forms start from the exchange they already run. The read is server-side: the console shows the browser no container path other than the identity file a file run in place names, and the key file .alcove.key sits beside the configuration, so a browser file pick would put both in reach. For a configuration on a channel the console runs, the read also checks the key file beside it the way a run of it would (see The key file beside an opened configuration) and names the fault it finds, so the operator learns of it on opening the configuration rather than when the run starts; nothing read from the key file is sent.

Each read replaces the configuration the server keeps as opened; a read that finds no file, or refuses the one it finds, keeps none (see The recurring-run hand-off for what a run does with it).

The file is read through the shared sensitive-YAML chokepoint (bounded parse; an error names the path, plus the line, column and kind of a syntax error) and then core's own exchange-file schema, the same chain the web application's configuration import uses.

The body ​

{ "configured": true, "present": <bool>, "document"?, "carriedThrough": [ ... ], "warnings": [ ... ], "signingPathSettings"?: [ ... ], "folderPathSettings"?: [ ... ], "relayRegistrarNamed"?: true, "iceProvisionNamed"?: true, "keyFileFault"?: "absent" | "invalid", "inPlaceSigningIdentity"?: { "identityFile", "consoleLocation"? } }.

  • present: false with an empty carriedThrough and warnings is a mount holding no configuration. That is the ordinary first run rather than a fault, and it is a 200.
  • An alcove.yaml the console can locate but that turns out not to be a regular file, or that the console cannot open or read, is a 400 { "error" } saying a configuration is in the folder but the console could not read it, naming neither the OS error nor the container path. That includes no permission on the path itself: the open, the file-type check, and the read all run against one descriptor, so a permission failure at any of the three reads as this refusal rather than as absent. Only a dangling symlink named alcove.yaml, or no entry there at all, answers as absent, so it reads as the ordinary first run.
  • An alcove.yaml over the size cap is a 400 { "error" } naming that it is too large to be a configuration, ahead of any parse.
  • document is an explicit projection of the settings the authoring forms edit, written field by field rather than stripped from the parse result, so a field a later schema version adds reaches no browser until the projection states it.
  • carriedThrough names the settings the document states that a run composed here does not adopt -- the settings this surface holds without an editor, which the portable-configuration rule requires a consumer to state (EXCHANGE_FILE.md).
  • warnings names the credential fields whose value the console cannot pre-fill.
  • signingPathSettings and folderPathSettings, present for a document that opens, name the paths the document states that a conversion replaces: signing.identity_file on an sftp or filedrop document, and a filedrop document's connection.path, connection.inbound_path, and connection.outbound_path. Both are empty for a webrtc document, whose run uses the file itself.
  • relayRegistrarNamed: true is present for a webrtc document naming connection.relay_registrar, and absent otherwise. The block's url and exchange_id are not sent.
  • iceProvisionNamed: true is present for a webrtc document stating connection.ice_provision, and absent otherwise. The CLI refuses to run such a document, so the browser withholds its run and offers only the save. The block's settings are not sent.
  • keyFileFault is present for a document whose run would be refused over the key file beside it: absent where no file is at <dataRoot>/.alcove.key, invalid where the file there cannot be read or fails the key-file shape -- the two refusals the create answers with. For an sftp or filedrop document the browser then offers to make a new invitation from the document's settings instead, which sends sharedSecret rather than mountedConfigurationOpened: true. For a webrtc document it withholds the run, since a new invitation writes a pair of its own where the file already is.
  • inPlaceSigningIdentity is present for a webrtc document the console runs in place that states signing.identity_file, in any mode, and absent otherwise; the hand-off's placeholder identity counts as none. identityFile is the path as the file spells it, the one that run signs with. consoleLocation is present where that path names one of the console's own identity locations, compared as the create compares it (Running an opened webrtc configuration in place): { "mount": "folder" } for the default identity in the data root, or { "mount": "secrets", "subPath": [ ... ] } for a file in the secrets mount, in the locator form the intent's signing.identityLocation takes. The browser compares it with the identity location the receipts card holds to decide whether a signed run starts.

Every list holds NAMES only, as the FILE spells them: snake_case under the path of the block holding them (connection.server.password, signing.identity_file). A setting's value can be a credential, and it enters neither list nor any refusal.

What it discloses ​

The projection holds the connection's host, port, remote directory (path, or the inbound_path/outbound_path pair), username, host_key_fingerprint, keyboard_interactive, and a credentialMethod naming WHICH credential the file states; the connection's tuning options; the linkage terms, metadata, and standardization; csv_delimiter, include_own_columns, include_matching_key, retention_disposition, and expected_partner_deduplicate; the signing block's mode and partner_fingerprint; and authentication.token_max_age_days, as tokenMaxAgeDays.

It holds none of: a password, private_key, or private_key_passphrase value, an @path reference among them; authentication.shared_secret or authentication.expires, which the load refuses; signing.identity_file; or any absolute path of the console's own. Outside the projection, inPlaceSigningIdentity states signing.identity_file of a file run in place, which can name the console's own identity path.

A webrtc document's projection holds its channel and, as signalingServer, its coordination server as the ws:// or wss:// address the CLI dials -- wss unless the file states secure: false, the port where it states one, an IPv6 host in brackets, and the path, or / where it states none -- which the browser shows on the review step as the server a run of the file dials. Where the run relays, it also holds, as relayTurnUrls, the TURN urls the run relays through, listed as the CLI lists them (core's selectRunRelay): the url of each connection.turn entry followed by the turn urls of connection.invitation_relay, each listed once. A url holds no credential under core's TurnUrlSchema. It also holds relayCredentialMinted: true where a connection.turn entry states no credential, since the CLI mints one for it. Where the file states connection.allowed_relays, it holds that list as allowedRelays, an empty list included, which the browser shows on the review step and does not edit. It holds no other connection field: no broker key or user name, STUN server, TURN username or credential, or registrar.

What it refuses ​

A 400 { "error": "..." }, in each case naming the settings to fix as the file spells them:

  • a document the shared exchange-file schema rejects, a key no schema block reads included -- the refusal the portable-configuration rule rests on;
  • a connection on a channel outside the console's allowlist of sftp, filedrop, and webrtc, so a channel a later schema version adds is refused until it is named;
  • an authentication block stating shared_secret or expires. Both belong in the key file beside the configuration, which a run of it uses and rotates, so the run would neither use a stated one nor keep it;
  • a document stating the record whose absence turns an enforcement off (expected_partner_deduplicate) that a run composed here could not state back. It has no hold-unchanged fallback: holding a record no run enforces is the same failure one exchange later (EXCHANGE_FILE.md). It is carried on the job intent instead, so the run composed here states the value the file stated;
  • a setting the composition writes no key for that sits INSIDE a block it writes -- an sftp connection.provider_options or connection.server.provision. The export writes a composed block over the opened document's whole, so such a setting can be neither adopted nor held, and the refusal names it rather than the export dropping a setting the held list claimed. A credential field is the exception and not a refusal: the hand-off writes a placeholder for it by design, and warnings names it.

How the held list is measured ​

carriedThrough is measured rather than listed. The console composes probe documents through its own composers -- the sftp one over each connection shape an authored entry can hold (a password or a private key with its passphrase, and a single remote directory or the inbound/outbound pair), and the filedrop one in both its single-folder and split forms -- and reports every setting the loaded document states that no probe holds. A field a composer stops emitting is therefore reported as held rather than going on being claimed as adopted.

A credential field is never in both lists. The operator authors the credential again and the composition writes what they author, so connection.server.password, connection.server.private_key, and connection.server.private_key_passphrase are reported in warnings alone; probing one credential shape only would report the other's fields as settings the console keeps unchanged, which would read as the file's reference surviving a re-author that overwrites it.

A setting a probe states at a value of the console's own -- the rendezvous folder (connection.path, connection.inbound_path, connection.outbound_path) and the signing identity (signing.identity_file) -- is written rather than held all the same: a run here writes its own value for each, and the hand-off writes either the document's value or, once converted, a placeholder, so the list does not claim it. signingPathSettings and folderPathSettings name them instead. The credential fields warnings names are held out for the same reason.

A webrtc document's connection is left out of the measure, and of the refusal of a setting inside a block the composition writes, whole: a run of it runs the file itself, so no run here composes a connection over it, and the save below writes it back exactly as the file states it. Its authentication.token_max_age_days is reported as held for the same reason: only a run composes that block, and the save writes it back as the file states it. The browser runs the document in place, or, where it states relay settings, withholds its run and names them (CONSOLE.md); what the load still refuses in it -- a stated secret, a record no composition states back -- it refuses as it does on any channel.

Holding a setting is what the export then acts on: the same document is the base the run's hand-off template is written over (see The recurring-run hand-off), so a setting reported as held is one the operator gets back unchanged in the file they schedule. What makes the two agree is the refusal above: the template is written top-level key by top-level key, so a held setting is one inside a block no composition here writes, and a document stating any other is refused rather than opened.

Converting an opened configuration ​

An opened sftp or filedrop document is the operator's file, kept as read; converting it to the console's own resources is a separate, explicit step the browser offers after the load, stating first which settings it replaces (the load's signingPathSettings and folderPathSettings). The browser holds the choice and sends it as the intent's mountedConfigurationConverted; closing the configuration forgets it.

  • The run. A run always uses the console's mounted shared folder, converted or not. It signs only with the console's own identity location, so a certificate run of an unconverted document stating signing.identity_file is withheld by the browser, naming the settings and offering the conversion. The server holds the same rule as its own check: such an intent is refused, after the key-file check and before the slot is claimed, with 400 { "reason": "mounted-signing-paths-unconverted" } and no workdir. The refusal reads the intent's mountedConfigurationOpened and mountedConfigurationConverted fields as the browser sent them, so it guards a stale client rather than a modified one, which the console's trusted-operator model accepts (CLAUDE.md, Applications). A run whose mode signs nothing uses neither path and is not withheld.
  • The hand-off. Unconverted, it states the shared-folder paths and each sftp credential @path the document read -- an inline credential value stays placeholdered regardless -- and, for a run with the signed receipt off, the document's signing block exactly as read, so the scheduled configuration still signs as the file does. Converted, it states the console's placeholders, and no signing block for a run that signs nothing (see The recurring-run hand-off).

The run is withheld rather than run on the document's own paths because the console cannot resolve them reliably as the command line would: the CLI resolves a relative path against its working directory, which for the console's child is the job's own folder, and an absolute path in the operator's file names their host's filesystem, not the container's. The signing card's fingerprint and bound-name checks also read the console's identity location, so a run signing with another file would pass those checks against a different key.

Saving an opened configuration back ​

PUT /api/jobs/config is the hand-back for a webrtc configuration, before the console runs it in place or for the command line to run: no run composes it, so no job carries a recurring-run template for it. The operator's edits are written into the mounted alcove.yaml itself -- the file the command line runs -- rather than shown for copying, because the document's connection can hold credentials (server.key, a turn entry's credential, ice_provision.auth, server.provision.auth, a provider_options value) that no browser receives.

  • Only over the file as the console last knew it. The save is refused, and nothing is written, unless alcove.yaml holds the bytes the latest open read or the latest save wrote: a file changed since -- by a run that recorded a partner's pin, or by any other editor -- is never written over with steps read before the change, and a console that restarted since holds no open, so it refuses too. It is also refused while a webrtc run is starting or its child is running, since that run reads and writes the file.
  • The body is a strict object: linkageTerms, and optionally metadata, standardization, includeOwnColumns, includeMatchingKey, csvDelimiter, retentionDisposition, each bounded as on the exchange intent, plus signing: { mode, partnerFingerprint?, identityLocation? } with mode one of none, session-derived, certificate, and identityLocation the intent's secrets-mount locator, admitted only under certificate. No connection, path, host, or credential field is representable, and a body naming one is a 400. It is read under a 32 MiB cap (MAX_CONFIG_HAND_BACK_BODY_BYTES).
  • What comes from the file. The server reads alcove.yaml again, through the same load and refusals as GET, at the moment of the write. Its connection is written back whole and unchanged; so is expected_partner_deduplicate, which no step edits; and so is every top-level key outside the blocks a composition writes, authentication among them, by the same merge the recurring-run template uses.
  • The signing block. A mode the file already states keeps the file's block unchanged. certificate keeps the file's identity_file, which is the operator's own path. Where the file states none, or the template's placeholder identity, certificate names the path identityLocation resolves to (the console's default identity in the data root where it is absent) for a file the console runs in place, since that run signs with it, and the placeholder identity for a file stating connection.ice_provision, which the console does not run. A location naming nothing in the secrets mount is a 400 { "error" }. none writes no block.
  • Nothing written where nothing changed. The merged document is validated by core's schema and composed by serializeExchangeDocument, so a hand-back of the values the file already stated composes the file Alcove would write for that document (EXCHANGE_FILE.md). Where the composed text, read back through the load's parse, states the same document as the file does, nothing is written and the file keeps its own bytes, comments included.
  • The write. Before the replace, the file's current bytes are written to alcove.yaml.previous in the mounted folder (PREVIOUS_CONFIGURATION_FILE_NAME), replacing any earlier copy, with the file's permission bits. That copy is skipped where those bytes are exactly the text this console's own latest save wrote, which it holds in memory, so the copy keeps the last version of the file the console did not write. A copy that cannot be written refuses the save and leaves the file unreplaced. Each write goes to a new file beside its destination, is flushed, and is renamed over it; the configuration keeps the replaced file's permission bits, and an alcove.yaml that is a link is replaced at the file it names. Since each is a new file renamed into place, both are owned by the account the console's server runs as rather than by the replaced file's owner, so a host operator whose file was 0o600 may need to re-own it before their own alcove can read it. An interrupted or refused write leaves the operator's file as it was.
  • What it answers. { "written": true } and nothing of the document, with "unchanged": true added where nothing was written. A 400 { "error" } names what stopped the write: the file changed since it was opened or last saved, or the console restarted since, a run is using the file, the file there has changed to a channel the console composes itself, the merged settings fail validation (naming the setting and the schema's reason), the identity location names nothing in the secrets mount, or the file could not be written. None names a value from the file or a container path. The first of those adds "reason": "configuration-changed", so the browser can offer to open the file again.

A sftp or filedrop configuration is refused here: a run composed from it hands back its configuration through the job's own hand-off.

Workdir layout ​

Each job gets a workdir at <dataRoot>/<jobId>/, created mode 0o700 (owner-only, rwx------), with an explicit chmod after mkdir because a restrictive umask is not guaranteed. Creation fails if the directory already exists, so a reused id cannot clobber an existing job. The data root itself is created (recursively) if missing. Inside the workdir, the server writes its own files at fixed, server-chosen names, each mode 0o600 (owner-only, rw-------, again chmod-enforced after write), and the CLI writes the run's artifacts under names it stamps per run:

NameContents
alcove.yamlThe composed CLI config document (exchange mode only, and not for a webrtc run, which writes it into the data root; see Composed CLI configuration).
.alcove.keyThe key file holding the shared secret (exchange mode only, and not for a run of the opened configuration, which uses the one beside it, or for a webrtc run, which writes it into the data root), owner-only on the same discipline as the CLI's own key-file write (see CREDENTIAL_STORAGE.md).
input.csvThe client's input CSV content, written only for an inline inputCsv intent; a mounted inputFile is read in place.
alcove-results-<time>.csvThe CLI's matched-result output (written by the CLI on success). The result download is offered under the same name.
alcove-record-<time>.jsonThe self-attested exchange record (written by the CLI on every run that disclosed, whether it then finished or terminated; the write is non-fatal, so it may be absent).
alcove-record-<time>.keys.jsonThe private verification keys paired with the record (owner-only; written alongside the record under the same non-fatal write).
alcove-record-<time>.terms.jsonBoth parties' agreed terms, written beside the record for alcove verify-receipt. The console does not serve it.
run.logThe CLI's own diagnostic log, written only when the intent asked for a diagnostic run. The CLI opens it itself (--log-file, append, created owner-only), so the mode is its write discipline rather than this server's.
alcove-receipt-<time>.jsonThe signed receipt, written only when the intent asked for certificate signing. The CLI writes it itself at its own name, owner-only on its own discipline.

<time> is the run's time stamp: the record's createdAt made filesystem-safe (EXCHANGE_RECORD.md). The run's artifacts land in the workdir because the workdir is both the CLI's OUTPUT folder and the directory the child runs in, which is where the CLI writes the record, keys, terms file and receipt by default (see Exchange invocation).

Locating a run's artifacts. The server finds every artifact by the run's stamp, never by a fixed name. The stamp is the one the run's result event names its result file with (the last segment of resultPath, when it parses as alcove-results-<time>.csv). A run whose event named no result file -- a withheld or count-only result, a failure, or a resultPath the relay cut short -- is the latest stamp among the workdir's artifact names, read once after the child exits and kept for the job's life; until then such a run has no artifact paths. Every path a route serves is under that one stamp, so a workdir holding several runs' artifacts serves the latest run's whole and never one run's result beside another's record. A name that does not parse as a stamped artifact is not one.

The client never supplies a filename: submitted content is written to the server's constant names, the CLI is pointed at them by absolute path, and a run artifact's name is built from a stamp that holds only digits, hyphens, T and Z. That is what makes "a client string never becomes a file path" hold.

The long-lived signing identity does not live in a workdir. A workdir is removed with its job, while one signing identity serves every exchange and every partner and must keep matching the fingerprint a partner pinned, so its DEFAULT location is <dataRoot>/.alcove-signing-identity.json -- in the operator's mounted working directory, where it outlives the job and is a file the operator still has on their own host. It is dot-prefixed for the reason .alcove.key is: it holds a private key, and the work-input listing's own admissibility rule excludes a leading dot, so it cannot turn up in the operator's input picker. A --export-certificate writes the public certificate at <dataRoot>/alcove-certificate.json (not dot-prefixed: it is the artifact the operator is meant to find and hand over), always in the data root even where the identity is kept elsewhere. The identity's directory is what a filedrop run's rendezvous mount is compared against before the run starts (see Refusing a run that would publish the signing identity); the operator may point the identity at a file in the secrets mount instead, which the console reads and does not create, apart from the one exception The signing identity states (see The signing identity).

Neither channel's rendezvous lives in the workdir. A filedrop job's rendezvous is the resolved JOB_RENDEZVOUS_DIR mount (which defaults to JOB_DATA_ROOT when unset; see Composed CLI configuration) -- or, on a split-provisioned console, that mount and JOB_RENDEZVOUS_OUTBOUND_DIR together -- shared across jobs and held by the single-holder latch; an sftp job's rendezvous is the authored connection's own directory. An sftp job's workdir has the same layout, and its alcove.yaml holds the authored connection's block -- locator, pinned fingerprint, and @path credential references, never a credential value.

Subprocess invocation ​

The CLI is spawned with spawn (not a shell, shell: false) and an argv array assembled only from fixed templates plus server-generated absolute paths and, for a zero-setup job, the server-side connection fields described below -- never a client string beyond the bounded values the intent schema already validated. Both invocation forms share the same post-spawn plumbing (the fd-3 event reader, the stderr tail, and terminal reconciliation); they differ only in the argv each builds.

Exchange invocation ​

An exchange job drives the exchange subcommand against its composed config and key files:

<node> <binaryPath> exchange --config-file <configPath> --key-file <keyPath> [--sweep-exchange-files] [--log-level=debug --verbose --log-file=<logPath>] [--event-stream] <inputPath> <workdir>/

The output positional is the job's workdir, spelled with a trailing separator, and the CLI writes every file of the run there: alcove-results-<time>.csv, and beside it the record, its keys, the agreed-terms file and the receipt under the same stamp at the CLI's own names (EXCHANGE_RECORD.md, Where a run's files go). The child also runs with the workdir as its working directory. The server finds them as Locating a run's artifacts states. Records are on by default, so no --no-record is passed.

The per-run controls ​

Both invocation forms have the same two bracketed groups, emitted from the intent's diagnosticRun and sweepExchangeFiles booleans and from nothing else.

A diagnostic run emits all three log tokens together: --log-level=debug raises the CLI's own logging, --verbose adds the sub-library logging -v enables, and --log-file names run.log in the job's workdir. The console offers one control rather than three, so a diagnostic run is the whole set or none of it. --log-file is preferred over reading the child's stderr, which the driver already tails: the file gets the CLI's own append and owner-only-create semantics, and it is a whole run's log rather than a rolling tail. The consequence is that a diagnostic run's stderr tail is empty -- the log has the output instead.

The sweep emits --sweep-exchange-files and nothing else. Protocol-file classification and the retain-mode guard stay entirely the CLI's, so the console cannot drift from what a command-line run would delete; --force-retain-sweep is never emitted, and no intent field can produce it. A refusal by that guard reaches the console as the CLI's own terminal error event, classified exchange, and is reported exactly as any other terminal of that category: the alert's title and recovery come from the category, never from the message's text. What the console says about the escalation is fixed copy on the card, shown from the operator's own draft rather than from anything a run reports.

Every value-bearing token here uses the =value form, so a path cannot be misparsed by yargs as its own flag.

Zero-setup invocation ​

A zero-setup job drives the CLI's literal $0 (no-subcommand) form instead: there is no subcommand token, no --config-file, no --key-file, and never --save -- a zero-setup run infers its terms from the input file, has no shared secret, and persists nothing beyond the single job's record. The connection rides the positional URL and, on the sftp arm, --server-* flags, in place of the composed config's connection block:

<node> <binaryPath> sftp://<host>[:<port>][/<path>] [--outbound-path=<dir>] [--server-username=<value>] [--server-password=<value> | --server-private-key=<value>] [--server-private-key-passphrase=<value>] [--server-keyboard-interactive] --server-host-key-fingerprint=<fp> [--retain-files] [--lockless-rendezvous] [--timestamp-in-filename] [--peer-id=<value>] [--polling-frequency=<n>ms] [--peer-timeout=<n>s] [--inactivity-timeout=<n>s] [--connection-timeout=<n>s] [--max-reconnect-attempts=<n>] [--connection-per-poll] [--write-without-renaming] [--identity=<value>] [--linkage-strategy=<value>] [--deduplicate] [--csv-delimiter=<value>] [--sweep-exchange-files] [--log-level=debug --verbose --log-file=<logPath>] [--event-stream] <inputPath> <workdir>/
<node> <binaryPath> file://<rendezvousDir> [--outbound-path=<dir>] [--retain-files] [--lockless-rendezvous] [--timestamp-in-filename] [--peer-id=<value>] [--polling-frequency=<n>ms] [--peer-timeout=<n>s] [--inactivity-timeout=<n>s] [--connection-timeout=<n>s] [--max-reconnect-attempts=<n>] [--write-without-renaming] [--identity=<value>] [--linkage-strategy=<value>] [--deduplicate] [--csv-delimiter=<value>] [--sweep-exchange-files] [--log-level=debug --verbose --log-file=<logPath>] [--event-stream] <inputPath> <workdir>/

The URL and every --server-* flag are built server-side from the same effective resource an exchange job's connection block draws on -- the authored SFTP connection (The authored SFTP connection), turned into an sftp:// URL plus flags, or the configured rendezvous directory, turned into a file:// URL (the filedrop channel has no host or credential, so the URL is the whole connection). A split-directory connection puts its inbound directory on the URL and its outbound one on --outbound-path, the mapping that flag defines; the CLI's own guard on it then holds the run to retain mode, which the operator's file-handling choice supplies. A split-provisioned filedrop console uses the same mapping: the file:// URL names the inbound mount and --outbound-path the outbound one, as a plain absolute directory rather than a second URL, because the CLI copies that value straight into the connection's outbound_path. Every credential flag takes an @path reference verbatim -- the same reference the authored entry holds -- never a resolved secret, so no secret byte is ever on argv; the CLI child resolves the reference at live use. The host-key fingerprint is mandatory and always emitted: a zero-setup run has no TTY, so trust-on-first-use is impossible and the pin is the only host-key defense; a multi-fingerprint authored entry is refused instead of a silently dropped pin, since the CLI flag is single-valued: the create answers 400 { "reason": "sftp-fingerprint-list" }, a fixed token like the signing-identity refusal's, because the authored connection is console state the intent does not state, and the browser composes copy telling the operator to edit it down to one fingerprint. The output positional and the working directory are the exchange invocation's, so the run's artifacts are written and found identically regardless of mode. identity, linkageStrategy, and csvDelimiter, when the intent set them, are the bounded label, closed enum, and graded delimiter from The zero-setup intent, forwarded unchanged. deduplicate rides the bare --deduplicate and only when the intent set it true, for the reason the tuning toggles below are emitted the same way: the flag is off by default in the CLI and a zero-setup run loads no configuration file for a negated form to override, so an explicitly-off value and an unset one select the same side of the matching cardinality. Every value-bearing flag uses the single =value token form, never a two-token pair, so a value that begins with - cannot be misparsed by yargs as its own flag.

The sftp:// URL is written by core's formatSftpUrl, the inverse of the parseSftpUrl the CLI reads it with, so the run uses the authored host, port and directory unchanged: each directory segment is percent-encoded, a relative directory is written under /~/, and an IPv6 host is bracketed. A directory with no URL form that reads back unchanged -- one holding a . or .. segment, which URL parsing removes, or the root / -- fails the create rather than running against a different directory, answering 400 { "reason": "sftp-url-directory" }. Core's sftpUrlDirectoryFault names those directories. The URL form: docs/CLI.md.

The tuning flags stand in for the composed config's options block, which this mode has no document to hold, and the set is exactly the fields the zero-setup arms admit (see The zero-setup intent). Each boolean toggle is emitted only when the intent set it true: they default to false in core and a zero-setup run loads no configuration file for a flag to override, so an explicitly-off toggle and an unset one select the same behaviour, and the negated forms would add tokens that change nothing. --peer-id takes the bounded single label the intent schema admitted, so it is never a path, a separator, or a flag-shaped value.

Each duration is emitted in the unit its own flag's grammar takes. --polling-frequency accepts a millisecond suffix, so pollIntervalMs is written verbatim as <n>ms; --peer-timeout, --inactivity-timeout, and --connection-timeout take a second-or-coarser unit, so those three are written as <n>s -- exact, because the arms admit only a whole-second value for them. --max-reconnect-attempts takes the count, 0 included (which means connect once and do not retry, and is therefore emitted rather than treated as unset). Whether each emitted token is in its flag's grammar is settled against the built CLI's own parser rather than asserted here: a web unit suite spawns it over the argv the console builds.

Shared spawn discipline ​

spawn is used rather than execFile by design: execFile caps stdio at three pipes and cannot pass fd 3, which the event stream requires. The child's stdio is ["ignore", "pipe", "pipe", "pipe"] (fd 3 wired for the event stream), cwd is the workdir, and the environment is minimal -- only PATH, HOME, LANG, LC_ALL, and TZ are forwarded from the server process, so the child inherits no ambient secret. The job's inputs reach the child through the workdir files (or, for a mounted inputFile, the operator's own mount), never the environment.

A bounded tail of the child's stderr (up to 8192 UTF-16 code units, kept as a rolling tail) is retained for diagnostics. It reaches the client only inside a synthesized terminal event (see Exit-code reconciliation), never as a stream of its own, and it is bounded again to a display budget and escaped where it is shown.

The console's other spawn boundaries drain the child's stderr and DISCARD it -- the captured-child probes behind the host-key, signing-identity, and relay-check endpoints, and the apply behind Applying a partner's terms change, which are reached unauthenticated over loopback and return a value the console then acts on, so a probe's own diagnosis crosses structurally (a closed vocabulary plus a bounded, escaped excerpt) rather than as forwarded stderr. This route forwards instead because nothing it returns is derived from the tail: the tail is not a value the console acts on but the CLI's own diagnostic output, which it escapes where it writes it, shown once to the operator whose machine ran it -- redacted of private-key blocks and bounded to a display budget on the way.

Applying a partner's terms change ​

A run whose partner changed its terms is unattended here -- the child's stdin is not a terminal -- so the CLI refuses it, writes the partner's terms beside the job's composed configuration as alcove.proposed-terms, and states the change on its terminal event (CLI_EVENTS.md). POST /api/jobs/:jobId/apply-terms applies that proposal to the configuration on the mount, for a run of the opened configuration only: any other run's configuration is composed per run from the browser's intent, so nothing on the mount holds its terms.

  • What makes a proposal applicable. The job is the live one in the slot, it ran the opened configuration (so the key file beside <dataRoot>/alcove.yaml is the one the run authenticated with, and the one the proposal is authenticated under), and its terminal error event states proposalWritten: true under category config. Anything else is 404, as is a proposal already applied. An apply is refused 409 while another runs -- one at a time, independent of the exchange slot, which the failed run holds until it is deleted -- and while the run's child has not exited.
  • The mount is unchanged since the open. Before anything runs, the mounted alcove.yaml is compared byte for byte with the bytes the open read, which the job held from its creation. A file that changed, or a run with no open on record, is 200 { "status": "configuration-changed" } and nothing runs: the change the operator was shown is against the opened terms, not against the file's new ones.
  • The run used the file's terms. The change shown is against the job's composed <workdir>/alcove.yaml, which states the terms the browser posted, and the operator may have changed them before running. So before anything runs, the linkage_terms of the composed configuration and of the mounted one, each read through the sensitive-parse chokepoint and the shared exchange-file schema, are compared for deep equality. Terms that differ are 200 { "status": "run-terms-differ" } and nothing runs; a configuration either read refuses is error.
  • The invocation. <node> <binaryPath> apply --consent-to-terms --config-file=<dataRoot>/alcove.yaml --key-file=<dataRoot>/.alcove.key @<workdir>/alcove.proposed-terms, spawned through the captured-child boundary with cwd the data root, stdin ignored, stdout capped, stderr discarded, and a 15 s watchdog escalating to SIGKILL 5 s later. The operator's Apply, given against the change the seat showed from the run's configuration, is the consent --consent-to-terms records, so the CLI asks nothing and nothing is written to its stdin; the run-terms check above is what makes that change the one the mounted file takes on. The CLI writes the configuration through its own terms-update write and changes neither the key file nor the connection block (CLI.md); the console writes nothing.
  • The answer. 200 { "status" }, read from the child's exit code alone: applied for exit 0, where the CLI rewrote the configuration; refused for exit 64, where the CLI changed nothing (the proposal failed its partnership check, or the configuration would not load with it); configuration-changed and run-terms-differ as above; timeout where the watchdog killed it; error for any other exit. Nothing from the child's output crosses.
  • What follows. The seat opens the configuration again (GET /api/jobs/config) before the next run, so the run composes from the terms the file now holds and the open the next run is compared against is the one after the apply.

Event relay over SSE ​

GET /api/jobs/:jobId/events streams the job's events as server-sent events. Each frame is:

id: <n>
data: <json>

The id line holds the event's monotonic id (so a browser EventSource echoes it as Last-Event-ID on reconnect), the data line the JSON event, terminated by the blank line that ends a frame.

Full-history replay. Every job retains its complete event list in memory for its lifetime. On connect, the stream replays every buffered event with an id strictly greater than the resume offset. The offset is the Last-Event-ID request header when present and a non-negative integer, else the ?lastEventId= query fallback (for a client that cannot set the header), else 0 (replay from the start). A malformed value is treated as 0 rather than rejected, so a bad reconnect replays the full history instead of failing. Because the full history is retained, a reconnect resumes losslessly.

Keepalive. The stream writes a keepalive frame every 15 seconds for its whole life, indifferent to real traffic -- the SSE comment : keepalive followed by the blank line that ends a frame; a fixed cadence needs no reset bookkeeping, and a redundant comment frame beside real events costs a few bytes that every client drops. It has no data: line, so it is no event: an EventSource ignores it and the console's own client parser drops it. An exchange is legitimately silent for minutes (a party waiting on its partner emits nothing), while a proxy or load balancer in front of the console cuts an idle response far sooner, so without the keepalive the operator's view of a waiting run would end while the run continued. The interval is a constant rather than a setting, sized well under the idle windows an intermediary defaults to; raising the deployment's idle window instead only moves the cliff and needs that deployment's cooperation.

Stream close. After the terminal event (a result or an error) is delivered, the stream closes. When the terminal event is already in the replay (the job finished before this connect), the stream closes immediately after replaying rather than holding an idle connection open. A client disconnect (request.signal abort), and a consumer cancelling the response body, each release the subscription and the keepalive timer. The console server builds each request's Request with a signal that aborts when the connection closes before the response has finished (apps/web/server/console/nodeBridge.ts), and cancels the response body when the connection closes first; apps/web/test/unit/consoleServer/bridge.test.ts and sse.test.ts drive both against the real server.

Client reconnect. A stream can still be cut -- a proxy restart, a network fault, a tab the browser suspended -- and the browser-side client treats a body that ends before its terminal event as a drop rather than a completed run. It re-opens the stream with Last-Event-ID, the id of the last frame it delivered, so the replay above resumes exactly where it stopped; the run itself is untouched, since re-attaching only reads. The retries are bounded: five attempts at 250, 500, 1000, 2000, and 4000 ms, with the budget reset whenever a connection advances the stream past the id the previous one reached, so a long run survives repeated drops while a server that answers but never progresses does not loop. Two failures are not retried: a fault on the first connect, which is reported immediately since nothing has been established to resume, and a 404 at any point, which is the confirmed "the console no longer has this exchange". When the budget is spent the client raises a stream-lost terminal that says the run may still be in progress and to reload to re-attach -- not a claim that the exchange failed.

Warning sources on the job stream ​

Every warning event the server writes on this stream holds a source naming which notice raised it -- the page's own notice for a frame it cannot read is never on the stream and holds none -- so a supervisor decides whether to alert on the line without parsing message. Two sets share that field. A warning the CLI child raised keeps the source it wrote on fd 3, relayed unchanged (the closed set in CLI_EVENTS.md). A warning the relay composed itself takes one of the values below. The two sets are disjoint, so the field also says which process raised the warning. RELAY_WARNING_SOURCES in apps/web/src/jobs/cliDriver.ts is the same set in code, and apps/web/test/unit/jobs/relayWarningSources.test.ts fails when a value lands on both streams. A new synthesized notice claims its value here.

A warning's text is escaped at the seat that shows it. The console seat's warning sink (appendSanitizedRunWarning in apps/web/src/psi/runWarnings.ts) escapes every warning it shows. A warning the CLI raised takes its message from the CLI's unescapedMessage (CLI_EVENTS.md) where the child supplied that field as a string at the top level of the event (the relay drops that key and relays the text as message), rather than from the CLI's own escape of it, so a partner value holding one backslash shows two at the seat. Where the child supplied no such field -- an older CLI, a payloadReceiveTaken warning, or a child outside the CLI's shape -- the relay escapes the child's message instead (Relay validation at the trust boundary), so that text reaches the seat escaped twice unless the server replaces it, as it does a payloadReceiveTaken warning's. A warning the relay or the manager composed is raw from the start. apps/web/test/unit/jobs/relayedWarningSingleEscape.unit.test.ts drives each CLI warning source from the line a child writes on fd 3, holding a value with a backslash and a non-ASCII character, through the manager, the SSE route and the browser-side client to the string a seat renders, and fails unless that value is escaped exactly once.

relayRendezvousPreflight names what the preflight found on the operator's own mount before the child started, and relayOpenedConfigurationChanged what the run found of the configuration the operator opened. Every other value accompanies degraded: true, the mark of a relay degradation, where what the relay lost is part of the CLI's stream (see Relay validation at the trust boundary).

The first-contact pin notice is rebuilt rather than passed through, as is the terminal failure of a first contact whose pin the adoption write could not record (see Relay validation at the trust boundary). A partnerCertificatePinned warning keeps its source and takes a message the server composes when the run signs receipts and its composed configuration holds no partner fingerprint -- the two conditions of a first contact this server launched, the same pair the failure rebuild below reads. The CLI's own wording names the configuration file it recorded the pin into, which on a console run is a path inside this container, and no container path crosses this boundary except on the two runs this rewrite does not reach -- one that signs nothing, and one whose composed configuration already held the fingerprint -- each of which relays the CLI's own wording, container path included. The rebuilt notice states the fingerprint and what the pin is authenticated by. For a webrtc run it states that the pin is saved to the alcove.yaml in the working folder; for every other run, that the operator must enter it before the next run, since the console writes each job's configuration itself and writes no adopted pin back into the configuration in the mounted folder. The value is read back out of the configuration the CLI was started with -- for a webrtc run, the data root's alcove.yaml (The working folder's configuration and key file) -- and re-validated against the canonical fingerprint shape, never parsed out of the CLI's sentence; a read or parse that does not yield one leaves the notice stating the pin without a value rather than failing the run.

The notice of payload columns taken without asking is rebuilt too. A console run has no terminal, so where its terms list no columns it receives it takes those its partner declares, and the CLI raises a payloadReceiveTaken warning (CLI_EVENTS.md). Its wording names the configuration the columns were written to, the job's own composed file inside this container, and says later exchanges hold the partner to them, which is true of that file alone. The warning keeps its source, columns and columnCount, and takes a message the server composes from columns: that the run took the columns without asking, for an exchange run that neither the configuration in the working folder nor the recurring-run hand-off lists them and where to add them, and then the names, each in double quotes with a double quote inside a name doubled, with the count of any the list left out. Each name is escaped exactly once, where the run view shows the warning: the CLI puts the names in columns unescaped, and the relay's field pass leaves that field unescaped, keeping its first 256 names -- the rest counted by columnCount -- each fitted to the per-value budget, so the composed message is raw like every manager-composed warning. The served columns is escaped as the notice is composed. The names come last, so a message cut to the display budget loses names rather than the step. The served columns may therefore list more names than the rendered notice shows: the console's notice text is longer than the CLI's and is cut again to the run view's budget, whole names from the end, with the "and N more" count kept, so a consumer that wants every relayed name reads columns, not the notice. A columns field not in the CLI's shape is dropped and leaves the names out of the notice.

sourceThe notice it names
relayRendezvousPreflightThe filedrop rendezvous preflight at job start: a mount that is missing, is not a directory, is not writable, cannot be listed, holds files in the exchange's filename grammar, holds or is nested inside the data root or the work-input directory, or could not be resolved to a real path. One value for the whole preflight, whose notices name the leg and the condition in their text (JOB_RENDEZVOUS_DIR).
relayOpenedConfigurationChangedA run of the opened configuration found the mounted alcove.yaml differing from the one the open read, or found no open on record. The run composes from the document as opened; the notice names the file and says to open it again (see The recurring-run hand-off).
relayStreamUnavailablefd 3 was not wired on the spawned child, so the relay has no event stream to read and the run's events are its exit alone.
relayStreamOversizedLineAn fd-3 line grew past the reader's per-line cap; that line was discarded.
relayStreamReadErrorThe fd-3 stream reported a read error.
relayUnparsableEventAn fd-3 line was not JSON the bounded parser accepts; the line was dropped.
relayUnknownEventAn fd-3 line parsed but fell outside the v1 schema -- another v or an unknown type; the line was dropped, and the notice names what arrived and says to run the console with the alcove CLI of its own release.
relayProcessErrorThe child could not be spawned, or died abnormally. The run is then classified as a failure (see Exit-code reconciliation).

Relay validation at the trust boundary ​

The CLI is a separate process, so the server re-validates every fd-3 line against the v1 schema the @alcove/cli-contract package declares -- all seven event types CLI_EVENTS.md defines -- requiring v === 1 and a known type. A warning whose source is not in the CLI's set is relayed as any other warning, since a new source is an additive change (NDJSON framing). Every string field is re-sanitized -- recursively, through arrays and nested objects -- through the display escaper before the event is buffered or relayed, deliberate defense in depth at the trust-boundary crossing on top of the CLI's own construction-time sanitization. Two fields are kept unescaped instead. A payloadReceiveTaken warning's columns is one, which the server composes its own notice from and escapes as it does. A warning event's message is the other: it is the child's unescapedMessage where that is a string, cut to a raw length, redacted, and fitted so the seat's one escape of it renders within the 4096-character warning budget, the truncation marker paid for out of that budget, and the relay drops the unescapedMessage key from the top level of a warning event, where it becomes the relayed message. On any other event type, or nested inside an object, a key of that name is an ordinary string field: the relay escapes it and nothing in the web app reads it. Where the child sent no string unescapedMessage -- a payloadReceiveTaken warning, whose message the server replaces, or a child outside the CLI's shape -- the child's message is escaped again under that budget instead, which costs fidelity and nothing else (Warning sources on the job stream). The relay's checks on the line -- the bounded parse, the schema check, the per-line cap and the degradations below -- are unchanged by either field. For every other fd-3 field, sanitization precedes serialization, so the \n that frames an SSE line is the writer's own terminator. Manager-composed events (the preflight warnings above, the synthesized terminal's stderr cause link below, and the notice that the CLI child could not be spawned or died abnormally) and a CLI warning's message bypass that re-sanitization by design -- their text is unescaped for the console sink's single escape -- so for them the frame's integrity rests on JSON.stringify alone escaping every newline class inside the serialized string. That property is held by a check rather than by this sentence: apps/web/test/unit/jobWarningFraming.unit.test.ts puts a directory entry whose name holds an LF, a CR, and a complete forged data: event on a real rendezvous mount. It then drives the preflight warning it raises through the real manager, the real SSE route, and the real browser-side client, and fails unless every physical line the stream emitted is one the writer produced and the forged text is still inside one JSON string when a seat escapes it. relayedWarningSingleEscape.unit.test.ts drives a CLI warning whose unescaped text holds an LF, a CR, a NEL, a line separator and an ESC the same way, and fails unless the seat renders exactly one escape of that text and every other string of the relayed event is printable ASCII.

The terminal error's cause chain. A terminal error event's message is a whole rendered cause chain rather than one value (CLI_EVENTS.md), so the relay states it structurally: beside the message field it re-sanitizes as any other value, the relayed event holds messageChain, derived by splitting the child's raw message text on the renderer's own \ncaused by: framing. That derivation is unconditional and always the relay's own, never a value a child supplies. The field pass drops a key of that name off any event it validates, whatever the event's type, and an error event is then assigned the split of its rendered message, or an empty chain when the event has no string message to split, which is where the seat reads the flat field instead. So a child that puts a field of that name on the event cannot substitute its own chain for the derived one. Each link the split produces is escaped at the composed-message cap the renderer itself uses (1024 output characters, and as many as 1038 for a link the cap cut, since the 14-character truncation marker is appended on top of it). The last link of a chain past the depth bound holds the 26-character elision marker on top of that, at as many as 1065. The link count is held to the renderer's own depth bound (MAX_ERROR_CAUSE_DEPTH), a longer chain marked as elided rather than shortened in silence, and a chain the child's own renderer already cut arriving with that marker keeps it, and the marker is unauthenticated text the child can forge. What a forged one buys stays inside the presentational bound stated below -- whole-escaped, so no relayed field holds an unescaped control character and the framing newline is re-introduced only where the chain is rendered. Those guarantees -- the field always being the relay's own derivation, the per-link escaping, the per-link cap, and the depth-bound count -- hold on every path, a subverted CLI child's included, so the volume and the byte content a hostile child can put in front of the operator are bounded exactly as a well-behaved child's are. What they do not authenticate is where a boundary falls inside the derived chain. The split looks for the same \ncaused by: text the renderer's own escape-then-join emits, and only for a well-behaved child does that text mark a real cause link -- a subverted child can spell the same framing straight into its raw message and thereby present one message as several links at the seat. The escalation available to it is presentational only: extra line breaks inside a message that is still capped and escaped exactly as above, never a wider budget, an unescaped byte, or a substituted chain. The console seat rebuilds the chain from the links and renders them on their own lines, a link with no text of its own rendered as [no message] so the rebuilt chain keeps the length the relay delivered, which is what delivers a terminal error's recovery step. Charged to the per-value cap instead, the chain is cut wherever 256 characters fall, and on a refusal that partitions by chooser that is inside the partner-chosen fragment on its first link.

The terminal error's self-explaining marker. A terminal error event's recoveryHint (CLI_EVENTS.md) crosses as the boolean the child emitted, read strictly at the seat: only the literal true counts, and anything else takes the failure class that shows fixed copy. What the marker buys a subverted child is the copy the seat shows, not a wider budget or an unescaped byte -- the message is capped, escaped, and split exactly as above either way. What it buys the operator is a security refusal that names its own cause: the seat's fixed copy for that category describes a failed partner check and sends them to re-invite, which is the wrong instruction for a partner certificate that does not match the pinned fingerprint. On an exchange terminal the marker replaces the seat's temporary-connection copy with a sentence pointing at the reported-cause block, where the message is shown (docs/notes/reported-failure-cause.md): an internal fault's own step is to report it, not retry.

The terminal error's internal-fault marker. A terminal error event's internalFault (CLI_EVENTS.md) crosses as the boolean the child emitted, or as the true the manager sets on the terminal it synthesizes for an exit 70 with no event (Exit-code reconciliation), read strictly at the seat the same way: only the literal true counts. The seat derives the failure's retry disposition from it, or from a bare InternalConsistencyError core raised in the tab on the browser channel, and every exchange section and hook withholds "Try again" on a failure holding it, which is otherwise the recovery an exchange terminal gets. A subverted child can set it to take that button away and nothing more; the start-over recovery stays.

Each of core's five terms-time pin refusals takes the console's own remedy copy at that seat, recognized from the literal core raised (apps/web/src/console/partnerCertificateRefusal.ts, and CLI_EVENTS.md for what a consumer may substitute). Their own next step is an edit of the run's configuration file -- a pin to replace, a signing mode to change -- which no console operator is given; the console's copy states the same cause and points at the receipts card's own controls. A refusal that arrives cut by the per-link budget matches none of the five and is shown as it arrived.

The first contact's unrecordable pin is rebuilt. A terminal error event of category config takes a message and a messageChain the server composes when three things hold: the run signs receipts, its composed configuration holds no partner fingerprint, and the relayed message states the path of that configuration. That path is looked for in the spelling a relayed string holds, which is the path under TWO display escapes -- the CLI's, where it builds the terminal event, and this relay's, where it re-sanitizes every string field -- so a data root holding a backslash or a character outside printable ASCII is recognized rather than passed through with the path on it, provided the escaped path still fits whole inside the field carrying it (256 characters flat, 1024 per chain link) -- past that cap the escaped path is truncated before the search runs and crosses unrewritten, a residual limit on how long the operator's own data-root path can run; searching the once-escaped spelling finds neither shape, and apps/web/test/unit/jobs/partnerPinNotice.unit.test.ts drives both. That is the CLI's adoption-time refusal of a first contact whose pin cannot be recorded -- the pin write at the terms exchange, raised inside the protocol run -- whose message names that file and offers an edit of it by hand or a writable mount of it: a path inside this container, and two remedies a console operator cannot act on, since the console writes the configuration each run is driven by itself. The rebuilt failure states that the run stopped without sending any of this party's data, and that the fingerprint has to be obtained out of band and entered before the exchange is run again. The category crosses unchanged, so the seat routes the failure exactly as it would any other; a config failure naming no such path crosses in the CLI's own words.

The pre-connection refusal is rebuilt the same way. The CLI's other refusal of an unrecordable pin is the check on the configuration directory's writability, made before the run connects. The CLI opens its event stream before it reads its configuration, so this refusal ends the stream with a terminal config event naming the same file (pinned against the command by apps/cli/test/unit/commands/exchangePinPreflight.test.ts), and the relay rebuilds it as above. A console run reaches that check only if the mounted working directory turns read-only between the job's creation and the child's spawn: the server creates the workdir and writes the configuration into it, so the directory is writable by the user the child runs as whenever the check is reached on the ordinary path.

The rebuild reads no wording, so any other config failure naming that configuration on a signing run with no pin on file is rebuilt too. The CLI's refusals of the configuration at load are such failures (CLI_EVENTS.md); a console run reaches one only where the configuration the server composed for it does not load.

That depth bound governs the chain field and nothing else. Every other field but the two unescaped ones above is escaped per value -- recursively, each string of an array or nested object at the per-value cap -- and has no entry-count bound of its own, so an array field arrives at whatever length the child sent it, escaped entry by entry. What bounds a field the relay does not derive is the line the child wrote -- the fd-3 reader discards a line past 1,048,576 UTF-16 code units -- not this pass.

The terminal error's terms change. A terminal error event's termsChange (CLI_EVENTS.md) is rebuilt from its known fields alone -- proposalWritten, received, sent, partnerDeduplicate, otherTerms -- each string escaped again at this boundary: a column name under the per-value cap, a diagnostic under the warning cap the CLI composed it under. Each list holds at most 256 entries (RELAY_TERMS_CHANGE_LIST_CAP). A field any part of which is not the shape the CLI emits is dropped whole, so the seat never shows part of a change. The seat reads it strictly again (relayedTermsChangeOf, apps/web/src/psi/jobClient/serverJobExchangeDriver.ts) and shows the change beside the alert.

The terminal error's failure cause. A terminal error event's cause (CLI_EVENTS.md) is rebuilt from the facts its kind holds by core's failureCauseFromUntrusted (packages/core/src/failureCause.ts): the kind must be on core's FAILURE_CAUSE_KINDS allowlist and each fact of the type and value set the catalog declares for that kind. The contract's failureCauseStreamField then copies those facts as the CLI does, escaping a folder path or registrar host again at this boundary under the 4096-character cap the CLI composed it under. A cause that does not pass -- an unknown kind among them, as a newer CLI's would be -- is dropped whole and the rest of the event is relayed, as a malformed termsChange is, so the seat reads the CLI's message instead. The seat reads the field strictly again with the same function and, on a cause it reads, shows core's sentence with the console's own remedy (apps/web/src/console/consoleFailureCause.ts) in place of the CLI's message, whose remedy names a command-line flag. What a subverted child buys with the field is which catalog sentence and remedy the seat shows, never an unescaped byte or a wider budget.

The terminal error's exit code. A terminal error event's exitCode crosses as the child emitted it and is read at the seat only where it is a whole number. On an exchange terminal holding the partner-refusal code, 76 (CLI_EVENTS.md), the seat shows the failure as the partner's refusal: the config category, so no "Try again", flagged so that neither seat offers a change of this party's settings as the fix, and the CLI's message in the reported-cause block. The seat reads the code as PARTNER_REFUSED_EXIT_CODE from @alcove/cli-contract, the declaration the CLI exits with. A subverted child can set it to take the retry away, as with internalFault.

The terms-change refusal is rebuilt. A terminal error event of category config whose termsChange states proposalWritten: true takes a message and a messageChain the server composes: the CLI's own message names the job's composed configuration, its key file and the proposal inside the container, and an alcove apply command for them that would rewrite a configuration no later run reads. A run of the opened configuration takes TERMS_PROPOSAL_REFUSAL, which points at the run step's Apply (Applying a partner's terms change); any other run takes TERMS_CHANGE_REFUSAL, which says to agree on the terms and start over (both apps/web/src/jobs/jobManager.ts). This rewrite replaces only message and messageChain: the termsChange field crosses as the relay rebuilt it above.

Degradation is fail-safe, never a crash:

  • A non-JSON line (relayUnparsableEvent), or one outside the known schema (relayUnknownEvent), is reported as a synthesized warning event with degraded: true and dropped -- the relay continues.
  • The browser-side client holds the stream it reads to the same schema: a frame whose data is not JSON, or is an event of another v or an unknown type, is shown to the operator as a run warning naming what arrived and saying to reload the page, and skipped.
  • An oversized fd-3 line (longer than 1,048,576 UTF-16 code units) is reported once as a degradation warning under relayStreamOversizedLine and discarded up to its newline. The cap applies to each line on its own: the lines before and after it are read as usual, and the reader never buffers more than the cap of an unterminated line.
  • fd 3 being unavailable (relayStreamUnavailable), or a read error on it (relayStreamReadError), is reported as a degradation warning.
  • A child that could not be spawned, or that died abnormally, is reported as a degradation warning under relayProcessError before the run is classified as a failure.

Each of those values, and the preflight's own, is described in Warning sources on the job stream.

Buffer cap. The event buffer is capped at 10,000 entries (a runaway safety check; a real CLI stream is dozens of lines). On overflow the job is failed rather than dropping events silently: a synthesized error terminal is appended, the child is SIGKILLed, and status becomes failed, so a supervisor never observes a truncated history.

Exit-code reconciliation ​

The child's exit is classified from the exit code and signal, per the CLI's terminal contract (CLI_EVENTS.md): exit 0 is succeeded; an interrupt -- exit 130 or 143, or death to SIGINT/SIGTERM -- is cancelled; exit 73 is completedWithPersistenceLoss; any other exit or signal is failed, with the exit code recorded. An interrupt legitimately produces no terminal fd-3 event, so a cancelled job's stream is not a broken one.

completedWithPersistenceLoss is the CLI's persistence-loss code (CLI_EVENTS.md): the exchange completed and a local write did not. It is a terminal outcome of its own rather than a failed one because the two demand opposite operator responses -- a failed run is re-run, and this one must not be, since re-running re-sends this party's data for an exchange that already happened. The classifier reads only the exit code, so it does not distinguish the two shapes the code covers; the terminal event does, and it is what sets status (see The GET /api/jobs/:jobId status body). The value 73 is declared once, as PERSISTENCE_LOSS_EXIT_CODE in the @alcove/cli-contract package (packages/cli-contract/src/exitCodes.ts), which the CLI and this server both import, so the two cannot disagree; the value itself is the one the exit-code contract in CLI.md lists.

Close-not-exit ordering. The classifier fires on the child's close event, not exit. close fires only after every stdio stream has drained, so the CLI's own terminal fd-3 event is always parsed before the exit is classified. On exit the terminal line can still sit in the pipe buffer, and the manager would synthesize a misclassified terminal in its place. A spawn or process error classifies as failed (exit code recorded as 1) with a sanitized diagnostic, so a terminal state is always reached.

Synthesized terminal events. Whether the CLI emitted its own terminal event is the manager's concern. If it did, that stands. If it did not, the manager synthesizes one matching the exit so the supervisor is always guaranteed a terminal event:

  • cancelled: a cancelled-flavored error terminal (category: "exchange", cancelled: true, message naming SIGINT or SIGTERM).
  • succeeded with no terminal event: a result terminal (resultWritten: true).
  • completedWithPersistenceLoss with no terminal event: an error terminal with category: "output", the local-output-stage category, whose message states that a local write was lost and that the console cannot say which one. The category is critical rather than cosmetic: it is the one a console seat renders with no way to run the exchange again, while the exchange category below is retryable and would invite the re-run this exit code exists to prevent.
  • exit 70 with no terminal event: an error terminal with category: "exchange", internalFault: true, and recoveryHint: true, whose message states that the exchange process stopped on a fault in Alcove itself before reporting it, and what to report it with: the run's diagnostic log where one was captured, otherwise the stderr cause link below, otherwise the message itself. The value 70 is the CLI's internal-fault code (CLI.md), declared once as INTERNAL_FAULT_EXIT_CODE in the same package as the persistence-loss code and imported by the console. The marker is what withholds "Try again" at the seat (the internal-fault marker, Relay validation at the trust boundary): a crash that wrote no event is the same fault a retry reaches again, where the stream-broke terminal below is retryable.
  • any other exit with no terminal event: a failed terminal (category: "exchange") noting the stream broke and, when known, the exit code.

The three synthesized terminals that stand in for a diagnosis the run never emitted -- the persistence-loss one, the internal-fault one, and the stream-broke one -- name what the child wrote on stderr when it wrote any, so the cause the CLI printed crosses to the client even though fd 3 stayed empty. It rides a labelled cause link of its own on messageChain rather than being composed into the message, so the flat message field stays this console's own text at every width of tail: bytes composed into that sentence can spell whatever delimiter closes them and read on as console copy, which on the persistence-loss terminal is a do-not-repeat instruction. The link holds the END of the retained window, redacted of private-key blocks and then fitted by rendered cost to the per-value display budget (256 output characters, the truncation marker in FRONT of what is kept and paid for out of that budget), because a run writes its diagnosis last and a cut taken from the front deletes exactly the final error line. The window itself holds redacted output only: the child's stderr passes a streaming private-key redactor BEFORE the window clips it, so a block wider than the window -- which would otherwise reach the window as body alone, its BEGIN marker evicted and its END not yet written -- is replaced where the stream delivered it, and the link's own strip runs over text already redacted (see DISPLAY_REDACTION.md). It crosses raw and the console seat rendering the chain escapes each link once, which is the single escape the tail is due (DISPLAY_ESCAPE.md); a tail spelling the renderer's own \ncaused by: framing therefore reads there as further links of the chain, escaped and capped exactly as the first is. Where a seat shows it follows the category: the persistence-loss alert appends that chain to its own fixed do-not-repeat sentences, one message with no block, while the retryable stream-broke alert keeps its fixed copy and the internal-fault alert, which offers no retry, points at the report, each showing the chain in the reported-cause block beside it, labeled as the exchange's report rather than as the console's words (docs/notes/reported-failure-cause.md). The interrupt terminal names none: the operator asked for that exit, so no diagnosis is missing. A run that emitted its own terminal event is synthesized nothing, so the tail is never a second copy of a diagnosis the CLI already sent. The escape count and the end-of-window cut are held end to end, from the child's stderr to the string a seat renders, by apps/web/test/unit/jobs/stderrTailBudget.test.ts, and the stream redaction ahead of the window by apps/web/test/unit/jobs/stderrTailRedaction.test.ts.

A synthesized result terminal sets status to succeeded and a synthesized error to failed -- except the interrupt case above, whose status is already cancelled before synthesis and keeps it. A persistence loss with no terminal event is therefore failed: the console never saw an artifact announced, so it promises none, which is the rule every broken stream takes regardless of exit code. The outcome still states the do-not-repeat signal, and the synthesized message points the operator at the workdir.

The transport-teardown notice ​

A run's transport close is bounded by its own ceiling, and a close that reaches it is abandoned with the exchange's outcome and the run's exit status unchanged. On the file channels that close is what removes this party's protocol files from the shared directory, so an abandoned one can leave them there, and the operator's next contact with them is the following run's refusal to start on a directory that is not clean.

The CLI reports it on its operator log alone: the report comes after the run's terminal event, and nothing goes on fd 3 past that (CLI_EVENTS.md). The console's source for it is therefore the retained stderr tail, and which run needs it told follows from that:

  • A run whose terminal the console synthesized already has the end of that tail on the terminal's own cause link, the report among it, and transportTeardownOverran stays false. The interrupt terminal is the exception on both counts: it names no tail at all (above), so an interrupted run whose close then overran states nothing either.
  • A run that emitted its own terminal event is synthesized nothing, so nothing else reads its tail. Its report goes on transportTeardownOverran in the status body, which is the only channel left: the terminal event has already closed the event stream it would have gone on (Event relay over SSE, Stream close), and no event is appended after it.

What the console recognizes in the tail is the clause the CLI states exactly when the abandoned close may have left files, so the classification is the CLI's rather than one re-derived here: a WebRTC run has no protocol files and a retain-mode run keeps its own as a transcript, and the notice states that clause for neither.

The field changes nothing else about the run. status, terminal, and the terminal event stand as the run left them, so a completed run still reports completed.

A client reads the field after the terminal, and asks again until the exit is reconciled. The CLI writes its terminal event before it closes the transport (CLI_EVENTS.md), so the status read that follows a terminal frame usually lands while the child is still closing -- and a close that overran reports it at its ceiling, minutes later. Until terminal is present the field's false is only "nothing reported yet", so a client that means to tell its operator keeps asking at an interval it chooses and gives up on a bound of its own. The console's own client asks every two seconds within a budget above the file channels' teardown ceiling, on the run whose terminal -- a result or a failure -- it has already delivered; a run it holds no warning sink for asks nothing.

The console's browser client states the report as its own fixed copy, raised through the same warning sink a run's mid-run notices take: the protocol files the close may have left, the refusal the next run would otherwise open with, and the console's sweep control (Clear leftover exchange files before starting) that clears them -- never the --sweep-exchange-files flag behind it, which a console operator cannot pass. Nothing from the status body is interpolated into it, and it sits inside the warning sink's own budget (WARNING_MESSAGE_MAX_DISPLAY_LENGTH). It arrives after the run's own terminal delivery, which is where a notice raised during teardown belongs: the in-browser exchange raises its own close notice there the same way. A run that failed is told it after the failure alert rather than not at all: the close is abandoned whichever way the run ended, and the files it would have removed outlive the failure. The in-browser notice is held back on a failure for a reason that is its own -- it states that the exchange finished, which this one does not.

A diagnostic run routes the CLI's whole log to --log-file and leaves the stderr tail empty (The per-run controls), so its report is in the log artifact and the field stays false.

Cancellation escalation ​

POST /api/jobs/:jobId/cancel requests cancellation and returns 202 (idempotent -- 202 even for an already-terminal job). It delivers a signal escalation to the running child:

  1. SIGINT immediately.
  2. SIGTERM after a 5000 ms grace, if the child is still running.
  3. SIGKILL after a further 5000 ms grace, if still running.

The escalation timers are cleared when the child exits, so a child that stops on SIGINT is never over-signaled; the timers are unrefed so they never hold the process open. The job's final cancelled/failed state reflects which signal took effect (a SIGINT-ignoring child that only stops on SIGTERM records exit 143).

Environment variables ​

VariableSemantics
VITE_DEPLOYMENT_PROFILEThe deployment-profile build signal, one half of the feature gate: console is the console deployment, and any other value (or unset, which is hosted) leaves every job route disabled. The console image sets it via a Dockerfile ENV (which persists to the container runtime); a hosted build leaves it unset. Set at build/deploy time, never derived from a request. Reading the same signal on both sides keeps the server gate from drifting from the client build.
VITE_ALCOVE_VERSIONThe release version baked into the client bundle, and no part of the feature gate: it configures nothing server-side and every job route ignores it. The image build sets it for the web build alone from apps/cli/package.json (see The partner accept kit), which is the only consumer, and fails rather than setting it empty; unset or in any shape but a release version, the kit names the floating image tag. Set at build time, never derived from a request.
JOB_DATA_ROOTThe data root and the other half of the feature gate. Non-empty, on a console build -> per-job workdirs are created under this resolved directory. Read with surrounding whitespace trimmed.
JOB_INPUT_DIRThe mounted work-input directory the console lists and profiles for this party's input CSVs (the CLI reads the selected file in place). Empty or unset -> falls back to JOB_DATA_ROOT, so one mount runs the listing; per-job workdirs under the data root are subdirectories and are skipped by the regular-file filter, and dot-prefixed names are inadmissible, so the flat layout lists only loose input files. Non-empty -> resolved to an absolute path. It resolves to undefined (feature off) only when it and JOB_DATA_ROOT are both empty -- a state that also disables the API. Read trimmed; set only server-side, never derived from a request.
JOB_RENDEZVOUS_DIRThe mounted synced-folder rendezvous directory a filedrop exchange reads and writes. Empty or unset -> falls back to JOB_DATA_ROOT, so one mount runs the filedrop transport; the fallback provisions the shared mount of an unsplit console only, and never the inbound leg of a split (see JOB_RENDEZVOUS_OUTBOUND_DIR). Non-empty -> resolved to an absolute path. At filedrop job start the manager emits ONE non-fatal warning per leg when this directory overlaps the work-input directory or the data root, however many of the two it overlaps -- but the operator's own directory layout is theirs to choose, so the exchange still runs. A leg that equals or contains either one -- the single-folder layout -- takes the exposure notice: the folder holds the operator's input file, configuration and results (naming only what it holds), so whoever syncs it gets them, and the recovery is a mount of its own through JOB_RENDEZVOUS_DIR. A leg only nested inside them takes a narrower notice naming each directory it is inside: the partner's writes land among the operator's files. Neither interpolates the other directory's path, only the mount's. That overlap is decided over each path as configured (lexically resolved) and as its realpath, in every pairing of the two forms, so a mount symlinked onto the data root raises the same notice a lexically nested one does: what a partner's sync writes reach is the directory at the far end of the link. It stays a warning either way -- the resolution decides which notice to raise, never whether the exchange runs. A directory on either side of that comparison whose own real path cannot be read (as opposed to one that does not exist yet, which is resolved through its nearest existing ancestor exactly as a split pair's legs are) narrows the comparison back to the configured paths and adds one further notice saying so, naming the side that could not be resolved and the read access to restore, so a narrowed check is never silent about which side narrowed it: the mount raises that notice for itself, and the data root and the work-input directory each raise their own, naming the leg they were compared with (they are compared once per leg, and the notice would otherwise reach a split console's operator twice over with nothing to tell the two apart). It stays a warning on every side, exactly as the overlap itself does. The mount that does not exist yet raises its own notice alone. It emits two further non-fatal warnings, in order, when the directory holds an entry in the exchange's filename grammar (isProtocolGrammarName, the CLI's entry guard and sweep classification): a lead with the recovery -- every filedrop job rendezvouses out of this one mount, so a completed retain-mode run leaves a transcript the next run's entry guard refuses, with no crash involved; the recovery is the console's own sweep control (Clear leftover exchange files before starting, named in the notice by as much of that label as fits beside the mount path) rather than a host-side deletion, and the operator's own input and results are not what the guard refuses over -- and then a listing of those entries (sorted, at most 10 named and the rest counted). An entry outside the grammar raises neither warning and is not listed. Which of two recoveries the lead states is decided by this launch's own sweepExchangeFiles intent, the same value that reaches the child as --sweep-exchange-files: a launch without it is told to turn the control on and re-run, and a launch already with it is told that the control is on, that the sweep runs first, and that the operator's own input and results are not what it sweeps -- the control's state and what the sweep spares, never a prediction of its outcome, since the CLI's sweep refuses a retain-mode transcript and --force-retain-sweep is unrepresentable in a job intent. The lead and the listing are composed raw, because a manager-composed event bypasses the relay's re-sanitization (which covers the CLI's fd-3 lines) and the console warning sink escapes the message it renders exactly once through sanitizeForDisplay, so escaping a partner-chosen entry name here as well would reach the operator doubled. That sink caps what it renders at the whole-warning budget (WARNING_MESSAGE_MAX_DISPLAY_LENGTH, 4096 characters), but every notice this preflight raises -- the two above and the missing, non-directory, non-writable, unlistable, overlap, and unresolved-real-path notices alike -- is fitted where it is composed to the tighter per-value DEFAULT_MAX_DISPLAY_LENGTH, measured as rendered cost, so none can truncate at either boundary. That fitting is why the leftover-files case is two messages rather than one, why an entry name that does not fit whole is counted rather than clipped (a clipped name reads like a whole name the operator could go and delete), and why a rendezvous path too long to fit beside a notice's sentence is left out of it whole -- the path is the operator's own server-side configuration for the one mount, while the sentence around it is what they cannot reconstruct. The warnings do not gate the launch; an inadmissible directory still fails with the exchange's own refusal. A directory that cannot be listed warns that it could not be checked rather than reading as empty. The listing is one synchronous readdirSync on the job-create request path, and the partner influences its cost by syncing files in; the bound on that read is the single-owner console model (one operator, one exchange at a time, on their own machine) rather than any cap on the entry count. Read trimmed; set only server-side, never derived from a request.
JOB_RENDEZVOUS_OUTBOUND_DIRThe mounted outbound (self-written) rendezvous directory of a split filedrop rendezvous, the companion to JOB_RENDEZVOUS_DIR's inbound leg. Empty or unset -> this console rendezvouses over one shared mount and nothing about a split is representable; no JOB_DATA_ROOT fallback, because the variable being set is the only signal that this console provisions a split and a fallback would turn every single-mount console into a split one. Non-empty requires a non-empty JOB_RENDEZVOUS_DIR beside it: a split takes each leg from its own variable, and an inbound leg resolved through the JOB_DATA_ROOT fallback is reported as the problem below rather than provisioned -- the data root is the API's own feature gate, so it resolves on every console that runs at all, and accepting it here would sync the directory holding every job workdir's config, key, input, and results to the partner as the inbound folder. Non-empty with both -> resolved to an absolute path, and every filedrop exchange the console composes then holds inbound_path/outbound_path (see Composed CLI configuration) and --outbound-path on a zero-setup argv (see Subprocess invocation). At filedrop job start the manager preflights each leg independently, with every notice naming which of the two it is about ("the inbound rendezvous directory", "the outbound rendezvous directory"); on a single-mount console the same notices keep the unqualified wording. Both legs join every containment list the single mount is on -- the pasted-credential scratch assertion and the credential-@path warning -- since either is a folder the partner syncs. A pair that cannot run an exchange (two legs that are one directory or nested, two legs whose locators resolve alike, a leg with no locator at all, or an outbound leg with no inbound one of its own) is reported through GET /api/jobs/rendezvous as configured: false with a problem, and POST /api/jobs refuses every filedrop create while it stands; SFTP on the same console is unaffected, and the boot logs the same sentence non-fatally rather than refusing to start. The same-directory-or-nested half of that is decided over each leg's configured path and its realpath, so a symlinked leg meets the refusal too (see The filedrop rendezvous report); a leg whose real path cannot be read narrows the comparison to the configured paths and adds a second non-fatal boot warning naming the variables that could not be resolved, never a refusal of its own. Read trimmed; set only server-side, never derived from a request.
JOB_RENDEZVOUS_NAMEThe shared folder's own name -- what the operator and their partner call the folder the rendezvous mount stands for -- which the filedrop invitation holds as its advisory locator and the accept kit prints. Unset -> the name is the resolved rendezvous mount's last path segment, the operator-authored mount whose mount point is their own naming. Set -> that value, trimmed, when it is a bare folder name; a value that is not (empty, ., .., containing / or \, containing a byte below 0x20 or 0x7f, or longer than 255 characters) leaves the console with no folder name rather than falling back to the mount point, because a caller that set the variable has already said the mount point does not name the folder. Set only server-side, never derived from a request; it reaches the partner in the invitation.
JOB_RENDEZVOUS_OUTBOUND_NAMEThe outbound folder's own name on a split console, the companion to JOB_RENDEZVOUS_NAME, under identical rules (unset -> the outbound mount's last path segment; set-but-not-a-bare-folder-name -> no name rather than a fallback). It exists because a split invitation holds a name per leg and core refuses a filedrop endpoint whose two halves resolve alike: two mounts whose last segments coincide (/mnt/in/alcove and /mnt/out/alcove) would otherwise fail at the mint with nothing an operator could act on, so the console reports that collision as a problem naming this variable instead. Set only server-side, never derived from a request; it reaches the partner in the invitation.
JOB_CLI_BINARYOverrides the CLI entry path the driver spawns. Unset -> the workspace-relative built entry (apps/cli/dist/index.js, resolved four levels up from the jobs module). Used by production overrides and by tests pointing the driver at a stub. It is set only server-side, never derived from a request.
JOB_SECRETS_DIRThe optional mounted secrets directory the console browses for a connection's credential file (see The secrets mount and browsing). Empty or unset -> the secrets mount is unavailable, and the operator references a credential by typing an @path instead (which may point into the data root, raising a non-blocking warning); there is no JOB_DATA_ROOT fallback (a fallback would default the credential-browse surface into the client-writable data root). Non-empty -> resolved to an absolute path. Read trimmed; set only server-side, never derived from a request.
JOB_SFTP_CREDENTIAL_DIROverrides the container-internal directory a pasted (raw) credential is materialized to (see Materializing a pasted credential). Empty or unset -> the fixed default (/run/alcove/sftp-credentials), which the image creates at build time and hands to the unprivileged account it runs as. Non-empty -> that directory, for a deployment running under some other account or for the test harness. Whichever it resolves to is asserted, created owner-only, and swept clean at startup (see Materializing a pasted credential). Set only server-side, never derived from a request.
HOSTThe address the console server binds. Empty or unset -> 127.0.0.1, this host's loopback interface alone. The image sets 0.0.0.0, because a loopback bind inside a container is unreachable through its published port; which host interfaces reach the console there is decided by the publish binding (see Startup and reachability). Read trimmed.
PORTThe port the console server listens on. Unset -> 3000.
SHUTDOWN_TIMEOUT_MSHow long a graceful shutdown waits, in milliseconds, before closing every connection and exiting (see Job lifetime and orphan handling). Unset, or anything but a positive whole number -> 30000. Read trimmed.

The console server serves plain HTTP on a TCP port only; TLS, where wanted, is a front end's.

Startup and reachability ​

The manager is constructed on the first request that passes the feature gate; on a deployment that fails it, no manager is constructed and no child is ever spawned. Because the work-input and rendezvous directories both fall back to JOB_DATA_ROOT, that one variable (on a console build) also lights up the input listing and the filedrop transport, so a single mount runs a full console. When JOB_DATA_ROOT is set but the profile is not console, the server logs one non-fatal startup warning that the data root is ignored and boots with the job API dark rather than refusing to start. Outside the image the console server binds loopback unless HOST names another address; it does not otherwise inspect its bind interface -- there is no loopback bind check and no bind-derived startup refusal. Reaching the unauthenticated API only from the operator's own machine is a deployment concern: the console container publishes its port to the host loopback (-p 127.0.0.1:PORT:PORT), so exposure is governed by that publish binding and the host firewall rather than by an app-level bind check (see CONSOLE.md). Measured 2026-10-08 on Docker Desktop (the publish binding is engine-specific) with -p 127.0.0.1:3000:3000: docker port reported 3000/tcp -> 127.0.0.1:3000, the console answered 200 on host loopback and 403 to a request whose Host was attacker.example, logging the refused Host and naming JOB_ALLOWED_HOSTS, and a connection to the host's LAN address on that port was refused. The profile gate is the app-level safety check that keeps a hosted build from serving the API even when the publish binding or a fronting proxy would reach it.

When the job API is enabled, startup also prepares the pasted-credential scratch directory: the containment assertion, the owner-only creation, and the sweep specified under Materializing a pasted credential run here. It is a no-op when the API is disabled (no manager is constructed, so no paste can be authored).

Job lifetime and orphan handling ​

The exchange lives in server memory only and is never persisted:

  • One exchange in one slot. The manager holds at most one exchange at a time. A second create -- of either channel -- is refused with a 409 naming the occupying exchange's id ({ "id" }, so the browser re-attaches to the running exchange rather than dead-ending) while the slot is occupied. The busy check runs before every other create refusal, so a create that would also be refused for its connection or rendezvous still answers 409 while the slot is taken. The slot is claimed before any workdir exists, and frees only when both hold: the exchange was explicitly DELETEd, and the child's exit was observed (or no child was ever spawned). Keying the release on the child's confirmed exit -- not merely on the terminal event, which the buffer-overflow path emits while a SIGKILLed child may still be running -- is what keeps a successor exchange from rendezvousing with a still-dying child over the authored connection or rendezvous directory. A settled but undeleted exchange keeps the slot: the reject-until-DELETE rule means a create never silently destroys the previous exchange's undownloaded result.
  • Restart forgets the exchange. The in-memory slot -- running state, the terminal reconciliation, and the full event history -- does not survive a server restart, and no in-flight exchange is resumed or persisted (a restart cancels it rather than persisting another party's material). The workdir remains on disk and is not listed: a restarted server serves 404 for the forgotten id on every route but two -- GET /api/jobs/:jobId/folder reports which of its files the workdir holds so the browser that stored the id can offer it to keep or discard, and DELETE /api/jobs/:jobId removes the workdir when it exists (204). The files stay until removed by an explicit DELETE of that id or by hand.
  • DELETE removes the disk. DELETE /api/jobs/:jobId is the only operation that removes the workdir. For the active exchange it SIGKILLs a still-running child first so the delete leaves no orphan, marks the exchange deleted (the surface 404s from that point), and rm -rfs the workdir (idempotent); the slot stays occupied until the child's exit is observed. A disk-only arm removes a workdir named by a valid v4 UUID but orphaned by a restart: it reads no artifacts and serves nothing, applies the same containment and real-directory (lstat, so a symlinked leaf is refused not followed) guards the live routes apply, and refuses the active slot's own id. Nothing is auto-deleted.
  • Shutdown waits for the child. On server shutdown (SIGINT/SIGTERM), the server stops accepting connections and closes its idle ones, then runs its close hooks: the job manager's sends SIGTERM to every CLI child the server has running -- the active exchange's child and a child of the host-key probe, the signing fingerprint, the terms apply, or the relay check -- and SIGKILL to each still running after the cancellation's SIGKILL grace (5 s), and resolves only once each child's exit has been observed. Once the hooks have resolved and the remaining connections have ended, or once SHUTDOWN_TIMEOUT_MS (30 s by default) has passed, every connection still open is closed and the process exits. So the CLI finishes its own cleanup before the server exits -- in the image the server is PID 1, and its exit would kill the child mid-cleanup -- and no orphaned CLI outlives the server; from the signal on, every route that would spawn a CLI child -- create, the host-key probe, the signing fingerprint, the terms apply, and the relay check -- answers 503, and a create still in flight when the signal arrives is refused before its child is spawned, its workdir removed. It signals no child when the API was never enabled or none of these children is running, and a child that has already exited, or never started, does not delay the shutdown. apps/web/test/unit/jobs/jobShutdown.unit.test.ts drives the exchange, host-key probe, signing fingerprint, and terms apply children through the console server's own graceful shutdown (apps/web/server/console/shutdown.ts).

See also ​

  • SECURITY_DESIGN.md - the single-party console trust invariant this API rests on
  • CONSOLE.md - the operator-facing overview: what the feature is for and how to enable it
  • CLI_EVENTS.md - the CLI's fd-3 event stream this API consumes and re-validates
  • PROTOCOL.md - the exchange protocol that produces the events
  • CLI.md - the zero-setup command the zero-setup mode drives