- Status: Accepted
- Date: 2026-08-03
- Deciders: thulasi.ram (architect)
- Builds on: 0061 (amends its part 4 LRU deferral, and restores its “the service’s volume is a cache” invariant), 0062 (cancels the lazy feed and deletes the Export/Farm machinery), 0063 (amends where log bytes land), 0064 (the tier stamp this reuses unchanged), 0065 (amends its point 2’s rationale), 0051 (multi-replica — for the control plane; the Depot’s story is new here), 0013 (amends “bodies never”), 0047 (the failure classes the clocks split into)
Amended 2026-08-24 by 0067 — the “soft, recommended” stance is reversed: Postgres and object storage are hard requirements (owner decision, 2026-08-23). 0067 is the packing ADR point 7 called for, and it implements point 1’s cache invariant by removing the replica-local state rather than routing back to it — so points 2 and 3 are overtaken for reads and records: any replica can serve committed content and answer for any fence’s record. The drain write path (in-flight pack sessions, warm-local closure validation) is still replica-local as of 2026-08-24 —
replicaCount > 1remains unsafe for drains until the ticketed follow-up lands — and point 4’s eviction problem dissolves for committed content rather than being solved. Point 10’s carve-outs stand; point 11’s layer 1 — algorithm-tagged addresses — is adopted by 0067 part 12 (tags ship now, BLAKE3 stays the tagged-in follow-up).
Context
Section titled “Context”Four things arrived between 0064 and this ADR, and together they change what the Data Depot is rather than how one of its legs performs.
The measurement inverted the remaining premise (git-bug 4ce7f2c)
Section titled “The measurement inverted the remaining premise (git-bug 4ce7f2c)”0061 s0 priced the feed as the expensive leg and part 2 — lazy
materialisation — as the load-bearing fix. 0062
then spent an enormous amount of design on delivering laziness without a node driver. A benchmark of
the real workspaced::router over the real WorkspaceClient says the premise no longer holds:
| leg | per file | at 50k files |
|---|---|---|
feed (Cas::materialize — what scarab-wsfetch calls) |
0.54 ms | ~27 s |
| cold drain | 2.68 ms | ~134 s |
The feed is roughly 10× cheaper than 0061 s0 measured, and the drain is now 5× the feed. The cheap leg got cheap enough that the expensive leg changed identity.
The composition of the feed finishes the argument. Of its 1076 ms (2000 files), only 434 ms is blob GETs; 55% is local filesystem writes — and a lazy mount pays those writes anyway, on first read. So laziness could at best defer a minority share of an already-cheap leg, in exchange for a privileged mount, a per-node prerequisite, an NFS server and the machinery in 0062.
The lazy feed is therefore cancelled and the eager scarab-wsfetch init container stays. That is
not a retreat to the status quo; it is the status quo winning on measurement, which is the precedent
0061 set when its own central premise inverted.
Object storage is a soft, recommended requirement (git-bug 42d997c)
Section titled “Object storage is a soft, recommended requirement (git-bug 42d997c)”With object storage Scarab is fully featured. Without it the Depot is a warm-only cache offering a
bounded recent window, with every degradation disclosed rather than silent — the mode
0064 part 4 already supports and part 5 already
stamps (attempts.output_durability = warm-only).
Logs are the exception, and the exception has a principled shape. They are the only class in the data plane with no recompute path (0063); workspaces are reproducible from the forge. So logs fall back to compressed blobs in Postgres, which is already a mandatory dependency, rather than to a volume that dies with a Pod.
Guarantee what cannot be recreated; degrade what can.
That single sentence is why logs and workspaces take opposite decisions in the same deployment.
We are not resilient today (git-bug e140121)
Section titled “We are not resilient today (git-bug e140121)”Verified 2026-08-03: a 20–30 second Depot outage during Init dead-letters Runs. The chain is short and every link is real:
scarab-wsfetchuses a barereqwest::Client::new()— no retry, no timeout (crates/scarab-workspace-client/src/lib.rs:151).- The fetcher runs as an init container with
restart_policy: Never(crates/scarab-executor-k8s/src/lib.rs:3782). - Connection refused → exit 1 →
Infra { never_started: true }→ three auto attempts, back to back → DeadLettered in seconds. - Exit 1 (transient: the Depot is rolling) and exit 2 (a genuine 404: the snapshot is gone) map to the same class, so a permanent error and a two-second blip are indistinguishable downstream.
This is the prerequisite for HA, not a consequence of it. Running more replicas is meaningless while a replica reschedule kills every Run in flight.
The failure principle: block at Step boundaries, never mid-execution
Section titled “The failure principle: block at Step boundaries, never mid-execution”Every boundary operation is idempotent, and a Pod blocks at a boundary rather than failing. What makes this safe is a structural property rather than a policy: with an eager feed a Pod touches the Depot exactly twice — at Init (fetch) and at the end (drain). A Depot outage in the middle of a 40-minute build is invisible to that build.
That property was bought by cancelling the lazy feed. A lazily-mounted workspace makes every
running Pod continuously dependent on the Depot for the whole Step, and the failure mode is not a
clean error: 20e8786 measured nfs-ganesha’s actual semantics, and a vanished server wedged a test
client unkillably in nfs4_proc_destroy_session. A design where a Depot restart can leave
uninterruptible processes on Step nodes cannot have a “block and retry” story at all.
Retry windows are sized as a fraction of the Step timeout, so the cost of waiting is proportional to the work at risk rather than a fixed number someone has to defend.
Waiting is not free, and this ADR does not pretend otherwise. The argument for blocking is that the alternative is worse at both boundaries, for different reasons: at Init nothing has been done yet, so waiting costs only latency; at drain the work is already done, and discarding a completed build’s evidence because the storage service was rescheduled is the expensive mistake.
The invariant was already written down, and violated twice
Section titled “The invariant was already written down, and violated twice”0061:261-262 says it plainly:
The service’s volume is a cache, and a cache that survives a Pod roll is a latency win, not a durability one.
Two changes since then made the Depot a system of record for something:
e58ce1fadded drain records and write ledgers — state that exists only on one replica’s disk and that a later request needs in order to succeed.- warm-only made the warm tier the only copy of a Workspace Snapshot.
Neither was wrong at the time and neither is being reverted. But an invariant that is stated in one ADR and quietly contradicted by two changes is not an invariant — it is a comment. This ADR makes it load-bearing again.
Decision
Section titled “Decision”1. The Depot is definitionally a cache. Anything that makes it a system of record is a defect.
Section titled “1. The Depot is definitionally a cache. Anything that makes it a system of record is a defect.”This is not a preference or a design goal. It is the invariant every other decision here is judged against, and the reason to state it that strongly is that it makes three separately hard problems trivially correct at the same time:
| hard problem | why the cache invariant dissolves it |
|---|---|
| HA | “run more replicas”. Replicas hold nothing unique, so there is no consensus, no replication protocol, no split-brain — losing one loses cached bytes |
| Eviction | safe by construction. There is nothing on the volume whose deletion can lose information |
| Spot preemption | costs a cold cache, which is a latency event, not a data-loss event |
Every one of those is a problem other systems solve with machinery. We solve them by refusing to put anything irreplaceable on the Depot.
The consequence is accepted and documented, not apologised for: HA requires object storage. If the only copy of a snapshot is one replica’s disk, no amount of replica count buys availability for it. That is a coherent position rather than a gap, because of the next paragraph.
Warm-only does not mean “the Depot is the system of record”. It means there is no system of
record for workspaces in that deployment — which is a smaller, true claim, disclosed per Attempt
through 0064’s existing
attempts.output_durability / DurabilityTier. No new vocabulary is needed; 0064 already built the
disclosure and this ADR simply declines to build a second one.
So the two violations get repaired rather than blessed: point 2 makes the drain record self-healing, and point 4 makes eviction reachability-driven. After both, a replica’s disk again holds only things that can be recreated.
2. The drain record stays replica-local; its absence becomes transient, not FatalConfig.
Section titled “2. The drain record stays replica-local; its absence becomes transient, not FatalConfig.”The bug today. A replica dying between the Pod’s POST /v1/drains and the control plane’s GET
turns a successful build into a permanent failure. classify_drain
(crates/scarab-executor-k8s/src/lib.rs:1913-1919) maps a missing record to
FailureClass::Config, which is allowed = 1 and ignores the author’s retry: policy
(0047). The Step exited 0 and the Run is red because
a storage Pod was rescheduled.
The tempting fix is to make the record durable somewhere. Both durable options were rejected:
- Postgres. Breaks the Depot’s no-database invariant — the Depot is the same binary in a different role precisely because it never connects to Postgres (CONTEXT.md §4.4).
- Object storage. Absent in warm-only, which would couple a core path to the optional
tier — exactly inverting
42d997c’s decision. - 0063 part 6’s volume-identity marker, extended to say “this replica is not the one that took your drain”. Strictly better than absence-as-transient, and not worth it yet — it can be added later without changing anything decided here, because it only ever converts a retry into a faster, more legible retry.
So the record stays replica-local and its absence is treated as transient. That is only sound if re-driving the drain is genuinely idempotent, which was verified in the strong sense rather than assumed:
| link | why a re-drive is safe |
|---|---|
the drain reads /workspace after the step container exited |
content is frozen; a second read sees the same bytes |
a re-drive re-PUTs every tree unconditionally |
no tree is assumed present from a prior run |
/have reports every blob missing on a fresh replica |
so the re-drive re-uploads exactly what that replica lacks |
| the 409 fires only for an existing SUCCESS record | a partial or absent record does not block the retry |
once ANNOTATION_WS_ROOT is patched, the already guard skips the leg |
so a succeeded drain is never redone |
Accepted cost, booked explicitly: a genuinely stale helper image — one whose drain will never succeed — now takes five minutes to fail instead of failing instantly. That is the price of not misclassifying a rescheduled replica as a configuration error, and it is the right way round: a slow true negative beats a fast false one.
Consequence: WS_DRAIN_ESCALATION_MS is promoted from an internal constant to a documented HA
parameter. It is no longer an implementation detail; it is the ceiling on how long a Depot outage
may stall a completed build before its evidence is abandoned. Operators must size it against
realistic replica-reschedule time in their cluster, and it must be documented where they will look for
it — not left as a const someone discovers while debugging.
3. Fence affinity is a correctness requirement, not an optimisation, the moment N > 1.
Section titled “3. Fence affinity is a correctness requirement, not an optimisation, the moment N > 1.”This is the finding that decides the shape of Depot HA, and it is easy to mistake for a performance note.
CONCURRENCY = 16 (crates/scarab-workspace-client/src/lib.rs:64) means a drain opens up to 16
connections, and kube-proxy load-balances per connection. So with two replicas behind one
ClusterIP, one fence’s blobs scatter across replicas. post_drain’s closure validation then runs
against one replica’s ledger and one replica’s warm tier, finds most of the snapshot missing,
and fails.
This is not a rare interleaving. It is every drain, as soon as you scale past one replica.
And the validation cannot be relaxed to make the problem go away — the ledger check is the
cross-fence exfiltration fix from e58ce1f. Weakening it to tolerate scatter would restore the
security hole in order to fix an availability one.
Mechanism: affinity by recorded choice, hashed by Run — not by fence.
- The control plane picks a replica at launch and stamps its address into the Pod env, where
WorkspaceFetch.urlalready goes. - The control plane reads that address back off the Pod spec for the drain
GETand for the flush. The Pod spec is already the durable record that survives a control-plane restart — this is the same pattern asANNOTATION_WS_OUTPUTS, not a new one. - The hash is over the Run, not the fence, so every Step of a Run lands on one replica and
Step 2 finds Step 1’s content warm. This matters more than it looks:
2e1a458establishes that fan-in is a sequential replay of each parent’s snapshot over one directory, so a Step with severalneeds:reads several parents’ content — Run-grain affinity keeps all of it local, fence-grain affinity would not.
No new column, no hash ring, no schema migration. The choice is recorded where the Pod already records everything else about its data path.
Costs, both real:
- The Service must become headless for per-pod DNS. And
deploy/helm/scarab/templates/service-workspace.yamlcurrently carries the comment “Deliberately NOT headless… nothing addresses an individual workspace replica” — that comment must change with the code. A stale rationale left beside a reversed decision is worse than no comment, because the next reader trusts it. WorkspaceFetch.urlstops being one field stamped identically into every Pod. It becomes a per-Run value, which is a small but genuine widening of what the launch path computes.
Inert at N = 1, which is every deployment today — so this is buildable and testable before anyone scales.
4. Warm eviction: layered, reachability first, access index deferred.
Section titled “4. Warm eviction: layered, reachability first, access index deferred.”A correction to the record first, because deleting the wrong sentence would be worse than leaving it. Two comments look false in warm-only and are not:
WarmTier::evict’s “Safe by construction: cold still holds them” (crates/scarab-storage/src/tiered.rs:567-569)note_warm_write_failure’s “the snapshot IS durable” (crates/scarab-storage/src/tiered.rs:242)
They are incomplete, not wrong: exactly true with object storage, wrong only in warm-only. Qualify them; do not delete them. They are the clearest existing statement of why eviction is safe in the normal case, and a reader who finds them deleted learns nothing.
With object storage the pin set is empty — the Depot is a pure cache and every byte is
recreatable. In warm-only, eviction is genuine loss, but loss that is within contract (the
Attempt is stamped warm-only), so pins protect only in-flight work rather than history.
The policy is layered:
- Evict unreachable content first. Point
crates/scarab-server/src/retention.rs’s existing mark walk at the warm tier. No new machinery, and it also fixes a finding that would otherwise sit undetected: nothing sweeps warm today. The chain is warm-only skips the cold flush → cold stays empty → the sweeper only deletes unmarked cold objects → it deletes nothing, ever. The GC is running, reporting success, and reclaiming zero bytes. - If still over the high-water mark and a cold tier exists, evict reachable-but-cold-backed
content by recency. This is the step that needs an access index, because
list_objectsgives least-recently-written, which is meaningless for immutable content — a blob written once and read a thousand times looks identical to a blob written once and never touched. This is the step we defer, gated on measuring whether (1) suffices. - In warm-only, over the mark: refuse writes, loudly. There is no (2) available, and warm
ENOSPCthere means the snapshot is nowhere. A loud refusal at the boundary is the honest outcome; a silent partial write is not.
Status (2026-08-26, git-bug cba7165): built, in its post-0067 form — and the deferral in (2) is resolved. With every durable byte bucket-backed and warm-only no longer a deployment mode, (1)’s reachability walk is not needed for safety (and is Depot-unknowable anyway: the mark walk is control-plane-side), and (3)’s case no longer exists. What ships is (2) generalised: a budget (
SCARAB_WORKSPACE_WARM_BUDGET_BYTES, default statvfs-90% of the dedicated volume) with high/low watermarks, evicting committed-durable content first and cache-only content second, oldest first within each class, over a 1h min-age floor. The access index this step was deferred on turned out to cost oneutimensatper object per hour — a coarse touch-on-read that refreshes mtime once per grain — not a new storage decision. Reachability survives only as the class ordering’s cheap Depot-side proxy: committed presence indepot_pack_members.
The budget is a size watermark, not a TTL. Space is what the operator actually controls, it is what is already instrumented (the warm-size gauge walks the volume), and it is the only bound that degrades predictably. A TTL bounds nothing when the fill rate changes.
One product surface must change with this. ui/scarab-web-ui/src/snapshot-retention.ts:129-131
promises that eviction “only ever makes a rerun slower, never wrong”. That is true with object
storage and false in warm-only — the same asymmetry 0064
part 5 handles for durability. It must become tier-aware, exactly like the durability stamp, and
for the same reason: the same UI string cannot describe two deployments with different guarantees.
5. Deferred storage candidates, recorded so nobody re-evaluates them from scratch.
Section titled “5. Deferred storage candidates, recorded so nobody re-evaluates them from scratch.”Four systems were considered. Recording the verdicts and their triggers is the point — an undocumented “we looked at it” costs the next person the whole evaluation again.
| candidate | verdict | trigger to revisit |
|---|---|---|
| SeaweedFS | Supported cold-tier option now | none — it works today |
| JuiceFS | Deferred; the named replacement if laziness revives | read-fraction measurement, or workspaces large enough that 27 s matters |
| Dragonfly | Deferred | the N-concurrent-fetch measurement |
| Kraken | Rejected | — |
SeaweedFS is supported now, with zero code change — it is S3-compatible, so it is a StoreConfig
value. Its Haystack-style needle layout suits millions of small blobs, which is precisely our
shape, and it has real replication and erasure coding. Document it beside MinIO and Ceph RGW, and
label it explicitly untested-by-us — “compatible” is a protocol claim, not an endorsement we have
earned.
JuiceFS is deferred and named, and naming it is the whole value: if laziness ever revives, the answer is JuiceFS plus its CSI driver, not a rebuild of the ganesha path. It needs the privileged CSI DaemonSet that 0062 already priced out, so the cost is known rather than hypothetical.
Dragonfly is deferred because it answers a question we have not asked. It solves origin
saturation under concurrent fan-out — many Pods pulling the same content at once. Our benchmark
(4ce7f2c) was single-client, so saturation is unmeasured. A P2P mesh bought on an unmeasured
premise is exactly the mistake 0061 s0 made in the other direction.
Kraken is rejected: archived upstream, so it fails the maintained-library rule. (Moderate confidence — verify the archival before citing this at anyone.)
The framing that makes all four one decision rather than four: every one of them optimises the
feed, which the measurement just made the smaller leg, along the concurrency axis rather
than the per-file axis we actually measured. So the gate is one experiment, not four
evaluations: measure N concurrent fetches of the same snapshot. And if saturation is found, reach
first for an unprivileged per-node Depot cache DaemonSet (wsfetch hits $NODE_IP) before a P2P
mesh — same benefit, an order of magnitude less machinery.
6. Per-object request cost is unmeasured and may favour a self-hosted cold tier independently of latency.
Section titled “6. Per-object request cost is unmeasured and may favour a self-hosted cold tier independently of latency.”One request per file has a bill as well as a latency:
| operation | requests | approximate S3 cost |
|---|---|---|
| 50k-file cold drain, all new | ~50k PUTs | ~$0.25 |
| 50k-file cold drain, fully deduped | ~50k HEADs | ~$0.02 |
(Pricing from memory — flag as needing verification before it is quoted at anyone.)
The reason this belongs in the same ADR as the latency argument: it scales on the same axis — file count. Anything that reduces the number of objects reduces both. That is the bridge to point 7.
7. Packing: promoted from deferred to the next ADR, gated on measuring the cold flush.
Section titled “7. Packing: promoted from deferred to the next ADR, gated on measuring the cold flush.”There is no batch PUT in S3. DeleteObjects batches deletes only; S3 Batch Operations is a
managed job over a pre-existing inventory, not a way to write 50k new objects in one call. So the
only lever is packing many blobs into one object with an index.
The key insight — and the reason this is now tractable when it was not before:
Laziness was the only reason to address blobs individually.
With eager materialisation, every read is “give me this whole snapshot”. /flat already returns the
entire manifest in one call, and we then make 50k requests to fetch what that one response
describes. So packing’s main downside — losing random access to an individual blob — is a cost we no
longer pay for anything. And it helps both legs, feed and drain, which nothing else on this list
does.
Critical gap, and the reason this is gated rather than decided: the cold flush is unmeasured.
flush_to_cold (Depot → object storage) was not covered by 4ce7f2c, which measured
client → Depot over loopback. The flush gates Succeeded
(0064 part 2) and runs against real S3, not
loopback. Measure it first, as a sizing input — pack size, index granularity and whether packing
is worth its costs all depend on numbers nobody has.
Packing’s real costs, to be carried explicitly by that ADR rather than discovered during it:
- (a) A durable global
hash → (pack, offset, len)index, realistically Postgres. This loses the property that the bucket is self-describing — that a fresh Depot pointed at an existing bucket just works. That property is currently free and is worth naming before it is spent. - (b) GC becomes repacking. A 5000-blob pack with 4000 dead blobs cannot be partially deleted; reclaiming space means writing a new pack and atomically swapping the index against concurrent readers.
- (c) Append-only immutability is lost. Repacking rewrites, and a bug in the rewrite loses blobs — a failure class the current store structurally cannot have.
- (d) Read efficiency depends on locality. Without reachability-order repacking (what
git gcdoes), a snapshot sharing 90% of its content with another needs blobs scattered across thousands of packs, and the win evaporates. - (e) The bucket becomes opaque to
aws s3 ls. Operators lose the ability to look.
What it gives back, beyond the request-count win: the presence index becomes free. A local index
lookup replaces the network HEAD, which subsumes the near-term cheap win and also addresses
1d4b3ce’s serial per-blob warm_has walk on the drain’s critical path.
Why it needs its own ADR with a migration story, rather than being decided here: it is a change to the frozen canonical on-disk form, and it carries a cross-binary skew tripwire — a Depot and a control plane at different versions must not disagree about how bytes are addressed.
8. 0065’s Cache stays a content-addressed tree; packing applies underneath it.
Section titled “8. 0065’s Cache stays a content-addressed tree; packing applies underneath it.”The logical/physical split is the right one, and git is the precedent: git’s object model is content-addressed and packfiles are a storage detail. A Cache being a content-addressed tree says nothing about how the bytes are laid out in a bucket.
So 0065 point 2’s statement survives; its justification does not. It was justified as “a Step
that touches 5% of node_modules materialises 5% of it” — that is laziness, now cancelled — and
it lists the Snapshot Farm / Workspace Export machinery as its dependency, which point 10 deletes.
Restate the rationale as blob-granular dedup, which is both a better justification and still true: two Runs with an unchanged lockfile share every blob of the cached tree, at file grain, with no key negotiation at all.
9. Split the clocks — the provisioning deadline is separate from the execution timeout.
Section titled “9. Split the clocks — the provisioning deadline is separate from the execution timeout.”This is a live bug, independent of everything else in this ADR. The engine backstop is anchored at
Attempt claim time (crates/scarab-engine/src/scheduler.rs:1645-1652), which is before
dispatch. Therefore:
Queueing delay already bills the Step’s timeout.
On a busy cluster, a Step that waits ten minutes for a slot starts with ten minutes less than its
author asked for. When it overruns, it surfaces as Timeout with no hint that waiting caused it —
the author reads it as their code being slow and raises a limit that was never the problem.
Principle: waiting for infrastructure must not bill the Step’s execution budget.
Design, verified cheap:
- A new nullable
attempts.execution_started_at, stamped by the scheduler the first timepollreports the step container running and the column isNULL. - No executor API change — the executor already tracks the step container specifically, in order to detect its termination for the drain. The signal exists; it is simply not recorded.
- The engine backstop re-anchors to
execution_started_at, so the author’stimeout:measures execution. - A new provisioning deadline (generous default) covers scheduling, image pull and the feed,
enforced as “
execution_started_at IS NULLandnow > started_at + provisioning_deadline”, and failing with its own legible class rather than asTimeout(0047). - kubelet’s
activeDeadlineSecondsbecomesprovisioning_deadline + timeout— the outer backstop — because a pod-level deadline cannot be split into two phases.
This fixes three things at once, which is why it is worth doing as one change: the Depot-outage
case (a blocked Init no longer eats the build’s budget), the queueing case, and the “failed as
Timeout having never run” misclassification.
The Run budget is left alone, deliberately — it is a wall-clock spend limit on a whole Run, and splitting that clock is a different question with a different owner.
10. Delete the Export / Farm / change-set machinery — with three carve-outs.
Section titled “10. Delete the Export / Farm / change-set machinery — with three carve-outs.”Executed 2026-08-25 (git-bug
0ec3b39):export.rs,farm.rs,settle.rsand the/v1/exports*surface are deleted. Carve-outs (b) and (c) stand; (a) was already its own function (sweep_fence_residue), so the host loop was renamed, not extracted (sweep_exports_once→sweep_residue_once), keeping the fence-residue sweep and the ADR-0067 pack-session sweep verbatim. Two deviations from the letter of this point:settle.rswent wholesale, not “the Export half” — line-verified against0215cb7, every entry point was reachable only from the Export settle path andpost_draintouches none of it (the fold stays in git;changeset.rs, the upper-layer reader, survives). And the stat cache went too (scarab-storage/src/statcache.rs+ingest_with_baseline), an orphan this point did not name: its only production caller was the copy rung’s re-ingest drain, deleted with the Export half.
Roughly 10k lines across crates/scarab-server/src/export.rs, farm.rs, changeset.rs and the
Export half of settle.rs. No control-plane or executor caller exists — established by an
HTTP-route grep, which is the form of search that cannot miss trait-object dispatch (the routes
are the entry points; a &dyn call cannot conjure one).
Dormant code is not free, and the three costs are concrete rather than aesthetic:
- Live HTTP routes are attack surface. We Browse-gated
/v1/exports*last week precisely because any fence token couldDELETEanother fence’s Export. Unused code with a live route is not unused. - Every refactor pays to keep it compiling — a tax levied on work that has nothing to do with it.
- It invites someone to wire it up without re-reading why it was shelved. Deleted code makes them read the ADR; dormant code lets them skip it.
It is in git, and this ADR is the record of what it was. That is the whole argument for deleting rather than keeping.
Three carve-outs, in order:
- (a) Extract the fence-residue sweep from
sweep_exports_oncefirst. The ledger and drain-record TTLs live inside it and are load-bearing — point 2 depends on drain records ageing out. Deleting the sweep with its host file would silently remove a cleanup nobody is watching. - (b) Keep
changeset.rsand its test tier. It is the only code anywhere that reads anoverlayfsupper layer, it was finally executed on 2026-08-02, and it is exactly what a revived laziness effort would want. Deleting a just-proven capability to save a file is a bad trade. - (c) Keep the reflink primitive (now
reflink-copy). A future local cache — the per-node Depot cache of point 5, or a warm-tier snapshot fast path — wants it, and it is small.
Name the revival path so nobody rebuilds ganesha: JuiceFS plus its CSI driver (point 5). What survives from 0062 is its substrate facts (measured, still true, and the reason the revival path is a known quantity) and the change-set tier.
Not affected: the artifact-harvest exec-tar leg (0d3666a) is a separate path with a separate
reason to exist — the artifact ObjectStore is control-plane-side with its own credentials, so the
framing machinery survives for artifacts only. Deleting Export does not touch it.
11. The address format stays algorithm-taggable, and BLAKE3 is the tagged-in candidate.
Section titled “11. The address format stays algorithm-taggable, and BLAKE3 is the tagged-in candidate.”Cancelling laziness raises a fair question: should we adopt BLAKE3 now, so that verified streaming
(bao) is available if laziness ever returns, rather than re-addressing the whole store later? The
answer is no, but the reasoning matters more than the verdict, because the instinct behind the
question — do not paint yourself into a corner on an irreversible choice — is right.
We use SHA-256 (sha2, declared in the pure-domain allow-block precisely because hashing is a
pure function; see 0029 and 0031). BLAKE3 appears
nowhere in the workspace.
Three reasons not to adopt it now:
- Verified streaming is not what laziness needs.
baoverifies a byte range of one object without fetching the whole object. Our laziness shape is per-file: a lazy read fetches a whole blob, and any hash verifies a whole blob. Range verification earns its keep when you read 4 KB of a 2 GB object; CI workspaces are millions of small files. Even under packing (point 7) you fetch a range of a pack, extract the blob, and verify it by its own address — SHA-256 does that. - Adopting it collides with the next quarter of work. Re-addressing every object breaks a frozen
canonical form guarded by a cross-binary skew tripwire (the Depot re-canonicalises every tree
PUTand400s on a byte difference), and the durability gate, the write ledger and the drain record all rest on the current addressing. - Storing both hashes is worse than either. It doubles hashing cost and gives one object two names — the “one address, two canonicalisations” hazard eliminated on 2026-08-01.
What we do instead, at zero cost: fix the escape hatch now. Addresses are bare hex today
(blobs/<hash>). This ADR records that bare hex means SHA-256, and any future digest gets its own
tag (blake3:<hex>, or its own prefix namespace). Content-addressed stores routinely carry more
than one algorithm this way — OCI does, Git’s SHA-256 transition does. That makes a later adoption
additive: new objects get tagged addresses, existing objects keep working, and nothing is
rewritten. An irreversible decision becomes a reversible one for the price of a paragraph.
Two triggers that would justify tagging BLAKE3 in. Either alone is sufficient; neither is speculative:
- Hashing shows up as a measured cost. This stands on its own merits, independent of laziness —
BLAKE3 runs at roughly 5–10 GB/s against SHA-256’s 1–2, and
feed_cost.rsalready notes that debug builds inflate the SHA-256 legs. It is not dominant in release for small files. If large artifacts become common, remeasure before assuming. - Packing adopts partial pack verification. The one genuine
baouse case that could arise here is verifying a partial pack read without extracting. That belongs in the packing ADR’s own evaluation (a0c28aa) — recorded there so whoever writes it decides rather than inheriting SHA-256 by default.
Alternatives considered
Section titled “Alternatives considered”- Make the Depot a replicated system of record (Raft over the drain records and write ledgers, or
a shared Postgres). The obvious HA answer, and it is what a storage service usually does. Rejected
on the invariant: the moment a replica holds something unique, HA needs consensus, eviction needs
a durability check, and spot preemption becomes a data-loss event. Every one of those problems is
bought back by the very thing that was supposed to solve them. Recorded because it was yesterday’s
answer — shard-by-fence with a drain protocol (
6cb4a27) — and cancelling the lazy feed is what made the simpler shape available. - Put the drain record in Postgres. One table, and the problem disappears. Rejected: it breaks the Depot’s no-database property, which is the thing that lets the Depot be deployed, scaled and lost independently of the durable core.
- Put the drain record in object storage. No new dependency where object storage exists.
Rejected: it is absent in warm-only, so a core path would depend on the optional tier,
inverting
42d997c. - Relax
post_drain’s closure validation so scattered blobs stop failing it. The one-line fix for point 3. Rejected outright: the ledger check ise58ce1f’s cross-fence exfiltration fix. Trading a security property for an availability one is never the trade it looks like. - A hash ring or a new
attemptscolumn for replica choice. More principled-looking than reading the Pod spec. Rejected as unnecessary: the Pod spec is already the durable record that survives a control-plane restart, andANNOTATION_WS_OUTPUTSis the precedent. A schema migration to store something already stored is cost without benefit. - Keep pursuing laziness with a different mount technology.
4ce7f2csays the feed is 0.54 ms/file and 55% of it is local writes a lazy mount pays anyway. Rejected on measurement — the same way 0061’s own premise was overturned, which is the precedent and not the exception. - Keep the Export/Farm code dormant “in case”. Free-looking. Rejected on live routes as attack surface, refactor tax, and the invitation to re-enable it without reading why it was shelved.
- Evict warm by TTL rather than by a size watermark. Simpler to reason about per object. Rejected: the operator’s actual constraint is space, a TTL bounds nothing when fill rate changes, and space is the bound already instrumented.
- Build the access index now rather than deferring it. It is the honest LRU. Deferred rather than rejected: reachability-first eviction may well suffice, and building an access-tracking write path for immutable content before knowing that is machinery bought on a guess.
- Fix the queueing-eats-the-timeout bug by simply raising default timeouts. Zero code.
Rejected: it hides a misattribution rather than correcting it, and the failure still reports
Timeoutfor a Step that never ran.
Consequences
Section titled “Consequences”- “Is this a cache?” becomes the review question for any change to the Depot. A change that puts something on the Depot’s volume that cannot be recreated is a defect by this ADR, regardless of how convenient it is.
- HA requires object storage, and that is now a documented product statement, not an inference an
operator has to make. Warm-only remains supported, with a bounded recent window and a per-Attempt
stamp (
42d997c, 0064 part 5). WS_DRAIN_ESCALATION_MSbecomes operator-facing documentation with a sizing rule, and a stale helper image now fails slowly. Both are new things to explain in the operations docs.- The workspace Service becomes headless, and
deploy/helm/scarab/templates/service-workspace.yaml’s “Deliberately NOT headless” comment must be rewritten in the same change as the code. WorkspaceFetch.urlbecomes per-Run. Anything that assumed one Depot URL for the whole deployment — tooling, docs, tests — has to stop.- The GC gains a warm arm, and the discovery that nothing has been sweeping warm at all means existing deployments have unbounded warm growth today. That is a live operational finding, not just a design gap.
snapshot-retention.ts’s “slower, never wrong” copy becomes tier-aware, joining the durability stamp as UI that must know which deployment it is describing.- A new nullable column
attempts.execution_started_at, a new provisioning-deadline failure class, and a changed meaning for the author’stimeout:— which is a user-visible semantic change, in the user’s favour, and must be released as one. - ~10k lines are deleted, and the fence-residue sweep must be extracted before the deletion, not during it.
- The next ADR is packing, and it is blocked on a measurement (
flush_to_coldagainst real S3) rather than on a decision. Filing it without that number would repeat 0061 s0’s error. - Three measurements are now owed, and each one gates a specific deferred decision — which is the point of recording them together: the cold flush (gates packing), N concurrent fetches (gates Dragonfly / a per-node cache), and the read fraction (gates any laziness revival).
References
Section titled “References”- 0013 — the log pipeline; amended here for the Postgres fallback
- 0047 — failure classes; why
FailureClass::Configignoringretry:is the sharp edge in point 2, and where the provisioning-deadline class lands - 0050 — the sweeper whose mark walk point 4 points at warm
- 0051 — multi-replica for the control plane; the Depot’s story is this ADR
- 0061 — the cache invariant this restores; amends its part 4 LRU deferral
- 0062 — the lazy Export; cancelled here on measurement, with three carve-outs
- 0063 — the Data Depot; its part 5 (absence is authoritative) and part 6 (volume-identity marker) are reused verbatim here
- 0064 —
DurabilityTier/output_durability, the disclosure mechanism this ADR reuses instead of inventing a second one - 0065 — Cache and
RetentionProfile; amends point 2’s rationale and supplies the warm space budget point 4 needs - git-bug
4ce7f2c— the measurement that cancelled laziness - git-bug
e140121— we are not resilient today; the prerequisite for HA - git-bug
20e8786— measured ganesha semantics; why a continuously-dependent Pod has no block-and-retry story - git-bug
42d997c— the warm-only matrix; object storage as a soft, recommended requirement - git-bug
6cb4a27— Depot HA; yesterday’s shard-by-fence answer and why it simplified - git-bug
974440b— logs swallow errors in every deployment, not just warm-only - git-bug
2e1a458— fan-in is a sequential per-path union; why affinity hashes by Run - git-bug
1d4b3ce— the drain’s serial closure validation; what packing’s free presence index subsumes - git-bug
0d3666a— artifact harvest stays on its exec-tar leg; unaffected by the deletion - git-bug
16a7768— the in-Pod drain’s resource story