Skip to content

ZFS storage: per-tenant pools and the rollback-integrity fix - #3

Merged
aruokhai merged 17 commits into
mainfrom
nitro-deployment
Oct 10, 2026
Merged

aruokhai merged 17 commits into
mainfrom
nitro-deployment

Conversation

@aruokhai

Copy link
Copy Markdown
Contributor

Brings the ZFS storage branch to main. Two headline pieces, both validated on real ZFS in the QEMU emulator (run-zfs-spike.sh 7/7, run-e2e.sh 8/8) and by scripts/ci-check.sh (fmt, clippy, 398 lib tests, bin, boot_origin).

Rollback integrity

  • Reproduced, then fixed, a case where an acknowledged write could be lost: an anchor's nonce reaches the disk a txg before the txg it names, so a write landing between could be acknowledged by an anchor that did not hold it. Boot now admits a pool by the txg the kernel reports loading (not -T), and never rewinds.
  • A per-boot generation fence stops a second enclave the host starts from letting the first go on to acknowledge a write the second has forked away from.
  • Object Lock retention and boot verification now take time from the trusted PTP clock, not the host system clock.

A pool per tenant

  • Each tenant's data lives on its own ZFS pool, in its own dm-crypt region with a domain-separated key, with its own anchor chain — so the cross-tenant same-nonce steal is impossible by construction, and two tenants anchor independently.
  • A control pool holds the catalog (tenant → pool/region, validated at boot) and the runtime's credentials and devices; each tenant's tasks and streams live on the tenant's own pool, scanned at boot via a catalog background flag.
  • Guest-visible effects wait for the anchor: a wake is held until its invocation's writes are durable; a task's attempt is anchored before it runs.
  • The layout is versioned zfs/state-origin/v2; a v1 store is refused with a migration message, never resumed or reformatted. See docs/STORAGE.md and docs/COMPATIBILITY.md.

Known, documented residuals

  • Stream dials still precede their anchor (advisory; the remaining piece of the operation boundary).
  • Region size is a fixed 200 MiB constant (making it a live flag would move pools; it must be persisted at genesis first).
  • A host copying a disk after an anchor and booting a fork later is not closed by this work; it needs authenticated storage.

🤖 Generated with Claude Code

aruokhai and others added 17 commits October 8, 2026 22:38
Everything the enclave keeps — each tenant's files and the runtime's own records (passkeys, tasks,
streams, devices) — now lives on one ZFS pool, on a disk the parent serves over vsock, under plain
dm-crypt with a key the parent never sees. s3fs, the copy-on-write filesystem written here, goes
with its own wasi:filesystem host and the data bucket: guests get wasmtime-wasi's filesystem over
their tenant's dataset, and what remained of s3fs-core (the S3 backend, keys, signing) moves into
the runtime as `store`.

ZFS cannot stop rollback when the host holds the disk, so after every write the pool is anchored:
`zfs set enclave:anchor=<seq>-<nonce>` (which also syncs), the txg it reached and the nonce signed,
chained and published to the Object-Locked roots bucket. A txg number alone cannot name a state —
ZFS reuses the abandoned ones after a rewind, and their uberblocks stay on the disk — so boot
imports as of the anchored txg (`zpool import -T`) and refuses unless the property it reads back is
the newest anchor's. A rolled-back disk and an abandoned fork are refused; a crash between sync and
publish rewinds. Nothing is acknowledged before its anchor: a response's end (a streamed body
streams and only its end and trailers wait), a registration, a task's outcome. passkey-client now
refuses a response cut off before it ended, which is what let the crash test pass a lost write.

The kernel is built from source with AWS's own enclave recipe (6.6, their config and patches,
including the vsock pushback fix block I/O over vsock needs) plus NBD, dm-crypt and OpenZFS; the
build user and host are pinned so PCR0 does not depend on the builder. The kernel refuses a vsock
socket for NBD, so it gets one end of a socketpair bridged to vsock. On Nitro the parent serves an
EBS volume with stock nbdkit from the Amazon Linux 2023 repositories (nbdkit.service; tofu creates
the gp3 volume); the emulator serves a disk image with deploy/qemu-nitro/nbd-stub.py.

The S3FS_ prefix becomes ENCLAVE_, and the guest denylist with it; the images are enclave.eif and
enclave-qemu.eif. tofu takes `roots_bucket` and `pool_size_gib` in place of `buckets`.

