ADR-150: attachments reach cloud runs — a staging seam on the store, a copy where no bind exists, a typed refusal where neither
Status: accepted (ARB-111, session-vagues46, 2026-10-29) Date: 2026-10-29 Issue: SocialGouv/iterion#2086
Context
An attachment is a file the operator gives a run: picked in the launch form, uploaded as it is picked, named by the launch, promoted to the run, bind-mounted read-only into the sandbox at /run/iterion/attachments, and handed to nodes as {{attachments.<name>.path}}. Every link of that chain was written for a server and a runner that share a filesystem. In a cloud deployment they share nothing, and the chain broke twice, on two independent links, with two different kinds of silence:
- The upload answered 503. The server staged an upload in a directory under the run store's root —
<root>/uploads/<upload_id>/— and the cloud store has no root by construction.POST /api/runs/uploadsrefused with "store has no filesystem root"; the launch form could not attach anything. - The sandbox dropped the bind and said so to nobody who reads runs. The kubernetes driver has no host filesystem, so the runtime removed every host bind mount it had declared — the attachments among them — with a warning on the runner's log and nothing on the run's stream. The nodes were then handed an empty path (a store with no root has no host path to offer either), a tool ran
cat "", an agent was told a file was at "", and both improvised. The ticket's own probe read ABSENT/ABSENT on an up-to-date cloud instance.
Three outcomes were on the table for the second gap, and the ticket named the worst: the silent skip. A run that goes on without what it was given reads, afterwards, as a run that never had it.
Options considered
Where the staging seam lives.
- A — methods on
store.RunStore. Every implementation and every test fake grows four methods it may not want. Rejected: staging is a capability a store has or has not, exactly like the run-files area. - B — an optional narrow interface,
store.StagingStore, probed withstore.AsStagingStore(theAsRunFilesStorepattern). The filesystem store keeps its directory layout — its tests and its reaper unchanged in substance — and the Mongo store gains a twin. The server never touches a disk for uploads; a store that stages nothing is refused typed. Chosen. - C — stage through the artifact API (runner → server). Covers a tool producing a file during a run, not the launch-time upload that was dead. Rejected for this ticket.
What a dropped bind owes the run.
- Keep warning on the runner's log. Rejected: measured twice already (#815, #834) as the shape of incident nobody can diagnose from the run.
- Refuse every run that holds attachments on a driver with no host filesystem. Rejected: it would also refuse the consumers a URL serves — vision inlining in cloud reads the presigned URL today and works.
- A run event for every drop; a typed refusal when the run holds attachments and the driver can neither bind nor copy them; a typed node failure when a node names a path the run cannot hand it.Chosen. The refusal is the Nth-variant guard: once the copy seam below lands, no shipped driver falls in it (docker binds, kubernetes copies, noop is the host).
How a driver with no host filesystem receives the files.
- Hand the nodes the URL only. Rejected: a tool that needs a file gets a URL it must fetch with credentials it does not have.
- A capability and a seed list —
sandbox.Capabilities.SupportsHostDirCopyandsandbox.RunInfo.SeedDirs: directories the driver copies into the container at start, after the workspace, the way kubernetes already copies the workspace (tar overkubectl exec). The runtime seeds only what it cannot bind: a driver that binds, binds; one that copies, copies; one that does neither meets the refusal above. Chosen. - Where the bytes come from on a store with no root: the engine materialises the run's attachments into a per-run temporary directory before the sandbox starts (list, open, write, read-only files), removes it with the sandbox, and fails the setup typed when a fetch fails. The attachment path handed to nodes is then the container path when the sandbox mounted or copied the directory, the local copy when the run executes in the runner's own process (cloud
sandbox: none), and the URL alone otherwise.
Decision
store.StagingStoreis the seam between the upload and the run:StageUpload,OpenStagedUpload,DiscardStagedUpload,ReapStagedUploads.FilesystemRunStoreimplements it under<root>/uploads/<upload_id>/{meta.json,<filename>}.mongo.Storeimplements it with a record in thestaged_uploadscollection —_idthe upload id, stamped with the tenant and the user that staged it, read under the tenant filter — and the bytes in the blob backend underuploads/<upload_id>/<filename>(blob.Client.PutUpload,GetUpload,DeleteUpload). Nothing presigns an upload; the record is the only way to its bytes. The reaper runs on every replica as a platform sweep (no tenant), deletes the bytes before the record, and there is no Mongo TTL index — it would drop the record and leak the bytes. Upload ids carry 128 bits of randomness under theup_<unix>_prefix clients already treat as opaque. The wire format ofPOST /api/runs/uploadsis unchanged.- Every dropped host bind is a run event,
sandbox_host_binds_dropped(driver, the targets the dropped binds served, the variables withdrawn with them, any value handed back). - A run that holds attachments on a driver that can neither bind nor copy them is refused at sandbox start, before any node:
ATTACHMENTS_UNDELIVERABLE, said on the run's stream assandbox_skippedwithrefused: trueand the names. - A node that names
{{attachments.<name>.path}}(or the bare form) while the run holds the attachment with no path it can open fails before it runs,ATTACHMENT_PATH_UNAVAILABLE, at the boundary every executor call crosses (executeWithResources). An image attachment referenced from an LLM node's USER prompt, when every route that may serve the node is claw (the primary and each fallback), is not a path reference — that executor sends an image block, by URL when no copy exists; a system or interaction prompt, a router and a CLI backend interpolate the path, and are admitted on it..urlserves anywhere; an attachment the run does not hold still renders empty — a declared optional attachment is the bot's to test for. sandbox.Capabilities.SupportsHostDirCopyandsandbox.RunInfo.SeedDirscarry the attachments to a driver with no host filesystem. The kubernetes driver declares the capability, mounts oneemptyDirper seed, streams the directory in with the workspace's tar pipeline under the workspace copy's budget, and makes the files read-only (the directory itself stays writable: anemptyDiris root-owned and group-writable throughfsGroup, and the sandbox user cannot change its mode). On a store with no root the engine materialises the attachments first, so the same seed serves cloud.
Consequences
- Attachments work in a cloud deployment on both paths the ticket named, with one contract for the two store twins (the
StagingStorecase of the shared conformance suite runs against both). - A new blob family,
uploads/<upload_id>/, keyed by something other than a run id: documented in the public-exposure checklist with its scoping rule (the tenant-filtered record), and in the backup page as transient. A new collection,staged_uploads, exempt from the per-run sweep guard because no run owns it. - Three failure codes join the reserved set and the retry classification:
ATTACHMENTS_UNDELIVERABLEandATTACHMENT_PATH_UNAVAILABLEare deterministic (a redelivery changes nothing),ATTACHMENTS_UNFETCHABLE— the store did not hand the files over before the sandbox — parks the run resumable, as a sandbox that could not be placed does, and the cloud runner re-offers it after two minutes rather than at once. Two event types join the sandbox family; the studio's hints and event union carry them. The launch paths that hand the run to another process (the cloud publisher, a detached runner) promote the staged uploads before the hand-off (runview.PromoteForHandoff); a promotion that fails fails the launch (LAUNCH_FAILED). - The runtime's attachments directory is a fact in one more case: a copied directory counts as landed, so
attachmentPathkeys off it as it keys off a bind. - What is deliberately left: the resume path's forecast of a local copy (before materialisation it answers the URL, as before), and read-only on the pod being per-file rather than per-mount.
Witnesses
pkg/runtime: TestSandboxRefusesARunWhoseAttachmentsNoDriverCanDeliver, TestANodeNamingAnAttachmentPathRunsWithAFileOrNotAtAll, TestFinalizeMountsForDriver. pkg/store/storetest: the StagingStore case, run by the filesystem suite and the Mongo suite. pkg/store/mongo: TestStagedUploadsAreScopedToTheTenantThatStagedThem (a real replica set). pkg/store/blob: the prefix-guard sweeps, DeleteUpload among them. pkg/server: TestUploadAttachment_StagesThroughTheStoreWhenItHasNoFilesystemRoot, TestUploadAttachment_IsRefusedTypedWhenTheStoreStagesNothing. pkg/sandbox/kubernetes and pkg/runtime: the seed copy and the materialisation witnesses of the third unit. Each saw its defect put back and reddened on its own assertion.
