Skip to content

Home the roxyd self-update contract in this crate (#91) - #92

Merged
AcoPiper merged 7 commits into
mainfrom
AcoPiper/issue-91
Sep 8, 2026
Merged

Home the roxyd self-update contract in this crate (#91)#92
AcoPiper merged 7 commits into
mainfrom
AcoPiper/issue-91

Conversation

@AcoPiper

@AcoPiper AcoPiper commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

What this does

Adds deploy_core::roxyd_selfupdate_contract, the frozen on-disk contract the roxyd self-update rollback supervisor coordinates through, moved from bootler-core::roxyd_selfupdate with 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 — SelfTestRecord with DecisionPathEntry/DecisionUnitState, ArmRecord, ConfirmMarker, StatusRecord, SupervisorVersionMarker, ReportRequest — along with BinaryDigest's strict hex representation, status_digest, validate_report_request and is_valid_self_test_nonce.

FORMAT sits 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.rs gains the pub mod and names the contract in the crate doc's list of what this crate holds, README.md gains the module's entry, and CHANGELOG.md announces it under ### Added. The README's existing roxyd_selfupdate entry is corrected in the same pass: it claimed roxyd's join embeds 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.rs is 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/bootler at 12795e4cda629783871ff5007ba151d4f65bf2a9, including the private SELF_TEST_UNIT_STEM: no constant is missing, none differs, and none is invented. every_contract_value_is_the_one_this_module_inherited then 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_did spells 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_test and SelfTestRefusal are the part of bootler-core::roxyd_selfupdate the issue holds back from this move. decision_path builds its unit names from roxyd_selfupdate::BOOT_ACTIVATION_SERVICE and its siblings, and accept_self_test reaches 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 — SelfTestRecord and 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/bootler is 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):

  • arm/confirm and status/marker JSON round trips against the frozen field names and enum encodings, self-test record round trip, and report-request round trip bound to exact durable status bytes;
  • report-request parsing refusals (unknown fields, wrong algorithm tag, short and uppercase digests) and the writer-side refusal of an unsupported format or a malformed digest;
  • arm and status parsing still permitting digest extension fields;
  • validate_report_request distinguishing its four refusals from Valid;
  • status_digest pinned to SHA-256 by a known vector;
  • wire encodings for every variant of Lifecycle, Decision, DecisionReason, DecisionUnitState and RollbackPolicy;
  • a record at an unaccepted format still parsing, so a reader classifies it rather than failing before it can, and an undeterminable observed build encoding as an explicit null;
  • the self-test template/instance unit names and the nonce alphabet and length rule.

No dependency was added; serde, serde_json and sha2 were already here.

Test plan

Every box below was run on this branch at 2640948.

  • Unit: the value-identity test — every_contract_value_is_the_one_this_module_inherited names each constant's value as a literal, the private SELF_TEST_UNIT_STEM among them, so a changed value fails rather than being caught by inspection.
  • Unit: path composition returns the same paths for the same inputs as the source module does — every_resolver_composes_the_path_it_always_did pins each composed path as a literal.
  • Round-trip: each record type serialises and deserialises through serde unchanged, FORMAT included, against the frozen JSON field names and enum encodings.
  • deploy_core::roxyd_selfupdate_contract is public and reachable, with every public item documented to this crate's standard (RUSTDOCFLAGS="-D warnings" cargo doc --no-deps clean).
  • src/roxyd_selfupdate.rs has an empty diff against main, and Cargo.toml gains no dependency.
  • cargo fmt -- --check --config group_imports=StdExternalCrate.
  • cargo clippy --all-targets -- -D warnings and cargo clippy --all-targets --features test-support -- -D warnings.
  • cargo build, cargo test and cargo test --features test-support — 523 passing, 0 failing.

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
@AcoPiper AcoPiper changed the title Home the roxyd self-update contract in this crate Home the roxyd self-update contract in this crate (#91) Sep 8, 2026
@AcoPiper

AcoPiper commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 1]

Finding

  • P2 — Assert the private self-test stem’s inherited value. src/roxyd_selfupdate_contract.rs:663 claims to pin every inherited constant, but omits SELF_TEST_UNIT_STEM. The source contract defines it, and the PR body specifically says the identity check includes it. The unit-name test protects its resulting names indirectly, but does not meet the issue’s explicit requirement to assert every constant value directly. Import it into the test and assert "roxyd-supervisor-selftest".

Otherwise, the module separation, public exposure, unchanged unit-text module, copied contract shape, and PR linkage/test-plan hygiene look sound.

@AcoPiper

AcoPiper commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

[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
@AcoPiper

AcoPiper commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

[Author Round 1]

Round 1 review addressed and pushed as 2640948.

Disposition

P2 — Assert the private self-test stem's inherited value — Fixed.

The finding was correct: every_contract_value_is_the_one_this_module_inherited is the test that makes value identity an assertion rather than an inspection, and SELF_TEST_UNIT_STEM was the one inherited constant it did not name. The template/instance name tests protect it only indirectly — a later edit that moved the stem and the expected names together would leave them green, which is exactly the silent behaviour change the identity test exists to catch.

  • src/roxyd_selfupdate_contract.rs:602 — imported SELF_TEST_UNIT_STEM into the test module (it is private to the module, so the child module reaches it through super).
  • src/roxyd_selfupdate_contract.rs:681assert_eq!(SELF_TEST_UNIT_STEM, "roxyd-supervisor-selftest");, placed with the other self-test constants.

No other item was raised.

Verification

  • cargo fmt -- --check --config group_imports=StdExternalCrate — clean.
  • cargo clippy --all-targets -- -D warnings and cargo clippy --all-targets --features test-support -- -D warnings — clean.
  • cargo test and cargo test --features test-support — 523 passing, 0 failing (unchanged count; the assertion joins an existing test).

Documentation

No 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 CHANGELOG.md entry (the crate's policy: an entry must be observable to a user of the last release).

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 SELF_TEST_UNIT_STEM too, and the test-plan SHA moved from 26a58b5 to 2640948. Closes #91 is intact and no other issue reference was added.

@AcoPiper

AcoPiper commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

[Reviewer Round 2]

[Reviewer Round 2]

No findings.

The prior P2 is resolved: the private SELF_TEST_UNIT_STEM is imported and asserted literally in src/roxyd_selfupdate_contract.rs:603 and :680.

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]

@AcoPiper

AcoPiper commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

[Review Verdict Round 2: APPROVED]

@AcoPiper

AcoPiper commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author

Suggested squash commit

Title

Home the roxyd self-update contract here

Body

The roxyd self-update supervisor is governed by two things, and only
one of them lived here: `roxyd_selfupdate` ships the unit text, while
the on-disk contract those units coordinate through lived in
`bootler-core`. roxyd is about to become a second writer of those
records, and it cannot depend on a private workspace member that
already depends on this crate without inverting the layering. The
alternative, re-declaring the shape in roxyd, leaves two copies of a
versioned agreement with no compiler tie between them, free to diverge
after deployment with both repositories' tests green.

So the shape moves here, unchanged. The constants, the path
composition and the serde record types keep the values already on
disk on every host, `FORMAT` included; a changed value would be a
silent behaviour change there rather than a refactor. Tests name each
value and each composed path as a literal instead of rebuilding them
from the constants beside them, so a moved directory or a renamed
file fails rather than passing by construction.

It is a sibling of `roxyd_selfupdate` rather than part of it, and
neither module depends on the other. The unit text is byte-identical
data with deliberately no parameters and one consumer, and its module
doc argues from exactly that; this is a versioned shape with two
writers, whose whole point is that both agree on it. Merging them
would make the units' "roxyd is not a consumer of this module" false
of the merged module while it stays true of the units.

The decision-path enumeration and the arming gate stay with bootler.
They are where the contract and the unit text meet, and moving either
would create the dependency between the two modules that keeping them
apart exists to prevent.

Closes #91

@AcoPiper
AcoPiper merged commit c8180f3 into main Sep 8, 2026
4 checks passed
@AcoPiper
AcoPiper deleted the AcoPiper/issue-91 branch September 8, 2026 02:18
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.

Home the roxyd self-update contract in this crate

1 participant