Skip to content

schemas: A/B fallback update contract v0.1 — never ship an update you cannot brick with (W9.2) - #212

Merged
mdheller merged 1 commit into
mainfrom
feat/ab-fallback-update-contract
Jul 29, 2026
Merged

schemas: A/B fallback update contract v0.1 — never ship an update you cannot brick with (W9.2)#212
mdheller merged 1 commit into
mainfrom
feat/ab-fallback-update-contract

Conversation

@mdheller

Copy link
Copy Markdown
Contributor

What

Lands the A/B fallback update contractUpdateSlot, UpdateTransaction, UpdateHealthProbe — as L0 normative schemas. TRUST FABRIC W9.2.

Why

The estate ships updates to fog nodes, to installed applications and to itself, and has no path back from a bad one anywhere. The nearest thing that exists is generation-based (sourceos-syncd applies a switch, a health timer fires at OnBootSec=120s, failure shells out to a rollback that infers its target as "the highest non-current generation"). That has three holes no amount of care closes:

  1. No attempt bound. If the candidate never reaches userspace, the health timer never fires, so nothing reverts it. The failure mode that most needs automatic fallback is the one the mechanism cannot observe.
  2. No candidate marking. Nothing records which payload is on trial, so "roll back" means "guess backwards" rather than "return to the slot that passed".
  3. No confirm step. A health pass is fire-and-forget. Nothing distinguishes "this worked" from "this has not failed yet".

The lesson taken is EVE-OS's: GRUB GPT priority-boot plus dual watchdogs — never ship an update you cannot brick with.

The invariant

The currently-good slot is never overwritten by the update being applied.

Enforced three ways rather than asserted once, because it is the only property whose violation is unrecoverable:

  • By schema, within a document.UpdateTransaction carries a top-level not/anyOf enumerating the two illegal (fromSlot, toSlot) pairs. This is expressible in JSON Schema precisely because the slot set is closed to exactly two — the illegal set is finite. UpdateSlot adds the rule from the other side: state: "writing"role: "candidate".
  • By schema, on the settle path. Four if/then clauses pin settledOnSlot to fromSlot on rollback/refusal and to toSlot on promotion. A failed update leaves the target where it started, decided by the contract rather than by whoever writes the record.
  • Across documents, by the validator. A settled transaction's preservedPayloadDigest must still equal the active slot's payloadDigest.

Three design points worth review attention

  • triesRemaining is decremented by the bootloader BEFORE control transfers, so a payload that hangs before userspace still consumes an attempt. A counter decremented after a successful boot never terminates for a payload that cannot reach userspace — the boot loop this contract forbids. refused is terminal for the same reason.
  • Both watchdog kinds are mandatory (two contains clauses). Software cannot fire through a wedged kernel — the process that would notice is the process that is stuck. Hardware only learns whether something petted it, so it cannot tell working from merely running. Requiring both makes "hung" and "running but failing" terminate in the same automatic fallback. The validator additionally requires the hardware timeout to exceed the software one, so the software watchdog gets the first, attributable word.
  • definitionDigest is RECOMPUTED by the validator, not read back, so a failing candidate cannot be promoted by weakening the gate it failed. A stored digest is a claim about pinning; a recomputed one is pinning.

Conformance

tools/validate_ab_update_examples.py runs five checks: schema conformance, the tranche strictness bar (additionalProperties:false, specVersion const, anchored urn:srcos: id pattern, type const == title), the recomputed probe digest, cross-document invariants, and fourteen negative vectors in fixtures/ab-update/conformance.json — negative by construction, in fixtures/, never in examples/.

The three cross-document invariants were mutation-tested — each was made to fire before being trusted:

MutationValidator response
active slot's payloadDigest changedthe good slot WAS overwritten — opened with sha256:b73d… in slot A, which now holds sha256:cccc…
refused candidate given bootPriority: 5refused but the candidate slot is still selectable (state=written, bootPriority=5) — nothing ends the boot loop
probe's minConsecutivePasses loweredprobe definitionDigest is stale: recorded sha256:2e06… but the check set hashes to sha256:4632…

Validation

$ make validate
… OK: ab-update family — all five checks passed
python3 scripts/check_duplicate_schema_ids.py
OK: 313 unique schema $id values
OK: validate

