Skip to content

Model the runner microVM in .dag: the prerequisite is a refusal, and it belongs to the topology rather than to virtualization - #9578

Merged
briansrls merged 3 commits into
mainfrom
session/cool-seal-638-microvm-runner
Sep 2, 2026
Merged

briansrls merged 3 commits into
mainfrom
session/cool-seal-638-microvm-runner

Conversation

@briansrls

@briansrls briansrls commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Replaces the markdown design note this PR opened with — the operator asked for the modeling itself, not a document describing it. A doc asserting that a microVM closes the credential-orphan class is a claim no Accepted program can read; a refusal that fires on the fleet's own declared lifecycle intent is one the substrate checks.

What lands

dag/extdeps/virtualization/firecracker.dag — cites Firecracker as upstream (v1.16.1/aarch64, /dev/kvm), keeping its configuration vocabulary in its own field names rather than renaming it.

dag/gunbc/runner_microvm.dag — the binding. It reuses product.fabric.isolation's existing guarantee vocabulary rather than minting a second one; that module deliberately leaves the delivering mechanism unnamed as a realization handler, and this is that handler. The VM configuration is derived from the image, size and credential transport, so fields a caller could get wrong independently are consequences instead.

The credential carrier is the measured defect, so it is refused by name

Five confirmed specimens on srv4 (slots 04, 08, 13, plus an earlier pair) share one shape: an orphaned listener from a prior incarnation — logged by systemd as remains running after unit stopped — deletes .credentials from the slot directory a live job is using; the job dies ~45 minutes later at token refresh. srv4-13 carried two orphan generations racing on one directory.

A per-VM read-only drive has no such sharing to express, so AttemptScopedSecrets becomes a property of the carrier rather than a promise a handler makes. JitConfigInSharedSlotDirectory is refused with its own typed cause.

The topology is a carried fact — the correction that matters

An earlier revision of this module refused every microVM under an orphanable lifecycle intent, reasoning that a microVM relocates the orphan to the VMM rather than removing it. That is sound for one topology and wrong for the other:

topology why guarantee needed?
VmmIsMainProcess the unit execs the jailer, which execs Firecracker, so the VMM is MainPID; killing it destroys the guest no
VmmUnderLifecycleController a controller stays MainPID to wait for the VMM and perform teardown and readback, putting the VMM back under a parent — the shape that orphans today yes

Collapsing these would have refused a correct design and called it safety. The prerequisite attaches to the topology, not to virtualization, and gunbc.runner_unit's lifecycle_intent_cannot_orphan is consumed rather than re-derived. Caught in review by the side-chat, not by me.

One concept, one version row

RunnerGuestImage.runner is the cited ActionsRunnerRelease itself, not a version string. The MVP image baked 2.337.0 while extdeps.github.actions_runner pins 2.336.0 with upstream digests — a second authority for one fact, drifting the moment either moved. There is now no field in which they can disagree.

Evidence

Ten witnesses green by execution. The discriminating REDs were established by mutation, not asserted:

mutation witness baseline → mutant
collapse the topology split direct_exec_vmm_is_admitted true → false
topology_is_what_decides true → false
orphanable_unit_refuses true → true
bypass the orphan gate orphanable_unit_refuses true → false
cgroup_teardown_admits true → true
shared_slot_credential_refused true → true

Each mutation reds exactly the witnesses encoding its claim and leaves the others standing, so none of the three gates is a constant. Both source files were restored and verified by sha256 after each run.

MVP proven by execution on srv4: Firecracker v1.16.1, Ubuntu 24.04 guest, tap networking, stock runner reaching Listening for Jobs at 5.3s from boot.

What this does not claim

The model is not the migration. Unmodeled here: successor creation gated on sanitation readback (Restart=always would start the next VM merely because the VMM exited); per-incarnation derivation of tap/MAC/subnet/writable layer — the MVP used singletons, which is a valid single-VM proof and not a fleet topology; guest-local jobserver; and sccache, where a miss compiles as a child of the sccache server, so a host server reached from a guest would execute the miss outside the isolation boundary.

No job has run end to end in a VM, no clean poweroff observed, and the floor's working set in a guest is unmeasured. Memory sizing is deliberately absent — one 15.0 GiB sample cannot support a cap.

CI

The 47 failing witnesses on this branch are not from this change. The sorted failing-identity set hashes to 1a3b72ff1e3f5332cdf1090e47dfdd8187f6ff80a870f4f48bb64e4c76964972, byte-identical across six independent samples — this PR, #9531, #9568, #9434, and main itself — and originates from #9106, which this branch's base inherits. Verified by merge-base ancestry, and no failing identity resolves to a file this PR touches.

…he prerequisite that makes it sound

A runner slot's Runner.Listener can be orphaned by a restart and then delete the
LIVE incarnation's credentials out of the shared slot directory, killing whatever
job is running at its next token refresh. Confirmed at pid grain twice. A microVM
per registration has no shared slot directory and no surviving listener, so the
defect has nowhere to live.

MEASURED ON srv4, touching no CI configuration: Firecracker v1.16.1 aarch64 on
Neoverse N1 (which upstream tests continuously), Ubuntu 24.04.4 guest, tap
networking with host-to-guest 2/2 at 0.297ms, and actions-runner 2.337.0
registering FROM INSIDE THE VM 5.3 seconds after boot. JIT config is minted on
the host from the org GitHub App -- the same path jit-runner.sh uses -- and
handed to the guest on a second block device; a one-shot unit runs one job and
powers off. NOT yet proven: a workflow job end-to-end, clean poweroff, and the
floor's working set inside a VM.

THE SHAPE IS ONE VM PER REGISTRATION, NOT PER JOB BODY, and the distinction is
load-bearing rather than stylistic: ROOTLESS_DOCKER_CI_PLAN.md isolates the job
body and leaves the runner on the host, so it could not have prevented either
specimen -- the victim and the killer are both listeners living outside that
body. That is a finding against a plan currently awaiting sign-off as a fix for
something it does not fix. Adopting an orchestrator is rejected on section 6
grounds: it would replace jit-runner.sh, unit-per-slot and JIT registration,
which gunbc.runner_unit already models.

THE PREREQUISITE IS THE PART A READER SHOULD NOT SKIP. Under KillMode=process the
unit's tracked child becomes the VMM, and an orphaned VMM keeps its guest alive
with the same credentials in the same directory -- the identical failure with a
heavier process holding it. gunbc.runner_unit already carries the deciding
predicate and the measurement behind it (the listener is a GRANDCHILD of MainPID
because run.sh does not exec). So this design DEPENDS ON #9401's ExitType=cgroup
being deployed rather than substituting for it.

Unsolved and named rather than discovered late: cache strategy (sccache over a
host UDS and the shared jobserver both assume a shared host), per-VM memory
sizing (the 15.0 GiB peak is ONE sample -- the distribution is required, and if
the honest size does not fit the slot count that is the finding), the cell
resource boundary relocating into the guest, and srv3 unmeasured.

Scaffold: the reproduction record in section 9 is temporary by construction, with
its dissolution obligation naming a .dag authority rendering it the way
runner_unit_file already renders the unit. Landed under explicit operator
direction, recorded because a dissolution condition never authorizes its own debt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sg8fwMAaPgbsNtns1gpVd9
@gunbai-bot gunbai-bot Bot changed the title floor runs keep getting cancelled Per-registration Firecracker microVMs: MVP proven by execution, and the prerequisite that makes it sound Aug 28, 2026
@gunbai-bot
gunbai-bot Bot marked this pull request as ready for review August 28, 2026 05:26
…it belongs to the topology rather than to virtualization

