Skip to content

Storage locker and execution boundary - #71

Draft
plotnick wants to merge 10 commits into
signer-loginfrom
locker
Draft

Storage locker and execution boundary#71
plotnick wants to merge 10 commits into
signer-loginfrom
locker

Conversation

@plotnick

@plotnick plotnick commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

A store for sled state that must never be stale, and an execution boundary built on it.

The locker writes every M.2 slot and acks only when both hold the record. A load applies the pair rules:

the slots the verdict
agree adopt the record
one missing restore from the survivor (M.2 swap)
both missing start fresh
disagree under one nonce adopt the higher seq (a torn write)
anything else discard

A discarded bookmark means a fresh identity. A discarded boundary means the sled refuses to run jobs.

The boundary records, before each spawn, the job, its session, and the sled's causal frontier. When the job ends, the sled adds its terminal status to the record. After a crash, if the join frontier covers the recorded frontier, nothing was lost and the session keeps working here. If not, some jobs died unwitnessed: the sled refuses that session's jobs until a new session supersedes it, and adjudicates the recorded job, announcing its real ending if known and interrupted if not. A resubmitted signed artifact can never run twice on one sled.

Upgrade: the envelope format changed, so existing SUSHBOOKMARK files will read as corrupt once, then the sled will assume a fresh identity. No manual cleanup.

Unit tests cover the pair rules, torn writes, fencing, and outcomes. Integration tests cover lost and witnessed restarts and universe swaps.

Residue: only the recorded frontie gets a verdict; earlier lost jobs get refusals when retried. If the sled crashes between a job's end and the record write, the verdict falls back to interrupted.

plotnick and others added 2 commits September 2, 2026 23:29
A store now writes every M.2 slot and acks only when all of them
hold the record. A load adopts only when the slots agree, restores
from the survivor when one is missing (an M.2 swap), and otherwise
discards, with one exception: every write carries a nonce drawn once
per process, and slots that disagree under one nonce were torn by a
single process, so the higher sequence number is that process's
newest write. Adopting it is safe because a store that never
returned had no effects. The locker is tenant-generic; the bookmark
tenant maps a discard to a fresh identity rather than risk resuming
a stale one.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
Before each spawn, the launcher writes the boundary: the job this
sled is about to run, its session, and the causal frontier of
everything the sled had seen when it committed. When the job ends,
its terminal status joins the record. Launches drain in release
order, so the record never regresses, and a job whose record cannot
be written is refused, with the refusal gossiped.

At rejoin, the sled compares the recorded frontier with the join
frontier. Covered means every request it had processed was witnessed:
replay refuses old jobs, the session chain never releases a
resubmitted one, and zombie reaping reports what the sled left
running, so the session keeps working here. Not covered means a
suffix of its history died with it: jobs may have run that no sled
can name, and their signed artifacts could be resubmitted and run
twice. The sled refuses jobs of the recorded session until a new
session supersedes it, and adjudicates the recorded job itself: the
recorded ending if the job got one, and interrupted if it did not. A
recorded job still running across a universe swap is skipped, since
its events land in the new universe when it finishes.

A start event no longer displaces a terminal status, so an
adjudicated interrupted survives a concurrent replayed start in
either arrival order. State's relationship to its own past (join
frontier, boundary, lost session, zombies) now lives in one struct,
Past. The wiring layer owns storage: JobManager takes the Locker, and
Seed::grow makes the seed together with the locker's one bookmark
source, which spawn_gossip consumes as a pair. BookmarkSource::probe
is gone; the seed probes the locker itself.

Accepted for now: jobs in a lost suffix other than the recorded one
get no verdict of their own, only a refusal when retried; and a crash
between a job's end and the outcome write falls back to interrupted
for that job.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>

@plaidfinch plaidfinch left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is definitely the right direction, but there are some devils in the details. I've tried to scrawl my thoughts in the margins, mostly intended as note-taking in advance to chatting with each other about it.

Comment thread server/src/bookmark.rs Outdated
shared: self.shared.clone(),
generation,
ratchet: self.ratchet.clone(),
generation: self.ratchet.generation.fetch_add(1, Ordering::SeqCst) + 1,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why we need a bookmark generation number under the new approach in this PR. It seems like we shouldn't? What state transition relies on it?