Verified: ci-check.sh, the guest-backed suites, and on the QEMU emulator run-zfs-spike.sh (anchored
writes, tenant isolation, resume, rollback refused, rewind, fork refused) and run-e2e.sh. Not yet on
Nitro hardware: the EBS volume and nbdkit path are validated only by tofu, packer and systemd.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant id is now the first 16 bytes of sha256("enclave/tenant/v1" || credential id), computed
where it is needed, rather than drawn from the NSM at registration and stored in the credential
record. It was minted so a host could not predict a tenant's directory and create it first. On the
pool a host cannot create anything, and a guest can name nothing but its own dataset, so an
unpredictable name protected nothing. The `join` path that would have let a second passkey share a
tenant was never wired up, so behaviour is unchanged: one passkey, one dataset.

Credential records drop their tenant id and become version 2. A version-1 record is refused rather
than given a derived id, which would be a different directory, so pools made before this need
recreating. `tenant_of_is_pinned` fixes the derivation, because changing it strands every tenant's
dataset. Registering an existing credential id again now reaches that credential's own directory
before it is refused, instead of leaving an empty dataset behind.

Verified on this commit alone: fmt, clippy, the workspace unit tests, boot_origin, serve_auth and
harness.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A tenant's warm instance is gone. Each request, task run and held-connection message now builds an
instance over the tenant's dataset under the tenant's lock, and drops it when the call ends. Nothing
a guest holds in memory survives a call, so background work no longer has to discard a cached HTTP
instance before it changes the dataset, and each passkey approval runs in an instance no earlier
request touched. The cosigner already reopened its seal on every call, so it loses nothing, and
instantiating costs about 24 µs.

The pool now holds only a lock per tenant. Deleted: LiveTenant, the rebuild counter and
--max-requests-per-instance (ENCLAVE_MAX_REQUESTS_PER_INSTANCE), the resources_settled check, and the
take-out and put-back handling for aborted calls. The tests and the emulator's e2e leg now expect
/memory to answer 1 on every request.

Verified: ci-check.sh, ci-guests.sh (every guest-backed suite) and harness. Not run: run-e2e.sh on
the emulator.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
deploy/Dockerfile built the runtime without ZFS's tools and without a roots bucket, both of which
the runtime has needed since the pool replaced s3fs; nothing built it. scripts/ci-sqlite.sh ran the
runtime directly on the host, which cannot measure its guest without an NSM or reach a pool without
the parent's disk; nothing ran it, and docs/STORAGE.md already shows SQLite running in the emulator.

The tofu user data wrote ENCLAVE_ROOTS_BUCKET, AWS_REGION and ENCLAVE_TLS_DOMAINS into the
parent's enclave.service, where nothing read them: nitro-cli passes no environment into an
enclave, whose settings are in its image and measured into PCR0. They and the tls_domains variable
that only fed them are gone; the pool's disk, which nbdkit does read, stays.

Verified: tofu fmt and validate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every setting is baked into the image and measured into PCR0, so changing one already meant a new
image, exactly as changing a constant does. A setting earns its place only where two deployments of
the same code need different values. Of the 52 ENCLAVE_* settings, 12 pass that test: the roots
bucket and prefix, the filesystem id, the KMS key and its parameter, root retention and the anchor
floor, the TLS domains, the relying party and its allowed origins, the push application and the
guest log group. With AWS_REGION, they are all a production binary reads; its --help lists nothing
else.

The emulator's alternatives — a static master key, unsigned receipts, the host clock, MinIO,
Pebble, push and log stubs — become flags of the `testing` build alone, grouped in one `Testing`
struct whose production values are written once in `Testing::production`. The static key and the
unsigned-receipt mode are compiled out of the library as well, so a production binary contains no
code that could take a key from configuration or accept an unsigned receipt.

Everything else is a constant: KMS, PTP, the NSM, gvproxy, ACME on :443, the guest at
guest/guest.wasm, the log stream `guest`, the origin https://<rp id>, the timeouts, TTLs and limits
everyone ran with, and "production" in the KMS encryption context, which keeps keys sealed under it
opening. `--tls off` goes with TLS: it could never start a server, since attestation is always on.

Background tasks run when the guest exports run-task, rather than when a setting says so: the guest
is measured into PCR16, so its exports already are the decision. A guest's environment is now
exactly the settings its file carries; the runtime's own environment no longer reaches it, so the
inheritance policy, its denylist and --guest-env/--no-inherit-env are gone.

ptp-check.sh passes flags that exist again, and ci-check.sh now runs the binary's own tests, which
`cargo test --lib` never did.

