Skip to content

Phase 3: recording sessions, m80 emulator through VMs core, conformance harness - #34

Merged
lex00 merged 12 commits into
mainfrom
feature/6-recording
Jul 31, 2026
Merged

lex00 merged 12 commits into
mainfrom
feature/6-recording

Conversation

@lex00

@lex00 lex00 commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Phase 3 through #10, plus the conformance-harness work the emulator flushed out. Ten commits, plus one restoring a fixture correction that #33 measured against but never committed — without it the branch reads 40 pass, 1 fail. The harness fixes and the emulator co-evolved, so they land together rather than as a stack.

Against a fresh m80: 43 pass, 0 fail, 7 unimplemented, 21 skipped. 22/29 operations exercised, 20/29 implemented. go build, go vet, gofmt and go test -race clean.

go build -o /tmp/m80 ./cmd/m80 && /tmp/m80 -addr :4290 -build-delay 300ms &
go run ./conformance/cmd/conformance -endpoint http://localhost:4290 -poll-timeout 20

m80 is stateful and image names stay reserved through the async delete window, so a second suite run against the same instance fails on already exists. Restart between runs.

What landed

Issue Work
#6 Two live recording sessions. All ten scenarios fixture-backed, 19 recorded corrections in docs/api-surface.md, transition order captured, throttle probe run
#7 cmd/m80, router over all 29 operations, internal/clock, internal/store, /_m80/health
#8 Images, versions, builds, state machines, failure injection
#9 Managed base-image catalog
#10 VMs core: run, get, list, terminate
#19 conformance/cmd/modelwatch plus a weekly workflow; first run clean at 29 operations

Review notes

Fidelity is tiered. conformance/tiers.json classifies members by whether a consumer reads them; m80 is held to -tier all, floci is gated on -tier load-bearing. A test re-derives the always-null set from the fixture corpus, so a fixture introducing a new always-null member fails the build until it is classified with a written reason.

Five normalization bugs came out of pointing a real implementation at fixtures that had only ever been recorded, never compared against: short AWS resource ids, regions outside ARNs, versionStateTimeBucket packing a UTC hour, UUID-versus-account ordering, and case params carrying an account placeholder. All fixed and regression-tested.

region and account are built-in params. A pinned region breaks against a region-scoped catalog; a pinned account makes probes cross-account, which live AWS answers 403 rather than 404.

Two rejected fixtures are correct as-is and documented in conformance/README.md: get-vm-missing.*.rejected-502 and vms-lifecycle/list.json.rejected-account-history.

Closes #6
Closes #8
Closes #9
Closes #10
Closes #19

lex00 and others added 11 commits July 30, 2026 12:04
… it exposed

Records all ten scenarios against the real service and folds the results back
into the docs and the harness. Refs #6, closes #19.

The recording. Every scenario is now fixture-backed, with .meta.json sidecars
carrying the status and modeled error type that the body alone loses. Nineteen
recorded corrections land in docs/api-surface.md: image identifiers are ARNs
and VM ids are microvm-<uuid>, four VPC_EGRESS connector members are modeled
optional and enforced live, SHELL_INGRESS and HTTP_INGRESS are connector types
absent from the model's enum, image delete and update are asynchronous, and
UpdateMicrovmImage is a full replace that mints a new version.

The harness fixes. Comparing an emulator against these fixtures showed the
suite failing correct behavior three ways, all suite-side:

  - Short AWS resource ids and the region in the per-VM endpoint hostname went
    unredacted, so a recorded sg-f0e6979f could never equal a generated one.
    The short-id rule also absorbs the UUID and ID placeholder tails, since
    fixtures on disk were normalized when written and a live connector already
    reads nc-UUID there.
  - The codeArtifactUri case default did not normalize to the recorded bucket,
    so every target failed a value that is a pure echo of the request.
  - errors-not-found/get-vm-missing probed the pre-recording guess mv-… for a
    VM id; the gateway answered the malformed path with an nginx 502 page that
    got recorded as service truth. The case now uses a well-formed id and the
    fixture is set aside as .rejected-502 pending a re-record.

Two harness gaps closed alongside. An until poll kept only the settled
response, discarding the transition order a live run is uniquely able to
answer; the runner now records the distinct states a poll walks through, as
recorded truth rather than an assertion, since an instant-settle emulator
walks a shorter path and is still conformant. And -poll-timeout caps the
AWS-sized case timeouts, taking an emulator run from ten minutes of almost
pure waiting to sixteen seconds with identical results.

Model freshness watch. conformance/cmd/modelwatch re-derives the inventory
from both vendored service-2 models and cross-checks the MicroVMs half against
aws-sdk-go-v2, reporting added, removed, rerouted, or re-errored operations.
A scheduled workflow files or comments on a model-drift issue. First run is
clean at 29 operations, which independently confirms the hand extraction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #7. Refs #6.

