A command walk for nucleus
Draft, 2026-09-15. One many-sorted signature covering the node’s HTTP API, the guest’s vsock workload API, and the CLI’s artifact operations — specified tightly enough that a random walk can compute the enabled set from its own model and use the disabled set as an oracle.
Two companions, one on each side:
command-grammar.mdindexes each CLI leaf by the authority it demands. It finds no inhabitants for;among the CLI’s leaves, which is true of the CLI. The surfaces here are the other case: the node API and the guest’s vsock protocol are used as sequences, and every law below is a law about one.- Gatehouse’s build-ops algebra (a design document in the private gatehouse
repository, not linked from here) does the same job for the build lane. Its
sorts (
Spec,Tree,Scratch,Prog) are imported here unchanged; this document adds the pod, the guest, and the artifact. Nothing below depends on reading it.
Why algebraic and not pre/post
Pre/post conditions specify each operation against a hidden state you have to
describe to state them. Equations specify compositions — cancel ; cancel = cancel needs no account of what a pod record looks like. The argument that
equations are the better fit for generic APIs is
Meyer’s; the reason it matters
here is narrower. Nucleus’s headline claim is a statement about compositions:
no host conclusion depends on anything a guest said. That is not a
postcondition of any one operation. It is an equation over sequences (A6 below),
and it is only expressible in a grammar that has sequencing in it.
So: three judgments per operation — a precondition pre, an effect eff on an
abstract state, an observation obs — plus a separate layer of equations that
quantify over sequences. The first three drive the walk. The equations are what
the walk is for.
Carriers
The signature is many-sorted, and the sorts matter because they decompose the walk. Three carriers, one bridge:
Pod -- the node's live state: pods, their lineage, their served capabilities
Artifact -- receipts, envelopes, lineage chains, bundles, manifests: bytes on disk
Build -- Spec x Tree x Scratch, imported from gatehouse’s build-ops algebra
Artifact operations never read or write Pod. Pod operations never read
Artifact except through the bridge. The bridge is exactly three operations —
run, receipt, ship — and everything else factors. A walker may therefore
run two independent sub-walks and only needs the product walk across the bridge,
which is where the interesting failures are and where the budget should go.
Principals
Every operation is indexed by who issues it. This is not decoration; pre
reads it.
Principal := Anon | Operator(scope) | Pod(id) | Guest(pod) | Node
Guest(p) is the principal inside pod p speaking over vsock. It is distinct
from Pod(p), the principal that authenticated to the node’s HTTP API as pod
p, because their authorities differ and the difference is the architecture:
Guest says things, Pod proves them.
Abstract state
The walker maintains this and nothing else. It must be small enough to carry
exactly, and complete enough to decide every pre. It is both.
Sigma = { pods : PodId -> PodRec, node : NodeRec }
PodRec = {
parent : Option<PodId>,
phase : Booting | Running | Exited(Option<i32>) | Errored | Cancelled,
prog : ProgramId, -- digest of the IN-projection of the spec
served : Set<VsockOp>, -- the one-shot ledger
barrier : Bool, -- SNAPSHOT_READY announced
mount : NeverMounted | Mounted | Unknown,
bootargs : PerPod | Shared,
}
Two derived predicates do most of the work, and both already exist in the code:
may_manage(c, p) = c = None or parent(p) = Some c or p = c
personalized(p) = exists v in served(p) . personalizes_the_vm(v)
may_manage is pod_api::caller_may_manage — direct children and self, not the
transitive closure. personalizes_the_vm is the exhaustive match in
workload_api_protocol.rs, which the compiler already forces to classify every
new command. The grammar does not introduce a new classification; it consumes
the one the build already refuses to let drift. A new vsock command cannot be
added without answering the question the walker’s model reads.
Signature: the Pod carrier
Node HTTP surface. p! marks a subject that must exist.
create : Principal x PodSpec -> PodId + Refusal
list : Principal -> [PodInfo]
cancel : Principal x p! -> Ack + Refusal
logs : Principal x p! -> Bytes + Refusal
result : Principal x p! -> WorkloadResult + Refusal
receipt : Principal x p! -> Receipt + Refusal (bridge)
stdout : Principal x p! -> Bytes + Refusal
stderr : Principal x p! -> Bytes + Refusal
snapshot: Principal x p! -> Base + Refusal
health : Principal -> Ok
oidc : Principal x Token -> Assertion + Refusal
Guest vsock surface. Every one is issued by Guest(p) and by nobody else; the
socket is per-pod and the host knows which pod it belongs to without being told.
ping : Guest(p) -> Pong
bundle : Guest(p) -> TrustBundle
pod_list : Guest(p) -> [PodInfo]
svid : Guest(p) -> Svid
task_tok : Guest(p) -> Token
dlc : Guest(p) -> Admission
pod_cert : Guest(p) -> Cert
caller : Guest(p) -> Token
pod_spec : Guest(p) -> PodSpec
broker : Guest(p) -> Secret + Refusal(Repeat)
audit : Guest(p) -> Creds + Refusal(Repeat)
mediate : Guest(p) -> Key + Refusal(Repeat)
ready : Guest(p) -> Ack
ship : Guest(p) x ReceiptBytes -> Ack + Refusal (bridge)
Preconditions
pre(create, c, spec) = spec well-formed under deny_unknown_fields
/\ resources within node budget
pre(cancel, c, p) = may_manage(c, p) /\ phase(p) in {Booting, Running}
pre(logs|result|receipt
|stdout|stderr, c,p)= may_manage(c, p)
pre(list, c) = true -- result FILTERED by may_manage
pre(snapshot, c, p) = may_manage(c, p)
/\ barrier(p)
/\ not personalized(p)
/\ mount(p) = NeverMounted
/\ bootargs(p) = Shared
pre(v, Guest(p)) = phase(p) = Running
/\ (one_shot(v) -> v not in served(p))
pre(ship, Guest(p), b) = phase(p) = Running /\ |b| <= RECEIPT_MAX
where one_shot = {broker, audit, mediate}.
Three things to notice, because each is a place the grammar disagrees with an obvious guess:
pre(list)istrue.listis always enabled and the answer is scoped. That is a different testable claim from “refuse if unscoped”, and the walker checks it as an observation (O2) rather than a refusal.pre(snapshot)is a conjunction of one host fact (personalized), one guest fact (barrier), and one measurement (mount). None is derivable from the others; the code asks all three for that reason. A walker that models onlybarrierwill callsnapshotenabled when it is not, and will report a false failure — so the model state carries all three or the walk is noise.prefor guest commands does not mention the principal’s authority at all, because a guest has none to vary. What varies is history — the served ledger. The guest surface is a one-shot-ledger machine, not an authorization machine, and the grammar says so by which conjuncts appear.
Effects
Only these change Sigma. Everything else is eff = id, which is itself the
claim O1 checks.
eff(create) Sigma[pods += p |-> {parent = subject(c), phase = Booting, ...}]
eff(cancel, p) Sigma[phase(p) := Cancelled] ; cascade over lineage(p)
eff(v, Guest(p)) Sigma[served(p) += v]
eff(ready, p) Sigma[barrier(p) := true]
-- environment steps, not commands:
step_boot(p) Sigma[phase(p) := Running]
step_exit(p, code) Sigma[phase(p) := Exited(code)]
step_fault(p, why) Sigma[phase(p) := Errored]
step_mount(p) Sigma[mount(p) := Mounted]
The environment steps are the honest part. Errored and
WorkloadResult::Unavailable are unreachable by any command sequence — no
composition of the signature above produces them. A walk over commands alone
therefore leaves two states of the result lattice dead, and every refusal path
that branches on them untested. Either the walker gets a fault alphabet (kill
the VMM, truncate the scratch, stall the supervisor) or the grammar should admit
that those states are outside it. Naming them as environment steps rather than
omitting them keeps the reachability claim honest.
Signature: the Artifact carrier
Closed under itself, and much simpler — which is why it is worth separating.
sign : Payload x Key -> Envelope
verify : Envelope x TrustBundle -> Ok + Refusal(reason)
extend : Chain x Entry -> Chain
vchain : Chain -> Ok + Refusal(reason)
manifest: [Path] -> Manifest
attest : Attestation x Registry -> Ok + Refusal(reason)
replay : Receipt x Bundle -> Verdict (bridge)
tamper : Bytes x Index -> Bytes -- walker-only
tamper is not a shipped operation. It is in the signature because the
interesting equations about verify are equations about tamper, and a
grammar that cannot say “flip a byte” cannot state them.
Equations
These are what the walk asserts. Each quantifies over sequences, and each is checkable by comparing two executions or by an invariant maintained along one.
A1 — reads do not move. For every r in {list, logs, result, receipt, stdout, stderr, health, ping, bundle, pod_list}: eff(r) = id. Checkable
along a single walk: the model predicts every subsequent answer unchanged, so
inserting any number of reads anywhere must not change any later observation.
The one exception is spelled out, not waived: logs, stdout, stderr are
monotone-growing on a pod in phase = Running, and stable once
phase in {Exited, Cancelled, Errored}. A walk asserts growth-monotonicity
before termination and byte equality after it.
A2 — one-shot absorption. For v in {broker, audit, mediate}:
v ; v = v ; Refusal(Repeat)
and, crucially, the second call returns a refusal and no bytes. A walk that only checks “the second call errors” misses the failure that matters, which is a refusal that still leaks the secret in a diagnostic. The observation is on the response body, not the status.
A3 — cancel is absorbing. cancel ; cancel = cancel, and for every
state-advancing x, cancel ; x = cancel ; Refusal. Reads survive: cancel ; r = cancel ; r for r in A1’s set — a cancelled pod’s logs and receipt remain
readable, which is the whole point of cancelling rather than deleting.
A4 — identity is invariant under OUT, sensitive to IN. Let δ be a
perturbation of one PodSpec field. Then
field in OUT -> prog(create(spec)) = prog(create(δ·spec))
field in IN -> prog(create(spec)) != prog(create(δ·spec))
OUT = { vsock, cgroup, audit_sink, credentials,
metadata.name, metadata.task_grant_id }
IN = { work_dir, timeout_seconds, policy, budget_model, resources, network,
image, credentialed_egress, workload, seccomp,
metadata.namespace, metadata.labels }
The identity.rs match is exhaustive, so this table cannot silently fall out of
date — but it can be wrong, and A4 is how a walk catches a field classified
OUT that changes the answer. Two entries are worth the walker’s weight because
they are the ones a reader guesses wrong:
metadata.labelsis IN, though it reads like annotation. Two pods differing only in a label are different programs.credentialsis OUT, though it reads like authority. What the pod may reach is named bylaunch_hashin the result, not byprog.
A4 must also be stated at the leaf, not the field. policy is IN, but the
time tag inside an inline lattice is OUT — that distinction is the
validity-window bug closed last week, and a field-level A4 would not have caught
it.
A5 — personalization and snapshot do not commute.
ready ; snapshot = ready ; Base
ready ; svid ; snapshot = ready ; svid ; Refusal(PersonalizedSince)
For every v with personalizes_the_vm(v). This is the only non-commutation in
the grammar that the walker can hit by pure luck, and it is the one with the
worst blast radius when it fails (a base that hands one pod’s identity to every
clone). It deserves a weight, not a uniform draw.
A6 — guest operations are invisible to host observations. The central law.
Let G be any finite sequence drawn from the guest vsock surface minus ship,
and let o be any host observation (result, receipt, prog, or an
attestation). Then
o ∘ G = o
A guest may ask for things; nothing it asks for changes what the host concludes.
The walk form is a two-execution comparison: run a pod to completion with an
empty G, run it again with a randomly generated G, and assert the host’s
signed observations are byte-identical. ship is excluded because shipping a
receipt is supposed to move data guest→host — and that exclusion is where the
next law goes.
A7 — a shipped receipt is data, never authority. For any b:
verify(receipt(p)) after ship(Guest(p), b) = verify(receipt(p)) before
unless b verifies against a trusted signer. A guest can put bytes in front of
the host; it cannot make the host sign them. Walk form: ship adversarial bodies
(truncated, oversized, a valid receipt for a different pod, a replay of this
pod’s earlier receipt) and assert the host’s own receipt is unchanged and the
verdict on b is a refusal with the specific reason.
A8 — verification is exact. verify(sign(m, k), bundle(k)) = Ok and
verify(tamper(sign(m, k), i), bundle(k)) = Refusal for every byte index i.
The universally-quantified form is the point: a walk that flips one random byte
per iteration covers the envelope’s whole surface over a run, and any index
where verification still passes is a finding.
A9 — lineage is append-only. vchain(extend(c, e)) = Ok if vchain(c) = Ok; and for any c' != c reachable by tamper, vchain(c') = Refusal.
Combined with A8 this says the chain is as strong as its weakest envelope, which
is a claim worth failing loudly.
The walk
walk(Sigma, budget):
while budget:
E <- { (op, args) : pre(op, args) holds in Sigma }
D <- { (op, args) : pre(op, args) fails in Sigma } -- with the reason
(op, args, expect) <- weighted_draw(E ∪ D)
ans <- execute(op, args)
if expect = Enabled: assert obs(op, ans, Sigma)
else: assert ans = Refusal(expected_reason)
Sigma <- eff(op, args, Sigma)
maybe: Sigma <- environment_step(Sigma)
The disabled set is the oracle
This is the design decision that earns the specification. A grammar whose pre
only filters the draw tests the happy path and nothing else; every
authorization bug, every one-shot leak, every premature snapshot lives in D.
Drawing from E ∪ D and asserting the specific named reason — not merely
“an error” — turns pre from a generator constraint into a security oracle.
Nucleus already refuses with named reasons everywhere and never with a silent
fallback, so the reasons exist to be asserted against.
The sharpest instances: cancel a pod you are a grandparent of (must refuse —
may_manage is deliberately non-transitive, and a walk generating three-deep
lineage is what keeps that deliberate rather than accidental); snapshot a pod
that announced ready and then fetched an SVID (A5); a second broker (A2).
Weighting
A uniform draw over E ∪ D spends its budget on ping and health. The
weighted random walk for CFSM
conformance
result is the one to copy: weight inversely by visit count over transitions,
not states, so the walk is pulled toward untried (state, command) pairs. Two
nucleus-specific adjustments:
- Depth costs. Lineage depth 3 is needed for the non-transitivity test and
is reached only by three nested
creates that a novelty walker has no reason to prefer. Seed it, or weightcreateby a lineage-depth histogram. - One-shots are consumed. After
brokeris served, every further draw of it is the sameDtransition. Cap repeats per pod and spend the budget on a fresh pod instead.
Shrinking
proptest-state-machine is the right
harness: it generates operation sequences against a reference model, checks
postconditions, and shrinks to a minimal failing sequence. The model above is
its ReferenceStateMachine — Sigma is the state, pre is
preconditions, eff is apply, obs is the postcondition check. The mapping
is close enough that the doc and the impl should share the names.
The harness belongs in nucleus-node, beside the handlers. An earlier draft of
this document put it in crates/nucleus-spec/tests/, which cannot work: every
classification the model borrows lives in nucleus-node — caller_may_manage
(pod_api.rs, private), WorkloadApiCommand::personalizes_the_vm
(workload_api_protocol.rs) and SnapshotSafety (snapshot.rs) — and
nucleus-spec sits below nucleus-node in the dependency graph. The goal that
placement was chosen for still holds from inside the node crate: build the
model’s material without .., so a field added to PodMaterial or a variant
added to WorkloadApiCommand stops the walk compiling until it is classified.
proptest-state-machine earns its dependency when generation has to read
state — the Pod carrier, where cancel must usually name a pod that exists.
The guest surface does not need it: every command is always drawable, and the
model alone decides whether it lands in E or D, so plain proptest over a
Vec of commands shrinks just as well.
Refusals must be values
“Assert the specific named reason” needs a reason that is a value. Where the
host refuses with free text — the workload API replied
{"error":"broker secret already served"} — a walk comparing strings breaks on
any rewording and cannot tell whether its list of reasons is complete. A
surface enters the walk once its refusals are an enum whose Display is the
wire text, pinned byte for byte, because guests match on those strings.
Shrinking matters more here than in a typical state-machine test because A6 and
A7 fail as pairs of executions, and an unshrunk counterexample to A6 is a
200-command guest transcript nobody can read. Shrinking must be over G, and it
must preserve the pod’s completion, or it will shrink to “the pod never ran”.
What this grammar does not yet cover
- The CLI’s 24 top-level subcommands are not all in it (51 leaves, counted
by
command-grammar.md).Audit,Trust,Guard,Setup,Lockdown,Observe,Grant,Node,Start,Stopare node- and operator-configuration operations whose state is the node’s, not a pod’s, andNodeRecabove is a placeholder. That carrier needs the same treatment and has not had it. The ones that are covered —Envelope,EnvelopeVerify,Lineage,LineageVerifyChain,Bundle,Verify,VerifyAttestation,Manifest,Replay— are the Artifact carrier, complete. ErroredandUnavailableare unreachable without a fault alphabet, as above. Until one exists, any claim that the walk “covers the result lattice” is false.obsis under-specified forreceipt. The grammar says a receipt is returned; it does not say what must be in it. A6 compares two receipts for equality, which is strong, but it does not check that the receipt says anything true. That check isreplay, and wiringreplayinto the walk as a postcondition on every terminal pod is the highest-value next step.- Time.
timeout_secondsis IN, so a pod that times out is a different program from one that does not, and the walk has no way to reach a timeout cheaply. Either timeouts get a scaled clock or that branch stays untested.
Commutation census
The laws above were written down; the census measures them. Treat the command
alphabet as the axes of a cube: a 2-face (a, b) is filled when a ; b and
b ; a are indistinguishable to the host from every reachable state, and
hollow otherwise. This is the higher-dimensional-automaton view of effects
(Pratt 1991): independent effects fill squares, conflicts leave them hollow.
Hollow faces are sequencing laws; filled faces are orderings a walk need only
explore once (partial-order reduction).
For the guest surface (workload_api_vsock/walk/census.rs), with the host’s
snapshot decision added as a letter — guest commands alone barely conflict —
the census finds exactly 10 hollow faces of 120: every personalising command,
and SNAPSHOT_READY, against the snapshot decision. That is A5, rediscovered
rather than restated. It is asserted both ways: an undeclared hollow face (a
new law) fails, and so does a declared one that filled. The non-idempotent
commands are exactly A2’s one-shots plus SHIP_RECEIPT. Each face is hollow
from some states and filled from others — once a VM is personalised, a further
personalising fetch and the snapshot decision commute — so a law is about where
state changes, not about a fixed pair.
The same census runs over the pod API (pod_api/walk/census.rs: 10 hollow faces
of 55, only the creates non-idempotent, A3 and non-transitive management as
consequences) and over the faces between the two surfaces on one pod
(pod_api/walk/cross.rs). The cross census found that cancel was not a barrier:
the workload-API bridge kept serving connections opened before the cancel (#2930).
Footprints: the laws are derived, not written
The three censuses’ laws were one fact in three spellings: two effects commute
unless one writes what the other reads (Mazurkiewicz independence from read/write
sets; the frame rule of separation logic). So each surface now declares a
footprint per command — the resources it Reads, Sets (a blind overwrite:
idempotent) or Updates (read-modify-write, append, a one-shot consumed) — and
effect_footprint.rs derives the hollow faces and the non-idempotent commands.
The censuses measure both against the code in both directions, and the
partial-order-reduced walk reads the same derived relation.
Two laws that were rules become footprints:
- A5. The host’s snapshot decision reads personalised and at barrier; the
personalising commands and
SNAPSHOT_READYwrite them. - Cancel is a barrier. Every guest command is scoped to its pod, which is a read of the pod’s liveness; a cancel writes it.
Driving a footprint wrong turns the census red on exactly the face it implies:
a listing that stops reading its children; POD_LIST not reading a child’s
liveness; PING not scoped to its pod; the snapshot decision not reading the
barrier (which also reddens the reduced walk); an Update counted as idempotent.
Status
The guest-surface walk (A2, A5) and the pod-surface walk (A3, lineage scoping)
are property tests in nucleus-node; A4 is a leaf walk in nucleus-spec; A8
and A9 are artifact walks in nucleus-envelope and nucleus-lineage; A6 and
A7 are a live harness, nucleus-perf guest-transcript, which needs a KVM host
and runs in no workflow. The commutation census covers the guest surface, the pod
surface and the faces between them, with its laws derived from footprints.
Sources
- Specification of generic APIs, or: why algebraic may be better than pre/post
- A weighted random walk approach for conformance testing of a system specified as communicating finite state machines
- Random Test Generation of Application Programming Interfaces
- A Random Walk Based Algorithm for Structural Test Case Generation
- Build Systems à la Carte