carlos / platform Public

WIP claim Plan 14 track E checkpoint 8 (not for merge): E census rows in their own overlay, recovery under merge ruling 5, E1 reconstruction status projection and signed replacement workflow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev f751559c43f7efd512d5f72eff3c707e23d23c14 parent 072eb38
11 files changed, +532 −15
  • internal/host/legacyrestart.go +1 −1
  • internal/host/namedrecovery_active.go +46 −4
  • internal/host/plan14_active_recovery_test.go +2 −1
  • internal/host/plan14_e8_outcome_test.go +113 −0
  • internal/host/recoverysettle.go +37 −0
  • internal/host/roothistory.go +13 −2
  • internal/host/roothistory_recovery.go +1 −1
  • internal/host/rootrestart.go +23 −0
  • internal/provision/testdata/plan14-root-reads-e.tsv +9 −6
  • internal/release/plan14_history_status_test.go +122 −0
  • internal/release/roothistory_status.go +165 −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	348.307s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.923s
ok  	github.com/carlosframework/platform/cmd/moveprobe	92.382s
ok  	github.com/carlosframework/platform/internal/activator	227.676s
ok  	github.com/carlosframework/platform/internal/agentsock	0.021s
ok  	github.com/carlosframework/platform/internal/alerting	0.005s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.329s
ok  	github.com/carlosframework/platform/internal/apperr	0.071s
ok  	github.com/carlosframework/platform/internal/applog	0.132s
ok  	github.com/carlosframework/platform/internal/awsinv	0.003s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.004s
ok  	github.com/carlosframework/platform/internal/boxid	0.695s
?   	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.009s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.198s
ok  	github.com/carlosframework/platform/internal/changelog	0.290s
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	368.306s
?   	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.034s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.009s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.101s
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.858s
?   	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.120s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.015s
ok  	github.com/carlosframework/platform/internal/geo	0.018s
ok  	github.com/carlosframework/platform/internal/geoip	0.029s
ok  	github.com/carlosframework/platform/internal/host	99.141s
ok  	github.com/carlosframework/platform/internal/hostactors	0.660s
ok  	github.com/carlosframework/platform/internal/hostbox	2.163s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.529s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.011s
ok  	github.com/carlosframework/platform/internal/hostexec	5.590s
ok  	github.com/carlosframework/platform/internal/hostfence	0.030s
ok  	github.com/carlosframework/platform/internal/hostfiles	11.098s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	0.971s
ok  	github.com/carlosframework/platform/internal/hostops	1.430s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.011s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.011s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.468s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.133s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.011s
ok  	github.com/carlosframework/platform/internal/infraview	0.011s
ok  	github.com/carlosframework/platform/internal/instancectl	1.719s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.743s
ok  	github.com/carlosframework/platform/internal/instancefs	0.011s
ok  	github.com/carlosframework/platform/internal/instancekey	0.011s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.017s
ok  	github.com/carlosframework/platform/internal/layout	1.423s
ok  	github.com/carlosframework/platform/internal/lease	9.511s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.015s
ok  	github.com/carlosframework/platform/internal/ledger	0.010s
ok  	github.com/carlosframework/platform/internal/mail	0.168s
ok  	github.com/carlosframework/platform/internal/maintenance	0.095s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.570s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.073s
ok  	github.com/carlosframework/platform/internal/metrics	0.004s
ok  	github.com/carlosframework/platform/internal/moves	21.710s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.771s
ok  	github.com/carlosframework/platform/internal/objstore	0.176s
ok  	github.com/carlosframework/platform/internal/ops	0.173s
ok  	github.com/carlosframework/platform/internal/park	0.221s
ok  	github.com/carlosframework/platform/internal/privatefile	0.005s
ok  	github.com/carlosframework/platform/internal/provision	15.526s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	4.123s
ok  	github.com/carlosframework/platform/internal/relay	7.267s
ok  	github.com/carlosframework/platform/internal/release	22.261s
ok  	github.com/carlosframework/platform/internal/rollout	3.974s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.008s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.008s
ok  	github.com/carlosframework/platform/internal/rootstate	0.025s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.006s
ok  	github.com/carlosframework/platform/internal/schedule	2.330s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	13.165s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.177s
ok  	github.com/carlosframework/platform/internal/status	0.041s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.241s
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.003s
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.001s
ok  	github.com/carlosframework/platform/internal/vet	1.548s
=== 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:46:58 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE24AAF725E0B9, 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 be4a5d070..55092d1b3 100644
--- a/internal/host/legacyrestart.go
+++ b/internal/host/legacyrestart.go
@@ -40,7 +40,7 @@ func (c rootRestartChecks) noteLegacy() {
func restartEffectFailed(err error) bool {
var notUp *release.UnitNotUpError
- return errors.As(err, &notUp) && !errors.Is(err, errRestartBinding)
+ return errors.As(err, &notUp)
}
// This is main's stamped-unit path, including its fallback on Show or
diff --git a/internal/host/namedrecovery_active.go b/internal/host/namedrecovery_active.go
index 0bc854bc5..cb1719d2b 100644
--- a/internal/host/namedrecovery_active.go
+++ b/internal/host/namedrecovery_active.go
@@ -18,6 +18,7 @@ import (
)
func (n namedUnitRecovery) recover(ctx context.Context, host string, c rootRestartChecks, getenv func(string) string, reg *registry.Registry) (handled bool, result error) {
+ c.preserveOutcome = true
legacy, err := c.allowLegacy()
if err != nil {
return true, err
@@ -27,10 +28,11 @@ func (n namedUnitRecovery) recover(ctx context.Context, host string, c rootResta
if err != nil || rt == nil || rt.Unit == "" || rt.Unit == activator.UnitName(host) {
return false, nil
}
+ c.recoveryOutcome = &recoveryRestartOutcome{route: *rt}
}
// An optional binding cannot veto the no-census restart path.
defer func() {
- if legacy && result != nil && !restartEffectFailed(result) {
+ if legacy && !c.recoveryOutcome.effect && result != nil && !restartEffectFailed(result) {
handled, result = false, nil
}
}()
@@ -128,8 +130,23 @@ func (n namedUnitRecovery) recover(ctx context.Context, host string, c rootResta
if err := restartBoundUnit(n.source.sys, n.source.optDir, getenv, old, false, nil, c); err != nil {
return true, err
}
+ if c.recoveryOutcome != nil && c.recoveryOutcome.unbound {
+ return true, effect(func() error {
+ current, err := reg.Get(host)
+ if err != nil || current == nil {
+ return errors.Join(err, os.ErrPermission)
+ }
+ if current.Version == pointer.Version {
+ return nil
+ }
+ return reg.SetVersions(host, pointer.Version, current.Version)
+ })
+ }
return true, effect(func() error {
if err := validate(); err != nil {
+ if legacy {
+ return nil
+ }
return err
}
current, err := reg.Get(host)
@@ -198,24 +215,49 @@ func (n namedUnitRecovery) recover(ctx context.Context, host string, c rootResta
if err != nil {
return true, err
}
- c.identity, c.effect = id, effect
+ c.identity, c.effect, c.targetDigest = id, effect, digest
if err := restartBoundUnit(n.source.sys, n.source.optDir, getenv, old, false, nil, c); err != nil {
- if legacy && (errors.Is(err, errRestartBinding) || errors.Is(err, errUnitStopped)) {
+ if legacy && !c.recoveryOutcome.effect && !restartEffectFailed(err) && (errors.Is(err, errRestartBinding) || errors.Is(err, errUnitStopped)) {
return false, settleRollbackAttempt(s, attempt)
}
+ if legacy && errors.Is(err, errUnitStopped) {
+ return true, settleRollbackAttempt(s, attempt)
+ }
attempt.Failed = true
if writeErr := writeRollbackState(s, rollbackJournalKey(host), attempt); writeErr != nil {
return true, errors.Join(err, writeErr)
}
+ if legacy && restartEffectFailed(err) {
+ attempt.Recovering = true
+ rollbackErr := effect(func() error {
+ if err := writeRollbackState(s, rollbackJournalKey(host), attempt); err != nil {
+ return err
+ }
+ if err := release.PointLive(n.source.optDir, host, rec.Account, old.Version, rec.App); err != nil {
+ return err
+ }
+ if err := legacyRollbackRestart(n.source.sys, n.source.optDir, getenv, c.recoveryOutcome.route, c); err != nil {
+ return err
+ }
+ return reg.SetVersions(host, old.Version, "")
+ })
+ return true, errors.Join(err, rollbackErr, settleRollbackAttempt(s, attempt))
+ }
return true, errors.Join(err, resumeHistoryRestart(ctx, n.source.sys, n.source.optDir, getenv, c, n.writes, s, attempt))
}
- attempt.Verified = true
+ attempt.Verified = c.recoveryOutcome == nil || !c.recoveryOutcome.unbound
if err := settleRollbackAttempt(s, attempt); err != nil {
return true, err
}
+ if !attempt.Verified {
+ return true, effect(func() error { return reg.SetVersions(host, pointer.Version, old.Version) })
+ }
return true, effect(func() error {
current, err := c.host(ctx, host)
if err != nil || current.Source != old.Source || current.Version != pointer.Version || current.Digest != digest {
+ if legacy {
+ return reg.SetVersions(host, pointer.Version, old.Version)
+ }
return errors.Join(err, os.ErrPermission)
}
return reg.SetVersions(host, pointer.Version, old.Version)
diff --git a/internal/host/plan14_active_recovery_test.go b/internal/host/plan14_active_recovery_test.go
index 722217a1d..18b8173cd 100644
--- a/internal/host/plan14_active_recovery_test.go
+++ b/internal/host/plan14_active_recovery_test.go
@@ -191,6 +191,7 @@ func TestPlan14SignedFixSupersedesOlderAttempt(t *testing.T) {
func TestPlan14ERecoveryBindingRefusalKeepsPromotedFix(t *testing.T) {
e, u, c, source, pub := signedRecoveryFixture(t)
c.legacy = func() (bool, error) { return true, nil }
+ c.legacyReady = c.ready
// Refuse the final readiness binding after the process effect.
original := c.host
c.host = func(ctx context.Context, host string) (proofBinding, error) {
@@ -211,7 +212,7 @@ func TestPlan14ERecoveryBindingRefusalKeepsPromotedFix(t *testing.T) {
if readErr != nil || attempt.Failed || !attempt.Settled {
t.Fatalf("binding refusal poisoned promotion: %+v %v", attempt, readErr)
}
- if handled || err != nil || liveErr != nil || live != "v2" || len(u.effects) != 1 {
+ if !handled || err != nil || liveErr != nil || live != "v2" || len(u.effects) != 1 {
t.Fatalf("binding refusal undid promotion: handled=%v err=%v live=%s/%v effects=%v", handled, err, live, liveErr, u.effects)
}
}
diff --git a/internal/host/plan14_e8_outcome_test.go b/internal/host/plan14_e8_outcome_test.go
new file mode 100644
index 000000000..c52fafe86
--- /dev/null
+++ b/internal/host/plan14_e8_outcome_test.go
@@ -0,0 +1,113 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type e8ExeUnits struct{ *plan14RestartUnits }
+
+func (u e8ExeUnits) Exe(pid int) (string, error) {
+ if len(u.effects) > 0 {
+ return "", os.ErrPermission
+ }
+ return u.plan14RestartUnits.Exe(pid)
+}
+
+func TestPlan14ERecoveryRestartOutcome(t *testing.T) {
+ for _, fault := range []string{"binding", "final binding", "exe", "failed", "auto-restart", "not ready"} {
+ t.Run(fault, func(t *testing.T) {
+ e, u, c, source, pub := signedRecoveryFixture(t)
+ u.afterRestart = func() { u.props.NRestarts = 0 }
+ c.legacy = func() (bool, error) { return true, nil }
+ c.legacyReady = func(registry.Route) func() error {
+ return func() error {
+ if fault == "not ready" && len(u.effects) == 1 {
+ return os.ErrDeadlineExceeded
+ }
+ return nil
+ }
+ }
+ orig := c.host
+ calls := 0
+ c.host = func(ctx context.Context, host string) (proofBinding, error) {
+ if len(u.effects) == 1 {
+ calls++
+ if fault != "final binding" || calls >= 2 {
+ if fault == "failed" {
+ u.props.ActiveState, u.props.MainPID = "failed", 0
+ }
+ if fault == "auto-restart" {
+ u.props.ActiveState, u.props.SubState, u.props.NRestarts = "activating", "auto-restart", 1
+ }
+ return proofBinding{}, os.ErrPermission
+ }
+ }
+ return orig(ctx, host)
+ }
+ if fault == "exe" {
+ source.sys = e8ExeUnits{u}
+ }
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ fallbacks := 0
+ err := release.Pass(t.Context(), e.root.reg, e.store, pub, u.opt, func(host string) error {
+ rt, e := e.root.reg.Get(host)
+ if e != nil {
+ return e
+ }
+ fallbacks++
+ return legacyCheckedUnitRestart(u, u.opt, func(string) string { return "5s" }, *rt, nil, c)
+ }, release.BakeDir(e.bake), release.WithRestartFailurePropagation(), release.WithRecovery(func(ctx context.Context, rt registry.Route) (bool, error) {
+ return n.recover(ctx, rt.Host, c, func(string) string { return "5s" }, e.root.reg)
+ }, c.allowLegacy))
+ failed := fault == "failed" || fault == "auto-restart" || fault == "not ready"
+ want, count := "v2", 1
+ if failed {
+ want, count = "v1", 3
+ }
+ live, liveErr := release.LiveVersion(u.opt, observedHost)
+ if liveErr != nil || live != want || len(u.effects) != count || fallbacks != 0 || (err != nil) != failed {
+ t.Fatalf("outcome: live=%s/%v effects=%v fallback=%d err=%v", live, liveErr, u.effects, fallbacks, err)
+ }
+ stamp, stampErr := e.root.reg.Get(observedHost)
+ if stampErr != nil || stamp.Version != want {
+ t.Fatalf("accepted outcome not stamped: %+v %v", stamp, stampErr)
+ }
+ disk, _ := c.attempts()
+ a, readErr := readRollbackAttempt(disk, observedHost)
+ if readErr != nil || !a.Settled || a.Failed != failed || (!failed && a.Verified) {
+ t.Fatalf("mint evidence: %+v %v", a, readErr)
+ }
+ })
+ }
+}
+
+func TestPlan14ERecoveryNotUpPrecedesBinding(t *testing.T) {
+ err := errors.Join(errRestartBinding, &release.UnitNotUpError{Unit: "console.service", Reason: "failed"})
+ if !restartEffectFailed(err) {
+ t.Fatal("binding masked real not-up result")
+ }
+}
+
+func TestPlan14ERecoveryStrictNotUpPrecedesBinding(t *testing.T) {
+ e, u, c, source, pub := signedRecoveryFixture(t)
+ original := c.host
+ c.host = func(ctx context.Context, host string) (proofBinding, error) {
+ if len(u.effects) == 1 {
+ u.props.ActiveState, u.props.MainPID = "failed", 0
+ return proofBinding{}, os.ErrPermission
+ }
+ return original(ctx, host)
+ }
+ n := namedUnitRecovery{source: source, pub: pub, readLive: os.Readlink}
+ handled, err := n.recover(t.Context(), observedHost, c, func(string) string { return "5s" }, e.root.reg)
+ var notUp *release.UnitNotUpError
+ if !handled || !errors.As(err, &notUp) || len(u.effects) != 3 {
+ t.Fatalf("strict outcome lost: handled=%v err=%v effects=%v", handled, err, u.effects)
+ }
+}
diff --git a/internal/host/recoverysettle.go b/internal/host/recoverysettle.go
new file mode 100644
index 000000000..508ef4ab0
--- /dev/null
+++ b/internal/host/recoverysettle.go
@@ -0,0 +1,37 @@
+package host
+
+import (
+ "context"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func settleLegacyRecovery(sys unitSystem, optDir string, getenv func(string) string, binding proofBinding, rollback bool, before, want string, ready func() error, c rootRestartChecks) error {
+ opts := legacySettleOptions(optDir, getenv, c.recoveryOutcome.route, want, c)
+ settle := func() error { return settleUnit(sys, binding.Route.Unit, before, opts) }
+ if err := settle(); err != nil {
+ return err
+ }
+ c.recoveryOutcome.unbound = true
+ observe := func() (proofBinding, error) {
+ if rollback && c.recovered != nil {
+ return c.recovered(context.Background(), binding)
+ }
+ return c.host(context.Background(), binding.Route.Host)
+ }
+ first, err := observe()
+ if err != nil || first.Source != binding.Source || want != release.VersionPath(optDir, first.Route.Account, first.Version, first.Route.App) ||
+ (c.targetDigest != "" && first.Digest != c.targetDigest) ||
+ (rollback && (first.Version != binding.Version || first.Digest != binding.Digest)) {
+ return settle()
+ }
+ if err := ready(); err != nil {
+ return settle()
+ }
+ last, err := observe()
+ if err != nil || last.Source != first.Source || last.Version != first.Version || last.Digest != first.Digest || last.Invocation != first.Invocation || last.PID != first.PID || last.Start != first.Start {
+ return settle()
+ }
+ c.recoveryOutcome.unbound = false
+ return nil
+}
diff --git a/internal/host/roothistory.go b/internal/host/roothistory.go
index fb842c344..b73cb0b3e 100644
--- a/internal/host/roothistory.go
+++ b/internal/host/roothistory.go
@@ -6,6 +6,7 @@ import (
"debug/buildinfo"
"encoding/hex"
"io"
+ "log"
"os"
"time"
@@ -26,7 +27,7 @@ func openRootHistory(store release.Store, optDir string) (*release.RootHistoryPa
if err != nil {
return err
}
- return h.History.ReceiveReconstruction(ctx, store, pub, b, time.Now(), func(want release.HistoryBinding) (string, error) {
+ resolve := func(want release.HistoryBinding) (string, error) {
got, err := source.forHost(ctx, want.Host)
if err != nil {
return "", err
@@ -35,7 +36,17 @@ func openRootHistory(store release.Store, optDir string) (*release.RootHistoryPa
return "", os.ErrPermission
}
return got.Source, nil
- })
+ }
+ err = h.History.ReceiveReconstruction(ctx, store, pub, b, time.Now(), resolve)
+ projection, statusErr := rootstate.Open(release.RootHistoryStatusDir, true, true)
+ if statusErr == nil {
+ statusErr = h.History.ProjectMigrationStatus(projection, b, resolve)
+ projection.Close()
+ }
+ if statusErr != nil {
+ log.Print(statusErr)
+ }
+ return err
}
return h, nil
}
diff --git a/internal/host/roothistory_recovery.go b/internal/host/roothistory_recovery.go
index fc3de6962..25eab4b6a 100644
--- a/internal/host/roothistory_recovery.go
+++ b/internal/host/roothistory_recovery.go
@@ -198,7 +198,7 @@ func resumeHistoryRestart(ctx context.Context, sys unitSystem, optDir string, ge
}
return errors.Join(err, settleRollbackAttempt(s, a))
}
- a.Verified = true
+ a.Verified = checks.recoveryOutcome == nil || !checks.recoveryOutcome.unbound
if err := settleRollbackAttempt(s, a); err != nil {
return err
}
diff --git a/internal/host/rootrestart.go b/internal/host/rootrestart.go
index 86893ed7f..8bc64065e 100644
--- a/internal/host/rootrestart.go
+++ b/internal/host/rootrestart.go
@@ -21,7 +21,15 @@ import (
"github.com/carlosframework/platform/internal/rootstate"
)
+type recoveryRestartOutcome struct {
+ route registry.Route
+ effect bool
+ unbound bool
+}
+
type rootRestartChecks struct {
+ recoveryOutcome *recoveryRestartOutcome
+ preserveOutcome bool
history func() (*release.RootHistoryPass, error)
readLive func(string) (string, error)
legacy func() (bool, error)
@@ -299,6 +307,9 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
if err := sys.ResetFailed(rt.Unit); err != nil {
return err
}
+ if c.recoveryOutcome != nil {
+ c.recoveryOutcome.effect = true
+ }
if err := sys.Restart(rt.Unit); err != nil {
return err
}
@@ -320,8 +331,14 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
if err := sys.ResetFailed(rt.Unit); err != nil {
return err
}
+ if c.recoveryOutcome != nil {
+ c.recoveryOutcome.effect = true
+ }
return sys.Restart(rt.Unit)
}
+ if c.recoveryOutcome != nil {
+ c.recoveryOutcome.effect = true
+ }
if err := sys.TryRestart(rt.Unit); err != nil {
return err
}
@@ -330,6 +347,9 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
}); err != nil {
return err
}
+ if c.recoveryOutcome != nil {
+ return settleLegacyRecovery(sys, optDir, getenv, binding, rollback, before.InvocationID, want, ready, c)
+ }
var observed proofBinding
observe := func() (proofBinding, error) {
if rollback && c.recovered != nil {
@@ -369,6 +389,9 @@ func restartBoundUnit(sys unitSystem, optDir string, getenv func(string) string,
})
if err != nil {
if bindingErr != nil {
+ if c.preserveOutcome {
+ return errors.Join(err, bindingErr)
+ }
return bindingErr
}
return err
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index 76136057a..c59897302 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -2,7 +2,7 @@
internal/host/agent.go runAdoptUnderRootRelease authority B3,E1 Root retains independent recovery results and includes exact failed target digests in proof mint refusals. Pending, malformed or unsynced evidence refuses minting; recovery remains independent. Fixtures: TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14RecoveryMintEvidence, TestPlan14SignedActiveFixIgnoresHistory. E7 applies merge ruling 4: unbound no-census routes use the legacy pass and restart; ordinary candidates make no binding store call. Fixtures: TestR2UnguardedERecoveryUpdate, TestR2UnguardedERecoveryIncidentRecipe, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix. registry:Host@14260,whole:Route@10431,whole:Route@17915,whole:Route@17951,whole:Route@7852 f24943a35599f7a46117aff91d3d3c11507757c7f0688f6f831fecee29a88f84
internal/host/agent.go runAdoptWithEnsureSystemUnits authority B3,C,D,E1,E2,E3,E4,F,J1 E1 selects the checked private history, canonical named-unit provider and root mint inventory under release exclusion with claims off or on; reconstruction and restart consumption remain incomplete. file:Open@191 9cdd0a958f5924c9c8d8c5613fd665f2c96a194cd11322adb6f0050fb183b541
internal/host/namedrecovery.go namedUnitRecovery.repair authority E1,J1 Signed channel target, canonical deployment record, checked named unit and root-owned live pointer authorize stopped-unit pointer repair. Rechecks source, claim selection, pointer and artifact at the effect; does not start a process or change grants. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. Uses account/app from the same canonical read as the unit binding. Fixture: TestPlan14SignedNamedRecoveryUsesOneCanonicalRecord. file:Open@2565,registry:Channel@1814,whole:Route@1060,whole:Route@1822 8a42a6a27537be49d30d32891f407789449f9b77d37674fea5cb288d908adf6a
-internal/host/namedrecovery_active.go namedUnitRecovery.recover authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. E7 applies merge ruling 4: unbound no-census routes use the legacy pass and restart; ordinary candidates make no binding store call. Fixtures: TestR2UnguardedERecoveryUpdate, TestR2UnguardedERecoveryIncidentRecipe, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix. file:Open@5410,registry:Channel@2666,registry:Unit@316,registry:Unit@333,registry:Version@3655,registry:Version@3761,whole:Route@1586,whole:Route@2668,whole:Route@303,whole:Route@3581 95e34cf07d344a1dd85d6c7da36d80c45d996cbe6dfb241d7d477eacb2019192
+internal/host/namedrecovery_active.go namedUnitRecovery.recover authority B3,E1 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding, TestPlan14ERecoveryStrictNotUpPrecedesBinding. file:Open@5928,registry:Channel@2779,registry:Unit@342,registry:Unit@359,registry:Version@3777,registry:Version@3886,registry:Version@4173,registry:Version@4279,whole:Route@1699,whole:Route@2781,whole:Route@329,whole:Route@3700,whole:Route@4099,whole:Route@468,whole:Route@476,whole:Route@7138 1b2296098659ba83def943d8cbb75cf537fad2ef97b78c093262ff3586c3728a
internal/host/proofbinding.go proofBinding.authorizedRecovery authority B3,E1 Independent signed recovery authority and retained attempt outcomes. Fixtures: TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14ActiveRecoveryProcessCrash. a7a7cc9edf8437ed2a1fd4d4ed3d54e9ffab78b21ed3d2867a2254c7836e4f13
internal/host/proofbinding.go proofBindingSource.binding authority B3,J1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:EvalSymlinks@1176,file:EvalSymlinks@1485,file:EvalSymlinks@518,file:EvalSymlinks@757,registry:Unit@3161,whole:Route@265,whole:Route@3658,whole:Route@3665 56edaf01411de3813fe327973302b1df35e403d2870625fc341355b5bc5cfa0b
internal/host/proofbinding.go proofBindingSource.recovered authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Host@123,whole:Route@125 9ddd5ae1e5b981654e47a0f97cba43b93e32617feddf57918136c57c9943f8ae
@@ -11,15 +11,15 @@ internal/host/proofroot.go proofRoot.mint authority B3,J1,J4,E1 E1 production us
internal/host/proofroot.go proofRoot.observeAndRecord authority B3,J1,J4,E1 E1 production uses canonical observed programs and complete root refusal inventory; legacy diagnostics do not grant root mint authority. file:Readlink@108,registry:Account@443,registry:App@468,registry:Host@151,registry:Host@657,whole:Route@186,whole:Route@453,whole:Route@478 88065f1cfe7ad31178ba65c2672d8dd1e25fab26820290368fc063907f7b3b98
internal/host/recoveryrefusals.go rootRestartChecks.mintRefusals authority B3,E1 Root retains independent recovery results and includes exact failed target digests in proof mint refusals. Pending, malformed or unsynced evidence refuses minting; recovery remains independent. Fixtures: TestPlan14IndependentRecoveryRetainsMintBarrier, TestPlan14RecoveryMintEvidence, TestPlan14SignedActiveFixIgnoresHistory. file:Read@616 1bb27522e17f4c16f7c09390f2a132ce6a1050e73fc807179d1fa9f4ff71278e
internal/host/roothistory.go boundRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@1061,file:Read@1379,registry:Account@613,registry:App@635,registry:Channel@456,registry:Channel@470,registry:Host@590,whole:Route@1469,whole:Route@377,whole:Route@472,whole:Route@592,whole:Route@615,whole:Route@637,whole:Route@846 fe46269101116374044658bbb87c1bd44317525dcadf227d4515b47845238be2
-internal/host/roothistory.go openRootHistory authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. file:Open@104,registry:Account@800,registry:App@837,whole:Route@804,whole:Route@841 77b7ee45bd93e0e5d0dfd4ea444cb9df293461875ed0a42f8c177b84c65ed317
+internal/host/roothistory.go openRootHistory authority E1 E1 reconstruction status reads exact private category revisions and canonical sources twice; public projection grants nothing. Fixtures: TestPlan14HistoryMigrationStatus, TestPlan14HistoryPendingStatus, TestPlan14HistoryStatusReplacementDelivery. Recovery outcome integration remains incomplete. file:Open@104,file:Open@980,registry:Account@740,registry:App@777,whole:Route@744,whole:Route@781 6615b63fd53196099dc7ced185ad25bf941c201dba4078481ac8e8e6b3aa854d
internal/host/roothistory.go recordRootProgram authority E1 E1 checked private store and canonical record/unit/process observation; program read is hashed and followed by another binding, readiness and process check. whole:Route@151,whole:Route@297,whole:Route@398 fbbc9451c39309090580486d75da6120b02e1ba054157159846bea057ae0c80f
internal/host/roothistory_recovery.go historyRestartAttempt authority B3,E1 Captured root capability and exact process/source identity authorize restart independently of release history availability. Fixture: TestPlan14HistoryUnavailableAfterCaptureCannotBlockRestart. registry:Host@187,registry:Host@361,whole:Route@189,whole:Route@363 1c2af7b90cb56053c1d58c10ccaa851c3aa25b0be9e595bf8e90a013ad0bae66
internal/host/roothistory_recovery.go linkRootHistory authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Prepare@121,registry:Account@374,registry:App@412,whole:Route@376,whole:Route@414 a60324796d2b25b3b130bb0439bbd9a415b679e6b51bc92a44f23f77e2d93e1e
-internal/host/roothistory_recovery.go resumeHistoryRestart authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@1025,registry:Account@2194,registry:App@1057,registry:App@2226,registry:Unit@824,whole:Route@1029,whole:Route@1061,whole:Route@2198,whole:Route@2230,whole:Route@828 7e11454ad9a1adf74bf437a54626674c285aa5e3f3eb3f37a0301daa2410ba71
+internal/host/roothistory_recovery.go resumeHistoryRestart authority B3,E1 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding. registry:Account@1025,registry:Account@2194,registry:App@1057,registry:App@2226,registry:Unit@824,whole:Route@1029,whole:Route@1061,whole:Route@2198,whole:Route@2230,whole:Route@828 79d86ba8876ab6cdeced4f27b0bf1f16097f2d56da1c08ae4839b5fc4143ec47
internal/host/roothistory_recovery.go rootRestartChecks.settleHistory authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@580,registry:App@1351,registry:App@627,whole:Route@1353,whole:Route@582,whole:Route@629,whole:Route@845,whole:Route@991 ea977169cb738ba5e3e9bb83f86fbe0fca6e66ec6fcd9416dae94e8f39bc47fc
internal/host/rootrestart.go checkedUnitRestart authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:EvalSymlinks@1798,registry:Account@1367,registry:Account@2166,registry:App@1399,registry:App@2198,registry:Host@1069,registry:Host@1854,registry:Host@2741,registry:Host@2963,registry:Host@3606,registry:Host@510,registry:Unit@3488,registry:Unit@711,whole:Route@1375,whole:Route@1407,whole:Route@2174,whole:Route@2206,whole:Route@3496,whole:Route@638,whole:Route@719 3a29634563392118e927685e478c8265916fbe89d922b9dde4c08aa5178c1c3c
internal/host/rootrestart.go productionRootRestartChecks authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Open@1356,whole:Route@1189,whole:Route@1990,whole:Route@2257 921445f1e11f9bfd202e309a0b0be4864f057a0907164f32ec8603f6949f4bdd
-internal/host/rootrestart.go restartBoundUnit authority B3,J1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. registry:Account@2482,registry:Account@3896,registry:App@2518,registry:App@3936,registry:Host@1585,registry:Host@2301,registry:Host@650,registry:Unit@1071,registry:Unit@1139,registry:Unit@1894,registry:Unit@1962,registry:Unit@2004,registry:Unit@3299,registry:Unit@395,whole:Route@198,whole:Route@2488,whole:Route@2524,whole:Route@317,whole:Route@3904,whole:Route@3944,whole:Route@4155 018a4502e3b1d18fde5f53d49805246b4aa83d74934d5ee0bcc7a5227ac4be5e
+internal/host/rootrestart.go restartBoundUnit authority B3,J1 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding, TestPlan14ERecoveryStrictNotUpPrecedesBinding. registry:Account@2848,registry:Account@4333,registry:App@2884,registry:App@4373,registry:Host@1659,registry:Host@2667,registry:Host@650,registry:Unit@1071,registry:Unit@1213,registry:Unit@1968,registry:Unit@2113,registry:Unit@2229,registry:Unit@3665,registry:Unit@395,whole:Route@198,whole:Route@2854,whole:Route@2890,whole:Route@317,whole:Route@4341,whole:Route@4381,whole:Route@4592 123efb827a00d4350aa69ab03560cc69b460e3b5cf7f7daed181346efe784fae
internal/host/rootrestart.go rollbackUnitRestart authority B3,J1,E1 Captured canonical rollback authority is unchanged. Durable verified outcome permits later history settlement without another restart. Fixtures: TestPlan14HistoryRollbackLink and TestPlan14HistoryUnavailableCannotBlockCapturedRollback. file:Read@1458,registry:Host@1750,registry:Unit@1852,registry:Unit@2153,registry:Unit@435,registry:Unit@692,whole:Route@1758,whole:Route@1860,whole:Route@2161,whole:Route@422,whole:Route@774 4d02504999ba2746f3815cba0e3ca7fe8723cdf2528e57d3fa78b5acc9b90998
internal/host/rootrestart.go rootRestartChecks.live authority B3,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:EvalSymlinks@158 28721577488e77d229d3a5cbe6eb5806a4af0696d350a144c5ed2e4d10c36828
internal/host/rootrollback.go captureRollback authority B3,J1,E1 Signed named-unit recovery is independent of release and health history. Root rechecks canonical source, unit, process, signed target and trusted live pointer; captures attempt authority before publication; verifies readiness and resumes without repeated effects. Fixtures: TestPlan14SignedActiveFixIgnoresHistory, TestPlan14SignedActiveFixAuthority, TestPlan14SignedFixSupersedesOlderAttempt, TestPlan14ActiveRecoveryProcessCrash, TestPlan14SignedRestartWithoutHealthHistory, TestPlan14CapturedRollbackWithoutHealthHistory, TestPlan14SignedRestartRequiresTrustedLiveLink. file:Read@704,registry:Account@1191,registry:App@1227,registry:Host@1154,registry:Host@341,registry:Host@823,whole:Route@1156,whole:Route@1193,whole:Route@1229,whole:Route@343,whole:Route@825 6d3ec51488ef1be9dac2d5fe168cb38e72614242fd18064ba441273b14af6efa
@@ -54,6 +54,9 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S
internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a
internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab
internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7
-@coverage internal/host d4f20f60919fd0c9e9228087c591e969dcb3ae7bb3b98f450d8a462f76f2efb1
-@coverage internal/release 9837d8dab6911a5d922389195b3b06849cea63a8ed92fa2b9ba1c6b0509f4a86
+@coverage internal/host f0cd6de14ac64636c123adde18a0b69ea46332ac6a075da5958a65e428e4432e
+@coverage internal/release 8d2ad3c9529ccf8a2da259399b7bb1de77b17a4914fda46c2c54c94779c3a617
@coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05
+internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 E1 reconstruction status reads exact private category revisions and canonical sources twice; public projection grants nothing. Fixtures: TestPlan14HistoryMigrationStatus, TestPlan14HistoryPendingStatus, TestPlan14HistoryStatusReplacementDelivery. Recovery outcome integration remains incomplete. file:Read@1062 c4dfc98bf7722bf88302eca9806027ad08eb446f0a0c8561bf926592665683a6
+internal/host/legacyrestart.go restartEffectFailed authority B3 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding. b68bb65884a14fe0cf78a65f56ded4ea41584bba99af9195827d1ea191b340a9
+internal/host/recoverysettle.go settleLegacyRecovery authority E1 E8 merge ruling 5: optional identity failures preserve the same restart outcome; no second restart; real not-up takes main rollback. Fixtures: TestPlan14ERecoveryRestartOutcome, TestPlan14ERecoveryBindingRefusalKeepsPromotedFix, TestPlan14ERecoveryNotUpPrecedesBinding, TestPlan14ERecoveryStrictNotUpPrecedesBinding. registry:Account@752,registry:App@788,registry:Host@616,registry:Unit@320,whole:Route@255,whole:Route@328,whole:Route@624,whole:Route@758,whole:Route@794 65785c42a5e21854873d8d9ea952b6754cecbb766495768ef64edc3da4a446a6
diff --git a/internal/release/plan14_history_status_test.go b/internal/release/plan14_history_status_test.go
new file mode 100644
index 000000000..db6d4cf2d
--- /dev/null
+++ b/internal/release/plan14_history_status_test.go
@@ -0,0 +1,122 @@
+package release
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestPlan14HistoryMigrationStatus(t *testing.T) {
+ for _, fault := range []string{"valid", "missing", "changed source", "malformed", "unsynced"} {
+ t.Run(fault, func(t *testing.T) {
+ h, s, b := rootHistoryFixture(t)
+ if fault == "missing" {
+ os.Remove(filepath.Join(s.dir, historyKey(b)))
+ }
+ if fault == "malformed" {
+ s.Write(historyKey(b), []byte("{"))
+ }
+ if fault == "unsynced" {
+ s.fail = 1
+ }
+ calls := 0
+ view, err := h.MigrationStatus(b, func(HistoryBinding) (string, error) {
+ calls++
+ if fault == "changed source" && calls > 1 {
+ return "new", nil
+ }
+ return "canonical", nil
+ })
+ if fault == "valid" || fault == "missing" {
+ if err != nil || view.Identity == "" || view.Revision != 1 || len(view.Hosts) != 1 || view.Hosts[0].Source != "canonical" {
+ t.Fatalf("view=%+v err=%v", view, err)
+ }
+ if fault == "missing" && (view.Hosts[0].Revision != 0 || view.Hosts[0].Programs.Complete || view.Hosts[0].RefusalStatus.Complete) {
+ t.Fatal("absence became completeness")
+ }
+ if fault == "valid" && view.Hosts[0].Revision != 1 {
+ t.Fatal("wrong category revision")
+ }
+ } else if err == nil {
+ t.Fatalf("%s projected usable status: %+v", fault, view)
+ }
+ })
+ }
+}
+
+func TestPlan14HistoryPendingStatus(t *testing.T) {
+ h, s, b, pub, wire, now := reconstructionCrashFixture(t)
+ h.Store = &reconstructionCrashStore{healthyRetryStore: s, stage: "intent", fail: true}
+ resolve := func(HistoryBinding) (string, error) { return "canonical", nil }
+ if err := h.Reconstruct(pub, wire, b.Account, b.App, now, resolve); err == nil {
+ t.Fatal("no crash")
+ }
+ h.Store = s
+ view, err := h.MigrationStatus(b, resolve)
+ if err != nil || !view.Pending || view.PendingDigest == "" || view.PendingGrant == nil || view.PendingGrant.Operation != "crash" {
+ t.Fatalf("pending status: %+v %v", view, err)
+ }
+ if _, err := h.Refusals(b); err == nil {
+ t.Fatal("status removed pending barrier")
+ }
+ projection := &healthyRetryStore{dir: t.TempDir(), fault: os.ErrPermission}
+ if err := h.ProjectMigrationStatus(projection, b, resolve); err != nil {
+ t.Fatal(err)
+ }
+ raw, err := projection.Read(HistoryStatusKey(b.Account, b.App))
+ if err != nil || len(raw) == 0 {
+ t.Fatalf("projection missing: %v", err)
+ }
+ projection.Write(HistoryStatusKey(b.Account, b.App), []byte(`{"complete":true}`))
+ if _, err := h.Refusals(b); err == nil {
+ t.Fatal("projection supplied authority")
+ }
+}
+
+func TestPlan14HistoryStatusReplacementDelivery(t *testing.T) {
+ h, s, b, pub, wire, now := reconstructionCrashFixture(t)
+ // Retain a real interrupted intent, then change its canonical source.
+ h.Store = &reconstructionCrashStore{healthyRetryStore: s, stage: "intent", fail: true}
+ if err := h.Reconstruct(pub, wire, b.Account, b.App, now, func(HistoryBinding) (string, error) { return "canonical", nil }); err == nil {
+ t.Fatal("no interruption")
+ }
+ h.Store = s
+ resolve := func(HistoryBinding) (string, error) { return "new-source", nil }
+ view, err := h.MigrationStatus(b, resolve)
+ if err != nil {
+ t.Fatal(err)
+ }
+ g := *view.PendingGrant
+ g.Operation = "replacement-from-status"
+ g.Replaces = view.PendingDigest
+ g.AppRevision, g.BakeRevision = view.AppRevision, view.BakeRevision
+ g.Hosts = nil
+ for _, host := range view.Hosts {
+ g.Hosts = append(g.Hosts, host.HistoryReconstructionHost)
+ }
+ // The deployment signer signs the exact revisions and sources from status.
+ priv, pub := testKeys(t)
+ st := NewDirStore(t.TempDir())
+ if err := PublishHistoryReconstruction(t.Context(), st, RootSigner(priv), g); err != nil {
+ t.Fatal(err)
+ }
+ if err := h.ReceiveReconstruction(t.Context(), st, pub, b, now, resolve); err != nil {
+ t.Fatal(err)
+ }
+ after, err := h.MigrationStatus(b, resolve)
+ if err != nil || after.Pending || after.Revision != view.Revision+1 || !after.AppComplete || after.BakeComplete == 0 {
+ t.Fatalf("replacement not complete: %+v %v", after, err)
+ }
+ if after.Clock.Holds[channelClockKey(b.Account, b.App, "stable")].Window != time.Hour {
+ t.Fatal("replacement lost intended hold")
+ }
+ g.Operation = "stale-revision"
+ stale, err := SignRootEnvelope(RootSigner(priv), g)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := h.Reconstruct(pub, stale, b.Account, b.App, now, resolve); err == nil {
+ t.Fatal("stale projected revision accepted")
+ }
+}
diff --git a/internal/release/roothistory_status.go b/internal/release/roothistory_status.go
new file mode 100644
index 000000000..da1c8f7ad
--- /dev/null
+++ b/internal/release/roothistory_status.go
@@ -0,0 +1,165 @@
+package release
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "slices"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const RootHistoryStatusDir = "/var/lib/carlos-release/history-status"
+
+type HistoryHostStatus struct {
+ HistoryReconstructionHost
+ Programs historyPrograms `json:"programs"`
+ RefusalStatus historyRefusals `json:"refusal_status"`
+ Attempt *HistoryAttempt `json:"attempt,omitempty"`
+}
+
+type HistoryMigrationStatus struct {
+ HistoryReconstructionStatus
+ Schema int `json:"schema"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ AppRevision uint64 `json:"app_revision"`
+ AppComplete bool `json:"app_complete"`
+ BakeRevision uint64 `json:"bake_revision"`
+ BakeComplete uint64 `json:"bake_complete"`
+ Hosts []HistoryHostStatus `json:"hosts"`
+ Clock FirstSeen `json:"clock"`
+ PendingDigest string `json:"pending_digest,omitempty"`
+ PendingGrant *HistoryReconstruction `json:"pending_grant,omitempty"`
+}
+
+func HistoryStatusKey(acct, app string) string { return historyAppKey(acct, app) + ".status" }
+
+func (h *RootHistory) MigrationStatus(b HistoryBinding, resolve func(HistoryBinding) (string, error)) (HistoryMigrationStatus, error) {
+ var out HistoryMigrationStatus
+ if !b.valid() || resolve == nil {
+ return out, os.ErrPermission
+ }
+ status, err := h.ReconstructionStatus()
+ if err != nil {
+ return out, err
+ }
+ out = HistoryMigrationStatus{HistoryReconstructionStatus: status, Schema: 1, Account: b.Account, App: b.App}
+ state, err := h.reconstructionState()
+ if err != nil {
+ return out, err
+ }
+ hosts := []string{b.Host}
+ if state.Pending != nil {
+ raw, err := json.Marshal(state.Pending.Grant)
+ if err != nil || reconstructionDigest(raw) != state.Pending.Digest {
+ return out, os.ErrPermission
+ }
+ if state.Pending.Grant.Account == b.Account && state.Pending.Grant.App == b.App {
+ out.PendingDigest = state.Pending.Digest
+ grant := state.Pending.Grant
+ out.PendingGrant = &grant
+ for _, host := range grant.Hosts {
+ hosts = append(hosts, host.Host)
+ }
+ }
+ }
+ read := func(key string, v any, valid func() bool) error {
+ raw, err := h.Store.Read(key)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, v) != nil || !valid() {
+ return os.ErrPermission
+ }
+ return h.Store.Write(key, raw)
+ }
+ var app historyApp
+ if err := read(historyAppKey(b.Account, b.App), &app, func() bool { return validHistoryApp(app) && app.Account == b.Account && app.App == b.App }); err != nil {
+ return out, err
+ }
+ out.AppRevision, out.AppComplete = app.Revision, app.Complete
+ hosts = append(hosts, app.Hosts...)
+ var bake rootBakeRecord
+ if err := read(rootBakeKey, &bake, func() bool { return validRootBake(bake) }); err != nil {
+ return out, err
+ }
+ out.BakeRevision, out.BakeComplete = bake.Revision, bake.Complete[prodKey(b.Account, b.App)]
+ out.Clock = FirstSeen{Production: map[string]*prodMemory{}, Holds: map[string]*holdMemory{}}
+ if p := bake.Clock.Production[prodKey(b.Account, b.App)]; p != nil {
+ out.Clock.Production[prodKey(b.Account, b.App)] = p
+ }
+ for key, hold := range bake.Clock.Holds {
+ if strings.HasPrefix(key, holdKeyPrefix(b.Account, b.App)) {
+ out.Clock.Holds[key] = hold
+ }
+ }
+ names, err := h.Store.Names()
+ if err != nil {
+ return out, err
+ }
+ for _, name := range names {
+ if name == rootBakeKey || !strings.HasSuffix(name, ".json") {
+ continue
+ }
+ var record historyRecord
+ if err := read(name, &record, func() bool { return validHistory(record, record.Binding) && name == historyKey(record.Binding) }); err != nil {
+ return out, err
+ }
+ if record.Binding.Account == b.Account && record.Binding.App == b.App {
+ hosts = append(hosts, record.Binding.Host)
+ }
+ }
+ slices.Sort(hosts)
+ hosts = slices.Compact(hosts)
+ if len(hosts) > historyLimit {
+ return out, os.ErrPermission
+ }
+ for _, host := range hosts {
+ binding := HistoryBinding{Host: host, Account: b.Account, App: b.App}
+ source, err := resolve(binding)
+ if err != nil {
+ return out, err
+ }
+ if source == "" {
+ return out, os.ErrPermission
+ }
+ record, err := h.readRecord(binding)
+ if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return out, err
+ }
+ out.Hosts = append(out.Hosts, HistoryHostStatus{HistoryReconstructionHost: HistoryReconstructionHost{Host: host, Source: source, Revision: record.Revision, Refusals: record.Refusals.Values}, Programs: record.Programs, RefusalStatus: record.Refusals, Attempt: record.Attempt})
+ }
+ for _, host := range out.Hosts {
+ source, err := resolve(HistoryBinding{Host: host.Host, Account: b.Account, App: b.App})
+ if err != nil {
+ return out, err
+ }
+ if source != host.Source {
+ return out, os.ErrPermission
+ }
+ }
+ return out, nil
+}
+
+func (h *RootHistory) ProjectMigrationStatus(dst rootstate.Store, b HistoryBinding, resolve func(HistoryBinding) (string, error)) error {
+ if dst == nil {
+ return os.ErrPermission
+ }
+ view, err := h.MigrationStatus(b, resolve)
+ if err != nil {
+ return err
+ }
+ raw, err := json.Marshal(view)
+ if err != nil {
+ return err
+ }
+ if len(raw) > rootstate.MaxRecord {
+ return os.ErrPermission
+ }
+ return dst.Write(HistoryStatusKey(b.Account, b.App), raw)
+}