Scaffold (#7). cmd/m80 serves all 29 operations from the start, each answering
501 until its issue lands, because the conformance runner reads 501 as
unimplemented and 404 as a wrong answer — routing everything up front means
the suite runs against the scaffold today and each implemented operation flips
one line of the report. It currently reads 0 fail, 0 error, 10 unimplemented.
/_m80/health reports coverage against the inventory.

internal/clock is the seam every state machine hangs off, so a
suspend-after-fifteen-minutes policy is testable in microseconds. Its test
caught a real bug: Advance jumped straight to the target before running
callbacks, so a transition scheduling the next hop measured from the wrong
instant and every chained state landed a full window late. Time now steps to
each deadline before that timer runs, which is what time.AfterFunc does.

internal/store fixes two properties that are expensive to retrofit: state is
region-scoped, because an emulator sharing one namespace across regions passes
tests real clients fail, and every access is serialized, because the runner
and KubeMicroVM's reconcilers both issue overlapping requests. Resource types
belong to #8 onward; this stops at the generic container.

The route table is cross-checked against conformance/inventory.json by test,
and that test immediately earned itself: TerminateMicrovm is a DELETE on the
VM, not a POST to a /terminate sub-resource, and connector update is PUT not
PATCH. The same mistake was live in the throttle probe's teardown path, where
it would have stranded billable VMs.

Second recording session. Transition order is answered: resume goes
SUSPENDED to RUNNING without passing back through PENDING, while the same
five-second poll did catch PENDING on the initial launch — so it is a real
difference between the two paths, not a sampling artifact. A suspended VM
still issues auth tokens, 200 with a full token. get-vm-missing re-recorded
clean as 404 ResourceNotFoundException once the case stopped probing the
malformed mv-… id, and revealed resourceId and resourceType members.

Throttle probe. conformance/cmd/throttleprobe fans out concurrent RunMicrovm
calls to provoke the limit QuotaGuard is built around. It is a command rather
than a case because it is irreducibly imperative: concurrent fan-out, ids
collected from responses it may not assert on, and teardown that must run even
on panic. Scoped deliberately small, and it refuses -n above 12.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes #6's last recordable target. Six concurrent RunMicrovm calls against a
fresh account: two admitted, four rejected.

The answers, none of which the model could give:

  - ServiceQuotaExceededException answers HTTP 402, Payment Required. Not the
    429 or 400 anyone would guess.
  - The binding limit is allocated memory, not VM count. Two VMs at the
    2048 MiB default tier put the account's base ceiling near 4096 MiB.
  - quotaCode, serviceCode, resourceId and resourceType are all present and
    all null, so a client cannot branch on which quota it hit.

It also answered a different question than the one asked. No ThrottlingException
and no ThrottleReason appeared, including ConcurrentSnapshotCreateLimitExceeded,
because the memory ceiling fires first and masks concurrency throttling
entirely. KubeMicroVM's QuotaGuard will meet 402 long before it meets a throttle
on a default account. Observing the throttle path would need an account whose
memory quota is raised well above its concurrency limit, which is a support
ticket rather than a recording run, so m80 implements those six reasons from
the model alone.

Teardown behaved: both launched VMs terminated and the image deleted, leaving
only the asynchronous DELETING window that is itself already a recorded fact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Serves ListManagedMicrovmImages and ListManagedMicrovmImageVersions from the
2026-07-30 recorded snapshot, embedded as data. The catalog is read-only and
identical in every account, so it is not state.

Both conformance steps now pass fixture-backed equality against the real
recorded responses — the first operations to go from unimplemented to pass,
which also proves the scaffold's register-to-fixture chain end to end.

Region comes from the caller's sigv4 credential scope rather than any
configured value, so one instance serves every region correctly and the
store's region isolation is testable without running several. An SDK pointed
at m80 with an endpoint override still signs normally, so this needs nothing
from the client.

Two details taken from the recording rather than invented: the account segment
of a managed ARN is the literal "aws", which is what distinguishes a
service-owned image from a caller-owned one, and versions come back newest
first, so a client taking items[0] as current gets the right answer. Version
timestamps are the live values, read back specifically because the fixtures
redact them and inventing them would have been indistinguishable in the suite
but wrong in the data.

api.WriteError puts the modeled error type in X-Amzn-Errortype and not in the
body. The live service sends no __type in these bodies and the fixtures
confirm it; an emulator adding one diverges on every error case.

Has() is the hook CreateMicrovmImage validates baseImageArn against. That
validation, and #9's rejection acceptance criterion with it, lands in #8 which
owns the operation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All thirteen images-lifecycle conformance steps pass fixture-backed equality
against the live recording, along with every create-image step the VM, token,
tag and conflict scenarios open with: 26 pass, 0 fail, 13/29 operations
implemented.

The three state layers stay separate because they are not redundant. An image
tracks whether the named resource is settled, a version tracks one build
lineage, and a build tracks one attempt against one chipset generation — which
is why a version carries two builds, Graviton 4 and 3. KubeMicroVM's image
build logs feature reads the build layer, so collapsing them would break it.
Each hop is its own timer, so a poller observes PENDING, IN_PROGRESS and
SUCCESSFUL rather than a jump, and the image settles one hop after its version
so CREATED never appears before the build is usable.

Response shapes come from the fixtures, not the model, because the model does
not describe them. Create returns the full resource with every unset member
present and null; Get returns a much smaller projection; List is smaller
again. Create sends tags as null and Get sends {} on the same untagged image,
and Update omits tags entirely. None of that is derivable — it is only
knowable from a recording, and sparse bodies are the single largest source of
emulator divergence.

Also recorded rather than invented: PUT is a full replace that mints a new
version and reports all three missing required members at once, a version
PATCH during UPDATING is a 409 ConflictException while a terminal-state VM
mutation is a 400 ValidationException, delete is refused mid-build and refused
over running VMs, and an image name stays reserved through the asynchronous
delete window.

One more suite bug surfaced. versionStateTimeBucket packs a state and the UTC
hour it was reached into one string, "SUCCESSFUL#26073006". No emulator can
match a recorded one — the step passed or failed depending on whether the run
happened in the same hour of the same day, which is why one scenario's copy
passed while another's failed. Normalization now keeps the state half, which
is real signal, and flattens the clock half.

VMs are #10. The delete-over-running-VMs rule is an interface so images does
not import them and a nil checker means none exist.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
43 pass, 0 fail, 22/29 operations exercised, 20/29 implemented.

VMs (#10). Run, get, list and terminate, with PENDING settling to RUNNING and
terminate walking through TERMINATING on the clock. The recording never
sampled TERMINATING at a five-second poll, but it is in the enum and a faster
client can see it, so m80 goes through it rather than jumping.

Recorded rather than derived: ids are microvm-<uuid>, every VM carries managed
default connectors in both directions including HTTP_INGRESS which is absent
from the model's enum, terminate answers 200 with an empty object rather than
the VM, a cleanly terminated VM reports stateReason "Success." with the
trailing period, and mutating a terminated VM is a 400 ValidationException —
neither modeled conflict type.

Images and vms now ask each other exactly one question through interfaces
rather than importing each other: images refuses to delete while a VM runs,
and vms refuses to run an image with nothing built, so a VM that could never
start is a miss up front.

Region templating. Cases spell a base image ARN once as ${region} and it
follows whatever region the run signs for; region is a built-in param and
scenario params may reference each other. Pinning us-east-1 made every case
silently wrong elsewhere, since a correct implementation scopes its
managed-image catalog by region. The suite now returns an identical 43/0
against us-east-1 and eu-west-1, which was impossible before.

Three more fixtures set aside, and they share a cause worth naming: they
recorded the account, not the service. Three not-found probes against an
invented image ARN came back 403 AccessDeniedException because the recording
account's IAM is resource-scoped — the bodies name user/alex and use a
capital-M Message, both tells they came from the IAM layer, and the cases'
own expect blocks say ResourceNotFoundException, so the recordings contradict
their own intent. ListMicrovms returns every VM the account has ever run,
terminated ones included, so a recorded list is a photograph of one account at
one moment. conformance/README.md now documents the category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ListMicrovms returns every VM an account has ever run, terminated ones
included and apparently forever, so a recorded list can never equal a fresh
target's. Dropping the step to a bare status check would have thrown away the
part that matters — whether the target returns the members a client reads.

expect.itemShape checks membership without checking values. exact rejects
members outside the required set as well as missing ones, since sparse bodies
and over-full ones are both real divergences and an emulator returning half
the members of a summary is the commonest of all. minItems guards against a
target that returns an empty list and satisfies every member check vacuously.

vms-lifecycle/list now passes on shape where its fixture was unusable: 43
pass, 0 fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…count

Three not-found probes recorded 403 AccessDeniedException and were set aside
as an artifact of the recording account's permissions. That was wrong. The
account holds AdministratorAccess, and re-probing the same operation against
a same-account ARN returns a plain 404.

The real cause was missingImageArn carrying the 123456789012 placeholder,
which makes the probe cross-account. Cross-account access needs a
resource-based policy no matter how much admin the caller holds, so the 403
was correct AWS behavior for the ARN actually sent — just not the behavior
the case meant to test, as its own expect block said ResourceNotFoundException
all along.

account is now a built-in param alongside region, defaulting to m80's own
000000000000 so emulator runs need no flag. All three re-recorded against
live AWS as 404 ResourceNotFoundException, and errors-not-found has no
rejected fixtures left.

Two fidelity gaps in m80 surfaced from the corrected fixtures. RunMicrovm
against a missing image reports the missing *version* — "No active version
found for MicroVM image <arn>" — not the missing image, even when the image
itself is what is absent. And the version endpoints answer in terms of the
version regardless of which half was really missing, so a missing image on
GetMicrovmImageVersion returns the version-flavoured message rather than
delegating to the image one.

43 pass, 0 fail, 22/29 operations exercised.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mber

Fidelity is not uniformly valuable, and scoring every member equally
overstated floci's problems by a third. conformance/tiers.json classifies
members and -tier load-bearing scores against the ones a consumer's control
flow actually depends on.

The cosmetic set was derived from the corpus rather than from opinion: a
member null in every recorded response is a candidate, and a test re-derives
that set from the fixtures so a re-recording cannot introduce an unclassified
member behind a passing suite. Four candidates were promoted back by hand and
the reasons written down — latestFailedImageVersion is null only because no
recorded build failed, and a reconciler reads it to detect one. Every entry
carries an argument; a test enforces that too.

Scoring floci both ways: 34 diverging steps at tier=all, 27 at
tier=load-bearing. Seven were pure decoration — always-null config knobs,
the leaked versionStateTimeBucket index key, __type on error bodies, message
wording. The 27 that remain are real, and they concentrate in two response
builders rather than spreading across the module.

A fifth normalization bug fell out of the tiered diff. UUID redaction ran
after account redaction, so a generated id whose final twelve characters are
all decimal had its tail eaten and no longer matched the uuid pattern —
roughly one id in a couple of million, and precisely the flake nobody would
ever reproduce deliberately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the 34-at-tier-all versus 27-at-load-bearing score, where the 27
concentrate, and what each cluster costs. The data already exists on the
domain model and is never written to the response, so this is serialization
work rather than state work — roughly 150 lines across four private response
builders plus eight missing model fields.

Also records why the sparse bodies are the part that matters: the operator
runs its own drift detection by reading back what it wrote, so a service that
does not echo the spec looks permanently drifted and the reconcile loop never
converges.

One correction worth keeping. The third acceptance gate was nearly written as
#31's "chant MicrovmApp deploys against floci". Nothing here depends on
chant — m80's go.mod is aws-sdk-go-v2 alone, the floci module carries no chant
reference, and the CFN integration test builds its template inline. chant is a
consumer; CloudFormation is the interface between them. Using the chant
end-to-end would have added an npm install, a pinned lexicon version and a
live chant-side bug to the gate for a change that only rewrites JSON.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fix

The fixture recorded the message as

    MicroVM not found: microvm-00000000-0000-0000-0000-ACCOUNT

which is the mangling the account rule produces when it runs before the
UUID rule and eats a uuid's final twelve hex characters. That ordering
was fixed in b0c92dc, but this fixture was recorded in a963201 and never
re-normalized, so it kept asserting the broken form and the case failed
against a correct emulator.

The case's id is hardcoded all-zeros, so its final group is decimal every
time rather than the roughly one generated id in a few hundred the
normalizer comment describes — which is why this was the only fixture
affected. The other 123 recorded before the fix carry no such mangling.

This file is what #33 was missing. The handoff reported 43 pass, 0 fail,
7 unimplemented, 21 skipped and 22 of 29 operations exercised; the branch
as pushed measures 40 pass, 1 fail, 6 unimplemented, 24 skipped and 21 of
29. Restoring the correction reproduces the handoff's figures exactly, so
it was measured against a working tree holding this edit and the file
never got committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6T4MeDN1RBiWaNtud6x77
main gained the mkdocs site and the strict docs gate (#27) and the floci
architecture findings (#28) after this branch was cut, and the two sides
had both reformatted 60-connectors.json.

The conflict is a genuine union rather than a pick. main added the
subset:floci tag, correcting which cases the floci subset covers. This
branch added the live-recorded request members the connector create and
update actually need — AssociatedComputeResourceTypes, NetworkProtocol,
ClientToken and OperatorRole, with the operatorRoleArn param behind them.
Both are kept; dropping either would lose a real finding.

This also unblocks CI on the PR. A conflicting pull request has no
mergeable ref for pull_request workflows to run against, which is why #34
showed no checks at all while #35 and #36 were green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X6T4MeDN1RBiWaNtud6x77
@lex00
lex00 merged commit 5e849c6 into main Jul 31, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant