Repository navigation
A deployment converges on member identities: tree, executable and serve unit mutate only when they differ - #10696
Conversation
…able and serve unit are observed, decided, and mutated only when they differ
Operator direction (2026-09-06): the live deployment path was not converging, it was replaying installation. `deployment_apply_plan` reconciled the desired roster against `observed: []`, so every member was an add on every apply, and four of the five source-compiling evaluators a nineteen-minute converge started were downstream of unconditional restarts. docs/plans/deploy-convergence-observed-side.md refutes feeding that path-keyed roster an observed side (a member whose value is its key cannot be seen to be stale), so this change gives the three release-consuming members a real identity and moves them off the apply-all roster.
- `gunbc.live_deploy.member_identity` (pure): `TreePublicationIdentity { revision, tracked_projection_clean }`, `ExecutableIdentity { sha256 }`, `ServiceUnitIdentity { unit, document content-hash, active state }`; `ObservedMember` (observed | established-absent | unobservable); `decide_member` → `AlreadyConverged | Create | Replace | MemberRefused` (an unobservable member refuses, it is never skipped or reinstalled); `derive_effects` → ordered `DeploymentEffect` list (install executable before publishing the tree, because the publishing oneshot runs the admitted executable; daemon-reload once iff a unit document changed; restart iff anything changed) beside a readiness readback that runs even for the empty plan.
- `gunbc.live_deploy.member_observe` (effectful): one interface, two realizations per DESIGN §3 — typed operations when the executor is the host (`extdeps.git.inspect` HeadCommitIn / ReadTreeIntoIndex / StatusAgainstIndex / IgnoredAgainstIndex, `extdeps.crypto.hash` `crypto.Sha256Sum.File`, `extdeps.systemd.systemctl` ShowProperty, `extdeps.filesystem`), and the same invocations as argv over `run_typed_argv_transport` when the host is behind ssh. Absence is established by listing the parent, never by a failed read. The tree observation reuses the deployed-tree record's three-leg instrument and its reading.
- Wiring: `LiveDeployApply` carries the `EffectPlan`; `live_deploy_apply_mutation_via_transport` derives it from the host before building the mutation and refuses with every member's cause if it cannot; `live_deploy_apply_script_for` renders the plan's effects from the same fragments the apply-all arms used, and reconciles only the remaining owned artifacts against `observed: []` — the declared frontier.
Witnesses (fresh build, hermetic): `member_identity_witness_test` 7/7 — already converged → zero mutations; executable-only drift → install + restart; unit-only drift → write + daemon-reload + restart; failed process under a current unit → restart; dirty projection at the right revision → republish; absence creates and unobservability refuses; an observation of another member is refused. `emit_test` 58/58 including two new: a zero-mutation plan renders no install, no repository-convergence oneshot, no serve restart; a binary-only plan installs and restarts without publishing the tree. `deployed_tree_remote`, `shell_exec_run_argv_embed`, `ssh_transport`, `apply_witness`, `intent_witness` green except `witness_readback_requires_service_ready_gate`, which fails identically on main and is not touched here.
Not in this change: the thin service-user actuator for the repository transition (the rsync and git-native publication oneshots both still run inside PublishTree, in their old order, when and only when the tree differs), the timer members' identities, and folding mode=deploy into the planner.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019iXPtAqzPaNBZbaaM5rxrY
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
|
Live measurement on srv1 (sum branch 2081e8f, this PR included):
|
… the deployment's own order carry the release Review on #10696 found five P1s; all five are repaired here. The identity plan no longer preempts the deployment. `apply_intent_from_effects` folded every `plan.mutations` effect ahead of every membership effect, so on a fresh or partially repaired host the serve process started before the roots it writes existed. The emitter now walks `spec.steps` -- the deployment's own order -- and projects each step to its effects; `apply_step_effects` adds no ordering of its own. The two contradictory authorities are resolved at the authority rather than overridden downstream: `deployment_owned_steps` lists ServeBinary before GunbcSourceTree, because the tree is published by a oneshot that RUNS the admitted binary. The old comment's reason was true of the running process, not of the installation. An active stale process was an absorbing refusal state. The serve-unit observation carried only the on-disk unit hash and ActiveState, so an interrupted deploy -- unit written, not restarted -- had every member compare equal, derived an empty plan, and refused at readiness forever. `ServiceUnit` is split into UnitFile (document on disk), UnitManager (systemd's own NeedDaemonReload) and ServiceProcess (the release the process reports over /healthz). File drift writes, manager staleness reloads, process staleness restarts, and nothing stale derives nothing. An Active process that cannot be probed is unobservable, not converged. Tree absence refuses by name instead of promising a create it cannot perform. The publish excludes .git and the convergence oneshot refuses a non-repository pre-state, so an attempted create turned a recoverable absence into a permanently refusing partial state. `RepositoryBootstrapUnavailable` names it until a real bootstrap transition exists. Separately, `entry_presence` now walks ancestors when a listing refuses, so a never-deployed host no longer refuses during planning. The planned identities bind actuation. `ReleaseTarget` is carried once on the plan, the duplicate revision argument is gone, and the install brackets the copy with sha256 verification of the planned digest -- source before, destination after -- so a source binary that changes between planning and copy stops the line. After readiness the deploy re-derives its own plan and requires it empty. The tree observation is an owned effect, not a readonly global. `ReadTreeIntoIndex` is no longer declared `readonly` -- it writes the file GIT_INDEX_FILE names -- and the consumer mints a unique scratch index per observation and removes it on every arm, refusing the observation if it cannot. Modeling: `List<MemberDecision>` made completeness and ordering a caller obligation and an empty list derived an accepted empty plan. It is now `ReleaseMemberDecisions`, one slot per member, and `ReleaseEffectPlan`, one switch per member. `EffectPlan.readback` is deleted; it was decorative. The two floor-red witnesses each lost a second full render, and the shared apply render is hoisted to module-scope data so the module renders it once rather than once per call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXPtAqzPaNBZbaaM5rxrY
Repairs for the five P1 findings, plus the required-floor redEvery finding is accepted. Nothing here is a narrowing of a claim to accommodate the change. P1-1 — Ordering: the plan no longer preempts the deployment
The emitter now walks The contradiction you named is repaired at the authority rather than overridden downstream: New control P1-2 — The unit is three members, not one
Both halves of your finding are now witnessed:
P1-3 — Absent tree refuses by nameCorrect on both counts.
The fresh-host half is separately fixed: P1-4 — Planned identity binds actuationThe operands were labels: the emitter discarded every one and copied the ambient path.
P1-5 — The scratch index is an owned effect, not a readonly global
The consumer now mints the index with Modeling correction — completeness is by construction
Required floorBoth interrupted claims are repaired by removing a second full render each, not by raising a budget:
The publication helper (separate production matter, diagnosed not yet repaired)You were right that it contaminates any measurement taken beside it. The cause is exact:
The defect is not the refusal, it is that an undecodable request has no disposition. It is never The repair belongs in the helper's own module — an undecodable request gets a typed refusal answer |
…ng the whole apply script The required floor preempted both at the 500ms changed-witness CPU ceiling (510ms and 515ms) with claims_failed=0, so neither reached a verdict and nothing was wrong with the model. A claim about one member's fragments should not cost a whole deployment render. Both now read emit_release_member_effects -- the steps ONE member contributes -- which is cheaper and a closer statement of the subject than grepping serialized bash for absent lines. a_zero_mutation went from 3606ms to 2ms locally; the_full_plan_lands from 4830ms to ~490ms. The ordering half is not asserted over text either, and does not need to be: the emitter folds flat_map over spec.steps, so fragment order IS step order by construction. What a regression would change is the AUTHORITY's order, so that is what the claim now pins. The module-scope hoist landed earlier is kept but is not what fixed this, and it carries a hazard worth naming: it makes the FIRST claim to touch the shared render pay for it, so per-claim cost is order-dependent and a local reading does not predict the CI one. That is why the local figure for the_full_plan_lands looked safe while CI interrupted it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019iXPtAqzPaNBZbaaM5rxrY
Required floor is green on the exact head, and both witnesses reach verdicts
How, and one correction worth recordingThe first head still interrupted both, at 510 ms and 515 ms against the 500 ms ceiling, with The module-scope render hoist made per-claim cost order-dependent. The first claim to touch the shared apply render pays for it. Locally an unchanged claim paid; in CI The repair is that neither changed claim renders the apply script at all. Both now read The ordering half is no longer asserted over text either, and does not need to be: the emitter folds A falsified annotation, corrected in place rather than quietly dropped. I attributed the residual 469 ms to Separately: the publication helperDiagnosed and fixed as its own PR (#10707), not folded in here. Two requests written under a superseded schema stopped the helper every tick for a day. The refusal was correct; the defect was that it retained the request, while every other terminal outcome consumes its request — so one undecodable document re-stopped the line forever. Production is repaired by hand ahead of that PR (8 ticks, 25 min, zero failures since). Worth noting for your measurement-contamination point: the helper's ~1 min 53 s CPU per tick is not caused by the failure — a successful run costs the same. That is the per-tick closure re-resolve, now measured at 178 s (belt) and 208 s (converge) of pure resolve with zero effects executed, i.e. roughly half of a no-drift converge spent before any observation happens. |
…derives it, not because a caller assembled it (#10717) Every release deployment plan contains exactly one decision for each required member; omission and duplication are unrepresentable at the production planning seam, and effect ordering is derived from the deployment's roster rather than caller-supplied. #10696 introduced that invariant and did not yet structurally guarantee it. A five-slot record closed the accepted-empty-plan hole for five members and could not extend past them -- the deployment owns eleven artifacts, and a shape costing one named slot in three places per member stops paying around there. Decisions are now MAPPED from the deployment's own member roster: map is total and order-preserving over its input, so the list cannot omit a member, hold two decisions about one, or order them differently from the authority. THE FIRST ROSTER DRAFT REOPENED THE EXACT HOLE IT REPLACED, and the fix is why derive_member_plan takes functions rather than a list. It kept derive_effects(decisions: List<MemberDecision>, ...) and relied on convention about who calls it; the TYPE still admitted a hand-built empty list, which derives a plan with no rows and an accepted empty plan. The only way to obtain a plan is now to hand over the roster and the desired/observed functions -- the caller never builds the list at all. ReleaseEffectPlan is sole_constructor, so the hole is closed from the construction side too: no module can write ReleaseEffectPlan { target, rows: [] } and hand an empty population to the emitter. NEGATIVE COMPILER EVIDENCE, both measured on this tree, neither enrolled as a runtime witness because the invalid states are unconstructible and such a witness would be permanently green: - delete the DirectoryRoot arm of member_observe desired_member_identity -> non-exhaustive match: missing variant(s) DirectoryRoot - write ReleaseEffectPlan { target, rows: [] } outside its module -> sole_constructor type 'ReleaseEffectPlan' cannot be constructed outside its defining module Positive control: apply closure resolves with zero diagnostics; emit 59/59; identity 11/11. THREE DIRECTORY ROOTS JOIN THE ROSTER, taking the frontier from three of eleven owned artifacts to six. A root's identity is its OWNING PRINCIPAL, not its presence: presence is already carried by ObservedMember, and what presence cannot say is whether a directory that exists belongs to the principal that must write it. A root does not restart the serve process -- roots are prerequisites, not inputs, and a process actually broken by a missing root is already stale on its own account. TWO DEFECTS THIS FOUND, both caught by execution rather than by reading: plan_mutation_of folded from a KeepMember init, making a member with NO ROW indistinguishable from one explicitly kept -- absence of knowledge rendered as a decision to do nothing. The lookup is typed now, and the emitter reads MemberNotInPlan as "emit what the apply-all era emitted", never as a skip. identity_member_of_step answers WHICH MEMBER a step is, and two consumers needed opposite things from it: deployment_apply_plan excludes a step from the membership reconcile when the step's commands come from emit_release_member_effects, while apply_step_effects asks whether the plan gates the step. Those coincided until a root, whose identity decides WHETHER but whose command still comes from its own upsert, broke the coincidence -- silently removing all three roots from the reconcile. Split into step_renders_its_own_member_commands. Two pre-existing witnesses caught it; nothing in the types did. RETIRED: a_decision_in_the_wrong_slot_refuses_the_plan, with the slots it policed. A climb normally keeps its discriminating red (DESIGN 4b), but that rule turns on whether the red stays authorable, and this one does not -- at the corpus boundary or in a fixture -- because expressing it requires handing the derivation a decision list, the call that no longer exists. Claude-Session: https://claude.ai/code/session_019iXPtAqzPaNBZbaaM5rxrY Co-authored-by: gunbc-ci-auto-heal <gunbc-ci-auto-heal@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
What
First slice of the convergence rework (operator direction 2026-09-06): a no-drift dashboard converge produces zero mutation effects for the release-consuming members.
deployment_apply_planreconciled againstobserved: [], so every member was an add on every apply and an already-converged srv1 replayed its installation (measured at 19 min, four of five source-compiling evaluators downstream of unconditional restarts). The observed-side record refutes feeding the path-keyed roster an observed side, so the three release-consuming members get real identities and leave the apply-all roster:git.Inspectops locally / same argv over sshcrypto.Sha256Sum.File/sha256sumargvFilesystem+systemctl showdecide_member→AlreadyConverged | Create | Replace | MemberRefused(unobservable refuses; never skipped, never reinstalled).derive_effectsorders install-executable before publish-tree (the publishing oneshot runs the admitted binary), emits daemon-reload once iff a unit document changed, restarts iff anything changed, and always carries the readiness readback.LiveDeployApplynow carries the plan; the emitter renders the plan's effects from the same fragments and reconciles only the remaining owned artifacts apply-all (declared frontier).Evidence
Fresh build,
claim_batch --hermetic:member_identity_witness_test7/7: converged →[]; binary drift →[install-executable, restart]; unit drift →[write-unit, daemon-reload, restart]; failed process under current unit → restart; dirty projection → republish; absent → create, unobservable → refused; wrong-member observation → refused.live_deploy/emit_test58/58 incl. new: zero-mutation plan renders no install / no repository-convergence oneshot / no serve restart while the full plan renders all three; binary-only plan does not publish the tree.deployed_tree_remote_witness_test,shell_exec_run_argv_embed_witness_test,ssh_transport_witness_test,apply_witness_test,intent_witness_test: green exceptwitness_readback_requires_service_ready_gate, which fails identically on current main (pre-existing).Not in this PR
Thin service-user actuator for the repository transition (both publication oneshots still run inside
PublishTree, only when the tree differs); identities for the timer members; foldingmode=deployinto the planner. Live measurement of a no-drift converge follows on srv1 via the sum branch.🤖 Generated with Claude Code
https://claude.ai/code/session_019iXPtAqzPaNBZbaaM5rxrY