Comment thread server/src/boundary.rs Outdated
Comment on lines +13 to +22
//! After a restart, compare the recorded frontier with the join
//! frontier. If the join frontier covers it, every request we had
//! processed was witnessed: replay refuses old jobs, the session
//! chain never releases a resubmitted one, and zombie reaping reports
//! what we left running. If not, a suffix of our history died with
//! us: jobs may have run here that no sled can name, and their signed
//! artifacts could be resubmitted and run again. The state machine
//! then refuses jobs of the recorded session until a new session
//! supersedes it, and adjudicates the recorded job itself: the
//! recorded ending if the job got one, and interrupted if it did not.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this comment is wrong, or, if it's accurately describing the implementation, I think the implementation is wrong. Specifically:

If not, a suffix of our history died with us: jobs may have run here that no sled can name, and their signed artifacts could be resubmitted and run again. The state machine then refuses jobs of the recorded session until a new session supersedes it.

We can learn about the history of messages gradually — it's not a guarantee that the first peer we connect to will tell us everything that every peer happens to know. So just because our first sync doesn't catch us up to our stored frontier, means nothing about whether more gossip will get us there. I also don't understand why this claims that "their signed artifacts could be resubmitted and run again" — no, they can't, because they'd be rejected? A resubmission of a signed job can't chain onto any existing job, so can't be re-executed. And if we're learning about an only-once-submitted job, but just late, we know whether or not to execute it, based on this very log.

It feels like this implies a lot more special-casing than is necessary.

Comment thread server/src/boundary.rs
pub network: Network,
pub session: SessionId,
pub job: JobId,
#[serde(with = "version_bytes")]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upcoming changes in Rumors should obviate this by making Version serialize itself as bytes meaningfully in human-readable and non-human-readable formats alike.

Comment thread server/src/boundary.rs Outdated
pub session: SessionId,
pub job: JobId,
#[serde(with = "version_bytes")]
pub frontier: Version,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But why do we need this? I am skeptical. I will see whether I agree by the end of reading.

Comment thread server/src/boundary.rs Outdated
Comment on lines +71 to +72
/// Whether the rack already knows everything we knew at
/// commitment. Covered means no committed job can be lost.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is false. There is no "the rack" as a singular entity that knows or doesn't know things. Just because you managed to gossip with another sled, does not mean that they will necessarily manage to propagate that information before dying.