Replaces the markdown design note with the modeling it was describing. A doc
asserting that a microVM closes the credential-orphan class is a claim no
Accepted program can read; a refusal that fires on the fleet's own declared
lifecycle intent is one the substrate checks.

WHAT LANDS

extdeps/virtualization/firecracker.dag cites Firecracker as upstream -- release
v1.16.1/aarch64, /dev/kvm, and its own configuration vocabulary in its own field
names, not renamed.

gunbc/runner_microvm.dag binds it. It reuses product.fabric.isolation's existing
guarantee vocabulary rather than minting a second one -- that module deliberately
leaves the delivering mechanism unnamed as a realization handler, and this is
that handler. The VM configuration is DERIVED from the image, size and credential
transport rather than authored beside them, so the fields a caller could get
wrong independently are consequences instead.

THE CREDENTIAL CARRIER IS THE MEASURED DEFECT, SO IT IS REFUSED BY NAME

Five confirmed specimens on srv4 (slots 04, 08, 13, plus the earlier pair) share
one shape: an orphaned listener from a prior incarnation, logged by systemd as
"remains running after unit stopped", deletes .credentials from the slot
directory a LIVE job is using; the job dies ~45 minutes later at token refresh.
srv4-13 carried two orphan generations racing on one directory.

A per-VM read-only drive has no such sharing to express, so AttemptScopedSecrets
becomes a property of the carrier rather than a promise a handler makes.
JitConfigInSharedSlotDirectory is refused with its own typed cause.

THE TOPOLOGY IS A CARRIED FACT, AND THIS IS THE CORRECTION THAT MATTERS

An earlier revision refused every microVM under an orphanable lifecycle intent,
reasoning that a microVM relocates the orphan to the VMM rather than removing it.
That is sound for ONE topology and wrong for the other:

  VmmIsMainProcess          the unit execs the jailer, which execs Firecracker,
                            so the VMM IS MainPID. Killing it destroys the guest.
                            No kill mode can orphan anything. Guarantee not needed.

  VmmUnderLifecycleController  a controller stays MainPID to wait for the VMM and
                            perform teardown and readback. That puts the VMM back
                            under a parent -- the shape that orphans today.
                            Guarantee IS needed.

Collapsing these would have refused a correct design and called it safety. The
prerequisite therefore attaches to the topology, not to virtualization, and
gunbc.runner_unit's lifecycle_intent_cannot_orphan is consumed rather than
re-derived.

ONE CONCEPT, ONE VERSION ROW

RunnerGuestImage.runner is the cited ActionsRunnerRelease itself, not a version
string. The MVP image happened to bake 2.337.0 while extdeps.github.actions_runner
pins 2.336.0 with upstream digests -- a second authority for one fact, drifting
the moment either moved. There is now no field in which they can disagree.

EVIDENCE

Ten witnesses, green by execution. The discriminating REDs were established by
mutation, not asserted:

  collapse the topology split       -> direct_exec_vmm_is_admitted        true -> FALSE
                                      topology_is_what_decides           true -> FALSE
                                      orphanable_unit_refuses            true -> true
  bypass the orphan gate entirely   -> orphanable_unit_refuses           true -> FALSE
                                      cgroup_teardown_admits             true -> true
                                      shared_slot_credential_refused     true -> true

Each mutation reds exactly the witnesses that encode its claim and leaves the
others standing, so none of the three gates is a constant. Both source files were
restored and verified by sha256 after each run.

MVP PROVEN BY EXECUTION, on srv4: Firecracker v1.16.1, Ubuntu 24.04 guest, tap
networking, stock runner reaching "Listening for Jobs" at 5.3s from boot.

WHAT THIS DOES NOT CLAIM

The model is not the migration. Successor creation gated on sanitation readback,
per-incarnation derivation of tap/MAC/subnet/writable layer (the MVP used
singletons), guest-local jobserver, and sccache -- where a miss compiles as a
child of the sccache server, so a host server would execute the miss outside the
isolation boundary -- are all unmodeled here. No job has run end to end in a VM,
no clean poweroff observed, and the floor's working set in a guest is unmeasured.
Memory sizing is deliberately absent: one 15.0 GiB sample cannot support a cap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sg8fwMAaPgbsNtns1gpVd9
@gunbai-bot gunbai-bot Bot changed the title Per-registration Firecracker microVMs: MVP proven by execution, and the prerequisite that makes it sound Model the runner microVM in .dag: the prerequisite is a refusal, and it belongs to the topology rather than to virtualization Aug 28, 2026
…not bare Int

Review finding (codex/gpt-5.6-sol, review 57217): RunnerMicroVmSize propagated
Firecracker's raw `mem_size_mib: Int` inward. Accepted.

A bare Int is a second representation of a magnitude std.measure already owns,
and product/fabric/envelope.dag -- the module family this VM will eventually
answer to -- already speaks HardwareThreadCount and the Memory measure family
for exactly these two quantities. Two spellings of one magnitude is the §3
failure, and it is what lets a vcpu count be compared against a memory size with
nothing refusing.

The raw Int stays at the cited extdeps boundary, which is correct: extdeps models
what the API actually accepts. Conversion happens once, at the point the config
is rendered.

MEBIBYTE RATHER THAN THE ByteSize THE REVIEW NAMED, deliberately. Firecracker's
field is denominated in MiB, so `Measure<Memory, Mebi, Nat>` grounds it EXACTLY --
the same authority, one scale along the axis that authority exists to carry, with
no inbound multiply to get wrong. Grounding in bytes would restate the unit
rather than ground it. The review's principle is right; ByteSize is one scale of
it and not the one that fits this field.

All ten witnesses re-run green by execution after the change, including
guest_image_runner_is_the_cited_fleet_release and both topology arms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sg8fwMAaPgbsNtns1gpVd9
@gunbai-bot

gunbai-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Both findings from review 57217 verified against the code and the authority doc. One accepted and fixed in 92e932b; one rejected with counts.

Finding 1 — accepted, fixed

RunnerMicroVmSize now carries HardwareThreadCount and Mebibyte from std.measure. The raw Int stays at the cited extdeps boundary — extdeps models what the API accepts — and conversion happens once, where the config is rendered.

The finding is right and the reason is stronger than style: product/fabric/envelope.dag, the module family this VM will answer to, already uses HardwareThreadCount for CpuRequirement.min_threads and the Memory measure family for MemoryRequirement.min_bytes. Carrying bare Ints inward was a second spelling of magnitudes std.measure already owns, and it is what would let a vcpu count be compared against a memory size with nothing refusing.

One deviation, stated rather than made quietly: I used Mebibyte, not the ByteSize the review named. Firecracker's field is denominated in MiB, so Measure<Memory, Mebi, Nat> grounds it exactly — the same authority, one scale along the axis that authority exists to carry, with no inbound multiply to get wrong. Grounding in bytes would restate the unit rather than ground it. If the intent was specifically the One-scale member rather than the Memory family, say so and I will change it.

All ten witnesses re-run green by execution after the change.

Finding 2 — rejected

Every authority this finding cites is absent from DESIGN.md. Measured by grep -ciF against the file:

cited occurrences
M12 0
Practice 13 0
predicate/walker 0
walker dissolution 0
disposition receipt 0
canonical fold/query 0

And the pattern called merge-blocking is the corpus idiom: 353 Bool-returning functions across dag/std/ and dag/product/, including both functions this PR consumes directly —

  • extdeps.systemd.unit_file systemd_kill_mode_reaches_whole_cgroup matches four SystemdKillMode variants to a Bool;
  • product.fabric.isolation isolation_profile_contains returns a Bool over IsolationGuarantee.

