feat: portable workspace checkpoint format and library - #99
Merged
Merged
Conversation
The durable, provider-neutral representation of a cold-suspended session's workspace: one framed-AEAD content object plus a manifest whose put-if-absent commit IS the durability barrier ADR-0003 §4.4 describes. The note argues the format choice (one framed stream over content-addressed chunks, decided by deletion provability and the atomic commit rather than by efficiency), the envelope hierarchy and its versioned key reference, the authenticated context that binds workspace, session, generation and format version at three independent layers, what "verified" means for the restore test, the bounded-memory rule, and what the library refuses to do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A new public package, checkpoint/, beside control, controlapp, v0wire and the planes, so rainier-cloud can import the one implementation of the format rather than growing a second one that diverges the day it is patched. Writer streams an fs.FS (minus an explicit exclusion list) into one content object of AES-256-GCM frames and then commits a manifest with put-if-absent, which is the atomic commit the durability barrier rests on. Reader gives a destination three things: Preflight, which proves authorization and key readiness without reading a content byte; Verify, the restore test — decrypt, integrity, and a structural walk, no tree written; and Restore, the same walk with the tree written. Delete removes the manifest first, so an interrupted deletion leaves the unreadable state. Envelope encryption with a versioned key reference behind a two-method Wrapper port, the data key fresh per checkpoint and stored only wrapped, and the authenticated context (workspace, session, generation, format version) bound at three independent layers: the key wrap's AAD, the manifest's tag, and every frame's AAD. The caller supplies the context on every read and the manifest's own identity is only ever compared against it, because a library that read the context out of the manifest would make the cross-context property vacuous. Nothing is O(entries) or O(tree bytes): one frame in each direction, one 32 KiB copy buffer, no API that returns a list of entries. Errors are typed and carry no path, file byte or key material — a source refusal names the entry's ordinal, which the design note's §14 admits is a poor substitute. check-public-control.sh holds the package to the same import table as its neighbours from the first commit: no internal/, no SQL, no provider SDK. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…olden The tests the format needs in order to be believed rather than described. Round trip over a tree with nested and empty directories, an empty file, an exec bit, a non-ASCII name, a contained symlink and a file that crosses four frames — then the strongest equality available without a second copy: re-checkpoint the RESTORED tree and compare tree digests and plaintext lengths, which pins names, modes, sizes, contents and file modification times in one assertion. Tamper coverage on every manifest field and on every frame. Context swap in all three components, done twice: once through the public API by copying both objects into another session's prefix, and once past the cheap identity check at each cryptographic layer separately — the key wrap, the manifest tag, and the frame AAD — because a context-swap test that only proves a string comparison failed has tested nothing. Truncation at and inside a frame boundary, trailing data, a swapped frame pair, a zeroed object, a missing content object. Strict manifest parsing including the self-consistency checks. Exclusion proved against every name the walk OPENED rather than against the restored tree, with a planted credential-shaped file inside the excluded subtree. Authorization required at construction, refusing before any content is opened, and never creating the target. A fuzz target for the parser: never panics, never returns an error outside the package vocabulary, and anything accepted round-trips. A reflection test asserts every manifest JSON tag appears in the hand-written authenticated input, which is the one way the format could acquire unauthenticated metadata. A golden manifest, produced with an injected deterministic random source and clock, so the wire shape cannot drift without a diff. And bounded memory on a 2 GiB sparse tree of 20,202 entries: the heap peaks 4.4 MiB above baseline. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Fixes from the independent review, plus one escape the review's own
containment argument missed and a probe confirmed.
THE ESCAPE. checkLink is lexical: it resolves a symlink's target against the
link's own NAME, which is sound only while the link is created where its name
says. It is not, if an ancestor component of the name is itself a link.
`a/d -> ..` is contained; `a/d/up -> ..` is contained; but `a/d/up` is
created inside whatever `a/d` points at — the target root — so its `..`
leaves the target and `a/d/up/pwned` lands outside it. Two individually
contained links compose into an escape.
THE BUG Verify could not see. A directory's recorded mode was applied when it
was created, so a tree containing a 0555 directory — which `go mod download`
produces for every module it extracts — packed cleanly, verified cleanly, and
failed at restore on its first child. The durability barrier releases the
workspace and eventually deletes the disk on Verify's word, so a rule Restore
applies that Verify does not is a rule that turns a verified checkpoint into
a failed cold resume.
One change closes both: consumeStream keeps a stack of the directories the
walk currently has open. Every entry's parent must be a directory this walk
created (a link is never one), and a directory's mode is applied when the
walk LEAVES it, with directories created 0o700 until then. Entries arrive
depth-first, so the state is a stack of depth O(tree depth), not a list of
entries — the note's §9 budget survives, and §4.3's ordering becomes an
enforced rule rather than an assumption about the writer.
Also from the review:
- ReaderOptions.Authorize now documents that its Manifest is UNAUTHENTICATED
and cannot be: only workspace, session, generation and the content key's
prefix have been checked. Decide with it, do not record from it. Delete
says the same and bounds what a forged manifest could reach.
- Write and Restore honour context cancellation inside the copy loop rather
than at the next object boundary.
- ErrTooLarge survives the trip out of the copy instead of being reduced to
"could not be read"; it was documented as part of the vocabulary and was
unreachable as documented.
- KeyRef gets a charset rule. It is the one free-form field an attacker with
bucket write access fully controls, it reaches an operator through Summary,
and it was carrying newlines, NULs and ANSI escapes.
- A per-entry byte bound against the manifest, and PAX sparse records
refused: archive/tar's reader implements sparse entries, where the header
size is logical and the plaintext is far smaller.
- Restore(".") works instead of blaming the checkpoint for the caller's
spelling.
- MemoryStore.Overwrite unexported — a put-if-absent bypass on the public
surface of a package whose atomicity story is put-if-absent.
- Manifest says not to log one, and which rendering to log instead.
Tests: the chained symlink and every parentage case, in verify mode as well
as restore mode; the write-protected directory round trip with its modes
asserted; a reflection test that changes each manifest field in turn and
requires the authenticated input to change with it (the coverage test only
proved each field's NAME appeared, and eleven fields are refused by a cheaper
check before the tag is ever reached); cancellation; setuid dropping; frame
boundaries at an exact multiple; quiesce violation both ways; the key-ref
charset; and the leak assertion aimed at the fixture's own root rather than a
"/tmp/" prefix that could never fire. The bounded-memory ceiling drops from
64 MiB to 12 MiB over 100,502 entries, where a per-entry map would be ~20 MiB
and used to sail under it; measured peak is 5.2 MiB.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ling Tightening the bounded-memory ceiling and growing the fixture until a per-entry map would have to cross it worked, and then starved internal/sandboxexec into a 600s package timeout on this four-core box. A minute of parallel cryptography is not a free thing to add to a suite, and a test that destabilises its neighbours is a test that gets deleted. The rework is also better evidence. A ceiling can only say "under N bytes at this fixture size", which passes for any structure whose constant factor the test's author underestimated — and a map of path to digest is exactly the kind of thing that gets underestimated. The claim is that the peak heap is a function of the FRAME SIZE, not of the entry count, so the second test now measures that directly: write 2,000 entries, write 40,000, require the difference to stay under 2 MiB. Measured 0.67 MiB for twenty times the entries, which rules out an O(entries) structure of any constant factor. The byte half keeps its 2 GiB sparse tree and its 12 MiB ceiling (measured 4.5 MiB) and drops back to 20,000 entries, since the entry count is now somebody else's job. Package time under -race: 126s to 46s standalone, 60s in the full suite, and sandboxexec is back to 22s from its timeout. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
jiashuoz
marked this pull request as ready for review
September 20, 2026 02:42
Two review follow-ups. DefaultExclusions() was opt-in: DirSource(root) with no arguments checkpointed /workspace/.rainier, and the only thing standing between a caller in a hurry and Rainier's own control directory in a tenant checkpoint was remembering to pass a list. Every Write now unions DefaultExclusions() with the caller's set before the walk begins, in the one place every tree passes through. Source.Exclude is renamed AlsoExclude so the union is visible at the type: there is no spelling of a Source -- zero value, struct literal, DirSource with no extra arguments -- that reaches the walk with a narrower set, and no escape hatch that produces the raw tree, because no test needed one. The exclusion test now checkpoints a source that was never told to exclude anything and still asserts the planted credential under .rainier is never OPENED; a sibling proves a caller-supplied path prunes alongside the defaults rather than instead of them. Design note §3.1/§3.2/§13 say exclusion is by construction at the API, not opt-in. And the checksum-equal-restore requirement is PRD §19, not ADR-0003 §19 -- ADR-0003 has nine sections. Fixed in tree.go's treeHasher comment and in the design note's §7. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Rainier Serverless cold-suspends a session by terminating its microVM and keeping
only the workspace. The portable workspace checkpoint is the durable,
provider-neutral representation of that workspace — the thing the control plane
has to be able to say a true sentence about, because if it cannot be read back
the session's work is gone.
This PR adds the format and a standalone library for it: a design note, a new
public
checkpoint/package, and its tests. No driver integration —internal/driver/is untouched on purpose, because it is being changed inparallel; wiring
Suspend(warm=false)toWriter.Writebehind the durabilitybarrier is a later PR.
Requirements it implements, from
tokencanopy/rainier-cloud:session filesystem and native agent resume state, restorable on another
qualified provider, created and restore-tested after every clean cold
suspension, with a key reference model that supports a maximum checkpoint age.
context of workspace, session and checkpoint generation; ciphertext copied into
another context must fail (§18 item 39). §14.2 — deletion, per storage class,
by key-wrapper deletion. §15.1 — nothing in the prohibited-logging list ever
reaches an error.
§4.4 — suspend is not successful until the upload completes and the manifest
commits atomically, and deep-dormant needs a verified checkpoint newer than the
disk's last write.
The design decisions
The note is
docs/design/2026-09-20-portable-workspace-checkpoint.mdand argueseach of these; the short version:
One framed stream, not content-addressed chunks. Chunking buys
deduplication, free resumable upload and parallelism — real wins for a workspace
checkpointed every ten idle minutes. It costs a chunk store with reference
counting and a garbage collector, which turns deletion from an operation into a
distributed refcount problem; tenancy §14.2 requires deletion to be provable
per storage class. It also shares ciphertext across contexts by construction,
which is what §11 forbids. Chosen: one content object, framed internally.
Deduplication can come back below the format (a blob store that chunks
internally) or beside it (a differential second version). A garbage collector
standing between a customer's workspace and its only durable copy cannot be added
later and then removed.
Note that "one stream" cannot mean one AEAD call:
crypto/cipher's GCM isone-shot over a
[]byte. Framing is what makes a single logical streamimplementable, and it is also what gives per-frame integrity and detectable
truncation.
Two objects, one commit. The content object's key carries a random attempt
suffix, so two writers at the same generation never collide and never have to
reason about whose half-written object they found. The manifest's key does not,
and it is written with put-if-absent: that single conditional write is the atomic
manifest commit ADR §4.4 rests on. Orphaned content objects are unreadable
without their manifest's wrapped key, so leaving one for the retention sweeper
costs storage and discloses nothing.
Three independent bindings of one authenticated context.
FormatVersion \0 workspace \0 session \0 generation, purpose-separated, boundby the key wrap's AAD, by the manifest's tag, and by every frame's AAD. Any one
would satisfy §18 item 39 on paper; three means a mistake in one degrades the
property instead of deleting it.
The caller supplies the context; the manifest never gets to. Every read path
takes the
Contextthe caller expects and uses it to build every AAD; themanifest's own identity fields are compared against it and otherwise not
consulted. A library that read the context out of the manifest and then used it to
open the manifest would make the cross-context property vacuous.
Authorization is a required argument.
NewReaderrefuses a nilAuthorizehook, and the hook runs after the manifest is parsed and before the data key is
unwrapped, before one content byte is read, and before the target directory is
touched. The policy is the cell's; that the step exists and runs first is the
library's.
Preflightproves authorization and key readiness from the manifestalone — one small request, no content — which is the call a placement decision
makes when PRD §4.3 filters capacity by "storage and key readiness".
"Verified" means decrypt, integrity, and a structural walk — not a second
copy.
Verifyreads the committed object back through the blob store(checking the in-memory bytes would test the encoder against itself), opens every
frame with its own AAD, hashes the ciphertext, walks every entry under the same
rules a restore applies, and writes nothing. It ends by comparing seven
aggregates against the authenticated manifest, including a tree digest that makes
ADR §19's "checksum-equal restore" checkable.
The manifest holds no path, file name, symlink target or file byte. The tree
listing is inside the encrypted stream. That is what lets the manifest be
plaintext, and it is why
Summaryhas nowhere to put a secret.No compression in v1. The field exists and must be
none, so addingzstdis a declared, strictly-parsed format change. The reasons are a decompression
bomb bound
Verifywould have to enforce, a second failure layer, a workspace'sbulk being mostly already-compressed, and compressed length being an entropy
estimate of the tree. It is listed as open, to be decided by measurement.
Deletion is one operation. The data key exists only in the manifest, wrapped.
Deleting the manifest makes the content object unreadable by anyone, including
Rainier; destroying the key version makes every checkpoint under it unreadable
without touching object storage at all.
Deleteorders manifest-first so aninterrupted deletion leaves the unreadable state.
Bounded memory as a rule, not an aspiration. No structure is O(entries) or
O(tree bytes); there is deliberately no API that returns a list of entries. The
test proves it: a 2 GiB sparse tree of 20,202 entries peaks 4.4 MiB above
baseline.
Package placement. Top-level
checkpoint/, besidecontrol,controlapp,v0wire,attachplane,runnerplaneandprotocol/*— the setscripts/check-public-control.shguards and rainier-cloud may import. It cannotbe in
internal/, and it must not be duplicated in rainier-cloud, because aformat implemented twice diverges the day the second copy is patched and this
one's divergence mode is unreadable customer work. This PR adds
checkpointtothat script's import-hygiene loop with no relaxations at all.
What rainier-cloud must implement
The library is deliberately three ports and no policy. Behind each one:
A GCS blob store with put-if-absent.
PutIfAbsentisx-goog-if-generation-match: 0, must return an errorsatisfying
errors.Is(err, checkpoint.ErrExists)when the object is there,and must either create the whole object or leave nothing — a failed write
aborts the resumable upload session rather than leaving a partial object.
Openreturns
checkpoint.ErrNotFoundfor a missing object. The callback shape iswhat lets a GCS
Writerbe handed straight to the format, which is also theonly resumability the format wants (the protocol-level kind, invisible to the
format).
Key service wiring behind
Wrapper. Cloud KMS, with the versioned keyreference model the interface already expresses:
Wraptakes the alias theworkspace's policy names and returns the concrete key version it used, which
is what the manifest records and what
Unwrapis later asked for. Tworequirements: the authenticated context MUST be passed through as the KMS
AdditionalAuthenticatedData(a wrapper that drops it silently removes one ofthree bindings), and it must fail closed —
ErrKeyUnavailableor a transporterror, never a fallback to a key it happens to have. The library passes a
wrapper's error through rather than flattening it to
ErrAuth, so a throttledKMS is never reportable as a tampered checkpoint.
Scheduling and policy. Everything the library declines: when to checkpoint,
upload retry and backoff, the maximum-checkpoint-age enforcement and the
operational view that flags workspaces outside it (PRD §10), the deep-dormant
precondition (comparing the manifest's authenticated
created_atagainst thedisk's last write — the library supplies the timestamp and the verification
report, not the comparison), the retention sweep that collects orphaned content
objects from lost attempts, the deletion ledger of tenancy §14.2, and the real
Authorizehook: authoritative workspace/creator lookup, placement generation,product-region policy, and key readiness, per tenancy §4 and §8.2.
The driver call site, when
internal/driver/settles:Suspend(warm=false)flushes and unmounts over vsock, then runs the four-step barrier — upload,
commit, verify, and only then release the workspace slot.
Out of scope, explicitly
internal/driver/is not touched in this PR.backoff, concurrency limits.
wrapper.
snapshots, disk formats, live migration.
try to; whether non-credential agent state should travel is ADR-0003 §9's open
question and the note's §14 keeps it open rather than quietly answering it.
rainier-cloud.The independent review, and what it changed
After the gates passed, a separate reviewer went at the diff and the note
adversarially: cryptographic misuse, plaintext leaks, unbounded memory, vacuous
tests. It read every file, ran the suite, and proved its findings with scratch
programs and overlay tests rather than asserting them. It found no critical
issue — it could not construct a nonce reuse, a cross-context or cross-purpose
confusion, an unauthenticated manifest field, or an undetected
truncation/extension/reorder/splice. Everything below is fixed in
8ac297b.The two that mattered
A restore that
Verifystructurally could not vouch for. A directory'srecorded mode was applied when the directory was created, so a tree containing a
0555directory — whichgo mod downloadproduces for every module itextracts; the reviewer counted 246 of them in this repository's own module cache
— packed cleanly, verified cleanly, and failed at restore on its first child
with
EACCES. That combination is the one the durability barrier is least ableto survive: ADR-0003 §4.4 releases the workspace and eventually deletes the disk
on
Verify's word, so any ruleRestoreapplies thatVerifydoes not turns averified checkpoint into a failed cold resume.
A symlink escape the review's own containment argument missed. The reviewer
crafted three chained-symlink streams and concluded containment held, with a
sound-sounding reason: a symlink exists at exactly one real location, so the
directory
checkLinkresolves against is the one the kernel resolves from. Thatis true only while the link is created where its name says — and it is not, if
an ancestor component of the name is itself a link pointing shallower. A probe
confirmed the gap:
a/d -> ..is contained (resolves to the root),a/d/up -> ..is contained (resolves toa), buta/d/upis physically created insidethe root, so its
..leaves the target anda/d/up/pwnedlands outside it. Twoindividually contained links compose into an escape. Only reachable with the data
key, since the stream is authenticated — but "the tree lands inside the target"
is a property §7 of the note claims, and it did not hold.
One change closes both.
consumeStreamnow keeps a stack of the directories thewalk currently has open: every entry's parent must be a directory this walk
created (a symlink never is), and a directory's mode is applied when the walk
leaves it, with directories created
0o700until then. Entries arrivedepth-first, so the state is a stack of depth O(tree depth) — a few hundred —
not a list of entries, so the bounded-memory budget survives, and the format's
ordering rule becomes enforced rather than assumed. Both checks run in verify
mode too, which is the whole point.
The rest
Authorizereceives an unauthenticatedManifest, and could not dootherwise — the tag needs the key whose unwrap it is authorizing. Now
documented outright: only workspace, session, generation and the content key's
prefix have been checked; decide with it, do not record from it.
Deletesays the same and bounds what a forged manifest could reach (one sibling
attempt object in the generation being deleted anyway).
restore kept filling the destination. Both directions now check inside the copy
loop and return
context.Canceled.ErrTooLargewas unreachable as documented, reduced to "could not be read"by a missing
%won the realistic path.KeyRefwas the one unvalidated free-form field, fully attacker-controlledfor anyone with bucket write access, reaching an operator through
Summarywith newlines, NULs and ANSI escapes intact. Now printable ASCII only — which
also makes the hand-rolled canonical rendering unambiguous for the right
reason rather than by accident of its neighbouring fields' charsets.
archive/tar's reader implements PAX sparseentries, where the header size is logical and the plaintext far smaller. Now
bounded against the manifest before a byte is written, and sparse records
refused.
Restore(".")refused every entry and blamed the checkpoint for the caller'sspelling.
MemoryStore.Overwritewas a put-if-absent bypass on the publicsurface of a package whose atomicity story is put-if-absent — unexported.
Manifestnow says not to log one and which rendering to log instead.Test quality
The reviewer's sharpest finding was that the manifest-tamper table proves less
than it looks. Eleven of the twenty-one authenticated fields are refused by a
cheaper, earlier check than the tag — the identity comparison, the
enumerations, the length arithmetic, the wrapper's key-reference check — so
those cases never reach the tag and say nothing about whether it binds them. And
the reflection test proved each field's name appeared in the authenticated
input, not its value:
add("frames", "0")would have passed it. There is nowa test that changes every field in turn through reflection and requires the
authenticated input to change with it.
assertNoContentInError's strongest token was the literal"/tmp/", which cannever fire because
t.TempDirresolves under aTMPDIRthat is not/tmphere — it now takes the fixture root, and is applied to the error families that
actually have a path available to leak.
The bounded-memory tests got the largest rethink. The ceiling was 64 MiB against
a ~3 MiB budget, which would have watched a per-entry map go straight by. The
first fix was to tighten it and grow the fixture until a map would have to cross
it — which worked, and then starved
internal/sandboxexecinto a 600s packagetimeout on this 4-core box, because a minute of parallel cryptography is not a
free thing to add to a suite. So the claim is now tested as what it actually is:
the peak heap is a function of the frame size, not of the entry count. One
test measures the bytes (2 GiB sparse tree, 12 MiB ceiling, measured 4.5 MiB);
a second measures the slope, writing 2,000 entries and then 40,000 and
requiring the difference to be under 2 MiB — measured 0.67 MiB for twenty times
the entries. That rules out an O(entries) structure of any constant factor,
which no ceiling can, and it costs seconds instead of a minute. The read side
moved off
MemoryStoreonto a store that streams from disk, so the measurementis of the library rather than of the test double. Package time under
-racewent from 126s to 46s, and
sandboxexecis comfortable again.Also added, for behaviours the note claimed and nothing checked: setuid dropping,
frame-writer boundaries at and around an exact frame multiple, a single write
spanning many frames, and quiesce-violation detection in both directions.
Gates
make verify,go test ./... -race -count=1,go vet ./...,git diff --check— all clean.
Two things worth recording rather than quietly re-running away.
sandboxexec's600s timeout described above was real, was mine, and is fixed by the rework of
the memory tests. And on one race run
TestAttachToFailedSessionininternal/controldfailed on a 5s timeout, then passed on a targeted re-run andon three further full runs of that package;
internal/controlddoes not importcheckpoint(verified withgo list -deps) and nothing on this branch touchesit, so that one looks like pre-existing flake rather than anything here.
Final state after the rework, all four gates in one pass:
go test ./... -race -count=1exits 0 with no failures —checkpoint60.0s,internal/sandboxexec22.7s,internal/controld18.1s — andmake verify,go vet ./...andgit diff --checkare clean.🤖 Generated with Claude Code