Skip to content

refactor(core): small layering roots for sealed values, authority checks, the health gate and ployz-decide - #4054

Merged
nick-potts merged 5 commits into
mainfrom
t3/1eec8ee2
Nov 10, 2026
Merged

nick-potts merged 5 commits into
mainfrom
t3/1eec8ee2

Conversation

@nick-potts

Copy link
Copy Markdown
Contributor

Why

Untangle the core/ Rust workspace so pieces that change together live together, with behavior unchanged, so later agent changes read less, break less silently and debug faster. This is chunk D of batch 1: four small roots that later fixes build on.

What changed

Four independent commits, plus a test commit that pins the dependency gate before its logic moves.

  • T4-F13 ClientSealedValue moves from config/service.rs into sealed.rs. config re-exports it, so ployz_core::config::ClientSealedValue still resolves.
  • R6-core-F1 Six hand-written "sorted and distinct" deserializers become canonical::sorted_distinct and canonical::sorted_by. Seven "build a (field, holds) table, return the first Mismatch" loops become refusal::first_mismatch, with the repeated labels as refusal::field::*.
  • T5-F4 The dependency gate in ployz/src/deploy/exec/health.rs asks health_wait.rs for its deadline. It uses HEALTHCHECK_RUNTIME_GRACE instead of its own RUNTIME_FAILURE_GRACE, calls monitor_deadline for the Http case, and names its "also wait on NotConfigured" quirk as Unconfigured::UseDeadline.
  • T8-F1 New crate ployz-decide: no items yet, the rule in its //!, and a clippy.toml that refuses the clock and filesystem calls. ployz-verify takes it as a dev-dependency. CODING_STANDARDS.md gains the placement rule.

Fixes and the problems they solve

T4-F13

Problem: crates/ployz-core/src/config/service.rs:332 ClientSealedValue lives in config, while sealed.rs (mod sealer) imports it back, a config <-> sealed cycle.

Decision log: "the type moves verbatim into sealed.rs, the module that defines what Sealed values are and who wraps them; config/service.rs imports it, and a pub use in config keeps crate::config::ClientSealedValue for existing callers. The edge becomes config -> sealed, one way. Move only; no body edits." Frozen risk: "ClientSealedValue serde form and any ts-rs export. Derives and serde attributes move verbatim and the ts-rs diff is the proof."

Proof: the SDK declarations from sdk-generated are byte-identical on trunk and on this branch.

R6-core-F1

Problems: the "sorted and distinct" deserialize check exists in four hand-written copies plus services_by_name twice (authority/session.rs:72); "build a table of (field, holds) and return the first Mismatch" is written six times with the same labels repeated (authority/session.rs:101).