Verified: ci-check.sh, the binary's tests, clippy on the production binary without features,
ci-guests.sh, harness, nix build of eif and eif-qemu (the production image bakes exactly the 12
settings and AWS_REGION), and run-e2e.sh on the emulator, background tasks and device wakes
included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An anchor's nonce reaches the disk in one txg (the `zfs set` marker), and the
anchor names a later one (the second `zpool sync`). Another tenant's write
that lands between them is covered by that anchor and acknowledged through
anchor()'s `synced == prev.txg` shortcut. The disk as of the marker holds the
same nonce without that write, and `zpool import -T` loads the newest
uberblock at or below the anchored txg: the property and guid checks pass.

The testing build now stops at named points in an anchor (anchor-wait,
after-marker, after-sync, after-publish) and asks the host over vsock port
9101 whether to go on; deploy/qemu-nitro/test-hooks.py answers from a rules
file. This replaces ENCLAVE_ZFS_CRASH_BEFORE_ANCHOR, so the crash leg no
longer rebuilds the image. The parent always runs the listener: with nobody
listening each ask waits out vsock's 2 s connect timeout, which is what this
run's 6.4 s anchors in leg 3 were. After every import the testing build logs
the kernel's own load notes from dbgmsg and the first txgs it opened.

Leg 7 of run-zfs-spike.sh, on the emulator, reproduces it and fails:
alice's anchor held at after-marker, the disk copied, bob's write
acknowledged behind it; one anchor (seq 111, txg 846) covered both. Served
the copy, the boot logged `spa_load(enclave, config untrusted): using
uberblock with txg=845`, then `zfs pool resumed at its anchor seq=111
txg=846`, and bob's acked.txt was gone.

Leg 6 shows the rewind it relies on is not dependable: after a crash three
txgs past anchor 107 (txg 810), `-T 810` failed on txg 810 ("couldn't get
'config' value in MOS directory [error=5]", its blocks reused) and ZFS fell
back to 809, the anchor's marker. That boot passed only through the same flaw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The anchor's nonce reaches the disk a txg or more before the txg the anchor
names, and a write landing between them is acknowledged by the anchor. Boot
imported with `zpool import -T <anchored txg>`, which takes the newest
uberblock at or below it, with no exact match, so the disk as of the marker
passed the nonce check without that write (leg 7, previous commit).

`-T` also falls back to older uberblocks when one fails to load. In leg 6's
honest crash, three txgs past the anchor, the anchored txg's blocks were
already reused and ZFS loaded the marker's txg instead. Requiring the exact
txg would have refused that disk for good, so there is no rewind any more:

- Boot imports the newest state on the disk, unmounted, from the exact
  device and by guid, and reads which txg it loaded from the kernel's own
  notes on that import (`spa_load(enclave, …): using uberblock with txg=N`
  in dbgmsg, from the last load that ended LOADED).
- It admits the state only if that txg is no older than the anchored one
  and its marker is the newest anchor's, or that of the next anchor (written,
  never published) naming the newest as its predecessor. The marker is now
  `<seq>-<nonce>-<previous anchor's hash>`; pools marked before still match.
- A crash leaves unacknowledged writes in place rather than rewinding them.

Also:
- anchor() runs in a task of its own, so a deadline, an abort or a dropped
  /auth future cannot stop it between the marker and the publish.
- A response that may not have a body (HEAD, 1xx, 204, 304) is held until
  its writes are anchored. hyper never polls such a body, so it went out
  complete before the anchor on HTTP/1.1.
- STORAGE.md says what the design rests on: plain AES-XTS authenticates
  nothing, ZFS checksums are not MACs, and fletcher4 metadata is protected
  only by the host being unable to choose plaintext.

Validation, on this tree:
- scripts/ci-check.sh: pass (392 lib tests, bin, boot_origin).
- deploy/qemu-nitro/run-zfs-spike.sh: all 7 legs pass. The marker's disk is
  refused ("it loaded txg 846, older than the anchored txg 847"), and the
  crashed disk is admitted through the unpublished successor's marker.
  POST p50 255 ms, GET p50 182 ms, anchor mean 77.6 ms.
- deploy/qemu-nitro/run-e2e.sh: all 8 legs pass.
- serve_h2 4/4, serve_grpc 12/12, serve_grpc_wire 3/3 with built guests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A second enclave the host starts while the first is mid-anchor can fork the
first's disk and still let the first publish the anchor that confirms a write
the fork has dropped. This closes the concurrent-writer half of that: the
fork-after-publish half needs authenticated storage and stays open, reduced
later by one writer per pool.