AJV compile (the CI step) passes for all three new schemas, and all 7 new examples pass the CI example-vs-schema path.

Also wires the duplicate $id guardrail into make validate — it ran only in CI, so a local run could not reproduce the check that gates the PR.

Scope / honest limits

  • L3 (GRUB slot selector + A/B partition layout) is NOT in this tranche.source-os/scripts/install-image.sh creates two partitions (512M ESP + one rootfs); an A/B layout needs a second rootfs slot and a GRUB config reading the priority attributes. That is not verifiable without booting a machine, and a test claiming to verify a bootloader without one would be worth less than no test. Tracked as OI-1.
  • L4 (Noetica) has no updater to consume this. No tauri-plugin-updater, no updater block in tauri.conf.json, Homebrew-cask distribution. Its own docs/WORKPLAN-111-gaps.md records this as ship-blocker Define Secure Host Interface contracts for Agent Machine terminal browser editor and agent tools #77. The contract is written to cover it — targetRef is any urn:srcos: URN and the example set includes an application target — but there is nothing on that side to wire yet.

Reference implementation (AbUpdateMachine) lands separately in sourceos-boot.

… cannot brick with (W9.2)
The estate ships updates to fog nodes, to installed applications and to itself,
and has no path back from a bad one anywhere. The nearest thing that exists is
generation-based and has three holes: no attempt bound (if the candidate never
reaches userspace the health timer never fires, so nothing reverts it), no
candidate marking (rollback infers its target as "highest non-current
generation" — a guess standing in for a pointer), and no confirm step (a health
pass is fire-and-forget, so "this worked" and "this has not failed yet" are
never distinguished).
Lands the EVE-OS shape as a contract: GPT priority-boot attributes plus dual
watchdogs.
- UpdateSlot: one of exactly two slots, carrying the attribute triple a slot
selector reads (bootPriority / triesRemaining / successful), the installed
payload digest, and a deliberate role-vs-currentlyRunning split so the
fallback slot stays describable during a trial boot.
- UpdateTransaction: one apply attempt — write target, pinned probe, per-attempt
boot record naming where each failure fell back to, terminal outcome with a
closed rollbackReason set.
- UpdateHealthProbe: the digest-pinned promotion gate. Non-empty check set with
at least one blocking check; mandatory hardware+software watchdog pair;
evaluatedIn closed to post-boot userspace; onProbeUnavailable closed to fail.
THE INVARIANT — the currently-good slot is never overwritten by the update being
applied — is enforced three ways rather than asserted once, because it is the
only property whose violation is unrecoverable: by schema within a document (a
top-level not/anyOf enumerating the two illegal (fromSlot, toSlot) pairs, which
is expressible precisely because the slot set is closed to two; plus
state:writing implies role:candidate on the slot), by schema on the settle path
(settledOnSlot pinned to fromSlot on rollback or refusal), and across documents
by the validator (a settled transaction's preservedPayloadDigest still equals
the active slot's payloadDigest).
Three design points worth the reviewer's attention:
- triesRemaining is decremented by the bootloader BEFORE control transfers, so a
payload that hangs before userspace still consumes an attempt. A counter
decremented after a successful boot never terminates for a payload that cannot
reach userspace, which is the boot loop this contract exists to forbid.
- Both watchdog kinds are required. Software cannot fire through a wedged kernel
because the process that would notice is the process that is stuck; hardware
only learns whether something petted it, so it cannot tell working from merely
running. Requiring both makes "hung" and "running but failing" terminate in
the same automatic fallback.
- definitionDigest is RECOMPUTED by the validator from the probe's own check set
rather than read back, so a failing candidate cannot be promoted by weakening
the gate it failed. A stored digest is a claim about pinning; a recomputed one
is pinning.
Fourteen negative vectors in fixtures/ab-update/, none in examples/. The three
cross-document invariants were mutation-tested — each was made to fire before
being trusted.
Also wires the duplicate schema $id guardrail into `make validate`; it ran only
in CI, so a local run could not reproduce the check that gates the PR.
@mdheller
mdheller merged commit 1f12587 into mainJul 29, 2026
7 checks passed
@mdheller
mdheller deleted the feat/ab-fallback-update-contract branch July 30, 2026 05:58
Sign up for freeto 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

@mdheller