- Status: Accepted — part 2 (the node driver) superseded by 0062
- Date: 2026-07-26
- Deciders: thulasi.ram (architect)
Read this first. A constraint arrived after acceptance — no DaemonSet from Scarab at all, privileged or not — which forbids part 2’s node driver. The goal part 2 exists for (lazy reads, so a Step moves what it reads rather than what it inherits, with the author declaring nothing) is not withdrawn; 0062 reaches it a different way. Parts 1, 3 and 4 stand unchanged. Three claims below are amended in place and marked: part 2 itself, the “privileged node driver becomes an install prerequisite” consequence, and the “cross-AZ traffic is confined to the archive drain” consequence.
Amended 2026-08-24 by 0067 — part 4’s tiering is amended again: the durable write happens in one pass at the drain. A drain now succeeds only when its content has landed in the object store as an atomic pack — no warm-first write, no deferred flush. The drain-record file and the per-fence write ledger leave the Depot’s local disk: the commit pack in the bucket is both the receipt and the ledger (0067 part 8), with Postgres holding only a derived, rebuildable index. Parts 1 and 3 — the service, the feed, the aggregation — stand.
Context
Section titled “Context”0004 chose pod-per-step with the workspace passed as content-addressed snapshots in object storage, and promised a control-plane / data-plane split: the durable brain records intent, data moves on its own path.
The implementation does not honour that promise. build_pod gives each Step Pod an
emptyDir plus two busybox doorstop containers, and drive_workspace moves the workspace
through the control plane: tar -xf in over kubectl exec, tar -cf out over
kubectl exec, hashed on the server. So every byte of every workspace crosses the
Kubernetes API server — a control-plane component never intended for bulk data — twice
per Step.
This ADR originally continued: “0029’s dedup and incremental transfer are real, but they apply only between the server and object storage, i.e. after the expensive part.” Measurement — s0, recorded under Open below — showed that sentence to be backwards. The server↔object-storage leg is the expensive part, 81–88% of every Step boundary, and the dedup saves approximately nothing: the cost is one sequential round-trip per file, not the bytes. Every structural complaint below still holds. The money is simply not where this paragraph guessed it was.
This is unsound by inspection, independent of any benchmark. A tar piped through an
exec stream is: a single sequential connection (no parallel ranges, no saturating a NIC);
unresumable (a break at 90% starts over); routed through the cluster’s most contended
component, competing with the control traffic that keeps the cluster alive; funnelled through
one scarab-server process that must also hold and hash every byte; and it moves the whole
workspace in both directions regardless of what changed. Object storage clients have the
opposite properties on every line — in principle; the client we actually wrote does not (s0
again: materialize/ingest walk the tree one file at a time and await a round-trip each, so
they have no parallelism either). No measurement is required to reject this path — only to
size the win.
The operating environment sharpens this into a design constraint rather than a performance bug. Scarab’s target deployment is Karpenter + spot: nodes are provisioned on demand, consolidated aggressively, and reclaimed without notice. Co-location of consecutive Steps cannot be assumed — consolidation actively works against it. Every Step boundary is a network boundary, and no scheduling hint changes that.
That rules out the topologies other CI engines use to make this problem disappear. They all avoid it by choosing a coarser unit that owns a machine for its lifetime — GitHub Actions (the job), Woodpecker (the pipeline, one volume, node-pinned under RWO), Tekton (the Task, plus an affinity assistant that pins a whole run). Sharing is free inside the unit and painful across it. Argo Workflows makes our choice — step-grain pods, object-storage passing — and pays what we pay. Pod-per-step is not the mistake; routing the data through the brain is.
The governing principle
Section titled “The governing principle”Minimise the substrate idiosyncrasies an author must know.
A Pipeline author should not have to learn that this engine is expensive at Step boundaries, that caches shouldn’t live in the workspace, or that wide edges cost money. Rules of that shape are unenforceable, they date badly, and they push our operational problems into other people’s YAML. Where the substrate is expensive, the system pays, not the author.
This principle decides things that would otherwise look arguable, and it is why this ADR
does not make outputs: a performance requirement (see Consequences).
Decision
Section titled “Decision”Four parts.
1. A workspace service — in the standard path, not an optional accelerator.
A long-lived, Scarab-operated service holding a warm content-addressed store on a persistent
volume, deployed once per failure domain (per AZ, where AZs exist). Generic object
storage sits behind it as the cold archive under a retention TTL. One path in every
deployment mode — dev, kind, colima, production — because two modes is two mental models
and the taxonomy cost is worse than the component cost.
The persistent volume attaches to the service, never to a Step Pod. Every problem PVCs have here — attach/detach latency at each boundary, stuck volumes when a spot node is reclaimed, per-node attachment quotas, provisioning delay — comes from binding volumes to short-lived pods. Bind them to the long-lived thing instead and all of it goes away.
One path is a direction-at-a-time claim, and both directions have now arrived.
s3-feed converged the read path: every Step that inherits a workspace materialises it from
the service, in every deployment mode, with no second route. s3-drain converged the write
path (2026-08-01): the drain runs inside the Pod — the control plane execs scarab-wsfetch drain in the egress helper, the helper ingests /workspace to the Depot warm-first
(/have-dedup), prunes to the authored outputs: and folds the content identity in-process,
and posts a drain record to the Depot as the rendezvous. The control plane reads the record
back, awaits the archival flush, and patches root/identity/durability onto the Pod — hashes
and a record, never bytes.
2. A Scarab node driver, shipped and installed as standard. It mounts a Workspace Snapshot into a Step Pod as a read-only lower layer with a pod-local writable upper layer on top. Materialisation is lazy: a Step transfers what it reads, not what it inherits. This is what makes wide edges affordable without authors declaring anything, and it is the only mechanism that does — intercepting reads requires a privileged mount, which an unprivileged Pod cannot arrange for itself.
Service and driver are one component in two halves (server and client), not two concepts.
⚠️ Part 2 is SUPERSEDED by 0062. A later deployment constraint — no DaemonSet from Scarab at all — forbids shipping a node driver, and the paragraph above then argued the goal was unreachable. That argument is wrong, and its own last clause is why. “An unprivileged Pod cannot arrange a privileged mount for itself” is true and irrelevant: in Kubernetes the privileged mounter is kubelet, which mounts on a Pod’s behalf, so an unprivileged Pod can hold an intercepting filesystem at
/workspacewithout any capability. 0062 keeps the goal — lazy reads, a read-only lower and a writable upper, nothing declared by the author — and moves the mechanism to a Workspace Export:overlayfsover a hardlink Snapshot Farm on the service’s own disk, delivered to the Pod as a PersistentVolumeClaim. The lazy read is unchanged; the driver is gone. The “one component in two halves” framing survives with the client half now being kubelet’s mount rather than our code.
3. The control plane leaves the data path. The server exchanges root hashes — tens of
bytes. Helper containers become Scarab-owned images rather than busybox doorstops. The
kubectl exec tar tunnel is deleted.
Status of part 3. There were three
exectar tunnels on a Step boundary, not one. s3-feed deleted the first: a Scarab-owned fetcher image pulls the Step’s inputs from the workspace service, and the feed-side tunnel and itsbusyboxdoorstop are gone. s3-drain (2026-08-01) deleted the second, and reshaped it rather than merely relocating it:
- The egress barrier is the wsfetch image now — one knob with the fetcher (
SCARAB_WSFETCH_IMAGE/ chartworkspace.fetcherImage) — held open byscarab-wsfetch holdand pinnedrunAsUser: 0(root-read is the privilege the drain always had: the busybox barrier ran as root, and a0600file left by arun_as_rootstep must not silently vanish from the snapshot).- The control plane execs
scarab-wsfetch drain --workspace /workspace --outputs <glob>…in that container — argv in, exit code out; the globs are transport, the Pod annotation is the truth. The helper ingests warm-first with/havededup, prunes and folds the content identity in-process (no HTTP tree read-backs on the hot path), and POSTs its DrainRecord to the Depot LAST — so a record’s existence implies the ingest completed. The Depot validates the record against the fence’s write ledger and the closure’s warm presence before accepting it.- Classification is record-first: the record is the truth, the exec’s exit code only a hint when no record exists (a success record publishes; an
OutputContractrecord fails Config with the helper’s detail; exit 11 with no record fails Config; 126/127/“executable file not found” is a named image/control-plane skew failure, and so is exit 0 with no record — a stale wsfetch has no subcommands, ignores thedrainargv, runs fetch and exits 0 having published nothing, while a current binary always POSTs its record before exiting 0 — prompt and legible, never a wedge to the step budget; everything else re-drives under the 5-minute escalation clock).ws-timingmoved to v2: the control plane keepsexec_drain_ms/cold_flush_ms/total_ms;files,tree_bytes,blobs_uploaded,bytes_uploaded,have_hits,ingest_ms,prune_mscome off the record, measured where the work now happens.tar_bytes/tar_unpack_ms/walk_msdied with the tunnel.Still live, on purpose: the artifact-harvest tunnel (
harvest_artifacts— the lastexectar, framed and fail-closed). Artifacts ride an independent store with an independent lifecycle; moving them onto the Depot is a filed follow-up (“artifacts ride the Depot”), not part of this slice.
4. An Attempt is not Succeeded until its Workspace Snapshot is durable. On spot, a node
can vanish between “the Step exited 0” and “its evidence is safe”. Declaring success before
durability puts a claim in the durable record that the record cannot back — the one thing
this product may not do.
Amended by 0064 (2026-07-28) — conditional on the tiers a deployment actually has. The rule above is unconditional, and the retention table below says warm “promises none”, so an Attempt succeeding on warm alone contradicts it. 0064 allows exactly that where no independent cold tier is configured — and the test is
st_devof the cold directory against the warm one, becauseStoreConfig::LocalDirmay point at the warm volume itself, in which case “written to cold” licenses nothing. A second PVC is a real cold tier; aLocalDirbeside the CAS is not.The invariant survives; the mechanism weakens. This paragraph’s own reasoning is that the product may not put a claim in the record that the record cannot back. Cold-before-
Succeededwas a mechanism for that, not the point of it. A deployment that declares “durability here is warm’s” and then succeeds on warm makes a smaller, true claim — so 0064 requires the degraded guarantee to be stated at startup and stamped on the Attempt, since a deployment’s tiers change over time while its old records do not. What this paragraph was really protecting against is the silent version, and disclosure removes it.The related prohibition three paragraphs into “the price of seeding the warm tier” — that writing warm-first and letting the service tier onward makes warm load-bearing for durability — still stands. 0064 writes warm-first for one batched flush that is synchronous with respect to
Succeeded; it does not tier onward asynchronously. Warm is the write path, cold remains the promise wherever it exists.
Vocabulary
Section titled “Vocabulary”CONTEXT.md previously used Workspace for two different things. They are now distinct:
- Workspace — the mutable filesystem a Step executes in. Pod-local, dies with the Pod.
- Workspace Snapshot — the immutable, content-addressed tree that flows along a DAG edge and that an Attempt owns as evidence. Retained per policy, then archived, then gone. It has two coordinates (s8, below): a snapshot root — where the bytes are, the address a Step materialises — and a content identity — what the bytes are, the digest restart invalidation compares. Only the root is an address.
Retention
Section titled “Retention”Two tiers, two policies, so eviction can never break a promise:
| tier | bounded by | promise |
|---|---|---|
| workspace service (warm) | space — evict least-recently-used | none; a miss is slower, never wrong |
| object storage (cold) | time — retention TTL, as Artifacts already are | this is the guarantee users are given |
Amended 2026-07-28 by 0063 and 0064. Three corrections, and the first is the one that matters:
- “A miss is slower, never wrong” is true only of re-derivable content. It holds for Workspace Snapshots, because a miss refetches from cold or widens a rerun. It is false for Step logs, which 0063 puts on this tier and which cannot be re-derived at all — re-running a Step produces different logs. So logs carry their own promise on this tier: pinned against eviction until a durable sink acknowledges them where one exists, and where none exists, evicted loudly so the absence is visible in the record rather than looking like a Step that printed nothing.
- Warm is not always promise-free. Where no independent cold tier is configured (0064’s
st_devtest), warm is the only tier there is, and it carries whatever the operator’s volume provides. The guarantee is then stated and stamped rather than absent.- “Workspace service” is now the Data Depot (0063). It hosts the CAS, Snapshot Farms, Workspace Exports, the log namespace, and Cache — so a tier table row named after one tenant no longer describes it.
Amended 2026-08-03 by 0066 — the LRU deferral is superseded, and the warm row’s eviction policy changes shape. This table’s “evict least-recently-used” was written as an eventual nicety and deferred in implementation (the s5 space bound this ADR’s own “GC deletes from cold and leaves warm” bullet calls “the only thing that will ever reclaim it”). It is now the gating gap, for two reasons this ADR could not see:
- Warm-only makes it load-bearing. With no cold tier there is no second copy, so what warm evicts is what a rerun loses — a bounded recent window rather than a latency event. An unbounded, unswept warm tier in that mode is not a cache with a soft edge; it is a volume that fills and then fails writes.
- Nothing sweeps warm today, at all. 0066 traced the chain: warm-only skips the cold flush → cold stays empty → 0050’s sweeper only deletes unmarked cold objects → it deletes nothing, ever. The GC reports success and reclaims zero bytes.
And plain LRU is not the mechanism. 0066 replaces it with a layered policy: evict unreachable content first (point the existing mark walk at warm — no new machinery), then, only where a cold tier exists, evict reachable-but-cold-backed content by recency; and in warm-only over the mark, refuse writes loudly rather than evict. Recency is the part deferred, and deliberately: for immutable content
list_objectsgives least-recently-written, which is meaningless, so true LRU needs an access index that is only worth building if reachability-first proves insufficient. The budget stays a size watermark, never a TTL — space is the bound an operator controls and the one already instrumented. The warm space budget itself lands as a 0065RetentionProfileknob.
Plus a manual pin (“keep this Run’s workspaces”) for investigations, and graceful degradation: expired inputs widen a rerun’s scope and say so, rather than failing. Rerun affordances must state which they are — “Rerun this step” vs “Inputs expired — this re-runs from clone” — per 0027’s rule that smart never means mysterious.
Consequences
Section titled “Consequences”-
A third stateful component. 0004 called object storage “the second (and last)”. That is now wrong, deliberately. Accepted because one standard path beats a fast path plus a fallback path.
-
A privileged node driver becomes an install prerequisite— withdrawn with part 2 (0062). It was booked here as an honest cost and it is now a forbidden one. 0062 replaces it with a privileged workspace service Pod — one operator-installed StatefulSet on the node Scarab’s server already runs on, not a component on every node — and makes even that preferred rather than required, since the Farm degrades to areflinkor plain local copy without the capability. The comparison to Woodpecker’s “only a default StorageClass” no longer applies in our favour or against us: what an operator must accept is one stateful component, which is what they were installing anyway. -
The implicit inherit-everything default stays, and
outputs:(0007) stays a precision tool — for cache keys, safe fan-out and restricting what flows — never a performance tax. Laziness plus content addressing is the automatic version of a narrow edge: a Step that reads 5% of a tree moves 5% of it, having declared nothing. Directly downstream of the governing principle. -
“Don’t put your cache in the workspace” is not a rule we will teach. It is unenforceable and it is substrate knowledge. 0007’s Cache concept remains the better tool; using it stays an optimisation, not a requirement.
-
The Helm-dogfood failure class goes — and the warm tier is not what removes it. This bullet used to credit the wrong component, which matters because the mis-attribution contradicts this ADR’s own retention table. The failure was real:
deploy/local-helmhad no object store, so the CAS fell back to a local directory on the server Pod’sscratchemptyDir, every deploy rolled the Pod, and every rerun of an older Run hung atInit:1/3and dead-lettered. What fixes it isdeploy.shdeploying MinIO and pointing the server at it: the durable copy lives in cold, and cold is the only tier that promises anything. The workspace service’s PV cannot be the fix — the retention table above says it is bounded by space, evicts, and “promises none” — so a design that relied on it to prevent data loss would have rebuilt the same bug with a bigger disk. The service’s volume is a cache, and a cache that survives a Pod roll is a latency win, not a durability one.Amended 2026-08-03 by 0066 — this sentence was right, and it was violated twice before anyone noticed it was an invariant. “The service’s volume is a cache” is stated here as an observation in a bullet about a different bug. Two later changes contradicted it:
e58ce1fput drain records and write ledgers on the Depot — state that exists on exactly one replica’s disk and that a later request needs in order to succeed — and warm-only (0064 part 4) made the warm tier the only copy of a Workspace Snapshot. Both made the Depot a system of record for something.0066 promotes the sentence to the governing invariant — anything that makes the Depot a system of record is a defect — and repairs both violations rather than blessing them. The drain record stays replica-local but its absence becomes transient rather than
FatalConfig, so a lost replica costs a re-drive instead of a permanently red build; the re-drive is idempotent by construction (the drain reads a frozen/workspace, re-PUTs every tree unconditionally, and/havereports everything missing on a fresh replica). Warm-only is not a violation once it is read correctly: it does not make the Depot the system of record, it means there is no system of record for workspaces in that deployment — disclosed per Attempt viaattempts.output_durability.The payoff for holding the invariant is why it is worth the repair: HA becomes “run more replicas” (replicas hold nothing unique), eviction is safe by construction, and spot preemption costs a cold cache. The accepted cost, documented rather than apologised for: HA requires object storage.
-
Cross-AZ traffic is confined to the archive drain, which can be throttled or scheduled. Step Pods talk only to their own zone’s service. (In-region object storage is typically free per byte; EC2-to-EC2 across AZs is not. The cost was never “object storage” — it was the zone boundary.)
Amended by 0062 part 4: “confined” is too strong. A Workspace Export lives on one replica’s disk and the volume must be named in the Pod spec before the scheduler places anything, so “Step Pods talk only to their own zone’s service” cannot be an invariant without hard-pinning every Step to a zone — which would fight the spot capacity this ADR names as the operating environment. 0062 makes zone affinity a preference: in-zone when capacity allows, cross-AZ when it does not, priced and never wrong. The sentence should be read as the intent, not the guarantee.
-
Object storage stays generic. Scarab names no product and makes no single-zone / multi-zone / storage-class call. That is the operator’s cost model, not ours.
-
Version skew between server, service and driver becomes a supported concern.
-
Browse (0056) reads snapshots from the service and falls through to object storage when the service cannot answer, which keeps working after the Run’s nodes are gone — on spot, that is within minutes, and a live-volume design would show a blank pane exactly when people look. The fall-through is the second half of the sentence and it is not a caveat: the control plane already holds object-store credentials, so going direct crosses no trust boundary and creates no second data path for Steps. It is the literal reading of “a warm miss is slower, never wrong”, and it is the opposite of a Step Pod, which must fail closed because it deliberately has no credentials (0042).
For a while this bullet was false rather than merely incomplete: the composition root handed Browse the object store directly and never constructed a client of the service at all, so “reads snapshots from the service” described nothing that ran. It is true now — the control plane’s workspace store is warm-then-cold with the service as the warm tier — which is also what makes
POST /v1/cas/have, bothPUTverbs and thebrowsetoken scope live code rather than test-only. -
OCI artifacts are an available internal representation for the service, not a separate concept. Kubernetes already distributes immutable content to ephemeral nodes with per-node caching and dedup; if the service chooses to ride that, nothing above it changes.
Alternatives considered
Section titled “Alternatives considered”- PVC per Step — every cost of PVCs, none of the sharing: provisioning and attach latency per Step, per-node attachment quotas, one volume to reap per Step, and no reuse between Steps, because isolation is the opposite of caching.
- One RWO PVC per Run — a detach-then-attach cycle at every boundary (tens of seconds each way on EBS-class storage), and on spot the reclaimed-node-holding-the-volume wedge is routine rather than exceptional. Only pays off under co-location, which is excluded.
- RWX PVC per Run — avoids the attach dance, but NFS-class storage is slowest at exactly
our file shape (many small files: checkouts,
node_modules), and it is a much heavier prerequisite than the driver. - PVC clone / VolumeSnapshot per Step — genuinely good on copy-on-write storage (Ceph, local LVM/btrfs); unusable at Step grain on EBS, where a clone means snapshot-then-restore with lazy block hydration. Also dedups only down a lineage, where content addressing dedups across unrelated Runs.
- git as the store, on a shared volume — the right idea (git is a merkle store with a
working tree) and the wrong tool:
git add -Awalks the whole tree, which is the cost the overlay diff exists to avoid; checkout is a full copy with no linking; git cannot represent an empty directory and does not preserve mtimes, which build tools use to decide what to rebuild (today’starlegs do preserve both); and concurrent pods contend on the index lock. Worth revisiting only for delta compression of large binaries. - Coarsening the unit (pod-per-pipeline or pod-per-stage) — the GHA / Woodpecker / Tekton answer, and it genuinely makes intra-unit sharing free. Rejected because it trades away per-Step restart, per-Step placement (0055) and per-Step right-sizing — which on spot is a direct cost lever — to solve a problem that a lazy mount solves without giving anything up.
- Node-local blob cache only (no service) — attractive until Karpenter: consolidation and spot reclaim keep node lifetimes short and pods land on fresh nodes, so the within-Run hit rate approaches zero. As a second-level cache behind the service it was parked pending measurement; s0 answers it: not yet, and not for this reason. The measured cost is per-file round-trip count, and a cache is a second-order fix for that — it lowers the latency of a round-trip that lazy materialisation would not have made at all, and it only ever helps on a hit, which is precisely what Karpenter denies. Do the first-order work (lazy reads, then concurrency/batching in the CAS legs) and re-measure; a cache tier evaluated before that would be credited with wins that belong to the cheaper fix.
- Fat helper with eager parallel fetch, no driver — removes the
exectunnel and needs no privileges, so it is a legitimate stepping stone. Rejected as an endpoint: it still moves the whole workspace to every fresh node, i.e. it rebuilds the same complaint over a shorter wire. - Teaching authors to declare narrow
outputs:— the cheapest fix on paper and rejected on principle: it is substrate knowledge in the authoring model, and it assumes an awareness of our idiosyncrasies that we have decided to minimise. - Naming a specific object-storage product or zone topology — an operator’s cost decision, not an engine decision.
Open — deliberately not decided here
Section titled “Open — deliberately not decided here”-
Measurement — of the win, not of the premise. DONE (s0); it changed the slice order. Two claims were kept separate on purpose: “the
exectar path is the wrong design” is structural and settled above on its properties, and “it is the dominant cost today” was empirical and unestablished. This ADR rests only on the first — which is just as well, because the second turned out to be false.drive_workspacenow emits onews-timingline per leg per Step boundary (just logs | grep ws-timing). Proc-mode stack, kind on colima, MinIO on loopback, two synthetic 3-step chains (produce → consume → verify) at a constant 8.19 MB workspace and two file counts, so per-file cost separates from per-byte cost:leg files exectunnelserver tar CAS (object storage) total CAS share feed 2000 1244 ms 1102 ms 11581 ms 13940 ms 83% drain 2000 385 ms 939 ms 8867 ms 10213 ms 87% feed 250 421 ms 94 ms 2300 ms 2823 ms 81% drain 250 105 ms 156 ms 1960 ms 2242 ms 88% Three findings, in descending order of how much they matter:
- The
exectar tunnel is 4–15% of a Step boundary. The CAS legs are 81–88%. The component this ADR was written to delete is not the bottleneck. Deleting it (part 3) is still right — it is unresumable, it abuses the API server, and it blocks lazy reads — but on its own it buys roughly a tenth of the boundary. - The CAS cost tracks file count, not bytes. At identical total bytes, an 8× file-count
reduction cut
cas_ingest4.4× andcas_materialize5.0×. Cause:materializeawaits aget_tree+get_blobper file andingest_dirawaits ahead+ maybe-putper file, strictly one at a time. ~4–6 ms/file against loopback MinIO — so real object storage over a real network makes this worse, and the CAS share above is a floor, not a ceiling. - Content-addressed dedup currently saves nothing in time.
consumeandverifyre-drain byte-identical content the CAS already holds; their fully-dedupedcas_ingest(9704 ms, 10096 ms) is no faster than the cold ingest that uploaded everything (8867 ms).put_if_absentpays aheadround-trip per file either way, and the avoidedputcosts about the same as thehead. Dedup saves storage, not wall-clock.
Consequence for sequencing. Part 2 (lazy materialisation) is the load-bearing part, not part 3: it attacks file count, which finding 2 identifies as the actual cost driver. Whatever else the workspace service does, it must not reproduce a per-file sequential round-trip walk — concurrency and batching in the CAS legs are worth more today than the tunnel deletion, and are cheap enough to be worth doing even before the service exists.
Caveats, stated so the numbers are not over-read: synthetic uniform-size files, not a real checkout; one machine, loopback object storage, no cross-AZ latency; and the measured binary also carried the in-progress s7 metadata-fidelity work, which adds per-file syscalls (not round-trips) to both CAS legs. None of that touches the ordering — the gap is 6–20×.
Amended 2026-08-01 (s3-drain). Every figure in this bullet and s2’s — the 4–15% tunnel share, the 81–88% CAS share — is pre-0064 and pre-s3-drain: measured on the shape where the control plane unpacked the drain tar into a tempdir and ingested it itself, with cold in the write path. Neither shape exists any more. The drain slice was accordingly not motivated by these latencies but by the architecture (part 3: the control plane leaves the data path — the prerequisite for 0062’s Export) and by control-plane RAM: the old drain buffered the entire workspace tar in one CP
Vecand unpacked it into a CP tempdir per Step boundary, a per-drain memory bill proportional to the largest workspace times drain concurrency. ws-timing v2 keeps the leg observable, but no fresh latency claim is made here. - The
-
Concurrency in the CAS legs — s2, the slice s0’s numbers created. DONE; it moved the bottleneck out of object storage entirely. s0’s own consequence paragraph names this: “concurrency and batching in the CAS legs are worth more today than the tunnel deletion, and are cheap enough to be worth doing even before the service exists.” Both legs now run with bounded parallelism (
SCARAB_CAS_CONCURRENCY, default 32), and both emit the byte/dedup counters s0 explicitly could not obtain from outside —Cas::ingesthashes and does its ownhead/putper blob internally, so a decorator could separate neither hashing from storage nor bytes-uploaded from bytes-deduped-away. Harvest everything with:just logs | grep ws-timing # the whole Step boundaryjust logs | grep ws-timing | grep cas= # just the two CAS legsRe-measured on the stack, same shape as s0 — proc mode, kind on colima, MinIO on loopback, the same two
produce → consume → verifychains at a constant 8.19 MB and the same two file counts. Each row is the mean over that run’s legs of that kind (2–4 legs per row), withSCARAB_CAS_CONCURRENCYat its default 32:leg files exectunnelserver tar CAS total CAS share s0’s CAS CAS speed-up boundary speed-up feed 2000 539 ms 4680 ms 1866 ms 7095 ms 26% (was 83%) 11581 ms 6.2× 2.0× drain 2000 362 ms 878 ms 5563 ms 6824 ms 81% (was 87%) 8867 ms 1.6× 1.5× feed 250 481 ms 364 ms 780 ms 1628 ms 47% (was 81%) 2300 ms 2.9× 1.7× drain 250 155 ms 130 ms 971 ms 1278 ms 75% (was 88%) 1960 ms 2.0× 1.8× Read the shares, not only the multipliers.
materializeis no longer the dominant cost of a feed leg — 83% → 26% at 2000 files.ingestimproved far less and is still 75–81% of a drain; the counters say exactly why, and it is not the object store (below).One number moved the wrong way and is reported because it changes an argument: the feed leg’s
tar_packrose from 1102 ms to ~4700 ms. Nothing in that code changed. The server writes the whole workspace into a tmpdir and immediately tars it back out; whenmaterializetook 11.6 s, page-cache writeback completed underneath it, and now that it takes 1.9 s,pack_dirpays that cost instead. So the feed-leg boundary improved 2.0×, not 6.2× — and that is a case for part 3 which s0’s numbers did not contain: the tmpdir round-trip is a write-then-read of the entire workspace through the page cache, and its cost does not disappear when you make one half of it faster. It has to be removed, not accelerated.Controlled A/B, in-process — the two CAS legs alone, not a Step boundary, and labelled as such because a shared dev machine moves stack numbers by up to 6× (the first attempt at the table above, taken at load average 22, reported
cas_materialize12471 ms andtar_pack21838 ms; it was discarded). Same harness (crates/scarab-storage-s3/tests/throughput.rs,SCARAB_BENCH_CAS=1), same fixture shape, release build, real MinIO on loopback. “Before” is the actual pre-s2 commit, run from a detached worktree — not an inference from a knob:leg before (pre-s2 commit, serial) after, c=1 after, c=32 speed-up cold ingest12277 ms 12094 ms 4540 ms 2.7× materialize10145 ms 2958 ms 1096 ms 9.3× The
c=1column matching the pre-s2 commit to within 1% (ingest) is the check that this is one variable: at one in-flight request the new code is the old behaviour.materializeatc=1is already 3.4× faster because the syscall fix below is independent of concurrency.scaling with the in-flight limit c=1 c=8 c=32 c=128 cold ingest12094 ms 4668 ms 4540 ms 3710 ms materialize2958 ms 1587 ms 1096 ms 1129 ms Both flatten by c=32, which is why that is the default: past it, the round-trips are already hidden behind the serial local-filesystem work described next, and the only thing a higher limit still buys is peak memory. A remote object store, with a real network in the path, would move that knee to the right — hence a knob and not a constant.
The counters relocated the bottleneck, and this is the finding that matters most. With the round-trips overlapped,
store_ms— a sum over concurrent requests, so it legitimately exceeds the leg’s wall clock; the ratio is the reading — runs 15–90× the leg’s duration. The object store is now essentially hidden. What is left isfs_ms: the local filesystem on the control plane. On the stack, 2000 files:cas="ingest" files=2000 fs_ms=4965 store_ms=97388 hash_ms=378 total_ms=5660 # 88% local FScas="materialize" files=2000 fs_ms=973 store_ms=30490 hash_ms=360 total_ms=1655 # 59% local FSThe CAS legs are no longer round-trip-bound. Three consequences, and none of them is “add more concurrency”:
materializewas doing five syscalls and three path lookups per file —fs::write, then a reopen forfutimens, then a pathchmod. One open handle does all three, in the same write→mtime→mode order s7 requires. Fixed here:fs_ms7261 → 870 ms at c=1 — and that is the whole ofmaterialize’s 3.4× gain before any concurrency, which is why the write leg ends up 4× cheaper than the read leg despite moving the same bytes.- What remains is
std::fscalled from inside the futures, andbuffer_unorderedpolls every in-flight future from one task — so local I/O is serialised even though the network is not. Moving the read/write halves onto a blocking pool is the next win. Deliberately left out: it makes this adapter require a runtime to be present, a real coupling to decide on its own merits rather than as a side effect of a perf change. - At 250 files,
hash_msis 340–348 ms of a ~500–970 ms leg — 36–67%. That is a debug build (just upbuilds debug; the release harness hashes the same 8.19 MB in ~30 ms), so it is an artefact, not a finding. It is recorded because it is the shape of the third bottleneck: once storage and the filesystem are dealt with, SHA-256 over the whole workspace is what is left, and that is a per-byte cost no concurrency removes — only not re-hashing unchanged content does, i.e. the overlay-diff drain already listed below.
s0’s finding 3 survives, and the counters replace its explanation. A fully-deduped drain still costs what a cold one does — 5660 ms vs 5637 ms at 2000 files, 937 ms vs 968 ms at 250 — but not for the reason s0 gave. It is no longer the
head-per-file: with the round-trips overlapped those are nearly free (objects_present=2040, bytes_put=5431, andstore_msis hidden either way). It is that 88% of the leg is reading every file off local disk in order to hash it, and dedup cannot avoid a read whose redundancy it has not yet discovered. The only thing that fixes it is not walking unchanged content at all — the overlay diff for the drain below. Two independent measurements have now landed on that slice. (An in-process run did show a deduped ingest 4× faster; that harness re-ingests the same directory immediately, so it measures a hot page cache as much as dedup. The stack numbers are the ones to believe.)A measurement bug the new counters caught in the harness itself, recorded because it is the kind that silently produces a flattering number: the fixture salt did not include the pid, so a second invocation against the same bucket reported
objects_put=41 objects_present=2000on what it called a cold ingest. Nothing outside the CAS could have seen that.Cost of the concurrency, stated plainly. A blob is still buffered whole, so peak memory is now roughly
concurrency × largest blobwhere it used to be1 × largest blob. At the default 32 that is nothing for the KB–MB files a checkout is made of and fatal for a workspace holding a 500 MB artefact. Mitigated today only by documentation and bySCARAB_CAS_CONCURRENCYbeing lowerable; the real fix is a byte budget beside the count budget, or streaming blob bodies instead of buffering them — the same primitive ADR-0029’s deferred sub-file chunking needs. -
mtime fidelity across the CAS.Answered (s7) — it did not, and now it does. Measured, not reasoned about: the CAS tree entry carried onlynameandtarget, so a round-trip silently dropped every mode (an executable came back0644) and every mtime (reset to the moment of checkout), andingestfailed outright on a symlink to a directory. So cross-Step incremental compilation was already degraded today, exactly as suspected, independently of this ADR. Fixed rather than characterised: a tree entry now carriesmode+mtime_ms, and a symlink is a blob holding the link target markedMODE_SYMLINK— git’s layout. Blobs stay addressed by their bytes alone, so metadata costs no dedup; a tree hash does move with an mtime, which is correct, since two checkouts with different timestamps are different workspaces to every tool that compares them.crates/scarab-storage-s3/tests/fidelity.rsis the standing proof, and s3 must keep it passing — whatever replaces thetarlegs inherits this contract.That last clause was half an answer, and s8 below is the other half. “A tree hash moves with an mtime, which is correct” is true of an address and false of the question ADR-0027 asks. s7 went further and argued the consequence was harmless — “Cost not paid: nothing decision-relevant — restart skip-if-unchanged compares the recorded snapshot roots of upstream steps that did not re-run, which are byte-identical strings regardless.” That sentence excluded from consideration exactly the case skip-if-unchanged exists for: an ancestor that did re-run and produced the same output. The live kube tier then measured it (git-bug
945b1f4): two Attempts writing byte-identical content produced roots differing inmtime_msalone. Recorded here because the reasoning error is reusable — a cost argument that names the cases it does not pay for is not yet an argument. -
Two digests, one address — s8. DONE, and it closes
945b1f4. A Workspace Snapshot now has two coordinates, and only one of them is an address:covers is an address? answers snapshot root names, targets, modes, mtimes yes — trees/<hash>where are these exact bytes? content identity names, targets, modes no is this the same content? The identity is the same merkle fold with every mtime dropped and each sub-tree named by its identity. It is folded up for free during
ingest(one extra SHA-256 per directory, zero round-trips — nothing is stored under it), recorded beside the root as Attempt evidence, and it is what 0027’s input signature compares.Why not the three options the ticket sketched. Each is a real position and each loses to a specific argument:
- Normalise mtimes on ingest (to the epoch, as reproducible-build tooling does) —
rejected as unsafe, not merely as a loss of s7’s gain. mtime-based build tools are
wrong in exactly one direction: a timestamp that is too old makes them skip a rebuild
they needed. Constant mtimes make every inherited source look ancient, so a Cache
(0007) restored beside the workspace with real timestamps
looks newer than the sources it was built from, and
make/cargoskip. Silently wrong output is a worse failure than a slow one. - Drop mtime from the hash preimage and carry it as unhashed entry metadata — the
cheapest fix, and the tempting one, because the counter-argument to s7’s objection nearly
works: two trees sharing a content identity hold identical content, so serving either
one’s timestamps cannot mislead a tool comparing files within the tree. It fails because
CI tools do not compare only within the tree. Under
put_if_absentthe first-stored hint wins, so a path whose content went X → Y → X (a revert, a flaky generator, two branches sharing a store) is served the week-old X timestamp, and a Cache built from Y yesterday then looks newer than the sources — the same wrong-output class as above. It also breaks the CAS’s defining property: the key would no longer determine the content, so a tree could not be verified against its address at all (blobs are, today), andmaterializewould stop being a pure function of the root. s7’s objection to this option holds — but for a sharper reason than s7 gave. Non-determinism of timestamps is not the problem; being served a stale one from a different lineage is. - Keep mtimes and make ADR-0027’s comparison structural (compare blob sets) — this is what s8 does, made cheap. Comparing blob sets means walking two trees at admission time; a single derived digest answers the same question in a string compare, and is computed where the tree is already in hand.
Also considered and rejected: carrying mtimes forward from a baseline — on ingest, reuse the previous snapshot’s mtime for any path whose content is unchanged. This is the only scheme that keeps one digest and stays safe, and it is genuinely elegant: it makes a root reproducible and keeps every timestamp truthful about when content last changed. It loses on this ADR’s own retention model. The baseline is a snapshot, snapshots expire (warm by space, cold by TTL), and when the baseline is gone the mtimes revert to wall clock and the root moves again. A determinism guarantee that decays with retention, silently, is the failure we are already fixing.
Consequences, stated plainly:
-
The identity is never an address. Nothing is stored under it,
materializenever sees it, GC’s mark walk still starts from roots,prune_treeis unchanged. It is a label on evidence. -
Cross-run tree dedup stays lost — two runs producing identical content still store two root objects, because their mtimes differ. Trees are small JSON, and s0/s2 measured that dedup buys storage rather than wall-clock, so this is priced and accepted.
-
The tree preimage is untouched, so no stored snapshot is orphaned.
tests/hashing.rsstill pins the same literals; the identity literals beside them were derived the same independent way. -
A snapshot recording no mtimes has an identity equal to its root (dropping an absent field changes no bytes), which is why the fallback for a pre-s8 row — compare by root — is exact for those rows and merely conservative for the rest.
-
One canonical form, one digest function. Both were hand-copied in
scarab-storage-s3andscarab-workspace-client, with a runtime tripwire intieredto notice them drifting. They now live inscarab-storage; the tripwire is kept for version skew between deployed binaries, which is a different hazard.Amended 2026-08-01. The
TieredCastripwire is removed as unreachable: both hashes it compared came from one statically-linkedcanonical_tree_bytesexecuting in one process —WorkspaceClient::put_treecanonicalises client-side — so no pair of real tiers could ever disagree, and the “version skew between deployed binaries” it claimed to guard was never in its comparison. The check now lives where the two binaries genuinely meet: the Depot’sPUT /v1/cas/treesre-serialises the parsed body through its own linkedcanonical_tree_bytesand answers a distinct400(“canonicalisation skew”) on a byte difference — the client’s serialiser versus the Depot’s, which is the comparison the old tripwire pretended to be. This makes the format’s evolution rule load-bearing: the tree format may only evolve by additiveOptionfields with#[serde(default, skip_serializing_if)], so parse → re-serialise stays byte-identical across one version of skew. A non-additive change breaks mid-rollout PUTs by design (fail-closed at the door, rather than one tree silently filed under two addresses).
- Normalise mtimes on ingest (to the epoch, as reproducible-build tooling does) —
rejected as unsafe, not merely as a loss of s7’s gain. mtime-based build tools are
wrong in exactly one direction: a timestamp that is too old makes them skip a rebuild
they needed. Constant mtimes make every inherited source look ancient, so a Cache
(0007) restored beside the workspace with real timestamps
looks newer than the sources it was built from, and
-
The price of seeding the warm tier on the write path — booked, not measured. Making the control plane’s snapshot store warm-then-cold (D1.6) is what stops every freshly-drained snapshot being a guaranteed cold miss, and it is what makes half the wire protocol live code. It is not free, and the costs are recorded here rather than discovered later:
Amended 2026-08-01 by 0064 (the control-plane section). This bullet’s prohibition — point 1’s “writing warm-first and letting the service tier onward would make the warm tier load-bearing for durability, which part 4 forbids” — is superseded for the control plane, whose write leg is now warm-first too: the Depot’s PUT handlers are warm-only, and durability is one explicit
POST /v1/cas/flushthe control plane awaits beforeSucceeded. That is not the “tier onward” this bullet forbade — the flush is synchronous with respect to success and it is commanded by the caller, not left to the service — so the invariant the prohibition protected (warm never silently licensesSucceeded) survives in the flush contract. The original reasoning below is kept: it correctly priced the cold-first shape, and points 1–3 describe the shape this amendment retires (point 2’s double cold write disappears with it; the flush’s existence probe is theexistsprimitive point 2 filed). The Depot being on the durability path means a Depot outage may fail Attempts — promptly and with a named cause, never as a timeout; 0064’s control-plane section records that as the architect’s accepted trade.- The drain walks its directory twice.
TieredCas::ingesthashes to cold (the leg that licensesSucceeded) and then asks the warm tier to ingest the same directory. s2 measured the drain leg at 88% local filesystem — reading every file in order to hash it — so this roughly doubles that leg. Both alternatives are worse: a merkle-level copy cannot be made concurrent inside a pure domain crate (nofuturesdependency), so it would be one sequential round-trip per file, which is precisely the pattern s0 identified as the dominant cost and this ADR forbids the new path from reproducing; and writing warm-first and letting the service tier onward would make the warm tier load-bearing for durability, which part 4 forbids. Removing the second walk needs a port change — aCasthat writes one walk to two backends, or aningestthat returns the tree it built so a tiered implementation could seed warm at merkle grain with the adapter’s concurrency. - Genuinely new content is written to cold twice — once by the drain’s own cold leg, once
by the service’s cold-first
PUT. Dedup bounds it:POST /v1/cas/haveanswers from warm, so a re-drain of unchanged content uploads nothing at all, and only new blobs pay. NeitherObjectStorenor the wire protocol has an existence primitive, so neither side can skip; addingexiststo the port is the fix and is filed. - GC deletes from cold and leaves warm. ADR-0050’s sweep runs against the control plane’s own object store, and the service exposes no delete verb, so a collected blob can survive in the warm tier as a phantom hit. Bounded and not a correctness bug — a phantom hit returns content that was real and is now unreferenced — but it does mean the warm tier’s space bound (s5, deferred) is the only thing that will ever reclaim it.
None of the three is measured. No
ws-timingnumbers were taken after this wiring; the ordering argument above is from s2’s existing shares, not a new run. - The drain walks its directory twice.
-
Overlay diff for the drain — hashing only the writable upper layer (so a Step never re-walks an unchanged tree) is the natural partner to lazy reads and needs the same privileged mount. Specified as a follow-up slice, not part of the first cut.
Answered by 0062 part 3, and the “same privileged mount” premise was wrong. The upper layer does not have to be on the node. With the
overlayfsmount on the workspace service — over a hardlink Snapshot Farm, verified on ext4 — the upper directory holds precisely the paths the Step touched, put there by the kernel, and the drain reads it locally with no network in the path. So the drain becomes exact rather than approximate, which also retires the hazard in the unprivileged approximation that was going to be built instead (a(size, mtime)stat cache, whose failure mode is silently publishing a stale hash on an mtime race). The stat cache remains the right drain for configurations with no Export — the local executor, and the lower rungs of 0062’s privilege ladder — where it is a fallback and not the mechanism.
References
Section titled “References”- 0004 — pod-per-step + content-addressed workspace; this ADR supersedes its data path, not its topology
- 0007 — Workspace / Result / Artifact / Cache
- 0027 — content-addressed invalidation; “smart never means mysterious”
- 0029 — per-file merkle CAS
- 0050 — retention and GC
- 0055 — placement profiles
- 0056 — attempt-grain evidence