Each boot, before the read-write import, claims the next free number in a
contiguous chain of signed, Object-Locked `zfs/generations/<g>` records
(claim_generation, racing other boots for each number). Every anchor, as late
as it can before the publish that would acknowledge its writes, refuses if
generation + 1 exists (seal_and_publish). So a superseded enclave cannot
acknowledge anything: in the attack, the second enclave's boot claims the next
generation before the first — frozen by the host mid-anchor — reaches its
check, and the first then refuses and never confirms.

The records are signed and verified so the host cannot fence an enclave out
with a record forged for another filesystem, and retained-read like anchors so
it cannot hide the newest. The anchor wire format is unchanged; the per-tenant
StorageManager will bump it once to ZFSANCH2 and carry the same fence per pool.

retained/exists/find_tip are generalised over a key so the generation chain
reuses the anchor chain's gallop-then-bisect.

Validation, on this tree:
- cargo test zfs: generation codec round-trips and refuses tampering, a
  wrong number, another master and truncation; boots claim 0,1,2 in order
  against a shared backend; a mutant that stops advancing the claim is caught.
- scripts/ci-check.sh: pass (394 lib tests, bin, boot_origin).
- deploy/qemu-nitro/run-zfs-spike.sh: all 7 legs pass; the bucket holds one
  generation record per boot, consoles log the claimed number, and the
  per-anchor fence check adds ~36 ms (114 ms mean anchor, was 78 ms).

The refuse path needs two concurrent enclaves against one bucket (two guest
CIDs, two NBD stubs, a shared store) and is a stage-7 harness item; here it is
covered by unit tests and the real-ZFS happy path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Ahead of a pool per tenant: the pool name, its dm-crypt device and its
anchor-chain prefix become fields on `Zfs` (name, mapped, anchor_prefix)
instead of the `POOL`/`MAPPED` constants and a `prefix`-derived anchor key,
and `synced_txg`/`pool_guid`/`log_loaded_state` take the pool name. The
generation chain stays on the global `prefix`, since it is one per enclave,
not one per pool.

The values are unchanged — name `enclave`, device `/dev/mapper/zcrypt`,
anchor keys `{prefix}zfs/anchors/<seq>` — so this is behaviour-preserving;
`POOL`/`MAPPED` remain as the control pool's defaults in `attach`. A later
commit adds the manager that holds several of these and the regions they sit
on.

Validated by compile, clippy and the zfs unit tests; the substitutions are
value-identical to the constants, and the next real-ZFS spike (with the
region work) covers it end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The per-tenant layout needs an anchor to say which pool it speaks for, so the
host cannot serve one pool's anchor for another. ZFSANCH2 adds, after the
filesystem id: a pool kind (control or tenant), a 16-byte pool id, and a
16-byte tenant id, all signed. decode_and_verify now takes the expected pool
id and refuses an anchor that carries a different one.

The one pool today is the control pool: kind Control, a fixed sentinel pool
id (`enclave-control!`), tenant id zero. Tenant pools, with random ids, come
with the storage manager. The marker and admission logic are unchanged; only
the signed body grew.

This changes the anchor bytes and so the genesis hash the state-origin receipt
commits to, so a v1 store will not resume under v2 — handled explicitly when
the receipt is versioned (stage 6); every test and the spike start fresh.

Validated by the zfs codec tests (round-trip carries kind/pool_id/tenant_id,
tamper/replay/foreign-key/foreign-pool-id all refused) and the 14 boot_origin
machine tests, which drive genesis -> resume -> receipt over v2 anchors on the
memory backend. The real-ZFS mechanics are format-agnostic; the next spike
(with the regions) exercises v2 on ZFS end to end.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Splits the one type into a per-pool `Pool` (the former `Zfs`: device, anchor
chain, last-anchor lock) and a new public `Zfs` manager that owns the control
pool and will own a pool per tenant. The rest of the runtime holds the
manager, as before.

`anchor()` is replaced at the call sites by where the write went:
anchor_tenant(tenant) for a request, a message or a task outcome;
anchor_control() for a credential. The manager routes both to the one control
pool for now — a tenant's writes still live in the control pool's /tenants —
so this is behaviour-preserving. The next commit gives each tenant its own
pool on its own disk region, at which point anchor_tenant anchors that pool
and tenant_dir returns a path in it.

