carlos / platform Public

claim Plan 14 Part 1 fix round 5: no census means main's restart effects, always

Merge ruling 6: with no root census every restart, rollback, start and
reset-failed effect is main's exact code (restored from origin/main); the
named-unit binding only feeds history, proofs and diagnostics, and drives
effects only once a census exists (Part 3). Startup journal recovery is
gated the same way. Removes the round 3-4 compensations. A 90-case
main-equivalence table compares effects, pointers and process state.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3246b68fd7ed3dbd2aadaeb832dce2936035de39 parent 2f401dd
11 files changed, +503 −89
  • internal/host/legacyrestart.go +26 −9
  • internal/host/plan14_fix2_live_linux_test.go +24 −7
  • internal/host/plan14_fix3_live_linux_test.go +3 −4
  • internal/host/plan14_fix5_live_linux_test.go +281 −0
  • internal/host/plan14_fix5_main_linux_test.go +97 −0
  • internal/host/releaseinit_linux_test.go +4 −1
  • internal/host/rootrestart.go +24 −68
  • internal/host/rootrollback_resume.go +10 −0
  • internal/provision/plan14_legacy_identity_test.go +20 −0
  • internal/provision/plan14_root_reads_test.go +3 −0
  • internal/provision/testdata/plan14-root-reads-fix5.tsv +11 −0

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	331.113s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.917s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.180s
ok  	github.com/carlosframework/platform/internal/activator	227.663s
ok  	github.com/carlosframework/platform/internal/agentsock	0.017s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.017s
ok  	github.com/carlosframework/platform/internal/appconfig	2.352s
ok  	github.com/carlosframework/platform/internal/apperr	0.073s
ok  	github.com/carlosframework/platform/internal/applog	0.126s
ok  	github.com/carlosframework/platform/internal/awsinv	0.005s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.785s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.008s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.004s
ok  	github.com/carlosframework/platform/internal/cgroup	0.199s
ok  	github.com/carlosframework/platform/internal/changelog	0.314s
ok  	github.com/carlosframework/platform/internal/clamd	0.054s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	363.173s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	6.502s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.005s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.004s
ok  	github.com/carlosframework/platform/internal/docsite	0.004s
ok  	github.com/carlosframework/platform/internal/domains	2.151s
ok  	github.com/carlosframework/platform/internal/doorkey	0.006s
ok  	github.com/carlosframework/platform/internal/economics	0.004s
ok  	github.com/carlosframework/platform/internal/edge	26.120s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.007s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.129s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.016s
ok  	github.com/carlosframework/platform/internal/geoip	0.031s
ok  	github.com/carlosframework/platform/internal/host	93.874s
ok  	github.com/carlosframework/platform/internal/hostactors	0.333s
ok  	github.com/carlosframework/platform/internal/hostbox	2.499s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.523s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.014s
ok  	github.com/carlosframework/platform/internal/hostexec	4.872s
ok  	github.com/carlosframework/platform/internal/hostfence	0.028s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.522s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.017s
ok  	github.com/carlosframework/platform/internal/hostguard	0.968s
ok  	github.com/carlosframework/platform/internal/hostops	1.446s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.013s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.013s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.453s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.164s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.015s
ok  	github.com/carlosframework/platform/internal/infraview	0.014s
ok  	github.com/carlosframework/platform/internal/instancectl	1.725s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.696s
ok  	github.com/carlosframework/platform/internal/instancefs	0.021s
ok  	github.com/carlosframework/platform/internal/instancekey	0.016s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.021s
ok  	github.com/carlosframework/platform/internal/layout	1.444s
ok  	github.com/carlosframework/platform/internal/lease	9.435s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.008s
ok  	github.com/carlosframework/platform/internal/ledger	0.013s
ok  	github.com/carlosframework/platform/internal/mail	0.162s
ok  	github.com/carlosframework/platform/internal/maintenance	0.077s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.544s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.075s
ok  	github.com/carlosframework/platform/internal/metrics	0.007s
ok  	github.com/carlosframework/platform/internal/moves	21.140s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.766s
ok  	github.com/carlosframework/platform/internal/objstore	0.172s
ok  	github.com/carlosframework/platform/internal/ops	0.165s
ok  	github.com/carlosframework/platform/internal/park	0.210s
ok  	github.com/carlosframework/platform/internal/privatefile	0.005s
ok  	github.com/carlosframework/platform/internal/provision	13.070s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.010s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.054s
ok  	github.com/carlosframework/platform/internal/relay	7.235s
ok  	github.com/carlosframework/platform/internal/release	19.436s
ok  	github.com/carlosframework/platform/internal/rollout	3.705s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.006s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/s3compat	0.004s
ok  	github.com/carlosframework/platform/internal/safefile	0.003s
ok  	github.com/carlosframework/platform/internal/schedule	2.295s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.418s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	3.590s
ok  	github.com/carlosframework/platform/internal/status	0.042s
ok  	github.com/carlosframework/platform/internal/steering	0.005s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.202s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.004s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.002s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.550s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.021s
=== RUN   TestT4StorageProbeMinio
2026/10/13 16:52:28 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE24F7BF764B18, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.020s
minio contract: PASS
diff --git a/internal/host/legacyrestart.go b/internal/host/legacyrestart.go
index 55092d1b3..dcbcc3afe 100644
--- a/internal/host/legacyrestart.go
+++ b/internal/host/legacyrestart.go
@@ -2,12 +2,12 @@ package host
import (
"errors"
+ "fmt"
"log"
"os"
"path/filepath"
"time"
- "github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/rootparticipation"
@@ -17,12 +17,13 @@ var errRestartBinding = errors.Join(errors.New("named-unit binding changed"), os
// A retained census keeps the strict path even after the flag is disabled.
// An unreadable census is never evidence that this is a legacy box.
+// The claims flag alone cannot select bound effects without a census.
func legacyRootRestart() (bool, error) {
l, required, err := rootparticipation.OpenForUpdate()
if l.State != nil {
l.State.Close()
}
- return !required && !hostwrite.Enabled(os.Getenv) && err == nil, err
+ return !required && err == nil, err
}
func (c rootRestartChecks) allowLegacy() (bool, error) {
@@ -44,11 +45,19 @@ func restartEffectFailed(err error) bool {
}
// This is main's stamped-unit path, including its fallback on Show or
-// ExecStart mismatch. Binding is optional until a root census exists.
+// ExecStart mismatch. Binding cannot drive effects without a root census.
func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, settled func(registry.Route) error, c rootRestartChecks) error {
before, err := sys.Show(rt.Unit)
link := release.LiveLink(optDir, rt.Host)
- if err != nil || before.ExecPath != link || before.ActiveState != "active" {
+ if err != nil {
+ log.Printf("adopt: %s: cannot read %s (%v); restart not checked", rt.Host, rt.Unit, err)
+ return sys.TryRestart(rt.Unit)
+ }
+ if before.ExecPath != link {
+ log.Printf("adopt: binding: %s runs %s, but the unit stamp is on %s; restart not checked", rt.Unit, before.ExecPath, rt.Host)
+ return sys.TryRestart(rt.Unit)
+ }
+ if before.ActiveState != "active" {
return sys.TryRestart(rt.Unit)
}
want, err := filepath.EvalSymlinks(link)
@@ -60,11 +69,15 @@ func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string)
}
err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c))
if errors.Is(err, errUnitStopped) {
+ log.Printf("adopt: %s: %s was stopped during settle; not judged, not restarted", rt.Host, rt.Unit)
return nil
}
- if err == nil && settled != nil {
- if e := settled(rt); e != nil {
- log.Print(e)
+ if err == nil {
+ log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want))
+ if settled != nil {
+ if e := settled(rt); e != nil {
+ log.Print(e)
+ }
}
}
return err
@@ -73,7 +86,7 @@ func legacyCheckedUnitRestart(sys unitSystem, optDir string, getenv func(string)
func legacyRollbackRestart(sys unitSystem, optDir string, getenv func(string) string, rt registry.Route, c rootRestartChecks) error {
before, _ := sys.Show(rt.Unit)
if err := sys.ResetFailed(rt.Unit); err != nil {
- log.Print(err)
+ log.Printf("adopt: %s: reset-failed %s: %v (continuing)", rt.Host, rt.Unit, err)
}
if err := sys.Restart(rt.Unit); err != nil {
return err
@@ -84,9 +97,13 @@ func legacyRollbackRestart(sys unitSystem, optDir string, getenv func(string) st
}
err = settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, rt, want, c))
if errors.Is(err, errUnitStopped) {
+ log.Printf("adopt: %s: %s was stopped during the rollback's settle; not judged, not restarted", rt.Host, rt.Unit)
return nil
}
- return err
+ if err != nil {
+ return fmt.Errorf("rollback did not come up either: %w", err)
+ }
+ return nil
}
func legacySettleOptions(optDir string, getenv func(string) string, rt registry.Route, want string, c rootRestartChecks) settleOpts {
diff --git a/internal/host/plan14_fix2_live_linux_test.go b/internal/host/plan14_fix2_live_linux_test.go
index f58a79c8c..2ab363b71 100644
--- a/internal/host/plan14_fix2_live_linux_test.go
+++ b/internal/host/plan14_fix2_live_linux_test.go
@@ -20,7 +20,9 @@ func TestPlan14Fix2LiveSafety(t *testing.T) {
}
for _, scenario := range []string{"console update with empty history", "two consecutive updates", "incident recipe", "failed forward fix", "crash rollback", "successful restart crash before settle"} {
t.Run(scenario, func(t *testing.T) {
- e, rt, u, c, _ := plan14AdoptionFixture(t)
+ e, rt, base, c, _ := plan14AdoptionFixture(t)
+ u := &r2Units{plan14RestartUnits: base}
+ c.legacy, c.legacyReady = legacyRootRestart, c.ready
if err := e.store.Delete(t.Context(), provision.RecordKey(rt.Account, rt.App, rt.Host)); err != nil {
t.Fatal(err)
}
@@ -113,17 +115,32 @@ func TestPlan14Fix2LiveSafety(t *testing.T) {
return release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, restart, release.WithRestartFailurePropagation(), release.WithRollbackRestart(rollback))
}
err = run()
- if scenario == "crash rollback" {
- if err == nil {
- t.Fatal("failed update succeeded")
- }
- } else if err != nil {
+ if err != nil {
t.Fatal(err)
}
want := "v2"
- if scenario == "crash rollback" || scenario == "incident recipe" {
+ if scenario == "incident recipe" {
want = "v1"
}
+ if scenario == "crash rollback" || scenario == "incident recipe" || scenario == "failed forward fix" {
+ // Main cannot settle a guard ExecStart against the live-link
+ // stamp. A stopped unit needs the operator's reset and start.
+ if u.props.MainPID != 0 {
+ t.Fatal("main path woke a stopped guard unit", u.effects)
+ }
+ if live, err := release.LiveVersion(u.opt, rt.Host); err != nil || live != want {
+ t.Fatal("main pointer result", live, err)
+ }
+ if scenario == "crash rollback" {
+ return
+ }
+ if err := u.ResetFailed(rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ if err := u.Start(rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ }
if u.props.MainPID <= 0 || u.exe != release.VersionPath(u.opt, "", want, "console") {
t.Fatalf("not serving %s: %+v", want, u)
}
diff --git a/internal/host/plan14_fix3_live_linux_test.go b/internal/host/plan14_fix3_live_linux_test.go
index 76a9049f8..c7b3861dc 100644
--- a/internal/host/plan14_fix3_live_linux_test.go
+++ b/internal/host/plan14_fix3_live_linux_test.go
@@ -246,7 +246,6 @@ func TestR2UnguardedIncidentRecipe(t *testing.T) {
if _, err := f.source.forHost(t.Context(), f.rt.Host); err != nil {
t.Fatal("PID-zero binding rejected", err)
}
- f.c.legacy = nil
}
f.promote(t, "v1")
if err := f.pass(t); err != nil {
@@ -277,15 +276,15 @@ func TestR2GuardedAutoRestartFixForward(t *testing.T) {
for _, state := range []unitProps{{ActiveState: "activating", SubState: "auto-restart"}, {ActiveState: "activating", SubState: "start"}} {
t.Run(state.SubState, func(t *testing.T) {
f := r2Fixture(t, "guard")
- f.c.legacy = nil
f.u.props.ActiveState, f.u.props.SubState, f.u.props.MainPID = state.ActiveState, state.SubState, 0
if err := f.pass(t); err != nil {
t.Fatal(err)
}
f.wantLive(t, "v2")
f.wantRunning(t, "v2")
- if len(f.u.effects) != 2 || f.u.effects[0] != "reset console.service" {
- t.Fatal(f.u.effects)
+ // Without a census, main try-restarts the activating unit once.
+ if len(f.u.effects) != 1 || len(f.u.tries) != 1 || f.u.tries[0] != "console.service" {
+ t.Fatal(f.u.effects, f.u.tries)
}
})
}
diff --git a/internal/host/plan14_fix5_live_linux_test.go b/internal/host/plan14_fix5_live_linux_test.go
new file mode 100644
index 000000000..0676ce7be
--- /dev/null
+++ b/internal/host/plan14_fix5_live_linux_test.go
@@ -0,0 +1,281 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+// Each unit has separate process state. In particular, restarting the local
+// binding must never look like a restart of a different box-local stamp.
+type fix5Units struct {
+ units map[string]*r2Units
+ events []string
+ resetError, tryError bool
+}
+
+func (u *fix5Units) Show(name string) (unitProps, error) { return u.units[name].Show(name) }
+func (u *fix5Units) Exe(pid int) (string, error) {
+ for _, unit := range u.units {
+ if unit.props.MainPID == pid {
+ return unit.Exe(pid)
+ }
+ }
+ return "", os.ErrNotExist
+}
+func (u *fix5Units) StartTime(pid int) (string, error) {
+ for _, unit := range u.units {
+ if unit.props.MainPID == pid {
+ return unit.StartTime(pid)
+ }
+ }
+ return "", os.ErrNotExist
+}
+func (u *fix5Units) JournalTail(string, int) string { return "" }
+func (u *fix5Units) TryRestart(name string) error {
+ u.events = append(u.events, "try-restart "+name)
+ if u.tryError {
+ u.tryError = false
+ return errors.New("try-restart: bus timeout")
+ }
+ return u.units[name].TryRestart(name)
+}
+func (u *fix5Units) Restart(name string) error {
+ u.events = append(u.events, "restart "+name)
+ return u.units[name].Restart(name)
+}
+func (u *fix5Units) ResetFailed(name string) error {
+ u.events = append(u.events, "reset-failed "+name)
+ if u.resetError {
+ return errors.New("reset-failed: bus timeout")
+ }
+ return u.units[name].ResetFailed(name)
+}
+func (u *fix5Units) Start(name string) error {
+ u.events = append(u.events, "start "+name)
+ return u.units[name].Start(name)
+}
+
+type fix5Result struct {
+ Events []string
+ Live, Previous string
+ Route registry.Route
+ Processes map[string]string
+}
+
+func fix5Run(t *testing.T, kind, history string, main bool) []fix5Result {
+ t.Helper()
+ sourceKind := kind
+ if kind == "record" {
+ sourceKind = "none"
+ }
+ if kind == "different stamp" {
+ sourceKind = "list"
+ }
+ f := r2Fixture(t, sourceKind)
+
+ if kind == "record" {
+ f.rt.Account = "bab"
+ plan14PutRecord(t, f.e.store, provision.Record{Schema: 1, Host: f.rt.Host, Account: "bab", App: f.rt.App, Kind: f.rt.Kind, Unit: f.rt.Unit, Addr: f.rt.Addr, Channel: f.rt.Channel, Health: &provision.Health{Path: "/ready"}})
+ }
+ u := &fix5Units{units: map[string]*r2Units{f.rt.Unit: f.u}}
+ target := f.u
+ if kind == "different stamp" {
+ copyUnit := *f.u.plan14RestartUnits
+ copyUnit.props.Name, copyUnit.props.MainPID = "stamped.service", 700
+ target = &r2Units{plan14RestartUnits: &copyUnit}
+ f.rt.Unit = copyUnit.props.Name
+ u.units[f.rt.Unit] = target
+ }
+ if err := f.e.root.reg.Upsert(f.rt); err != nil {
+ t.Fatal(err)
+ }
+ f.source.sys = u
+ if kind != "none" && !(kind == "record" && os.Getenv("CARLOS_HOST_CLAIM") == "1") {
+ if _, err := f.source.forHost(t.Context(), f.rt.Host); err != nil {
+ t.Fatal("fixture must bind", err)
+ }
+ }
+ incident := strings.HasPrefix(history, "incident") || history == "TestR4ForwardResetFailedError"
+ if incident {
+ if err := release.StageArtifact(f.u.opt, "", "v2", f.rt.App, []byte("new build")); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.PointLive(f.u.opt, f.rt.Host, "", "v2", f.rt.App); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.e.root.reg.SetVersions(f.rt.Host, "v2", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ target.exe = release.VersionPath(f.u.opt, "", "v2", f.rt.App)
+ target.props.ActiveState, target.props.SubState, target.props.MainPID = "failed", "failed", 0
+ f.promote(t, "v1")
+ }
+ switch history {
+ case "crash", "failed-then-reset-failed before rollback":
+ target.crashNew = true
+ case "auto-restart":
+ target.props.ActiveState, target.props.SubState, target.props.MainPID = "activating", "auto-restart", 0
+ case "inactive":
+ target.props.ActiveState, target.props.SubState, target.props.MainPID = "inactive", "dead", 0
+ case "incident reset before":
+ if err := u.ResetFailed(f.rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ case "TestR4ForwardResetFailedError":
+ u.resetError = true
+ case "TestR4ForwardTryRestartError":
+ u.tryError = true
+ }
+ getenv := func(string) string { return "5s" }
+ observer := f.e.root.obs
+ observer.resolve, observer.sys = f.c.resolve, u
+ observer.procExe = func(pid int) string { exe, _ := u.Exe(pid); return exe }
+ pr := f.e.root
+ pr.obs = observer
+ restart := rootRestartRouteWith(f.e.root.reg, u, f.u.opt, getenv, observer.recordSettled(f.e.bake), f.c)
+ rollback := rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, f.c)
+ startChecks := f.c
+ startChecks.legacyStart = u.Start
+ start := rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, startChecks)
+ if main {
+ restart = makeRestartRoute(f.e.root.reg, func(host string) { _ = u.TryRestart(activator.UnitName(host)) }, fix5MainCheckedRestart(u, f.u.opt, getenv, nil, f.c), nil)
+ rollback = fix5MainRollbackRestart(f.e.root.reg, u, f.u.opt, getenv, f.c)
+ start = makeStartRoute(f.e.root.reg, u.Start)
+ } else {
+ // Binding may observe health and retain a running build, but cannot enter
+ // the strict effect machinery or touch its durable restart journal.
+ f.c.attempts = func() (rootstate.Store, error) {
+ t.Fatal("no-census pass opened restart journal")
+ return nil, os.ErrPermission
+ }
+ f.c.dispatch = func(context.Context, registry.Route) (bool, error) {
+ t.Fatal("no-census pass dispatched binding")
+ return false, os.ErrPermission
+ }
+ restart = rootRestartRouteWith(f.e.root.reg, u, f.u.opt, getenv, observer.recordSettled(f.e.bake), f.c)
+ rollback = rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, f.c)
+ startChecks = f.c
+ startChecks.legacyStart = u.Start
+ start = rollbackUnitRestart(f.e.root.reg, u, f.u.opt, getenv, startChecks)
+ }
+ originalRollback := rollback
+ rollback = func(host string) error {
+ if history == "failed-then-reset-failed before rollback" {
+ if err := u.ResetFailed(f.rt.Unit); err != nil {
+ return err
+ }
+ }
+ return originalRollback(host)
+ }
+ snapshot := func() fix5Result {
+ t.Helper()
+ live, err := release.LiveVersion(f.u.opt, f.rt.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ previous, err := filepath.EvalSymlinks(filepath.Join(f.u.opt, "previous", f.rt.Host))
+ if err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ rt, err := f.e.root.reg.Get(f.rt.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rt.CreatedAt = 0
+ processes := map[string]string{}
+ for name, unit := range u.units {
+ processes[name] = strings.TrimPrefix(unit.exe, f.u.opt) + "|" + unit.props.ActiveState + "|" + unit.props.SubState + "|" + unit.props.InvocationID
+ }
+ return fix5Result{append([]string(nil), u.events...), live, strings.TrimPrefix(previous, f.u.opt), *rt, processes}
+ }
+ pass := func() {
+ t.Helper()
+ run := func() error {
+ opts := []release.PassOption{release.WithStartUnit(start), release.WithRollbackRestart(rollback)}
+ if !main {
+ opts = append(opts, release.WithRunningVersion(f.c.runningVersion), release.WithRestartAttemptSettled(f.c.finish))
+ }
+ return release.Pass(t.Context(), f.e.root.reg, f.e.store, f.pub, f.u.opt, restart, opts...)
+ }
+ var err error
+ if main {
+ err = run()
+ } else {
+ err = pr.around(t.Context(), run)
+ }
+ if err != nil {
+ t.Fatal("pass status differs from main", err)
+ }
+ }
+ pass()
+ results := []fix5Result{snapshot()}
+ if incident {
+ u.resetError = false
+ if history != "incident reset before" {
+ if err := u.ResetFailed(f.rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := u.Start(f.rt.Unit); err != nil {
+ t.Fatal(err)
+ }
+ results = append(results, snapshot())
+ }
+ // A second pass detects a synthetic refusal that would reject the fix.
+ pass()
+ return append(results, snapshot())
+}
+
+func TestPlan14Fix5MainEquivalence(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, flag := range []string{"", "1"} {
+ t.Run(map[string]string{"": "claims-off", "1": "claims-on"}[flag], func(t *testing.T) {
+ t.Setenv("CARLOS_HOST_CLAIM", flag)
+ for _, kind := range []string{"none", "record", "list", "guard", "different stamp"} {
+ for _, history := range []string{"healthy", "crash", "failed-then-reset-failed before rollback", "auto-restart", "inactive", "incident reset before", "incident reset after", "TestR4ForwardResetFailedError", "TestR4ForwardTryRestartError"} {
+ t.Run(kind+"/"+history, func(t *testing.T) {
+ want := fix5Run(t, kind, history, true)
+ got := fix5Run(t, kind, history, false)
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("no-census effects/pointers differ from main:\ngot %+v\nwant %+v", got, want)
+ }
+ })
+ }
+ }
+ })
+ }
+}
+
+func TestPlan14Fix5NoCensusSkipsJournalRecovery(t *testing.T) {
+ if !plan14RootFixture(t) {
+ return
+ }
+ for _, flag := range []string{"", "1"} {
+ t.Setenv("CARLOS_HOST_CLAIM", flag)
+ f := r2Fixture(t, "list")
+ f.c.attempts = func() (rootstate.Store, error) {
+ t.Fatal("read retained journal without a census")
+ return nil, os.ErrPermission
+ }
+ if err := recoverRootRestarts(t.Context(), f.u, f.u.opt, nil, f.c, hostwrite.Config{}); err != nil {
+ t.Fatal(err)
+ }
+ if len(f.u.effects) != 0 {
+ t.Fatal(f.u.effects)
+ }
+ }
+}
diff --git a/internal/host/plan14_fix5_main_linux_test.go b/internal/host/plan14_fix5_main_linux_test.go
new file mode 100644
index 000000000..3ffea14fe
--- /dev/null
+++ b/internal/host/plan14_fix5_main_linux_test.go
@@ -0,0 +1,97 @@
+package host
+
+import (
+ "errors"
+ "fmt"
+ "github.com/carlosframework/platform/internal/registry"
+ "log"
+ "path/filepath"
+ "time"
+)
+
+// Frozen from origin/main 34a4328f, internal/host/agent.go. Only names,
+// clock and readiness injection differ, so the oracle cannot drift with
+// the production legacy helpers.
+func fix5MainCheckedRestart(sys unitSystem, optDir string, getenv func(string) string, settled func(rt registry.Route), c rootRestartChecks) func(rt registry.Route) error {
+ return func(rt registry.Route) error {
+ before, err := sys.Show(rt.Unit)
+ if err != nil {
+ log.Printf("adopt: %s: cannot read %s (%v); restart not checked", rt.Host, rt.Unit, err)
+ return sys.TryRestart(rt.Unit)
+ }
+ link := filepath.Join(optDir, "live", rt.Host)
+ if before.ExecPath != link {
+ log.Printf("adopt: binding: %s runs %s, but the unit stamp is on %s; restart not checked", rt.Unit, before.ExecPath, rt.Host)
+ return sys.TryRestart(rt.Unit)
+ }
+ if before.ActiveState != "active" {
+ return sys.TryRestart(rt.Unit) // stopped by an operator: stays a no-op
+ }
+ want, err := filepath.EvalSymlinks(link)
+ if err != nil {
+ return sys.TryRestart(rt.Unit)
+ }
+ if err := sys.TryRestart(rt.Unit); err != nil {
+ return err
+ }
+ timeout := unitSettleTimeout(getenv)
+ err = settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{
+ Timeout: timeout, Poll: 500 * time.Millisecond, Hold: 5 * time.Second,
+ WantDir: filepath.Dir(want), Ready: c.legacyReady(rt),
+ Now: c.now, Sleep: c.sleep})
+ if errors.Is(err, errUnitStopped) {
+ // An operator stopped it between the read above and now. Not a
+ // verdict on the build, and a rollback restart would undo their
+ // stop: same as a unit that was stopped before the restart.
+ log.Printf("adopt: %s: %s was stopped during settle; not judged, not restarted", rt.Host, rt.Unit)
+ return nil
+ }
+ if err == nil {
+ log.Printf("adopt: %s: %s settled on %s", rt.Host, rt.Unit, filepath.Dir(want))
+ // D3 (proofobserve.go): a build that passed the settle check is a
+ // build this box has run well.
+ if settled != nil {
+ settled(rt)
+ }
+ }
+ return err
+ }
+}
+
+// rollbackUnitRestart is D1's rollback restart (release.WithRollbackRestart):
+// the live link already names the restored build; replace whatever is
+// running (a deadline failure can leave the rejected build up) and check
+// the restored build comes up too.
+func fix5MainRollbackRestart(reg *registry.Registry, sys unitSystem, optDir string, getenv func(string) string, c rootRestartChecks) func(host string) error {
+ return func(host string) error {
+ rt, err := reg.Get(host)
+ if err != nil || rt == nil || rt.Unit == "" {
+ return nil
+ }
+ before, _ := sys.Show(rt.Unit)
+ if err := sys.ResetFailed(rt.Unit); err != nil {
+ log.Printf("adopt: %s: reset-failed %s: %v (continuing)", host, rt.Unit, err)
+ }
+ if err := sys.Restart(rt.Unit); err != nil {
+ return err
+ }
+ want, err := filepath.EvalSymlinks(filepath.Join(optDir, "live", host))
+ if err != nil {
+ return err
+ }
+ err = settleUnit(sys, rt.Unit, before.InvocationID, settleOpts{
+ Timeout: unitSettleTimeout(getenv), Poll: 500 * time.Millisecond, Hold: 5 * time.Second,
+ WantDir: filepath.Dir(want), Ready: c.legacyReady(*rt),
+ Now: c.now, Sleep: c.sleep})
+ if errors.Is(err, errUnitStopped) {
+ // The restored build is what the link names; a stop an operator
+ // made while it settled is theirs to keep, not a failed recovery.
+ log.Printf("adopt: %s: %s was stopped during the rollback's settle; not judged, not restarted", host, rt.Unit)
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("rollback did not come up either: %w", err)
+ }
+ return nil
+ }
+}
diff --git a/internal/host/releaseinit_linux_test.go b/internal/host/releaseinit_linux_test.go
index f9c94da44..0ae5ffdca 100644
--- a/internal/host/releaseinit_linux_test.go
+++ b/internal/host/releaseinit_linux_test.go
@@ -74,7 +74,10 @@ func TestPlan14ProductionReleaseInitialization(t *testing.T) {
if got := release.ReadHealthy(release.RootHealthyDir, "", "console"); len(got) != 1 || got[0].SHA256 != b.SHA256 {
t.Fatal(got)
}
- for path, mode := range map[string]os.FileMode{release.RootHealthyDir: 0755, filepath.Join(release.RootHealthyDir, "healthy"): 0755, filepath.Join(release.RootHealthyDir, "private"): 0700, rootRestartState: 0700} {
+ if _, err := os.Stat(rootRestartState); !os.IsNotExist(err) {
+ t.Fatal("no-census initialization created restart state", err)
+ }
+ for path, mode := range map[string]os.FileMode{release.RootHealthyDir: 0755, filepath.Join(release.RootHealthyDir, "healthy"): 0755} {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm() != mode {
t.Fatalf("%s mode: %v %v", path, info, err)
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go
index 1f5ff06c6..eb87e6355 100644
--- a/internal/host/rootrestart.go
+++ b/internal/host/rootrestart.go
@@ -26,8 +26,6 @@ type rootRestartChecks struct {
legacyLog *sync.Once
legacyReady func(registry.Route) func() error
legacyStart func(string) error
- legacyRoute *registry.Route
- effectStarted *bool
targetDigest string
targetFile func(string) (*os.File, error)
dispatch func(context.Context, registry.Route) (bool, error)
@@ -117,6 +115,16 @@ func (c rootRestartChecks) runningVersion(ctx context.Context, rt registry.Route
return "", nil
}
b, err := c.resolve(ctx, rt)
+ if err == nil && b.Route.Unit != rt.Unit {
+ // Without a census, retain only the unit main will restart.
+ legacy, checkErr := c.allowLegacy()
+ if checkErr != nil {
+ return "", checkErr
+ }
+ if legacy {
+ return "", nil
+ }
+ }
if err != nil {
legacy, checkErr := c.allowLegacy()
if checkErr != nil {
@@ -145,7 +153,8 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
return err
}
if legacy {
- c.legacyRoute = &candidate
+ c.noteLegacy()
+ return legacyCheckedUnitRestart(sys, optDir, getenv, candidate, settled, c)
}
if c.attempts == nil {
return os.ErrPermission
@@ -241,12 +250,7 @@ func checkedUnitRestart(sys unitSystem, optDir string, getenv func(string) strin
}
err = restartBoundUnit(sys, optDir, getenv, binding, false, settled, c)
if err == nil {
- settleErr := settleRollbackAttempt(s, attempt)
- if legacy && settleErr != nil {
- log.Print(settleErr)
- return nil
- }
- return settleErr
+ return settleRollbackAttempt(s, attempt)
}
if errors.Is(err, errUnitStopped) {
return errors.Join(err, settleRollbackAttempt(s, attempt))
@@ -291,9 +295,6 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
effect = func(fn func() error) error { return fn() }
}
if c.verifyOnly {
- if c.effectStarted != nil {
- *c.effectStarted = true
- }
before.InvocationID = ""
} else if err := effect(func() error {
if rollback {
@@ -302,13 +303,7 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
return errRestartBinding
}
if err := sys.ResetFailed(rt.Unit); err != nil {
- if c.legacyRoute == nil {
- return err
- }
- log.Print(err)
- }
- if c.effectStarted != nil {
- *c.effectStarted = true
+ return err
}
if err := sys.Restart(rt.Unit); err != nil {
return err
@@ -327,9 +322,6 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
if err != nil || current.Source != binding.Source || current.Version != binding.Version || current.Digest != binding.Digest || current.PID != binding.PID || current.Invocation != binding.Invocation {
return errRestartBinding
}
- if c.effectStarted != nil {
- *c.effectStarted = true
- }
if pointerBoundUnit(before) {
if err := sys.ResetFailed(rt.Unit); err != nil {
return err
@@ -344,17 +336,6 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
}); err != nil {
return err
}
- // Without a census, optional binding checks cannot change main's verdict
- // after the effect. Settle this invocation once, keeping D1's crash result.
- if c.legacyRoute != nil {
- err := settleUnit(sys, rt.Unit, before.InvocationID, legacySettleOptions(optDir, getenv, *c.legacyRoute, want, c))
- if err == nil && settled != nil {
- if e := effect(func() error { return settled(binding.Route) }); e != nil {
- log.Print(e)
- }
- }
- return err
- }
var observed proofBinding
var bindingErr error
checkedReady := func() error {
@@ -425,29 +406,19 @@ func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string,
}
return legacyRollbackRestart(sys, optDir, getenv, *rt, c)
}
- effectStarted := false
- c.effectStarted = &effectStarted
legacy, err := c.allowLegacy()
if err != nil {
return err
}
if legacy {
+ c.noteLegacy()
if routeErr != nil || rt == nil {
return nil
}
- c.legacyRoute = rt
- defer func() {
- if errors.Is(result, errUnitStopped) {
- result = nil
- } else if result != nil && !effectStarted && !restartEffectFailed(result) {
- c.noteLegacy()
- if c.legacyStart != nil {
- result = c.legacyStart(rt.Unit)
- } else {
- result = legacyRollbackRestart(sys, optDir, getenv, *rt, c)
- }
- }
- }()
+ if c.legacyStart != nil {
+ return c.legacyStart(rt.Unit)
+ }
+ return legacyRollbackRestart(sys, optDir, getenv, *rt, c)
}
if c.attempts == nil || c.rollback == nil {
return os.ErrPermission
@@ -465,14 +436,7 @@ func rollbackUnitRestart(reg *registry.Registry, sys unitSystem, optDir string,
attempt, binding, err := loadRollback(s, host, c.now())
if attempt.ID != "" {
defer func() {
- settleErr := settleRollbackAttempt(s, attempt)
- if legacy && effectStarted {
- if settleErr != nil {
- log.Print(settleErr)
- }
- return
- }
- result = errors.Join(result, settleErr)
+ result = errors.Join(result, settleRollbackAttempt(s, attempt))
}()
}
if err != nil {
@@ -558,20 +522,16 @@ func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string,
if err != nil {
return err
}
+ if legacy {
+ checks.noteLegacy()
+ return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks)
+ }
if checks.dispatch != nil {
need, err := checks.dispatch(context.Background(), *rt)
if err != nil {
- if legacy {
- checks.noteLegacy()
- return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks)
- }
return err
}
if !need {
- if legacy {
- checks.noteLegacy()
- return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks)
- }
return restartVerifiedOrdinaryUnit(sys, optDir, host, checks.targetFile)
}
}
@@ -579,10 +539,6 @@ func rootRestartRouteWith(reg *registry.Registry, sys unitSystem, optDir string,
if errors.Is(err, errUnitStopped) {
return nil
}
- if legacy && err != nil && !restartEffectFailed(err) {
- checks.noteLegacy()
- return legacyCheckedUnitRestart(sys, optDir, getenv, *rt, settled, checks)
- }
return err
}
}
diff --git a/internal/host/rootrollback_resume.go b/internal/host/rootrollback_resume.go
index 41a61bcb2..920b37fdf 100644
--- a/internal/host/rootrollback_resume.go
+++ b/internal/host/rootrollback_resume.go
@@ -14,6 +14,16 @@ import (
)
func recoverRootRestarts(ctx context.Context, sys unitSystem, optDir string, getenv func(string) string, checks rootRestartChecks, writes hostwrite.Config) error {
+ legacy, err := checks.allowLegacy()
+ if err != nil {
+ return err
+ }
+ if legacy {
+ checks.noteLegacy()
+ // D3 still records root health without a census. Preparing its
+ // public parent must not create or recover bound restart state.
+ return rootstate.EnsurePublicDir(release.RootHealthyDir)
+ }
if checks.attempts == nil || checks.prepareRollback == nil {
return os.ErrPermission
}
diff --git a/internal/provision/plan14_legacy_identity_test.go b/internal/provision/plan14_legacy_identity_test.go
index d0d7caa36..146f54083 100644
--- a/internal/provision/plan14_legacy_identity_test.go
+++ b/internal/provision/plan14_legacy_identity_test.go
@@ -171,3 +171,23 @@ func TestPlan14Fix4OverlayPreservesLegacySeal(t *testing.T) {
t.Fatal("unexpected legacy seal", seal)
}
}
+
+func TestPlan14Fix5OverlayPreservesLegacySeal(t *testing.T) {
+ root := censusRoot(t)
+ inv := rootReadInventory{Entries: map[string]classifiedRootRead{}, Coverage: map[string]string{}}
+ for i, name := range []string{"plan14-root-reads.tsv", "plan14-root-reads-moves.tsv", "plan14-root-reads-merge.tsv", "plan14-root-reads-fix1.tsv", "plan14-root-reads-fix2.tsv", "plan14-root-reads-merge3.tsv", "plan14-root-reads-fix4.tsv"} {
+ if err := loadRootReadInventory(root+"/internal/provision/testdata/"+name, &inv, i > 0); err != nil {
+ t.Fatal(err)
+ }
+ }
+ seal := rootReadLegacySeal(inv)
+ if err := loadRootReadInventory(root+"/internal/provision/testdata/plan14-root-reads-fix5.tsv", &inv, true); err != nil {
+ t.Fatal(err)
+ }
+ if errs := checkRootReadLegacy(inv, seal); len(errs) != 0 {
+ t.Fatal(errs)
+ }
+ if seal != "65dba959f80cc47e9fd99be145ba8b24bb3b37ffaf77b47d1b9cc3e01da14b47" {
+ t.Fatal("unexpected legacy seal", seal)
+ }
+}
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index 42b03aa9d..43d6dc9a1 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -634,6 +634,9 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix4.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-fix5.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-fix5.tsv b/internal/provision/testdata/plan14-root-reads-fix5.tsv
new file mode 100644
index 000000000..b1399475e
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-fix5.tsv
@@ -0,0 +1,11 @@
+# Plan 14 fix5: merge ruling 6 makes main effects unconditional without a census; earlier overlays and the legacy freeze are unchanged.
+internal/host/legacyrestart.go legacyCheckedUnitRestart authority B3 rev3=merge-ruling-6;basis=Restore main stamped-unit restart and D1 settle, including diagnostics; only clock, readiness and health-hook adapters differ. file:EvalSymlinks@675,registry:Host@1044,registry:Host@1139,registry:Host@241,registry:Host@335,registry:Host@543,registry:Unit@1053,registry:Unit@1148,registry:Unit@198,registry:Unit@344,registry:Unit@382,registry:Unit@517,registry:Unit@576,registry:Unit@649,registry:Unit@744,registry:Unit@782,registry:Unit@844,whole:Route@1218,whole:Route@910 999d8be82a85a24691baaa2d4ffadd0fc6ba073bcc1eee57c40f2167708c370e
+internal/host/legacyrestart.go legacyRollbackRestart authority B3 rev3=merge-ruling-6;basis=Restore main reset-failed, restart and D1 settle even after an operator reset; retain main error wrapping and diagnostics. file:EvalSymlinks@378,registry:Host@276,registry:Host@425,registry:Host@706,registry:Unit@157,registry:Unit@193,registry:Unit@285,registry:Unit@325,registry:Unit@491,registry:Unit@715,whole:Route@557 f55b81780b4da5fb5f08fd18079919514edc606bd18e2189e69cf78f1aea8202
+internal/host/legacyrestart.go legacyRootRestart authority B3,G rev3=merge-ruling-6;basis=Census presence alone selects bound effects; the claims flag without a census keeps main effects. 5fd48683abd82664baa77c2ddc7528f92f557b04586225d64b4a6e77147df931
+internal/host/rootrestart.go checkedUnitRestart authority B3 rev3=merge-ruling-6;basis=Return main effects before opening any bound restart state when no census exists; strict journal and identity checks remain census-only. file:EvalSymlinks@1247,file:EvalSymlinks@2024,registry:Account@1593,registry:Account@2392,registry:App@1625,registry:App@2424,registry:Host@1294,registry:Host@2080,registry:Host@2967,registry:Host@3234,registry:Host@3906,registry:Host@703,registry:Unit@3788,registry:Unit@904,whole:Route@1601,whole:Route@1633,whole:Route@2400,whole:Route@2432,whole:Route@3796,whole:Route@479,whole:Route@831,whole:Route@912 1eadec9e355427edc4f8211c2106e855b7d95a0aeae5b71f7961cc19fee0a33e
+internal/host/rootrestart.go restartBoundUnit authority B3 rev3=merge-ruling-6;basis=Remove dead no-census settle and effect-tracking exceptions; retain the census-backed strict effect path. file:EvalSymlinks@635,registry:Account@2355,registry:Account@3854,registry:App@2391,registry:App@3894,registry:Host@1618,registry:Host@2257,registry:Host@2851,registry:Host@3593,registry:Host@682,registry:Unit@1104,registry:Unit@1172,registry:Unit@1927,registry:Unit@1995,registry:Unit@2037,registry:Unit@3200,registry:Unit@395,whole:Route@198,whole:Route@2361,whole:Route@2397,whole:Route@317,whole:Route@3862,whole:Route@3902,whole:Route@4113 75130e52351fe6272887aafece15446eeef0760c2e02f9a51cc1dd4e2a6a1455
+internal/host/rootrestart.go rollbackUnitRestart authority B3 rev3=merge-ruling-6;basis=Select main rollback or start before bound journal discovery on no-census boxes; preserve ordinary and Hibernate guards. file:Read@1568,registry:Hibernate@393,registry:Host@1860,registry:Unit@1962,registry:Unit@2263,registry:Unit@351,registry:Unit@446,registry:Unit@538,registry:Unit@829,whole:Route@1868,whole:Route@1970,whole:Route@2271,whole:Route@337,whole:Route@433,whole:Route@606,whole:Route@743,whole:Route@897 569a52f96e0206a496b7718a5103fdb8d6e379a81f9c143e2df5c15ca024fb6b
+internal/host/rootrestart.go rootRestartChecks.runningVersion authority B3 rev3=merge-ruling-6;basis=Without a census, ignore another unit local binding for retention so the stamp determines the rollback target; keep resolved-path retention. registry:Host@151,registry:Unit@104,registry:Unit@121,registry:Unit@230,registry:Unit@246,whole:Route@208,whole:Route@232 9618c96657140602f59ade8b967f7fe4729d84d915c7fa33e0a276dd82377a40
+internal/host/rootrestart.go rootRestartRouteWith authority B3 rev3=merge-ruling-6;basis=Select main stamped-unit effects before binding dispatch without a census; ordinary routes still bypass census and record reads. registry:Unit@354,registry:Unit@420,whole:Route@341,whole:Route@515,whole:Route@708,whole:Route@818,whole:Route@978 fad63ffb31320e0154e3a6c76ed0b887812bfefae607d698cd2e6c9386787546
+internal/host/rootrollback_resume.go recoverRootRestarts authority B3 rev3=merge-ruling-6;basis=Prepare the shared D3 health parent but skip bound journal recovery without a census, including with the claims flag set; retained-census recovery stays strict. file:Read@819 5b33733153e36a48058ee3245f0710debe8b9ccffc0bb9c40c0b2d5b8de3599d
+@coverage internal/host a2fe6f145509946acb6953012b232f894ece8d79063f254193b13b28a2c97214