gunbc.runner_unit lifecycle_intent_cannot_orphan — the predicate this PR's whole refusal is derived from — is built on the first of those. Accepting this finding would require rejecting the corpus, on a rule that is not in the authority document.

There is a real adjacent rule in DESIGN, under recurring failure modes: "total at the level examined, blind one level down". It warns against a match that is exhaustive over the type it examines while collapsing distinct causes into one arm — the tell being "a _ or a bare binding in the arm that carries the payload". Both predicates here name every variant explicitly, carry no wildcard, and each arm has one remedy. They are the shape that rule prescribes, not the one it forbids.

If there is a specific DESIGN clause intended here under a different name, quote it and I will act on it.

— sent from cool-seal-638

@gunbai-bot

gunbai-bot Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

CI: the two failing checks are not from this change (8th identical sample)

Verified on this head (92e932b, floor job 98800063009) rather than assumed:

planned=12959 executed=12959 terminal=12959 passed=12625
failed=47  interrupted_before_verdict=44  known_red_held=33  stale_quarantine=1

The sorted failing-identity set hashes to 1a3b72ff1e3f5332cdf1090e47dfdd8187f6ff80a870f4f48bb64e4c76964972 — byte-identical to #9531, #9568, #9434, main itself, and this PR's previous head. That set originates from #9106, which this branch's base inherits (confirmed by git merge-base --is-ancestor, not by timestamp). No failing identity resolves to a file this PR touches (grep -iE "runner_microvm|firecracker" over the failing set returns nothing).

This PR's witnesses are enrolled and passing

Stated with its derivation, because absence from the log is not evidence on its own — the floor enumerates only non-passing rows (94 here: 47 FAIL, 44 INTERRUPTED, 2 OVER-COST, 1 STALE-QUARANTINE). No passing identity is ever named, so a green witness is invisible by construction.

The discovery line is what settles it:

offered=13500 routed=12959 declined_long=541 declined_fixture=0
 — every discovered site is exactly one of these

test.claim.runner_microvm_witness_test is neither a long home nor a fixture, so by that line's own totality claim it is in the routed 12959. It appears in none of the 94 non-passing rows. Therefore it executed and passed.

That is consistent with the local evidence: all ten witnesses green by execution, and the discriminating REDs established by mutation — collapsing the topology split reds direct_exec_vmm_is_admitted and topology_is_what_decides while leaving orphanable_unit_refuses standing; bypassing the orphan gate reds orphanable_unit_refuses while leaving both controls standing.

I am not pushing a fix for the 47. It is a main-wide regression under separate triage, and a fix attempted from this branch would be re-measuring a defect already attributed eight times over.

— sent from cool-seal-638

@briansrls
briansrls merged commit 4605989 into main Sep 2, 2026
1 of 3 checks passed
@briansrls
briansrls deleted the session/cool-seal-638-microvm-runner branch September 2, 2026 07:21
gunbai-bot Bot pushed a commit that referenced this pull request Sep 2, 2026
…olve the coproduct predicates review 57217 named

#9578 landed the microVM model with three things still open. This closes them.

THE VM SIZE IS THE CELL'S RESOURCE ENVELOPE, DERIVED. FCI-1 freezes
`cell-resource-envelope` as an output identity, and this fleet already answers it once
in gunbc.runner_slot_allocation gunbc_runner_slot_desired. runner_microvm authored a
second one beside it -- 4 vCPU / 4 GiB against a cell declaring 6 cores / 16 GiB -- which
is the section 3 failure, and not a harmless one: a guest sized under the cell's ceiling
makes the same work die inside a slice that still had room, with the cgroup ledger
showing the slice well under its limit while the job died. The size is now derived from
that one authority, so there is no second row to drift.

THE PREDICATES ARE DISSOLVED INTO THE DECIDING SITE, not rostered. Review 57217's live
finding was two Bools classifying coproducts -- jit_transport_is_attempt_scoped over
JitCredentialTransport and topology_requires_cgroup_reaching_teardown over
VmmProcessTopology. gunbc.non_fold_residue rosters matches carrying a top-level WILDCARD
arm; these are total, so the roster is not their form. commit_workflow's CheckRemoval
selection is: match the coproduct where the consequence is decided, so a new variant
fails to compile at the site that must rule on it. runner_microvm_admission is now that
cascade. The two functions I would otherwise have added for the envelope comparison are
absent for the same reason.

An unresolved envelope and a disagreeing envelope are separate refusal arms. They have
different owners and different remedies, and one Bool would have answered for both.

std.measure gains mebibyte_scale_factor_bytes and mebibyte_to_byte_size, exact peers of
the gibibyte and kibibyte conversions already there -- a fill along the scale axis, not a
new concept.

Both files move into the runner family directories, where every other runner_*.dag and
runner_*_witness_test.dag already lives; they were the only two stragglers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc
gunbai-bot Bot pushed a commit that referenced this pull request Sep 2, 2026
…s a ConstructionMechanism where a Disposition belongs

main at 4605989 refuses the entire required floor on one line:

  dag/extdeps/virtualization/firecracker.dag:15:3:
  error: field 'reason' not found in type 'SingleAuthority'

`SingleAuthority` is a variant of ConstructionMechanism, not of Disposition, and it
carries no fields at all; the row wrote it as a record with a `reason`. std.disposition's
reason-carrying Disposition variant is `Terminal`, which 64 modules already use, and this
is the ONLY site in the corpus spelling a Disposition as `SingleAuthority { .. }` -- a
census of one, landed by #9578.