Decision log (a seventh table, AutoDeployRule::verify_step, merged with #4047 after the log was written and moves too): "In authority/canonical.rs add sorted_distinct ... and a helper for the by-name case ... One error text, 'must be sorted and distinct'. ... In authority/refusal.rs add pub(super) fn first_mismatch<const N: usize>(checks: [(&'static str, bool); N]) -> Result<(), AuthorityRefusal> and a pub(super) mod field. The six sites named in the log (GrantBody::covers, RestartApproval::matches, deploy::verify, proposal::open_step, join::verify, rule claims) build their array and call it. AuthorityRefusal::Mismatch { field } and every label string stay byte-identical." Frozen risk: "None on the wire: the check runs at deserialize and accepts exactly the same lists (strictly ascending). Only the serde error text for a bad list changes (it is a Malformed refusal's problem)."

T5-F4

Problem: crates/ployz/src/deploy/exec/health.rs:74 the dependency wait re-derives the deadline, the Http-monitor case and the 5s grace, and has drifted from health_deadline_for.

Decision log: "Make health.rs use HEALTHCHECK_RUNTIME_GRACE instead of its own RUNTIME_FAILURE_GRACE. Add one monitor_deadline helper in health_wait.rs that health_deadline_for and the dependency wait both call, so the Http-monitor case lives once. The dependency gate's quirk (it also falls back on NotConfigured, which health_deadline_for does not) stays and is named explicitly as an Unconfigured::{Settled, UseDeadline} argument at the one call site that needs it, so the divergence is visible and nothing changes behavior. No new single 'verdict and next wake' function: the two callers differ in what they do with the verdict." Risk: "Write a characterization test of today's dependency-gate verdicts before moving the logic."

The test dependency_gate_deadline_follows_the_healthcheck_kind is its own commit. It passed on the old code, then on the new:

spec runtime verdict
Http, timeout 3s Restarting fails "runtime" at 3s
Http, timeout 3s unhealthy fails "timed_out" at 3s
none Restarting fails "runtime" at 5s
none running, no check reported fails "timed_out" at 185s
Disabled running, no check reported fails "timed_out" at 185s

T8-F1

Problem: crates/ployz-verify/Cargo.toml:1-14 the only Ployz dev-dependency is ployz-core, so decisions in ployzd and ployz cannot be called, which is the root cause of the model copy findings. upgrade_attempt.rs:1 each model is a hand copy linked by prose cites.

Decision log: "new workspace crate crates/ployz-decide with ployz-core and serde as its only dependencies. Its lib.rs //! states the rule: a decision a model checks is a free function or small enum machine over plain facts; time arrives as SystemTime/Duration parameters; no tokio, std::fs, Instant::now, Docker or SQLite types. The crate ships clippy.toml with disallowed-methods ... so the purity claim is mechanical, not a dependency-list hope. ployz-verify takes it as a dev-dependency and [profile.dev.package.ployz-decide] opt-level = 3. Add one paragraph to CODING_STANDARDS.md."

The crate has no items yet. ployzd, ployz and ployz-store do not depend on it until the first decision moves in (T8-F2 onward). ployz-verify's lib.rs sentence "calls the real code where it can" now lists what it calls.

I checked that adding a crate touches no packaging or release script: scripts/artifacts.py and scripts/ci.py derive closures from cargo metadata (the crate is outside the SDK closure), .goreleaser.yaml names packages explicitly, and the cli test group is --workspace --exclude ployzd --exclude ployz-store.

DESIGN.md bets

  • Bet 10, client-first, daemon when forced (T8-F1): the new crate is shared by CLI and daemon and moves no behavior between them. No red flag crossed.
  • Bet 6, Machine-local authority (R6-core-F1): a Machine still verifies each statement against its own facts. The helpers change where the check is written, not what it reads or in what order. No red flag crossed.
  • Bet 3, bounded imperative commands (T5-F4): the dependency gate keeps its deadline and its poll interval. The choice it changed is who computes the deadline: health_wait.rs now, not a copy in the gate.
  • Bet 8, evidence over claims (R6-core-F1): every Mismatch label is byte-identical, so each refusal still names what the request failed to match.

Scope

core/ only. No wire or row format changes. No serialized type is added or changed (the ts-rs proof above covers ClientSealedValue).

Blast Radius

Does this alter Machine behavior? No. It touches no daemon (ployzd), Docker, WireGuard, ZFS, move or deploy-wire code. One runtime path did change in shape: the dependency gate's deadline computation (T5-F4) now lives in ployz-core. Its verdicts are pinned by the characterization test above and are unchanged. I'm skipping the Boat swarm because no behavior a Machine shows changes; the gate's verdict table, run before and after, is the evidence.

Verification

  • cargo nextest run -p ployz-core -E 'test(authority) | test(sealed) | test(config::) | test(health_wait)': 187 passed.
  • cargo nextest run -p ployz -E 'test(exec::tests::health) | test(container_health)': 33 passed, including the new characterization test.
  • cargo check -p ployz-decide -p ployz-verify --tests and focused cargo clippy on ployz-core, ployz, ployz-decide and ployz-verify: clean. cargo fmt --check: clean.
  • cargo tree -p ployz-decide --depth 1: ployz-core and serde only.
  • The disallowed-methods list works: a probe calling Instant::now, SystemTime::now, fs::read_to_string and File::open produced four clippy warnings (probe reverted).

Residuals

  • Serde error text. A list that is unsorted or repeats now fails with "must be sorted and distinct" in every body. Before it said "Machines must be sorted and distinct", "Services must be sorted by name and distinct" or "a list must be sorted and distinct". It surfaces as a Malformed refusal's problem, and the decision log accepts it. Nothing in docs/user, the tests or the dashboard pins the old text, and the browser always sends sorted lists.
  • Conflict with Volume approval, its verifier and the Machine's operation records (E2) #4051. It makes restart::ordered_machines pub(super), which this PR deletes. Whichever lands second points volume.rs at canonical::sorted_distinct. It already conflicts with trunk in ployzd.
  • Unused crate dependencies. ployz-decide declares ployz-core and serde, and ployz-verify its dev-dependency, before anything uses them, as the decision log specifies.
  • The Unconfigured flag is a mode argument by design (the log declines a merged verdict function). If the two callers ever need more differences, split it into two functions.

@coderabbitai

coderabbitai Bot commented Nov 10, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: d451393b-46d6-4b80-81fe-c7cf16f8dd4a

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

Share

@nick-potts
nick-potts marked this pull request as ready for review November 10, 2026 13:34
@nick-potts
nick-potts added this pull request to the merge queue Nov 10, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Nov 10, 2026
Config imported Sealed's type and sealed.rs imported it back. The type now
lives beside SealedValue, and config re-exports it for existing callers.
The sorted-and-distinct list check had six copies across the signed bodies
and the Mismatch loop had six more. Both now live in authority, so a bad
list reads one problem and every check runs through first_mismatch.
Http and unconfigured Containers wait different deadlines in the gate than in
a start's wait. The test holds today's verdicts before the gate's logic moves.
The gate carried its own copy of the grace constant, the Http monitor case
and the default-deadline fallback. health_wait now owns each once, and the
gate's NotConfigured fallback is a named Unconfigured argument.
A decision a model checks lives in one crate as a pure function that the model
calls. clippy.toml refuses the clock and filesystem calls, so the purity is
checked rather than hoped for.
@nick-potts
nick-potts enabled auto-merge November 10, 2026 14:33
@nick-potts
nick-potts added this pull request to the merge queue Nov 10, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Nov 10, 2026
@nick-potts
nick-potts added this pull request to the merge queue Nov 10, 2026
Merged via the queue into main with commit c9933fb Nov 10, 2026
21 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

Development

Successfully merging this pull request may close these issues.

1 participant