Behaviour-preserving: compile, clippy (both feature sets), fmt, and all 394
lib tests. No on-disk or real-ZFS change, so no spike needed; the spike runs
with the region work next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Each tenant's data now lives on a ZFS pool of its own, so one tenant's anchor
can never cover another's write — the same-nonce cross-tenant steal the single
pool was prone to is gone by construction, not by the admission check alone.

The one disk is cut into equal, 4 KiB-aligned regions (region_bounds). Region
0 is the control pool, which holds the runtime's records and a catalog; each
tenant is allocated one of the rest on first use. A region is mapped as its
own dm-crypt device with a domain-separated key, HKDF(master, fs_uuid,
"zfs/dmcrypt/v2" | kind | pool_id), and carries one pool with a single `data`
dataset — the guest's only preopen.

`Zfs` is now a storage manager over a per-pool `Pool`: it brings up the disk
once, claims the boot generation once, creates/opens the control pool, and
imports a tenant's pool on demand (a `OnceCell` slot per tenant serialises its
first use without serialising others; a create lock keeps two new tenants off
one region). The catalog (`/runtime/catalog/<tenant>`: pool id, region,
genesis hash) is on the control pool, validated at boot (regions distinct, in
range, not the control pool's; pool ids distinct), so the host cannot point a
tenant at another's pool. A pool id is random and minted once, so a tenant's
history cannot be restarted under a guessed id.

Each pool anchors on its own chain (`zfs/control/anchors/`,
`zfs/pools/<id>/anchors/`) with its own last-anchor lock, so two tenants
anchor independently. A request anchors its tenant pool and, for any record it
left there, the control pool. v2 anchors (previous commit) bind each to its
pool, and every pool is verified on import exactly as the boot verifies
control, so a rolled-back or forked tenant disk is refused when that tenant is
next used. Records stay on the control pool for now; moving them into each
tenant's pool, and the operation boundary that drops the control anchor from a
pure-data request, come next.

Registration is bounded: a tenant that fills its region gets ENOSPC, and the
disk filling up refuses new registrations (`--region-mib`, 200 MiB default).
No tenant-pool eviction yet; pools are kept imported, capped by the region
count.

Validated on the emulator, deploy/qemu-nitro/run-zfs-spike.sh, all 7 legs:
two tenants write to two pools (two anchor namespaces in S3) and cannot reach
each other; POST p50 255 ms / GET 188 ms / anchor 93 ms, no regression; a
restart resumes control and imports a tenant pool; a rolled-back disk, an
abandoned fork, and a crash are each handled by the control pool's admission
(loaded txg vs anchored, marker chain); and bob anchors while alice is held
mid-anchor — tenants anchor independently. Plus scripts/ci-check.sh: fmt,
clippy, 396 lib tests (region geometry, two tenants get independent pools,
catalog validation), bin, boot_origin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The module-doc stack diagram, the anchor_prefix and mapped field docs still
described the single pool. They now describe the control pool, the per-tenant
pools, and their per-region dm-crypt devices and anchor prefixes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A tenant's background work now lives on the tenant's own pool, not the shared
control pool: its tasks and streams go in a `runtime` dataset beside `data`,
out of the guest's reach (only `data` is preopened). Devices and credentials
stay on the control pool, which the forwarder and the pre-auth lookup need to
see across all tenants.

So the boot need not import every pool to find the work that is due, the
catalog carries a `background` flag per tenant. It is set (durable on the
control pool) before a tenant's first task or stream record, so a crash never
leaves a record the scan would miss — a flag with no record only costs a
spurious scan. At boot the task queue and stream registry import and scan only
the flagged tenants; `mark_background` flips the flag, `tenant_runtime_dir`
gives a tenant its own records directory, and `background_tenants` lists them.
The `runtime` dataset carries a 16M reservation so a guest that fills `data`
cannot stop a task being recorded.

Also, from review: drop the dead `legacy` 2-field marker branch in `admit`
(the ZFS layout never shipped, so no pool carries the pre-predecessor marker),
and bound the spike's crash-leg client so an aborted enclave cannot wedge the
run.

Validated on the emulator: run-e2e.sh 8/8 — a background task is enqueued to
the tenant's own pool, runs, wakes its owner, and (leg 7) survives a reboot,
which exercises the background flag and the boot scan reloading it from that
pool. run-zfs-spike.sh legs 1-5 pass with the data+runtime layout (two tenants
in separate pools, isolation, bench unchanged, resume, rollback refused). Plus
scripts/ci-check.sh: fmt, clippy, 396 lib tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…P time

Every `retain_until` was computed from `std::time::SystemTime::now()`, the
host-controlled system clock. A host that wound its clock back could shorten
the Object Lock on an anchor, a generation record, the sealed key, the
state-origin receipt or a pair record — making them deletable before their
retention was really up, the rollback the lock exists to prevent. The boot's
receipt and pair verification took "now" from the same host clock too.

All of it now comes from the enclave's trusted clock — PTP (`/dev/ptp0`,
Amazon Time Sync) on Nitro — the same `TrustedClock` the guest's wall time
already uses. `open_clock` and `WallClockAdapter` now yield an
`Arc<dyn TrustedClock>` so the one trusted clock is shared; `boot` takes it and
threads it to `Zfs::create`/`open`, and `Pool`/the generation claim compute
`retain_until` from `clock.now()`. `boot`'s `locked`, `open_receipt`,
`verify_origin` and `record_pair` take a trusted `now`.