The blast radius is the point rather than a footnote: the floor is one prepared subject
and one fold, so a single module that fails to compile refuses all 2170 resolved modules
before any witness executes. That is why no witness identity appears in the failing run
and why required_floor_disposition.tsv was never written -- the run did not reach the
phase that produces it. It also means the ci_budget_tree question is not answered by this
run in either direction: those witnesses did not execute.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc
gunbai-bot Bot added a commit that referenced this pull request Sep 2, 2026
…s a ConstructionMechanism where a Disposition belongs (#10023)

main at 4605989 refuses the entire required floor on one line:

  dag/extdeps/virtualization/firecracker.dag:15:3:
  error: field 'reason' not found in type 'SingleAuthority'

`SingleAuthority` is a variant of ConstructionMechanism, not of Disposition, and it
carries no fields at all; the row wrote it as a record with a `reason`. std.disposition's
reason-carrying Disposition variant is `Terminal`, which 64 modules already use, and this
is the ONLY site in the corpus spelling a Disposition as `SingleAuthority { .. }` -- a
census of one, landed by #9578.

The blast radius is the point rather than a footnote: the floor is one prepared subject
and one fold, so a single module that fails to compile refuses all 2170 resolved modules
before any witness executes. That is why no witness identity appears in the failing run
and why required_floor_disposition.tsv was never written -- the run did not reach the
phase that produces it. It also means the ci_budget_tree question is not answered by this
run in either direction: those witnesses did not execute.


Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

Co-authored-by: Brian Searls <briansearls1@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: gunbc-ci-auto-heal <gunbc-ci-auto-heal@users.noreply.github.com>
gunbai-bot Bot pushed a commit that referenced this pull request Sep 3, 2026
…cracker boundary (#10021)

* Reconcile runner_microvm with FCI-1's bounded cell envelope, and dissolve the coproduct predicates review 57217 named

#9578 landed the microVM model with three things still open. This closes them.

THE VM SIZE IS THE CELL'S RESOURCE ENVELOPE, DERIVED. FCI-1 freezes
`cell-resource-envelope` as an output identity, and this fleet already answers it once
in gunbc.runner_slot_allocation gunbc_runner_slot_desired. runner_microvm authored a
second one beside it -- 4 vCPU / 4 GiB against a cell declaring 6 cores / 16 GiB -- which
is the section 3 failure, and not a harmless one: a guest sized under the cell's ceiling
makes the same work die inside a slice that still had room, with the cgroup ledger
showing the slice well under its limit while the job died. The size is now derived from
that one authority, so there is no second row to drift.

THE PREDICATES ARE DISSOLVED INTO THE DECIDING SITE, not rostered. Review 57217's live
finding was two Bools classifying coproducts -- jit_transport_is_attempt_scoped over
JitCredentialTransport and topology_requires_cgroup_reaching_teardown over
VmmProcessTopology. gunbc.non_fold_residue rosters matches carrying a top-level WILDCARD
arm; these are total, so the roster is not their form. commit_workflow's CheckRemoval
selection is: match the coproduct where the consequence is decided, so a new variant
fails to compile at the site that must rule on it. runner_microvm_admission is now that
cascade. The two functions I would otherwise have added for the envelope comparison are
absent for the same reason.

An unresolved envelope and a disagreeing envelope are separate refusal arms. They have
different owners and different remedies, and one Bool would have answered for both.

std.measure gains mebibyte_scale_factor_bytes and mebibyte_to_byte_size, exact peers of
the gibibyte and kibibyte conversions already there -- a fill along the scale axis, not a
new concept.

Both files move into the runner family directories, where every other runner_*.dag and
runner_*_witness_test.dag already lives; they were the only two stragglers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* The witness fixture's unresolved arm recursed instead of refusing, and the coverage hole gets its second cited instance

TWO CHANGES, ONE A REAL DEFECT CAUGHT BY REVIEW 58648.

THE UNRESOLVED ARM WAS UNBOUNDED MUTUAL RECURSION. specimen_config's unresolved arm
substituted two_root_config(), and two_root_config built three of its four fields FROM
specimen_config(). No descent, in a file whose subject is fail-closed refusal, reachable
exactly when the cell envelope stops resolving -- the one case the arm existed to handle.

It was a fabricated fallback on its own terms as well. Substituting a deliberately
malformed configuration makes admission refuse for a DIFFERENT reason, converting "the
envelope did not resolve" into "the VM was malformed" -- two states with different owners.
That is the absorbing arm DESIGN 5 forbids, and this PR's own module changes split those
exact two states into separate refusal arms while the fixture quietly re-merged them.

specimen_config -> specimen_resolution, returning RunnerMicroVmConfigResolution with NO
fallback arm. All nine witness call sites match it, so an unresolved envelope makes each
witness false by its own arm. two_root_config is rebuilt from mvp_sized_config, a plain
constructor, so it no longer reaches the specimen at all.

THE UNOWNED-MIRROR COVERAGE HOLE GETS ITS SECOND CITED INSTANCE, as an annotation on the
witness that already documents it rather than as a new artifact. cli_run.rs IS in
HAND_MAINTAINED_STAGE0_FILES; v1_compiler_compiler_tests_rust.rs is NOT, so it is emitted
rather than copied. One instance on a hand-authored file is a bookkeeping gap; an instance
on a GENERATED mirror says the crate-partition authority does not cover files the regen
itself produces. The two differ on exactly the axis that separates those readings, which
is why the second is evidence rather than a duplicate. Observed 2026-09-02 when
--regen-round-cost refused its partitioned rebuild naming that mirror.

Not fixed here: stage0_crate_partition_generated.dag declares itself generated from
v2.workflow.rust_crate_partition and not hand-editable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* Regenerate std_measure.rs: the mirror of the two Mebi conversions this branch added

required-witnesses-build refused this branch:

  required-ci: regen FAIL generated surface drift: std_measure.rs
  required-ci: FAILED PHASE regen (1 failure(s))

dag/std/measure.dag gained mebibyte_scale_factor_bytes and mebibyte_to_byte_size in this
branch; src/v1/stage0/src/std_measure.rs is its mirror and was never regenerated, so the
committed surface did not carry them.

PRODUCED, NOT HAND-WRITTEN: installed from the --required-regen candidate tree, which was
also the check that std_measure.rs is the ONLY drift (diff -rq against the candidate names
that file and no other; the run's own line reads declared_divergent=1 [main.rs], the
standing declared divergence, and generated-artifact rostered=35 adjudicated=35 matches=35
drifted=0).

ONE ROUND, NOT TWO: std_measure.rs is compiled from source rather than emitted through a
seed function, so there is no install-and-rebuild step between the authority edit and the
mirror -- unlike the compiler_tests.rs chain, where the emitted text comes from the seed's
own compiled-in producer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* The guest was sized at the WHOLE cell ceiling, so the slice would OOM its own VM

Deriving the guest's RAM from the cell envelope fixed the AUTHORITY question and left the
ARITHMETIC one. `machine_config.mem_size_mib` is GUEST RAM, and the Firecracker VMM process
that owns the guest lives inside the SAME cell. Setting guest RAM to the cell's entire
MemoryMax leaves nothing for the VMM's own resident footprint, so the slice OOM-kills the
VM it exists to bound.

It is the same class as the authored 4 GiB this module used to carry, inverted. Undersized,
work dies inside a guest whose slice had room. Sized at exactly the ceiling, the guest
believes it has an envelope the cell cannot honour. The safety property is not that the
guest EQUALS the envelope, it is that the guest PLUS its realization FITS INSIDE it, and
equality is the specific wrong answer that most resembles the right one -- which is why it
survived both authoring and review. The refusal reason one function away already said "one
sized above it escapes the boundary the slice exists to hold": the prose and the
construction were never checked against each other.

So the memory axis REFUSES, naming the missing fact rather than a symptom: guest RAM is the
cell envelope MINUS the Firecracker realization's own footprint, and no such quantity is
cited in extdeps.virtualization.firecracker. Inventing one would be a fabricated plausible
output wearing a derivation's clothes.

THE vCPU AXIS IS UNAFFECTED, and the witnesses keep that visible. The core resolution is now
a PARAMETER, because gunbc_runner_cores_per_slot is nullary and always resolves -- at the
fleet call site no witness input can reach its unresolved arm, so the pair would have been
unauthorable. The two arms reach DIFFERENT causes, which is what separates a refusal that
reports which fact is missing from one that refuses uniformly.

The whole-mebibyte check is DELETED rather than kept for safety: the quantity whose
wholeness mattered was the GUEST size, which no longer resolves, and asking it of the
envelope would test an operand the code no longer states to Firecracker -- a check whose red
cannot be authored.

COVERAGE LOSS, STATED AS A LOSS. The admission checks the envelope last, so
RunnerMicroVmAdmitted is now unreachable and THE SUCCESS PATH HAS NO EXECUTED COVERAGE AT
ALL. The four witnesses that asserted admission now assert they REACH the envelope-unresolved
arm -- strictly stronger for the axis each actually tests, since it proves the topology,
credential and malformed-VM gates all passed -- but nothing demonstrates that an admissible
VM is admitted. Filed as gunbc.guarantee_rung_drop runner_microvm_guest_size_derivation_stall,
whose trigger requires BOTH the grounding and the restoration of that witness, so the accept
arm cannot go live having never run.

Also corrects an annotation this branch was carrying: #10037 gave
v1_compiler_compiler_tests_rust.rs an owner, so the second MirrorHasNoOwningPackage instance
filed here is CLOSED, and the text no longer claims it is live beside a witness proving it is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* Widen the microVM sizing stall: the CPU axis is unfounded too, and the cell is not what enforces it

The row as filed named only the missing Firecracker footprint. That is a subset of
the real obstacle, and a trigger naming only it would be satisfied while the CPU
axis stayed unfounded -- the exact shape of a trigger naming less than the
capability it restores.

gunbc.fabric.fabric_cell_effect fabric_cell_slice_desired_directives emits five
directives, and its CPU one is CPUWeight: a RELATIVE share that guarantees no
amount of anything under contention. The absolute ceiling this fleet does declare
-- gunbc.host.host_converge runner_cpu_boundary_knobs writing CapacityQuota as a
CPUQuota drop-in -- lands on the RUNNER UNIT, a different systemd object from the
cell slice. So one bounded execution context is realized twice and the two
realizations enforce different axes; deriving a guest vCPU count from the cell
equates it to a quantity the cell never enforces.

The trigger now carries three conjoined clauses -- memory grounding, CPU
entitlement or an honest rename of guest topology as a declared product decision,
and restoration of the admissible-accept witness -- and states that the controls
must return as FIT controls where a SMALLER guest PASSES, since containment and
not equality is the safety property.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* Delete the vacuous microVM witness rather than repair it

no_fleet_constructed_vm_is_admissible_while_the_footprint_is_unmodeled was GREEN
BY VACANCY. Its unresolved arm returned true, so it returned true without ever
reaching runner_microvm_admission -- the fleet constructor derives the size, so an
unresolved sizing makes the config unresolved and the witness took that arm every
time. It could not fail, and it cleared the floor and two approvals in that state
while standing in the PR body as the replacement for five witnesses withdrawn for
demonstrating a false claim.

A witness whose refusal arm returns true is not a control; it is a restatement of
the refusal.

Deleted rather than repaired, and the asymmetry is what decides it: a MISSING
witness costs nothing while it waits for the redesign, while a GREEN one that
cannot fail is active misinformation for as long as it exists and gets cited as
coverage. Repairing it would be work against a block being rewritten wholesale;
deleting removes a false claim and forecloses nothing, since the redesign authors
the honest form regardless -- assert that the resolution IS unresolved AND that its
cause NAMES the missing footprint, which has an authorable red.

The seven `MicroVmConfigUnresolved => false` witnesses are deliberately left red.
Their subject is topology and credentials, so the redesign must judge them against
a plain constructor rather than the fleet-constructed config that drags unresolved
sizing into them. That is a design requirement carried forward, not a patch owed
here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* Re-attach the deleted-witness annotation to a declaration: it was stranded at EOF

The deletion left its explanatory annotation as the LAST thing in the file, with no
module item after it. DESIGN 4c admits only standalone LEADING `//` blocks attached
to a module-scope declaration, so the parse phase refused with 17 copies of "source
annotation names no subject: no module item follows it" and took the whole floor
with it. The wall is right; deleting the last declaration in a file strands any
annotation that followed it.

The annotation now leads the surviving replacement pair, which is also where it
belongs semantically: it explains what happened to the fifth member of that set.

One over-correction reverted rather than kept. I also collapsed the blank lines
between adjacent comment runs, assuming a run had to sit immediately against its
declaration. It does not: 118 files in dag/ separate comment runs with a blank line
and parse clean. Only the end-of-file case was ever the defect, so the fix is the
relocation alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* Separate the three memory quantities and judge FIT, not equality

THE DEFECT. One number answered three different questions. The withdrawn derivation
set the guest's RAM to the cell's whole MemoryMax, so the realization reserve was
zero and unnamed -- and the Firecracker VMM process lives inside that same cell, so
the slice would have OOM-killed the VM it exists to bound. The withdrawn controls
were wrong in BOTH directions while looking discriminating: the accept arm admitted
a guest sized at the whole ceiling, and the refuse arm rejected a 4 GiB guest
against a 16 GiB cell, which fits.

THE THREE QUANTITIES, now distinct: CellMemoryEnvelope (what the cell is granted),
GuestMemoryRequest (what the guest is handed), RealizationReserve (what the VMM
process itself costs, inside that cell). The property is containment --
guest + reserve <= envelope -- and a SMALLER guest passing is a requirement of the
repair, asserted as such.

THE RESERVE IS A PARAMETER, NOT A GLOBAL, and that is what makes the fit controls
authorable. Production cannot supply one: no VMM footprint is cited in
extdeps.virtualization.firecracker, and it is not a constant -- it scales with guest
size, device model and host page size, so upstream documents a measured overhead for
one configuration rather than a property of the realization. If the reserve were
read from a global, that refusal would be the only reachable outcome and neither fit
arm could ever execute -- a wall whose red cannot be authored. Passing it lets a
witness declare one from a controlled fixture and drive both polarities while
production stays honest.

THAT ALSO RESTORES THE ACCEPT ARM. The previous revision left RunnerMicroVmAdmitted
unreachable, so the admission's success path shipped unexercised. It executes again
under a declared reserve, and the pair that matters most is
the_same_guest_is_refused_under_the_production_reserve: the same guest, the same
admission, only the reserve changed, and the answer flips. The admission is
therefore neither admitting everything nor refusing everything.

THE CPU AXIS STILL REFUSES AND IS NOT REPAIRABLE HERE. fabric_cell_effect emits
CPUWeight -- a relative share guaranteeing no absolute amount -- while the fleet's
absolute CPUQuota lands on the runner UNIT, a different systemd object. Either an
absolute cell entitlement becomes real, which is a change to fabric_cell_effect, or
guest topology is declared as its own product decision. The sizing names which axis
is unavailable rather than refusing uniformly.

An earlier comment claiming "the vCPU axis is not what is blocked" was FALSE and is
corrected: a resolved core count is a fact about the host survey, not a cell
entitlement.

The stall row's third clause is DISCHARGED -- the accept arm executes -- and the row
says explicitly that PRODUCTION reaching it is not discharged and not claimed.
mebibyte_to_byte_size is now genuinely consumed, by the fit comparison; four reviews
had credited it while it had no callers at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* Close the fail-open at the signed Firecracker boundary; re-census the stall population

BLOCKER 3, AND IT IS THE SERIOUS ONE. FirecrackerMachineConfig carries vcpu_count
and mem_size_mib as SIGNED Int -- upstream's shape, faithfully modeled. The admission
took mem_size_mib straight through mebibyte -> mebibyte_to_byte_size ->
GuestMemoryRequest with nothing between the signed external value and the magnitude,
and `Nat` downstream does not rescue it because the realization is i64. So a config
with mem_size_mib = -1 satisfied -1 MiB + 256 MiB <= 16 GiB, reached
GuestFitsWithinCell, and was ADMITTED -- a fail-open arm inside the very relation
this change exists to establish. Non-positive vcpu_count was never validated at all.

guest_resources_of_machine_config now turns the signed pair into validated magnitudes
or a typed refusal BEFORE fit and before admission. The upper bound is the
REPRESENTABLE one, (2^63-1)/2^20, not a policy guess: refusing where the arithmetic
cannot be performed is honest, while a smaller "reasonable" ceiling would be unstated
policy wearing a representability argument's clothes.

The addition is GONE rather than guarded. `guest + reserve <= envelope` is the law,
but evaluating the left side can exceed the representable range on a hostile value
and wrap, answering the wrong question silently. The comparison is now by
subtraction -- guest <= envelope - reserve, with reserve-exceeds-envelope split out
so the subtraction never runs below zero -- which cannot overflow for any
non-negative inputs.

Five witnesses, and the fifth is not optional: negative memory refuses, zero memory
refuses, non-positive vCPU refuses, unrepresentable memory refuses, AND a small
positive guest is still ADMITTED. Without the last one the four refusals are
satisfied by an admission that refuses everything.

BLOCKER 2. admission_is_envelope_unresolved_refusal accepted
RunnerMicroVmRefusedEnvelopeUnresolved { reason: _ } -- it established the
disposition and DISCARDED the located cause, so it stayed green under a mutation
where the producer names the missing reserve and the admission returns a bare
"unresolved". A witness on the producer cannot close that: it establishes what the
producer emits, not that this path preserves it. The recognizer now matches the
reason and requires the Firecracker identity AT THE ADMISSION.

BLOCKER 1. The bounded population named runner_microvm_sizing_of_cores, which the
previous commit deleted, and gave the witness module an intermediate `.runner`
segment it does not declare. A phantom member beside an omitted live one means the
row never carried the population 4b requires, so it is RE-CENSUSED from the module's
declarations rather than spot-repaired -- the property that failed is completeness,
and two typo fixes would leave completeness unestablished. The membership rule is
now written into the row so it is checkable: a symbol is a member when, in
PRODUCTION, it cannot reach its intended answer.

Also removes an annotation citing runner_microvm_mvp_size_is_not_the_cell_envelope
as an executed statement. That declaration does not exist -- it went with the
withdrawn equality design -- and an annotation asserting a machine fact no Accepted
program can read is what 4c forbids. Removed rather than repointed, because the
claim belonged to a design that no longer stands.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

* Move the subtraction rationale to module-item grain, and control the split it depends on

The floor refused #10021's parse phase at six consecutive lines: the annotation
explaining why guest_memory_fits compares by subtraction was written INSIDE the
function body, and DESIGN 4c admits only standalone leading blocks attached to
module-scope declarations. The prose is unchanged in substance and now leads the
declaration it explains, which is the nearest grain the substrate admits.

It also gains the two things a reader needs in order not to undo the design: why
the addition is absent (there is no overflow to handle because the operation that
could overflow does not exist -- rung 4, not a checked-add guard at rung 1), and
that the reserve-exceeds-envelope split is load-bearing for that claim rather than
a redundant early-out.

That split had no control, so it gets one:
a_reserve_larger_than_the_whole_envelope_admits_no_guest declares a reserve larger
than the envelope and asserts the refusal. Its RED is authorable -- delete the
split and the subtraction runs below zero -- which is what makes it evidence
rather than decoration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2pmV7GbBZYhCZWYqFbCc

---------

Co-authored-by: Brian Searls <briansearls1@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
@gunbai-bot
gunbai-bot Bot restored the session/cool-seal-638-microvm-runner branch September 3, 2026 08:07
gunbai-bot Bot pushed a commit that referenced this pull request Sep 5, 2026
…n failure

TWO REVIEW FINDINGS, BOTH REAL, AND THE FIRST IS THE SAME BUG FOR THE THIRD TIME.

ACTIVATION, AGAIN. The original defect was a regular file in multi-user.target.
wants/, which systemd skips because it is not a symlink. The repair moved to a
drop-in and named it `10-gunbc-runner-agent.service` -- which systemd skips
because it does not end in `.conf`. Two mechanisms, one outcome: an activation
file that is written, looks right, and is never read. The suffix is now a named
row so the next reader sees why it exists.

THE WITNESS ALREADY DEMANDED IT AND WAS NEVER RUN. the_agent_is_pulled_in_by_a_
drop_in_systemd_will_actually_read asserts `.conf` in the path. It went RED in
CI at 103bc7b and said so plainly (adjudication BLOCKING phase=floor ...
returned Bool(false)), before any reviewer looked. The defect survived because
this lane reported "compile clean" as though that were the gate, and dispatched
a host run against a head whose required floor was failing. A typecheck is not a
consumer; DESIGN 5 names this exactly, and the machinery that would have caught
it was working the whole time.

THE CONSOLE WAS DISCARDED EXACTLY WHEN IT MATTERED. The probe routed the VMM
through run_shell_command_capture, whose classifier returns ProcessRefused
{ exit_code, stderr } on a non-admitted exit and DROPS stdout -- and a
Firecracker guest's serial console arrives on stdout. So a guest that panicked
and was then killed by its deadline lost its panic message, and the receipt
labelled the surviving stderr "the console": a fabricated label over missing
evidence, which DESIGN 5 forbids by name.

The repair is a second function rather than a widened type. run_shell_command_
observe returns the raw triple and lets the caller classify; capture is now
implemented over it, so there is one place a process is run instead of two.
"What did this print if it succeeded" and "what did this print, especially when
it failed" are different questions, and only the second is this probe's. Every
failing verdict arm now carries the console, and BootVmmFailed joined as its own
arm rather than being folded into a refusal that hid the exit code.

A THIRD FIX, WHERE THE TEST WAS WRONG AND THE CODE WAS RIGHT. the_config_
document_carries_upstreams_own_key_spelling asserted `"network-interfaces":[]`;
the emitter writes `": "` with a space, so it failed while every key it exists
to check was correct. It is narrowed to the key spellings, which are its subject.
A witness that reds on formatting it does not own is a change detector wearing a
check's clothes, and the emptiness it reached for is already covered structurally
by the witness that COUNTS interfaces instead of matching bytes.

Also: the note claiming #10525's install "had never run on any host" is
corrected where it was written. The observation was firecracker=absent at the
executor-owned path ON srv2; #9578 reports an MVP executed on srv4 at this same
version, so a binary existed there outside the modeled path. One host's absence
is not a fleet's history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN
briansrls added a commit that referenced this pull request Sep 5, 2026
* Model the runner guest image as a built artifact, and build it without root

gunbc.runner_microvm says WHAT makes two guest images the same image. Nothing
said how bytes answering to that identity come to exist, so no host could
produce one. This is that procedure, and it consumes the identity rather than
restating any field of it: the image path is derived from all four, so a runner
bump cannot land on the path the previous release's image occupies.

THE BUILD NEEDS NO PRIVILEGE, and that is a property of the mechanism rather
than a permission we decided to do without. The conventional path -- loop
device, mount, root copy, unmount -- is three privileged mechanisms, each of
which would have to be granted in sudoers on every runner host. mke2fs -d
populates the filesystem from a directory in userspace, so none of the three is
reached and there is nothing to grant.

What that costs is stated in the module rather than hidden: an unprivileged
extraction cannot restore ownership, setuid bits or device nodes. All three
look survivable in THIS guest -- init is uid 0 regardless, the runner and job
run as root, and the kernel mounts devtmpfs over /dev -- and survivable is not
proven. The boot is the measurement, it fails loudly if any of them mattered,
and that failure is the evidence that would justify buying root rather than a
reason to have bought it up front.

THE BASE ARTIFACTS CARRY PROVENANCE, NOT A FABRICATED PIN. Upstream publishes
its CI kernel and rootfs with no digests beside them. Measuring one is a
receipt in THIS layer, never a row appended to extdeps.virtualization.
firecracker -- an observed digest written there would look like something the
vendor published. The vocabulary is gunbc.fleet_host_key_enrollment's, because
it is the same problem over bytes instead of keys: a digest taken from the
first fetch grounds every later fetch against the first one and nothing more.
Only a CONTRADICTED pin refuses; first contact reports what arrived so a row
can be authored from it, and an unreadable file is a third state rather than a
lenient second.

THE GUEST AGENT IS A UNIT FILE, NOT A SCRIPT. The poweroff is ExecStopPost=
rather than a second ExecStart=, and the placement is the property: systemd
runs it on both arms, so a guest whose job FAILED still stops existing. Appended
to the main command it would run only on success, and the one VM that most needs
to disappear -- the one that failed holding a credential -- is exactly the one
that would stay up. StandardOutput=journal+console for a structural reason too:
a guest that powers off takes its journal with it.

The two fleet-converge modes are how a named host is reached at all. observe
reports and builds nothing; converge builds. Fusing them would leave no way to
ask a host what it holds without changing what it holds.

Their step budget is its own constant at 30m rather than the shared aux 5m,
which the Spark serving envelope one section up already explains: that value is
multiplied by step count in the job backstop, so raising it inflates every
unrelated step fleet-wide. It is stated as an envelope and not a measurement --
none of the three downloads has a modeled byte size and throughput to either
origin has never been measured -- and carries its dissolve-on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Boot the guest image: the sizing repair, and the measurement the rootless build owed

Slice 1 modeled the image and could not boot it, for two independent reasons.

FIRST, NOTHING COULD BE SIZED. gunbc_runner_microvm_vm_config returned
MicroVmConfigUnresolved on every production path: the cell grants a RELATIVE
CPU share and no absolute quantity, so no entitlement existed to derive a vCPU
count from. The module named two honest repairs and this takes the second --
making an absolute cell entitlement real is a change to fabric_cell_effect, a
fabric decision wearing a runner module's clothes. So the guest's topology is
declared HERE as this fleet's own fact.

The cell did not stop mattering; it stopped SIZING. guest_memory_fits still
decides containment at admission. The cell BOUNDS the guest, it does not
dimension it -- those were one function and they were two facts.

THE DECLARATION WITHDRAWS ITSELF. The day the cell grants an absolute
entitlement, the declared size is a stale answer to a question that has become
derivable, and a stale answer that keeps returning successfully is what would
stop anyone from noticing the repair is available. That arm refuses and names
the repair, which is what keeps this from being the absorbing fallback DESIGN
section 5 forbids: not a widened substitute for a computation that failed, but a
product fact standing where no computation exists, retracting the moment one
does. Both arms are driven; an implementation returning the declaration on both
passes the control and reds on the discriminating test.

runner_microvm_mvp_size is FOLDED IN rather than left beside it. It carried this
exact configuration as "a RECORD OF ONE BOOT, not a sizing anyone may provision
against", retained as the only configuration known to have started a VM. Once
this fleet provisions at a declared size, the only defensible number to declare
is the one that has booted -- two rows holding identical values is the
nicknaming section 3 forbids. The number did not change; its STANDING did.

SECOND, A GUEST WITHOUT A TAP WAS INEXPRESSIBLE. The builder required a device
and a MAC, so a boot smoke could only be spelled by naming a device that does
not exist -- Firecracker would refuse at device setup and the failure would read
as an image defect. A guest that cannot reach the network is not a degraded
runner, it CANNOT REGISTER, so RunnerMicroVmNetworking says which kind of VM it
is instead of leaving an argument blank.

THE PROBE IS THE PROOF OBLIGATION THE ROOTLESS BUILD OPENED. runner_guest_image
builds without root and states that this restores no ownership, no setuid bits
and no device nodes, argues all three survivable here, and says plainly that
survivable is not proven. A guest that boots and prints its runner's version has
shown the kernel found a root filesystem, init came up, systemd reached its
target and the runner binary is executable -- the set an ownership or device-node
loss would have broken. A guest that does not fails loudly on a captured console.

IT IS A PROBE AND NOT A PROVISIONER. Admission still refuses every production
configuration because the VMM's own reserve is uncited; this boots ONE VM
outside any cell to observe whether the image works. Reading its success as
permission to provision would be reading a smoke test as a capacity decision.

It boots a COPY: a root drive is attached read-write, so booting the artifact
would mutate the bytes whose digest the build receipt just recorded.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Repair guest agent activation, and put the VM's lifetime in the host's hands

TWO DEFECTS, AND THE SECOND IS ONLY VISIBLE BECAUSE OF THE FIRST.

ACTIVATION. The merged image "enabled" its guest agent by writing the unit's
bytes a second time into multi-user.target.wants/ as an ordinary file, under an
annotation asserting systemd resolves such an entry exactly as it would an
enable symlink. THAT CLAIM IS FALSE. systemd's dependency loader
(src/core/load-dropin.c, process_deps) skips every entry in a .wants/ or
.requires/ directory that is not a symlink, logging "dependency dropin ... is
not a symlink, ignoring". The agent was never pulled into the boot: the image
would have reached multi-user with nothing running.

HOW IT SHIPPED IS WORTH RECORDING, because the mechanism is general. The false
claim lived in an ANNOTATION, which DESIGN 4c says no Accepted program can read,
so nothing could go red on it. The witness that "covered" it asserted
WantedBy=multi-user.target appeared in the unit TEXT -- true, and silent about
whether the placement systemd actually reads was correct. A correct assertion
over the wrong subject. Reviewers reproducing it also observed that BOTH
systemd-analyze runs exit 0, so the exit status would not have caught it either;
only the diagnostic text distinguishes them.

THE REPAIR IS A DROP-IN, NOT A SYMLINK, and the choice is reasoned rather than
convenient. A symlink is the closer analogue of systemctl enable and is a file
KIND this build cannot create -- Filesystem.Write writes regular files -- so
reaching for one means minting an `ln -s` builder and making the image depend on
a second mechanism. systemd already documents a fragment that expresses the
dependency and IS a regular file honestly used as one: <target>.d/*.conf
carrying [Unit] Wants=, merged by the ordinary drop-in path, no symlink
requirement. extdeps.systemd.unit_file already modeled SystemdDropIn; the first
implementation invented a placement instead of using it.

The unit keeps its [Install] section and that is not a second authority: the
section says which target the agent BELONGS to, so systemctl enable inside a
running guest still works, while the drop-in is what activates it in an image
nobody enables. Both read runner_guest_agent_target, so they cannot disagree.

TEARDOWN. The probe's claim that a VM "powers itself off" rested entirely on the
agent's ExecStopPost=, which covers only the arm where the agent STARTED -- and
the defect above is precisely an agent that never starts. A kernel panic before
userspace, a hung init, or a guest that never reaches its target present the
same ownership problem, and in all of them the VMM holds a guest indefinitely.

So the deadline is now the HOST'S, imposed on the VMM process where no guest
behaviour can extend it, and it escalates to KILL unconditionally: a bare
timeout sends TERM and then waits, so a wedged VMM outlives the bound it was
supposed to be under, and a deadline the subject may decline is the escape hatch
DESIGN 5 refuses. A step timeout is not a substitute -- it kills the step and can
leave the VMM it spawned running, which is the uncontrolled VM this must not
create. Exit 124 is timeout's own answer that the bound FIRED, which is a
different event from a VMM that ran and failed, so it is its own verdict rather
than folded into the failure arm.

Both defects now carry regression controls written against the mechanism rather
than the text: one asserts the drop-in systemd will actually read, one reds if
any placement ever returns to a regular file in a .wants/ directory, and one
distinguishes a killed-by-deadline guest from a failed one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Regenerate fleet-converge.yml for the microvm_boot_probe mode

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Fix the activation defect properly, and stop discarding the console on failure

TWO REVIEW FINDINGS, BOTH REAL, AND THE FIRST IS THE SAME BUG FOR THE THIRD TIME.

ACTIVATION, AGAIN. The original defect was a regular file in multi-user.target.
wants/, which systemd skips because it is not a symlink. The repair moved to a
drop-in and named it `10-gunbc-runner-agent.service` -- which systemd skips
because it does not end in `.conf`. Two mechanisms, one outcome: an activation
file that is written, looks right, and is never read. The suffix is now a named
row so the next reader sees why it exists.

THE WITNESS ALREADY DEMANDED IT AND WAS NEVER RUN. the_agent_is_pulled_in_by_a_
drop_in_systemd_will_actually_read asserts `.conf` in the path. It went RED in
CI at 103bc7b and said so plainly (adjudication BLOCKING phase=floor ...
returned Bool(false)), before any reviewer looked. The defect survived because
this lane reported "compile clean" as though that were the gate, and dispatched
a host run against a head whose required floor was failing. A typecheck is not a
consumer; DESIGN 5 names this exactly, and the machinery that would have caught
it was working the whole time.

THE CONSOLE WAS DISCARDED EXACTLY WHEN IT MATTERED. The probe routed the VMM
through run_shell_command_capture, whose classifier returns ProcessRefused
{ exit_code, stderr } on a non-admitted exit and DROPS stdout -- and a
Firecracker guest's serial console arrives on stdout. So a guest that panicked
and was then killed by its deadline lost its panic message, and the receipt
labelled the surviving stderr "the console": a fabricated label over missing
evidence, which DESIGN 5 forbids by name.

The repair is a second function rather than a widened type. run_shell_command_
observe returns the raw triple and lets the caller classify; capture is now
implemented over it, so there is one place a process is run instead of two.
"What did this print if it succeeded" and "what did this print, especially when
it failed" are different questions, and only the second is this probe's. Every
failing verdict arm now carries the console, and BootVmmFailed joined as its own
arm rather than being folded into a refusal that hid the exit code.

A THIRD FIX, WHERE THE TEST WAS WRONG AND THE CODE WAS RIGHT. the_config_
document_carries_upstreams_own_key_spelling asserted `"network-interfaces":[]`;
the emitter writes `": "` with a space, so it failed while every key it exists
to check was correct. It is narrowed to the key spellings, which are its subject.
A witness that reds on formatting it does not own is a change detector wearing a
check's clothes, and the emptiness it reached for is already covered structurally
by the witness that COUNTS interfaces instead of matching bytes.

Also: the note claiming #10525's install "had never run on any host" is
corrected where it was written. The observation was firecracker=absent at the
executor-owned path ON srv2; #9578 reports an MVP executed on srv4 at this same
version, so a binary existed there outside the modeled path. One host's absence
is not a fleet's history.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Record that srv2's KVM writability is a shell session, not convergence

The operator asked whether the /dev/kvm grant was modeled or done by hand. It
is modeled -- EnsureSupplementaryGroupMembership renders an argv and a sudoers
line committed for every host, and provisioning/srv2/gunbc-ghrunner.sudoers:84
is verbatim the command I typed. So the hand action did not fill a modeling
gap; it executed a row the model already owns, out of band, and the annotation
in runner_microvm_host_ready that recorded "executor NOT in kvm" was true when
written and false an hour later because I changed the host.

No Accepted program reads an annotation, so nothing could ever go red on the
stale sentence. It was found only because the operator asked.

The annotation now states the observation as dated and attributed, says plainly
that srv2 reads kvm=writable while every other host does not, and names the gap
that permitted the hand step: EnsureSupplementaryGroupMembership is AUTHORIZED
and never APPLIED. runner_host_privileged_operations feeds only the sudoers
renderer, and this reconciler explicitly disclaims the grant -- so the operation
is granted by one module, disclaimed by another, and executed by neither. The
refusal here stays correct; the missing applier is an ownership decision about
host actuation, not this module's to take unilaterally.

srv1/srv3/srv4 are deliberately left unconverged rather than hand-patched to
match srv2. They will refuse at the boot probe with kvm=present-not-writable,
which is the informative failure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Drop the dead image parameter from the boot probe's config builder

review 61096 (non-blocking) found runner_microvm_boot_probe_config taking an
image: RunnerGuestImage it never references. Correct, and checking why it was
dead was worth more than the fix: identity IS threaded, one step earlier, by
runner_microvm_boot_probe_stage_commands, which copies
runner_guest_image_path(root, image) onto the FIXED probe path the config then
boots.

The question that mattered was whether a failed copy could leave the probe
booting a stale probe-rootfs.ext4 from an earlier image while the receipt named
the new one -- a silent wrong answer that would be near-impossible to see after
the fact. It cannot: staging is sequenced before the boot and any non-ExitSuccess
refuses instead of booting whatever the previous run left behind.

So the parameter is dropped rather than threaded; threading it would invent a use
for a value the fixed probe path deliberately does not need. An annotation now
states why the function takes no identity, so it is not re-added as an apparent
improvement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Rebuild the guest tree with rm -rf, which its annotation already claimed

fleet-converge run 33993440148 on srv2 failed at
  rm -f .../tree/ubuntu-24.04-aarch64-runner-2.336.0
  rm: cannot remove '...': Is a directory

runner_guest_tree_commands staged the tree with rm_force_command -- `rm -f` --
on a DIRECTORY it rebuilds every run. It could only ever succeed on a host that
had never built before, which srv2 was at run 33985760921 and will never be
again. An idempotence defect invisible on exactly one run per host is the shape
to expect from staging only ever exercised on fresh state, and the earlier
SUCCESS was the misleading result, not this failure.

The substrate was correct. rm_force_command's own row says in terms that it
never recurses and REFUSES a directory precisely so a mistaken call cannot
destroy a tree; it fail-closed as designed. Had it accepted -rf silently, the
build would have passed and produced an image carrying an earlier release's
files wherever the new one did not overwrite them.

What was wrong was this caller, whose annotation asserted "The removal is
`rm -rf`" while the code issued `rm -f` -- a §4c annotation claiming the exact
property that was missing, which no Accepted program can contradict. That is the
third falsified annotation in this lane, and the second found only by execution.

No new vocabulary: coreutils_rm_force_recursive_flags already existed, so
rm_recursive_force_command is built from it, enrolled in extdeps.exec.command's
admit_callers (the seal refused the mint until it was, working as intended).
While there, consolidated a §3 fork found in the same file -- rm_program and
coreutils_rm_binary_name, both "rm" -- rather than adding a third spelling;
rm_program had no callers outside the module.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

* Compare the built image against itself, not against the base rootfs's pin

fleet-converge run 33995287883 on srv2: the rm -rf repair worked -- the tree
rebuilt over the leftover directory and the drop-in landed at the repaired path
(multi-user.target.d/10-gunbc-runner-agent.service.conf) -- and the build then
failed with

  guest-image=CONTRADICTS-PIN
    expected=c61b6ff3...  (the BASE ROOTFS's pin)
    observed=369bff28...  (the built image)

Both image_digest sites passed runner_guest_base_rootfs_artifact as the artifact
whose pin to compare against, while observing the BUILT IMAGE's bytes. Those are
different files by construction, so the comparison could only ever contradict.

IT WAS DORMANT UNTIL AN UNRELATED ROW WAS FILLED IN. At run 33985760921 the
rootfs was still Unpinned -- that run is what measured it -- so the mismatched
comparison rendered as a REPORT and refused nothing. Committing the pin ARMED a
check that had been wrong since it was written. That is the third defect in this
lane whose common shape is a check that could not fail in the state it was
written in: the .conf witness passed while the image was inert, rm -f worked on
a host that had never built, and this reported instead of refusing while the
rootfs had no pin. In each case some later, unrelated change armed it.

The correct descriptor already existed but only INLINE at the render site, so
"what artifact is this digest about" had two answers and the two verdict sites
took the wrong one. It is now one row, runner_guest_built_image_artifact, used
by all three. Its integrity is permanently Unpinned: a built image has no pin to
verify against -- its digest is a receipt of the build that just ran -- and
pinning it would demand byte-reproducible mke2fs output and red on every
legitimate change to the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQ1HbKBP5SH24N2zxExYmN

---------

Co-authored-by: Brian Searls <briansearls1@gmail.com>
Co-authored-by: Claude Opus 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.

1 participant