Comment thread server/src/gossip.rs
Comment on lines +117 to +128
let bookmarks = BookmarkSource::new(log, locker);
let handle = match locker.probe().await {
Ok(()) => bookmarks.next_handle(),
Err(_) => bookmarks.shed_handle(),
};
let rumors = match Peer::seed().bookmark(handle).await {
Ok(peer) => peer.into_rumors(),
Err(unbookmarked) => match unbookmarked.peer.bookmark(bookmarks.shed_handle()).await {
Ok(peer) => peer.into_rumors(),
Err(_) => unreachable!("a shed bookmark never touches storage"),
},
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, cool, I think this pattern makes sense. What I'm reading here is that we automatically discard bookmarks we fail to load, then try again, which means we don't permafail if a bookmark is torn or corrupted, we just (safely) leak identity space.

Comment thread server/src/state.rs Outdated
job_id,
ProcessError::Io {
what: "consulting the execution boundary".to_string(),
error: "the store is untrusted; this sled needs service".to_string(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NICE! Though, maybe we can make the error a little more specifically instructive, perhaps indicating potential failure of the M.2 directly, so the operator knows what this means.

Comment thread server/src/state.rs Outdated
Comment on lines +348 to +350
what: "consulting the execution boundary".to_string(),
error: "a restart lost part of this session's history on this sled; start a new session"
.to_string(),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why or how this is relevant. We can figure out what ran based on the boundary, and we can conclude what got interrupted based on the output / exit log files. We shouldn't have to restart the session entirely just because one sled restarted and interrupted a job before gossiping; that is going to be a pain, and I think it's evitable.

Comment thread server/src/state.rs Outdated
pub struct Past {
/// The causal frontier we joined this universe at, if we joined
/// rather than seeded it.
join_frontier: Option<Version>,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am very concerned about the correctness of this. Is this just the causal version of whichever peer we gossip with first when we bootstrap? That peer could be arbitrarily causally related (ahead, behind, concurrent) to the operator's notion of the "current" state of the session at the time the sled connects, it just depends on which peer you talk to and how caught-up they are, right? If the intent is to prevent the operator from seeing past jobs in the session execute on newly installed sleds, isn't this already prevented by binding execution at job submission time to the sled baseboard ID? If a sled hasn't been adopted by the rack, it will never execute any jobs submitted prior to its adoption. Once it has been adopted, we want it to participate in "catch-up" behavior. So I think this is a red herring, and will lead to unexpected behavior.

Comment thread server/src/state.rs Outdated
Comment on lines +1239 to +1241
/// Sleds that witnessed a real terminal event keep it; the ruling
/// convinces only sleds that knew nothing, so verdicts can differ
/// across sleds.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what this means. One only adjudicates one's own local verdicts?

plotnick and others added 8 commits September 4, 2026 18:48
The set may never drop an entry, and an attacker must not be able to
grow the file by inserting garbage, which rules out an exact set. The
bit layout and the hash algorithm are on-disk format, pinned by
tests. The filter errs only by calling a network burned when it is
not; that error refuses a session, and never runs a job.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
A skip is a decision, not a failure. Without it, a job the boundary
machinery has ruled out sits Queued forever on that sled; that hangs
client waits and records no outcome. The status is terminal and
carries a reason: the job's session sits below the sled's execution
floor, or a previous life of the sled already handled it. The client
renders a skip with its own row and reason. Nothing emits the status
yet; the boundary rework that follows will emit it.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
U+26A0 (warning sign) is East Asian Width neutral, so wcwidth counts
one column while the variation selector makes most emulators draw
two, and aligned output drifts on those rows. U+2757 (exclamation
mark) means the same thing and carries the wide property.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
The old record stored the causal frontier at commitment and compared
it against the join frontier to decide whether history was lost. That
comparison does not survive a crash: a restarted sled re-issues its
dead predecessor's untransmitted versions as soon as it sends, so the
recorded frontier can be re-covered by different messages and the
lost-history verdict silently flips. This change removes the frontier
from the record; the chain itself becomes the watermark.

The record now stores the network, a burned set, the join of executed
session starts, and the committed job. The commit also computes the
successor's chain id from the signed bytes and stores it. The sled
screens each session's start version against this record. The
committed session resumes at its stored successor when it activates,
so jobs at earlier chain positions drain without executing and a lost
suffix no longer strands the session. A session whose start lies
strictly above the executed join has never run here and is admitted.
A start the record cannot order makes the sled hop: the sled reports
a SessionHop error to the gossip set and raises its floor at the
frontier that includes the report. The record joins every executed
start rather than keeping only the last one, because a record that
remembers only its last session cannot rank the session before it,
and a third session could then replay the first session's jobs.

The burned set holds every network whose watermark this record
overwrote. It is a Bloom filter because entries may never be dropped,
and a flood of forged networks must not grow the file. The launcher
writes the burn and the advance together, so no crash window
separates them. A sled that re-enters a burned universe reports a
UniverseFlipFlop error and raises its floor the same way. Without the
burn, returning to an earlier universe would overwrite the watermark
that remembers the jobs it ran there, and those jobs could be
resubmitted and re-executed. A sled that enters a universe with
history while holding no record raises its floor at the frontier that
includes its own announce, since it cannot order any session started
before it arrived.

Floors live in memory only. A floor's version is created by sending a
message, and if the sled dies before that message reaches anyone, no
other copy of it ever exists and no future session can dominate the
floor; a persisted floor would then refuse every session in that
universe until a cold boot. Every restart re-detects the burn, the
missing record, or the unordered session and raises the floor again,
so persisting the floor gains nothing. Jobs below the floor end as
JobStatus::Skipped, and a new JobEvent carries the skip to the gossip
set, so client waits resolve instead of hanging on a job the sled
will never run.

Arrival and birth marks replace the join frontier. The zombie check
classified this sled's own job starts against the join frontier,
which is one peer's view of history: a start from a previous life
could sit above a lagging peer's frontier, be classified as this
life's, and leave the rack believing the job still runs. The birth
mark, the frontier just past this life's first send, separates the
lives exactly: nothing a previous life sent dominates it, and
everything this life sends does. The arrival mark, the frontier just
before that first send, keeps the general replay line for foreign
traffic, which can reach this sled before its sender has seen that
first send. Both marks are computed locally and never persisted. They
replace every use of Universe::frontier, so that field is deleted.

This change also deletes the bookmark generation numbers. The record
keeps every universe's identities, so a straggling write costs at
most a stranded identity, and the locker's sequence guard already
refuses stale writers. The gossip manager stops every session before
handing a new peer its handle.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
Both tenant records are now stored in a two-element CBOR envelope:
the format's version number, then the body. A trait chains each
version to the one it superseded, and the chain's Previous bound
requires the conversion, so declaring a new version does not compile
until the upgrade from the old one exists. Decoding matches the
stored version against the chain, parses the body at that version,
and converts the result up to the latest.

The chain follows the ledger versioning in omicron's
config-reconciler (see omicron#11249), with three changes. The
version is stored explicitly instead of inferred from the body's
shape, because two versions that differ only by an optional field
encode identically. A record that fails to parse or convert is
reported and left to the caller, so the boundary store stays
untrusted and the bookmark assumes a fresh identity; omicron panics
instead. Nothing is written back at load time; the first ordinary
write persists the latest format.

A Versioned ancestor carries the version number, and two policy
traits carry the chain and its conversion. Record covers tenant
records, and its conversion may fail: the caller can quarantine a bad
record. Wire covers gossip messages, and its conversion is
infallible: delivery is prefix-closed, so the state machine could
neither skip a message that failed to convert nor stop at it. The
VersionedMessage enum tag already serves as the wire envelope, so the
message chain converts up when the state machine unwraps it;
v0::Message is wire version 0.

Version 0 is the shipped baseline for both tenants: the boundary
record, and the bookmark record wrapping the bytes rumors writes. The
latest format of each tenant is the live type itself, with no frozen
copy. A pinned snapshot freezes its bytes instead, so a change to the
live type fails the pin rather than silently changing version 0. The
format module documents the steps a format change requires. A version
newer than the software recognizes gets its own error, so a
downgraded sled reports what happened instead of calling the record
corrupt.

An adversarial review of this machinery drove five hardening
changes. Chain version numbers are checked at compile time: walk
carries a const assertion that each version exceeds its predecessor,
so a duplicate number cannot silently decode old records as the new
format, and a decreasing one cannot refuse a known version as
Future. The envelope refuses trailing elements instead of silently
ignoring them. Schema snapshots freeze the shapes of the two signed
types every sled rebuilds from the wire (the job request and the
challenge response), because a field added under the house serde
extension idiom would otherwise change what verifies, silently and
only in a mixed rack. And the certificate and login-key wire fields
now carry raw DER and OpenSSH bytes, parsed where used, so an
artifact a future dependency refuses to parse costs one import or
one login, never the gossip sessions that replay it. The Unknown message variant carries the whole received message
re-encoded, so its Serialize cannot fail; rumors treats a message
that fails to serialize as a panic. The wire bytes of all of these are
unchanged; every pinned snapshot passes as before.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
Fixes #76.

A session can end while jobs still wait in its queue: stopped by its
starter, superseded by a causally later start, or annihilated by a
concurrent one. Until now the sled dropped the queue silently, and
each waiting job stayed Queued forever. Now every queued job this
sled would have run gets the terminal skip status with a new reason,
SessionEnded, and jobs already started finish and report as usual.

A job request arriving after its session ended gets the same skip in
place of the old error status, so a job's fate does not depend on
whether its request arrived before or after the end. Replayed
requests report nothing, as with floor skips: re-reporting history on
every rejoin would grow the message set.

The new reason joins the pinned wire snapshots.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
We'll use a `sush` directory on the Omicron side.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
Match current omicron/main.

Co-Authored-By: Claude Mythos 5 <noreply@anthropic.com>
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.

2 participants