What was already right and is unchanged: background tasks schedule off the
`WallClockAdapter` (trusted); token and challenge TTLs, request timeouts,
tenant-pool eviction and wake backoff use monotonic `Instant`, which a host
cannot wind back. What stays on the host clock is telemetry only — CloudWatch
log timestamps, S3 `last_modified` metadata — and the `clock_check`
diagnostic; none bears on integrity.

Validated: compile, clippy (both feature sets), fmt, 396 lib tests, and the 14
boot_origin machine tests, which drive genesis -> lock -> verify -> resume ->
record-pair over the trusted clock. The emulator runs the host clock
(ENCLAVE_CLOCK_SOURCE=host), so this is behaviour-neutral there; the point is
wiring integrity-bearing time to the trusted source on Nitro.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…task begin

Two ack-ordering fixes, so nothing is announced before it is durable.

A `wake` the guest raises is now staged, not sent: the `Notifier` holds it per
tenant (stage/deliver_staged/drop_staged), and each invocation delivers its
staged wakes once its writes are anchored — the request path in the spawned
anchor task, the message path after its anchor, the background task after
`finish` anchors the outcome — or drops them if the anchor fails. A wake tells
an owner "come and look", so sending one before the change is durable could
wake them for a state a crash then rewinds. A tenant is serialised against
itself, so one invocation stages and delivers before the next.

A task's `begin` now anchors the attempt before the guest runs. Without it the
Running record rewound with a crash, the attempt count never advanced, and a
task that brought the enclave down looped on it for ever instead of failing at
MAX_ATTEMPTS.

Not done, and documented in STORAGE.md as the remaining piece of the operation
boundary: a stream dial or send still goes out during the invocation, before
its record is anchored. It carries no committed state (an attested request the
far side deduplicates; a pre-anchor crash rewinds the record and the supervisor
re-establishes or drops it), and deferring it means reworking the reconnect
supervisor.

Validated: run-e2e.sh 8/8 on the emulator — leg 6b confirms a finished task
still wakes its owner, now only after `finish` anchors; plus scripts/ci-check.sh
(fmt, clippy both feature sets, 397 lib tests incl. a new staging test, bin,
boot_origin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The state-origin receipt now names `zfs/state-origin/v2` — the per-tenant pool
layout — so it cannot be read under a runtime that means the single-pool v1 by
it. On resume, before it claims a generation or imports anything, the runtime
checks for a v1 store (anchors under `{prefix}zfs/`, none under
`{prefix}zfs/control/`) and refuses it with a message pointing at the migration
procedure, rather than the opaque "no anchors" a v2 import would give. A v1
store is never resumed or reformatted in place — and never could be, since it
still holds its sealed key, so the boot takes resume, not genesis.

docs/COMPATIBILITY.md gains the version table and the offline migration
procedure (keep the v1 volume and prefix, bring up v2 fresh on new ones, retire
the old only once v2 is verified). docs/STORAGE.md records why the 200 MiB
region size is fixed for a store's life and not a runtime flag: a region's disk
offset is its index times its size, so changing it would move every tenant pool
out from under its dm-crypt mapping — to make it configurable it must be fixed
at genesis, committed in the anchored control state, and checked on resume.

Validated: scripts/ci-check.sh (fmt, clippy both feature sets, 398 lib tests
incl. a new test that a v1 store is refused before a generation is claimed,
bin, 14 boot_origin tests driving genesis/resume/verify over the v2 schema).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@aruokhai
aruokhai merged commit c2ea60e into main Oct 10, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant