Home the roxyd self-update contract in this crate (#91) - #92
Conversation
The on-disk shape the self-update supervisor coordinates through lived in bootler-core, and roxyd is about to become a second writer of those records. bootler-core is a workspace member of a private repository that itself depends on this crate, so a roxyd dependency on it would invert the layering and put bootler credentials in front of every roxyd build; re-declaring the shape in roxyd would leave two copies of a versioned agreement with no compiler tie between them, free to diverge after deployment with both repositories' tests green. It arrives as a sibling of `roxyd_selfupdate` rather than as part of it, and neither module names the other. The unit text is byte-identical data with deliberately no parameters and one consumer; this is a versioned shape whose whole point is that two writers agree on it, so merging them would make the unit module's "roxyd is not a consumer" false of the merged module while it stays true of the units. The enumeration of the decision-path units and the arming gate applied to a self-test record are where the two subjects meet, so they stay with the consumer that installs the units. Every value is unchanged, `FORMAT` included: the records are already on disk under these names on every host, so a changed value here would be a silent behaviour change there rather than a refactor. A test names each value and each composed path as a literal instead of rebuilding it from the constant beside it, so a later edit that moves the directory or renames a file fails here rather than on a host. Closes #91
The status record's round trip fixes one variant of `lifecycle`, `decision` and `reason` each, so the other seventeen were free to be renamed with the suite still green. They are the frozen wire strings one side writes into `status.json` and the other reads back out, and this module exists precisely so the two cannot drift, so enumerate them the way the decision-unit state and the rollback policy already are. Part of #91
The README's per-module list is the crate's front door, and the new contract module was absent from it while `lib.rs` and the changelog both announced it. A reader looking for the shape the supervisor coordinates through would have found the unit text and concluded the contract lives in the consumer, which is the arrangement this issue exists to end. Its neighbour's entry still said the installer and roxyd's own join each embed the unit bytes from their pinned dependency. The module doc was corrected away from exactly that sentence when the join-only installer subcommand shipped -- roxyd join invokes the installer on a pre-placed payload-free package, so the installer reaches both populations and roxyd is not a consumer of that module. The README kept the reverse, which matters because the two-consumer claim is the stated reason the unit text takes no parameters, and this issue's contract module argues its own separateness from that consumer count. Part of #91
The report request parses its digest through a stricter private view with its own guard attached, so the suite's malformed-digest cases all ran against that view and none against `BinaryDigest` itself. Drop the `deserialize_with` from the shared type and every test still passed, while arm and status records began accepting an uppercase or short digest that no writer produces. Those two digests are what a rollback decision holds the on-disk binary against, so a representation outside the contract has to fail the parse rather than arrive as a value matching nothing. Pin the absent observed build as well. It is optional because a decision can be reached against a binary answering no identity query, and the round trip named only the present case; the absent one is an explicit null, so a skip added to the field would leave a different record on disk for the same decision. Part of #91
The `format` field's stated rule is that a consumer not accepting a record's revision takes no rollback action on it, which it can only decide after reading the revision out of the file. Nothing held any record to that: every parse test names the current revision, so a guard added to a `format` field would turn the decision into a parse error indistinguishable from a corrupt or truncated record, and the consumer would decline for the wrong reason and report the wrong one. The report request already carries such a guard on its writing side, which is what makes the reading side's silence worth pinning. Spell the self-test resolver's directory-relative case as a literal too. It was the one assertion in the path test rebuilt from the constant beside it, which is what that test exists to not do. Part of #91
The entry said both writers of the self-update records now name one definition instead of two. That is not this crate's to state: bootler still carries its own copy until its companion change re-points at this module, so as written the line describes a state no reader can observe and which nothing here brings about. Say instead what homing the contract here does deliver -- one definition that both writers can name -- and leave the count of copies to the repository that still holds the second one. Part of #91
|
[Reviewer Round 1] Finding
Otherwise, the module separation, public exposure, unchanged unit-text module, copied contract shape, and PR linkage/test-plan hygiene look sound. |
|
[Review Verdict Round 1: NOT_APPROVED] |
The value-identity test names every inherited constant so that a later edit to one is a failing test rather than a silent behaviour change on every host. It skipped the self-test unit stem because that constant is private, leaving the one value whose names systemd resolves protected only indirectly, through the template and instance name tests. Those would keep passing if the stem and the names it composes moved together. Part of #91
|
[Author Round 1] Round 1 review addressed and pushed as DispositionP2 — Assert the private self-test stem's inherited value — Fixed. The finding was correct:
No other item was raised. Verification
DocumentationNo documentation change is warranted. The change is a test-only assertion — nothing public moved, and no user of the last release could observe it, so no The PR description was accurate but understated the fix, so I amended three spots: the value-identity paragraph and the matching test-plan box now say the in-tree test names |
|
[Reviewer Round 2] [Reviewer Round 2] No findings. The prior P2 is resolved: the private The updated module remains a public sibling, preserves the existing unit-text module unchanged, retains only the contract surface specified by the issue, and has appropriate value, path, serialization, and wire-format tests. PR linkage and test-plan hygiene are also correct. [Review Verdict Round 2: APPROVED] |
|
[Review Verdict Round 2: APPROVED] |
Suggested squash commitTitle Body |
What this does
Adds
deploy_core::roxyd_selfupdate_contract, the frozen on-disk contract the roxyd self-update rollback supervisor coordinates through, moved frombootler-core::roxyd_selfupdatewith every value unchanged.It carries the constants (
FORMAT, the record directory, the record file names, the canonical roxyd binary path, the decision subcommand, the three activation reasons, the self-test freshness window and nonce rules, and the supervisor subcommand and drop-in names), a resolver for every path composed from them, and the versioned serde record types —SelfTestRecordwithDecisionPathEntry/DecisionUnitState,ArmRecord,ConfirmMarker,StatusRecord,SupervisorVersionMarker,ReportRequest— along withBinaryDigest's strict hex representation,status_digest,validate_report_requestandis_valid_self_test_nonce.FORMATsits at the head of the record types it versions rather than with the file-name constants above them, with a comment saying why it is not where this repository otherwise puts a constant.src/lib.rsgains thepub modand names the contract in the crate doc's list of what this crate holds,README.mdgains the module's entry, andCHANGELOG.mdannounces it under### Added. The README's existing roxyd_selfupdate entry is corrected in the same pass: it claimed roxyd'sjoinembeds the unit bytes from this crate, which overstates what this crate delivers — the installer is that module's only consumer, and it is what puts the units on both host populations.Closes #91
Why a sibling and not a merge
src/roxyd_selfupdate.rsis byte-for-byte untouched, and neither module names the other in code — the only reference between them is a//!sentence in the new module linking the old one to say they are siblings.The unit text is data with deliberately no parameters and one consumer, and its module doc argues from exactly that; the contract is a versioned shape with two writers, whose point is that both agree on it. Merging them would make the units' bolded "roxyd is not a consumer of this module" false of the merged module while it stays true of the units.
Value identity
Every constant's value was checked mechanically against
aicers/bootlerat12795e4cda629783871ff5007ba151d4f65bf2a9, including the privateSELF_TEST_UNIT_STEM: no constant is missing, none differs, and none is invented.every_contract_value_is_the_one_this_module_inheritedthen names each value as a literal in-tree, that stem included, so the identity is asserted rather than inspected.every_resolver_composes_the_path_it_always_didspells each composed path as a literal too, rather than rebuilding it from the constants the resolvers use — a test that composed them the same way would pass on a host where the directory had moved.What stays with bootler, as the issue specifies
decision_path,DecisionUnit,ReadyRequirement,accept_self_testandSelfTestRefusalare the part ofbootler-core::roxyd_selfupdatethe issue holds back from this move.decision_pathbuilds its unit names fromroxyd_selfupdate::BOOT_ACTIVATION_SERVICEand its siblings, andaccept_self_testreaches the units only through it, so moving either would create exactly the dependency between the two modules this issue forbids. The record shape they read —SelfTestRecordand its entries — is here, and so are the freshness window and nonce rules the gate applies; what stays behind is the mechanism that ties those to the installed units.Nothing in
aicers/bootleris deleted from here, and its contract document and capability-tag derivation rule are untouched, as the constraints require.Tests
Ported from the source module, minus the ones that drive bootler-only material (its contract document, its E2E shell script, its workspace source sweep, and the decision-path gate above):
formator a malformed digest;validate_report_requestdistinguishing its four refusals fromValid;status_digestpinned to SHA-256 by a known vector;Lifecycle,Decision,DecisionReason,DecisionUnitStateandRollbackPolicy;formatstill parsing, so a reader classifies it rather than failing before it can, and an undeterminable observed build encoding as an explicitnull;No dependency was added;
serde,serde_jsonandsha2were already here.Test plan
Every box below was run on this branch at
2640948.every_contract_value_is_the_one_this_module_inheritednames each constant's value as a literal, the privateSELF_TEST_UNIT_STEMamong them, so a changed value fails rather than being caught by inspection.every_resolver_composes_the_path_it_always_didpins each composed path as a literal.serdeunchanged,FORMATincluded, against the frozen JSON field names and enum encodings.deploy_core::roxyd_selfupdate_contractis public and reachable, with every public item documented to this crate's standard (RUSTDOCFLAGS="-D warnings" cargo doc --no-depsclean).src/roxyd_selfupdate.rshas an empty diff againstmain, andCargo.tomlgains no dependency.cargo fmt -- --check --config group_imports=StdExternalCrate.cargo clippy --all-targets -- -D warningsandcargo clippy --all-targets --features test-support -- -D warnings.cargo build,cargo testandcargo test --features test-support— 523 passing, 0 failing.