carlos / platform Public

WIP claim Plan 14 track E checkpoint 18 (not for merge): E2 claims-on root launch reservations; also the E1 operator workflow's CLI files (history_cli.go, proof_cli.go, docs/site/cli.md) left out of the checkpoint 17 commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev a96e55d362b8787024688729744e1ddc2cd17c73 parent 789dafa
12 files changed, +1295 −9
  • docs/site/cli.md +43 −0
  • history_cli.go +198 −0
  • history_cli_test.go +173 −0
  • internal/host/claimlaunch_production.go +1 −0
  • internal/host/claimlaunch_root.go +22 −2
  • internal/host/plan14_root_generations_test.go +321 −0
  • internal/host/rootgenerations_launch.go +167 −0
  • internal/provision/plan9_b8_census_test.go +1 −0
  • internal/provision/testdata/plan14-root-reads-e.tsv +29 −6
  • internal/release/plan14_root_generations_test.go +171 −0
  • internal/release/rootgenerations.go +166 −0
  • proof_cli.go +3 −1

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	345.564s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	3.169s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.199s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.704s
ok  	github.com/carlosframework/platform/internal/activator	233.952s
ok  	github.com/carlosframework/platform/internal/agentsock	0.015s
ok  	github.com/carlosframework/platform/internal/alerting	0.003s
ok  	github.com/carlosframework/platform/internal/analytics	0.018s
ok  	github.com/carlosframework/platform/internal/appconfig	2.389s
ok  	github.com/carlosframework/platform/internal/apperr	0.071s
ok  	github.com/carlosframework/platform/internal/applog	0.131s
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.805s
?   	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.005s
ok  	github.com/carlosframework/platform/internal/cgroup	0.720s
ok  	github.com/carlosframework/platform/internal/changelog	0.291s
ok  	github.com/carlosframework/platform/internal/clamd	0.055s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	363.766s
?   	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	5.572s
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.003s
ok  	github.com/carlosframework/platform/internal/docsite	0.005s
ok  	github.com/carlosframework/platform/internal/domains	2.141s
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.079s
?   	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.124s
ok  	github.com/carlosframework/platform/internal/gate	1.656s
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.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.157s
ok  	github.com/carlosframework/platform/internal/host	134.089s
ok  	github.com/carlosframework/platform/internal/hostactors	0.308s
ok  	github.com/carlosframework/platform/internal/hostbox	2.304s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.503s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.013s
ok  	github.com/carlosframework/platform/internal/hostexec	4.877s
ok  	github.com/carlosframework/platform/internal/hostfence	0.033s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.493s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.013s
ok  	github.com/carlosframework/platform/internal/hostguard	1.013s
ok  	github.com/carlosframework/platform/internal/hostops	1.465s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.012s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.012s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.462s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.160s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.014s
ok  	github.com/carlosframework/platform/internal/infraview	0.014s
ok  	github.com/carlosframework/platform/internal/instancectl	1.827s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.706s
ok  	github.com/carlosframework/platform/internal/instancefs	0.016s
ok  	github.com/carlosframework/platform/internal/instancekey	0.014s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.019s
ok  	github.com/carlosframework/platform/internal/layout	1.457s
ok  	github.com/carlosframework/platform/internal/lease	9.468s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.005s
ok  	github.com/carlosframework/platform/internal/ledger	0.011s
ok  	github.com/carlosframework/platform/internal/mail	0.177s
ok  	github.com/carlosframework/platform/internal/maintenance	0.079s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.528s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.075s
ok  	github.com/carlosframework/platform/internal/metrics	0.005s
ok  	github.com/carlosframework/platform/internal/moves	21.061s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.782s
ok  	github.com/carlosframework/platform/internal/objstore	0.177s
ok  	github.com/carlosframework/platform/internal/ops	0.160s
ok  	github.com/carlosframework/platform/internal/park	0.207s
ok  	github.com/carlosframework/platform/internal/privatefile	0.003s
ok  	github.com/carlosframework/platform/internal/provision	13.633s
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.033s
ok  	github.com/carlosframework/platform/internal/relay	7.241s
ok  	github.com/carlosframework/platform/internal/release	20.705s
ok  	github.com/carlosframework/platform/internal/rollout	3.727s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.004s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.012s
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.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.294s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.505s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.139s
ok  	github.com/carlosframework/platform/internal/status	0.039s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.130s
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.007s
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.006s
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.560s
=== 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.023s
=== RUN   TestT4StorageProbeMinio
2026/10/14 06:01:51 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DE500B4F737699, 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.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.023s
minio contract: PASS
diff --git a/docs/site/cli.md b/docs/site/cli.md
index e2bc11208..72df28d99 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -1882,6 +1882,49 @@ refused.
`get` and `put` work only with `CARLOS_DEPLOYMENT_BUCKET` or
`CARLOS_DEPLOYMENT_DIR` set, because a proof lives only in a bucket.
+#### carlos proof history
+
+Read or reconstruct a box's root release history. These operator commands use
+`CARLOS_DEPLOYMENT_BUCKET` (or `CARLOS_DEPLOYMENT_DIR` for a local store),
+`CARLOS_RELEASE_PUBKEY` and the deployment root signing key in
+`CARLOS_RELEASE_KEY`. Scoped signing keys cannot authorize this work.
+
+```sh
+carlos proof history status --bucket <bucket> --box <box> --enrollment <id> --account <sqid> --app <app> --host <host> --state status.json
+```
+
+The command saves its signed request before delivery, then waits up to 30
+seconds for a signed response from the enrolled box. The JSON response includes
+category completeness, revisions, pending reconstruction and retained recovery
+outcomes. A successful status read does not mean migration is complete.
+
+To reconstruct history, prepare a JSON `HistoryReconstruction` decision using
+the identity, revision, app revision, bake revision and host sources from that
+response. Set `schema` to `1`, `kind` to `carlos-release-history`, a unique
+`operation` and the current `issued_at` time. Name every affected host and its
+revision and refusals. Explicitly state the historical `production`,
+`production_set_at` and `holds` bounds; hold windows use nanoseconds. Review
+those decisions before signing. For an interrupted reconstruction, `replaces`
+must name the exact `pending_digest`. Root retains the pending decision's denies.
+
+```sh
+carlos proof history reconstruct --bucket <bucket> --box <box> --enrollment <id> --account <sqid> --app <app> --host <host> --input decision.json --state reconstruction.json
+carlos proof history resume --bucket <bucket> --state reconstruction.json
+```
+
+`reconstruct` succeeds only after the box confirms the exact signed decision.
+It does not retire independent recovery outcomes or establish a fresh install.
+`retire` takes the same flags with a `HistoryRecoveryRetirement` decision:
+`schema: 1`, `kind: carlos-release-recovery-retirement`, `identity`, `operation`,
+`issued_at`, `binding`, `source`, `attempt_id`, `outcome_digest`, `verified_id`
+and `verified_digest`. The witness must be a later root-verified success.
+Pending originals and unrelated outcomes remain restrictive.
+
+Use a new `--state` file for each decision. Keep it if the command times out or
+is interrupted. `resume` reuses the saved authorization and challenge without
+a signing key. It does not refresh their 24-hour validity. `--wait` changes the
+readback wait. A delivery acknowledgement alone never reports completion.
+
### carlos economics
Records one month's AWS bill so the console's economics dashboard has a real
diff --git a/history_cli.go b/history_cli.go
new file mode 100644
index 000000000..bff69965a
--- /dev/null
+++ b/history_cli.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "path/filepath"
+ "time"
+
+ "github.com/carlosframework/platform/internal/historyreadback"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+const historyUsage = "usage: carlos proof history <status|reconstruct|retire|resume> --bucket <bucket> --state <file> [--box <box> --enrollment <id> --account <sqid> --app <app> --host <host>] [--input <file>] [--wait <duration>]"
+const historyPending = "History readback is not complete. Resume with the same --state file. Last result: %v"
+
+type historyConnect func(string, bool) (hostactors.Directory, release.Signer, error)
+
+func runHistory(args []string) {
+ err := historyCommand(context.Background(), args, func(bucket string, resume bool) (hostactors.Directory, release.Signer, error) {
+ if configured := os.Getenv("CARLOS_DEPLOYMENT_BUCKET"); configured != "" && configured != bucket {
+ return hostactors.Directory{}, release.Signer{}, errors.New("--bucket must match CARLOS_DEPLOYMENT_BUCKET")
+ }
+ d := hostactors.Directory{Store: openStore(), PublicKey: publicKey(), Bucket: bucket}
+ if resume {
+ return d, release.Signer{}, nil
+ }
+ return d, releaseSigner(), nil
+ }, stdout)
+ if err != nil && !errors.Is(err, flag.ErrHelp) {
+ log.Fatal(err)
+ }
+}
+
+func historyCommand(ctx context.Context, args []string, connect historyConnect, out io.Writer) error {
+ return historyCommandWithSave(ctx, args, connect, out, historySave)
+}
+
+func historyCommandWithSave(ctx context.Context, args []string, connect historyConnect, out io.Writer, save func(string, historyreadback.Operation) error) error {
+ if len(args) == 0 {
+ return errors.New(historyUsage)
+ }
+ verb := args[0]
+ if verb != "status" && verb != "reconstruct" && verb != "retire" && verb != "resume" {
+ return errors.New(historyUsage)
+ }
+ fs := flag.NewFlagSet("proof history "+verb, flag.ContinueOnError)
+ fs.SetOutput(out)
+ bucket := fs.String("bucket", os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), "deployment bucket named in the signed box roster")
+ box := fs.String("box", "", "box named in the signed roster")
+ enrollment := fs.String("enrollment", "", "current box enrollment ID")
+ account := fs.String("account", "", "account sqid")
+ app := fs.String("app", "", "app name")
+ host := fs.String("host", "", "canonical instance hostname")
+ state := fs.String("state", "", "save the signed operation here before delivery; resume reuses this file")
+ input := fs.String("input", "", "JSON reconstruction or retirement decision to sign")
+ wait := fs.Duration("wait", 30*time.Second, "how long to wait for verified root readback")
+ if err := fs.Parse(args[1:]); err != nil {
+ return err
+ }
+ resume := verb == "resume"
+ scope := historyreadback.Scope{Bucket: *bucket, Box: *box, Enrollment: *enrollment, Binding: release.HistoryBinding{Account: *account, App: *app, Host: *host}}
+ if fs.NArg() != 0 || *bucket == "" || *state == "" || *wait <= 0 || (resume && (*input != "" || *box != "" || *enrollment != "" || *account != "" || *app != "" || *host != "")) || (!resume && (*box == "" || *enrollment == "" || *account == "" || *app == "" || *host == "")) || ((verb == "reconstruct" || verb == "retire") != (*input != "")) {
+ return errors.New(historyUsage)
+ }
+ ctx, cancel := context.WithTimeout(ctx, *wait)
+ defer cancel()
+ var op historyreadback.Operation
+ var reconstruction *release.HistoryReconstruction
+ var retirement *release.HistoryRecoveryRetirement
+ if resume {
+ if err := historyReadFile(*state, &op, true); err != nil {
+ return err
+ }
+ } else if verb == "reconstruct" {
+ reconstruction = &release.HistoryReconstruction{}
+ if err := historyReadFile(*input, reconstruction, false); err != nil {
+ return err
+ }
+ } else if verb == "retire" {
+ retirement = &release.HistoryRecoveryRetirement{}
+ if err := historyReadFile(*input, retirement, false); err != nil {
+ return err
+ }
+ }
+ d, signer, err := connect(*bucket, resume)
+ if err != nil {
+ return err
+ }
+ if !resume {
+ op, err = historyreadback.PrepareOperation(ctx, d, signer, scope, reconstruction, retirement, time.Now())
+ if err != nil {
+ return err
+ }
+ if err = save(*state, op); err != nil {
+ return err
+ }
+ }
+ if err = op.Deliver(ctx, d, time.Now()); err != nil {
+ return err
+ }
+ for {
+ receipt, readErr := op.Read(ctx, d, time.Now())
+ if readErr == nil {
+ enc := json.NewEncoder(out)
+ enc.SetIndent("", " ")
+ return enc.Encode(receipt)
+ }
+ timer := time.NewTimer(time.Second)
+ select {
+ case <-ctx.Done():
+ timer.Stop()
+ return fmt.Errorf(historyPending, readErr)
+ case <-timer.C:
+ }
+ }
+}
+
+func historyReadFile(path string, dst any, repair bool) error {
+ f, err := safefile.OpenFile(path, os.O_RDONLY, 0)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ raw, err := io.ReadAll(io.LimitReader(f, 3*release.MaxRootEnvelopeBytes+1))
+ if err != nil {
+ return err
+ }
+ if len(raw) > 3*release.MaxRootEnvelopeBytes {
+ return os.ErrPermission
+ }
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if err = dec.Decode(dst); err != nil {
+ return err
+ }
+ if err = dec.Decode(new(any)); err != io.EOF {
+ return os.ErrPermission
+ }
+ if repair {
+ return historySync(f, path)
+ }
+ return nil
+}
+
+func historySave(path string, op historyreadback.Operation) error {
+ raw, err := json.Marshal(op)
+ if err != nil {
+ return err
+ }
+ f, err := safefile.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ if n, err := f.Write(raw); err != nil {
+ return err
+ } else if n != len(raw) {
+ return io.ErrShortWrite
+ }
+ return historySync(f, path)
+}
+
+func historySync(f *os.File, path string) error {
+ if err := f.Sync(); err != nil {
+ return err
+ }
+ parent, err := safefile.OpenDir(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ defer parent.Close()
+ current, err := safefile.OpenFile(path, os.O_RDONLY, 0)
+ if err != nil {
+ return err
+ }
+ defer current.Close()
+ a, err := f.Stat()
+ if err != nil {
+ return err
+ }
+ b, err := current.Stat()
+ if err != nil {
+ return err
+ }
+ if !os.SameFile(a, b) {
+ return os.ErrPermission
+ }
+ return parent.Sync()
+}
diff --git a/history_cli_test.go b/history_cli_test.go
new file mode 100644
index 000000000..609d94d43
--- /dev/null
+++ b/history_cli_test.go
@@ -0,0 +1,173 @@
+package main
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/historyreadback"
+ "github.com/carlosframework/platform/internal/hostactors"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestPlan14HistoryCommandResume(t *testing.T) {
+ for _, boundary := range []string{"timeout", "save failure"} {
+ t.Run(boundary, func(t *testing.T) {
+ pub, key, _ := ed25519.GenerateKey(nil)
+ _, boxKey, _ := ed25519.GenerateKey(nil)
+ signer := release.RootSigner(key)
+ d := hostactors.Directory{Store: release.NewDirStore(t.TempDir()), PublicKey: pub, Bucket: "bucket"}
+ enrollment := strings.Repeat("a", 32)
+ roster := hostactors.Roster{V: 1, DeploymentBucket: "bucket", Revision: 1, Boxes: map[string]hostactors.Box{"box": {Enrollment: enrollment, Namespace: "platform", BoxID: "box", Label: "box", Region: "ie", Pool: "default", Cloud: hostactors.Cloud{Provider: "aws", Account: "123", Region: "eu-west-1", InstanceID: "i-box"}, EvidenceSHA256: strings.Repeat("b", 64), State: "active", CensusPublicKey: boxKey.Public().(ed25519.PublicKey)}}, Consoles: map[string]hostactors.Console{}, Holds: map[string]hostactors.Reservation{}, Activations: map[string]hostactors.Reservation{}}
+ wire, err := release.SignRootEnvelope(signer, roster)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = d.Store.Put(t.Context(), hostactors.Key, wire, ""); err != nil {
+ t.Fatal(err)
+ }
+ state := filepath.Join(t.TempDir(), "operation.json")
+ connect := func(bucket string, resume bool) (hostactors.Directory, release.Signer, error) {
+ if bucket != "bucket" {
+ t.Fatal(bucket)
+ }
+ if resume {
+ return d, release.Signer{}, nil
+ }
+ return d, signer, nil
+ }
+ args := []string{"status", "--bucket", "bucket", "--box", "box", "--enrollment", enrollment, "--account", "abc", "--app", "web", "--host", "web.example", "--state", state, "--wait", "1ms"}
+ var out bytes.Buffer
+ save := historySave
+ if boundary == "save failure" {
+ save = func(path string, op historyreadback.Operation) error {
+ if err := historySave(path, op); err != nil {
+ return err
+ }
+ return os.ErrPermission
+ }
+ }
+ if err := historyCommandWithSave(t.Context(), args, connect, &out, save); err == nil {
+ t.Fatal("missing receipt succeeded")
+ }
+ raw, err := os.ReadFile(state)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var op historyreadback.Operation
+ if err = json.Unmarshal(raw, &op); err != nil {
+ t.Fatal(err)
+ }
+ if boundary == "save failure" {
+ scope := historyreadback.Scope{Bucket: "bucket", Box: "box", Enrollment: enrollment, Binding: release.HistoryBinding{Account: "abc", App: "web", Host: "web.example"}}
+ if _, _, err := d.Store.Get(t.Context(), historyreadback.RequestKey(scope)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("published after save failure", err)
+ }
+ if err := op.Deliver(t.Context(), d, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ var q historyreadback.Request
+ if err = release.VerifyRootEnvelope(pub, op.Request, &q); err != nil {
+ t.Fatal(err)
+ }
+ status := release.HistoryMigrationStatus{Schema: 1, Account: "abc", App: "web", HistoryReconstructionStatus: release.HistoryReconstructionStatus{Identity: strings.Repeat("c", 64), Revision: 1}, Hosts: []release.HistoryHostStatus{{HistoryReconstructionHost: release.HistoryReconstructionHost{Host: "web.example", Source: "canonical"}}}}
+ if err = historyreadback.Respond(t.Context(), d, q.Scope, boxKey, time.Now(), func(context.Context) (release.HistoryMigrationStatus, error) { return status, nil }); err != nil {
+ t.Fatal(err)
+ }
+ out.Reset()
+ if err = historyCommand(t.Context(), []string{"resume", "--bucket", "bucket", "--state", state, "--wait", "1ms"}, connect, &out); err != nil {
+ t.Fatal(err)
+ }
+ var got historyreadback.Receipt
+ if err = json.Unmarshal(out.Bytes(), &got); err != nil {
+ t.Fatal(err)
+ }
+ if got.Status.AppComplete {
+ t.Fatal("invented migration completion")
+ }
+ again, _ := os.ReadFile(state)
+ if !bytes.Equal(raw, again) {
+ t.Fatal("resume changed saved operation")
+ }
+ if err = historyCommand(t.Context(), args, connect, &out); err == nil {
+ t.Fatal("overwrote existing operation")
+ }
+
+ })
+ }
+}
+
+func TestPlan14HistoryCommandInputRefusals(t *testing.T) {
+ for _, args := range [][]string{{}, {"wat"}, {"resume"}, {"status", "--state", "x", "--wait", "0s"}, {"resume", "--state", "x", "--input", "other"}, {"status", "--state", "x", "--unknown"}} {
+ connect := func(string, bool) (hostactors.Directory, release.Signer, error) {
+ t.Fatal("invalid arguments connected")
+ return hostactors.Directory{}, release.Signer{}, nil
+ }
+ if err := historyCommand(t.Context(), args, connect, &bytes.Buffer{}); err == nil {
+ t.Fatal("accepted", args)
+ }
+ }
+}
+
+func TestPlan14HistoryCLIDispatch(t *testing.T) {
+ bin := buildCarlos(t)
+ cmd := exec.CommandContext(t.Context(), bin, "proof", "history", "status", "--help")
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = t.TempDir()
+ out, err := cmd.CombinedOutput()
+ if err != nil || !bytes.Contains(out, []byte("save the signed operation here before delivery")) {
+ t.Fatalf("dispatch: %v\n%s", err, out)
+ }
+}
+
+func TestPlan14HistorySavedFiles(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "state.json")
+ op := historyreadback.Operation{Request: json.RawMessage(`{"saved":"request"}`)}
+ if err := historySave(path, op); err != nil {
+ t.Fatal(err)
+ }
+ before, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := historySave(path, historyreadback.Operation{Request: json.RawMessage(`{"other":"request"}`)}); err == nil {
+ t.Fatal("replaced saved operation")
+ }
+ var got historyreadback.Operation
+ if err := historyReadFile(path, &got, true); err != nil {
+ t.Fatal(err)
+ }
+ after, _ := os.ReadFile(path)
+ if !bytes.Equal(before, after) {
+ t.Fatal("repair changed state")
+ }
+ for _, raw := range []string{`{"request":{}`, `{"request":{},"unknown":true}`, `{"request":{}} {}`, strings.Repeat("x", 3*release.MaxRootEnvelopeBytes+1)} {
+ bad := filepath.Join(t.TempDir(), "bad.json")
+ if err := os.WriteFile(bad, []byte(raw), 0600); err != nil {
+ t.Fatal(err)
+ }
+ if err := historyReadFile(bad, &got, true); err == nil {
+ t.Fatal("accepted incomplete state")
+ }
+ }
+ if runtime.GOOS == "linux" {
+ alias := filepath.Join(dir, "alias")
+ if err := os.Symlink(path, alias); err != nil {
+ t.Fatal(err)
+ }
+ if err := historyReadFile(alias, &got, true); err == nil {
+ t.Fatal("followed state symlink")
+ }
+ }
+}
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go
index f661ca022..2ef8eee94 100644
--- a/internal/host/claimlaunch_production.go
+++ b/internal/host/claimlaunch_production.go
@@ -74,6 +74,7 @@ func WithProductionClaimLaunch(ctx context.Context, handler http.Handler) (http.
}
wrapped.Launch.Local = a.Local
wrapped.Launch.history = root.history
+ wrapped.Launch.generations = productionRootGenerations(a.Claims, a.ManagedRoots["opt"])
wrapped.Launch.installedProgram = wrapped.Launch.rootInstalledProgram
return wrapped, nil
}
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go
index 8870c832c..ee3dd2b21 100644
--- a/internal/host/claimlaunch_root.go
+++ b/internal/host/claimlaunch_root.go
@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"net/http"
+ "os"
"sort"
"strconv"
"time"
@@ -61,6 +62,7 @@ type ClaimLaunchObserved struct {
// A request only names which reservation to run.
type ClaimLaunchRoot struct {
history rootHistoryAccess
+ generations *rootGenerationSource
installedProgram func(context.Context, hostbox.RoleLaunchRequest, instancectl.ClaimUnitStarted, ClaimLaunchObserved) (string, error)
executionState rootstate.Store
ValidateExecutable func(host, path, digest string) error
@@ -163,6 +165,9 @@ func (h *ClaimLaunchRoot) launchRecord(ctx context.Context, peer StopPeer, t hos
func (h *ClaimLaunchRoot) start(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest) (hostbox.RoleLaunchGrant, error) {
if h.history == nil {
+ if h.generations != nil {
+ return hostbox.RoleLaunchGrant{}, os.ErrPermission
+ }
return h.startRecorded(ctx, peer, q, nil)
}
var out hostbox.RoleLaunchGrant
@@ -219,8 +224,17 @@ func (h *ClaimLaunchRoot) startRecorded(ctx context.Context, peer StopPeer, q ho
return err
}
}
+ var pin *generationLaunchPermit
+ if h.generations != nil {
+ var err error
+ pin, err = h.generations.reserve(ctx, h, peer, q)
+ if err != nil {
+ return err
+ }
+ defer pin.ledger.Store.Close()
+ }
var e error
- grant, e = h.launchLocked(ctx, peer, q, r)
+ grant, e = h.launchLocked(ctx, peer, q, r, pin)
if e == nil && history != nil {
started := instancectl.ClaimUnitStarted{Unit: grant.Unit, InvocationID: grant.UnitInvocationID, MainPID: grant.PID, ControlGroup: grant.Group.RelativePath}
observed, err := h.waitReady(ctx, started, r.Executable)
@@ -253,7 +267,7 @@ func (h *ClaimLaunchRoot) startRecorded(ctx context.Context, peer StopPeer, q ho
return grant, err
}
-func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest, r hostbox.LaunchReservation) (hostbox.RoleLaunchGrant, error) {
+func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hostbox.RoleLaunchRequest, r hostbox.LaunchReservation, pin *generationLaunchPermit) (hostbox.RoleLaunchGrant, error) {
t := r.Target
if err := h.launchRecord(ctx, peer, t); err != nil {
return hostbox.RoleLaunchGrant{}, err
@@ -330,6 +344,9 @@ func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hos
if err := h.authorize(ctx, peer, q, fresh.Claim); err != nil {
return err
}
+ if err := pin.check(); err != nil {
+ return err
+ }
_, boot, err := h.Clock.Now()
if err != nil {
return err
@@ -350,6 +367,9 @@ func (h *ClaimLaunchRoot) launchLocked(ctx context.Context, peer StopPeer, q hos
if err == nil {
err = h.Journal.StartedUnit(ctx, r, group, started.MainPID, observed.ProcessStart)
}
+ if err == nil {
+ err = pin.started(hostbox.RoleLaunchGrant{PID: started.MainPID, ProcessStart: observed.ProcessStart, Group: group, UnitInvocationID: started.InvocationID}, observed)
+ }
if err != nil {
// A replacement detected while waiting is never stopped by the old
// reservation. Settle only if root can prove the old invocation gone.
diff --git a/internal/host/plan14_root_generations_test.go b/internal/host/plan14_root_generations_test.go
new file mode 100644
index 000000000..877ea6dc4
--- /dev/null
+++ b/internal/host/plan14_root_generations_test.go
@@ -0,0 +1,321 @@
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "net"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/cgroup"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+ "github.com/carlosframework/platform/internal/safefile"
+)
+
+type generationDisk struct {
+ *rollbackDisk
+ fail int
+}
+
+func (s *generationDisk) Write(k string, b []byte) error {
+ if err := s.rollbackDisk.Write(k, b); err != nil {
+ return err
+ }
+ if s.fail > 0 {
+ s.fail--
+ return os.ErrPermission
+ }
+ return nil
+}
+
+func generationLaunchFixture(t *testing.T) (*launchFixture, *generationDisk, provision.Record) {
+ t.Helper()
+ f := newLaunchFixture(t, servingOp())
+ store := release.NewDirStore(t.TempDir())
+ rec := provision.Record{Host: f.target.Host, Account: "bab", App: "shop", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}
+ plan14PutRecord(t, store, rec)
+ inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ previous, err := f.journal.Read(t.Context(), f.target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.target.Incarnation = inc
+ f.req.Reservation.Target = f.target
+ previous.Target = f.target
+ previous.Groups[0].RelativePath = filepath.Join("system.slice/carlos-edge.service", cgroup.ExecName(claimContainmentName(f.target)))
+ if err := f.journal.Prelaunch(t.Context(), previous); err != nil {
+ t.Fatal(err)
+ }
+ f.claims.set(func(c *hostclaim.Claim) {
+ c.Holder.Incarnation = inc
+ c.Holder.Account = rec.Account
+ c.Holder.App = rec.App
+ c.Holder.Key = provision.RecordKey(rec.Account, rec.App, rec.Host)
+ c.Holder.Admissions = []hostclaim.AdmissionRecord{{Box: f.target.Box, Region: f.target.Region, Session: f.target.Session, Generation: c.Holder.Generation}}
+ })
+ f.root.Guard.Root = hostguard.NewRootAuthority(&rollbackDisk{dir: t.TempDir()})
+ in := hostguard.RootInstallation{Host: rec.Host, Account: rec.Account, App: rec.App, Record: provision.RecordKey(rec.Account, rec.App, rec.Host), Incarnation: inc, Stamp: f.target.Stamp, Box: f.target.Box, Enrollment: f.target.Enrollment, Migration: 1, InventoryRevision: 1}
+ if err := f.root.Guard.EstablishRoot(t.Context(), in, func(*hostclaim.Claim) error { return nil }); err != nil {
+ t.Fatal(err)
+ }
+ history := &release.RootHistory{Store: &rollbackDisk{dir: t.TempDir()}}
+ if err := history.RecordCreation(release.HistoryBinding{Host: rec.Host, Account: rec.Account, App: rec.App}, rootHistoryInstallation(in)); err != nil {
+ t.Fatal(err)
+ }
+ f.root.history = func(ctx context.Context, fn func(*release.RootHistory) error) error { return fn(history) }
+ f.root.installedProgram = func(context.Context, hostbox.RoleLaunchRequest, instancectl.ClaimUnitStarted, ClaimLaunchObserved) (string, error) {
+ return "observed/program", nil
+ }
+ opt := t.TempDir()
+ body := []byte("the signed build")
+ if err := release.StageArtifact(opt, rec.Account, "v1", rec.App, body); err != nil {
+ t.Fatal(err)
+ }
+ path := release.VersionPath(opt, rec.Account, "v1", rec.App)
+ file, err := os.Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := rootstate.Identity(file)
+ file.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(body)
+ f.kernel.path = path
+ f.kernel.digest = hex.EncodeToString(sum[:])
+ f.kernel.want = hostbox.LaunchFile{Device: id.Device, Inode: id.Inode}
+ f.kernel.observed.Executable = f.kernel.want
+ f.kernel.observed.Running = f.kernel.want
+ f.req.Executable = path
+ f.req.Reservation.Executable = f.kernel.want
+ f.req.Spec.Executable = path
+ f.req.Spec.Digest = f.kernel.digest
+ digest, err := f.req.Spec.Hash()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.req.Reservation.Inputs = digest
+ f.claims.set(func(c *hostclaim.Claim) {
+ a := *c.Holder.WakeRetry.Attempt
+ a.LaunchInputs = digest
+ r := *c.Holder.WakeRetry
+ r.Attempt = &a
+ c.Holder.WakeRetry = &r
+ })
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ m := release.Manifest{App: rec.App, Version: "v1", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}}
+ if err := release.Ship(t.Context(), store, rec.Account, m, map[string][]byte{release.AdoptPlatform(): body}); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.Promote(t.Context(), store, priv, rec.Account, rec.App, "canary/console", "v1"); err != nil {
+ t.Fatal(err)
+ }
+ disk := &generationDisk{rollbackDisk: &rollbackDisk{dir: t.TempDir()}}
+ f.root.generations = &rootGenerationSource{store: store, optDir: opt, pub: func() (ed25519.PublicKey, error) { return pub, nil }, open: func() (rootstate.Store, error) { return disk, nil }, openFile: func(p string) (*os.File, error) { return safefile.OpenFile(p, os.O_RDONLY, 0) }}
+ return f, disk, rec
+}
+
+func readGenerationLaunch(t *testing.T, d *generationDisk) release.GenerationReservation {
+ t.Helper()
+ names, err := d.Names()
+ if err != nil || len(names) != 1 {
+ t.Fatalf("reservations: %v %v", names, err)
+ }
+ raw, err := d.Read(names[0])
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r release.GenerationReservation
+ if err := json.Unmarshal(raw, &r); err != nil {
+ t.Fatal(err)
+ }
+ return r
+}
+
+func TestPlan14GenerationReservedBeforeClaimSpawn(t *testing.T) {
+ f, d, _ := generationLaunchFixture(t)
+ checked := false
+ f.units.beforeCheck = func() {
+ r := readGenerationLaunch(t, d)
+ if r.ID == "" || r.Binding.Path != f.req.Executable || r.Binding.Digest != f.req.Spec.Digest || r.Binding.Binding.Account != "bab" || r.Binding.Binding.App != "shop" || r.Process.PID != 0 {
+ t.Fatalf("pre-spawn record: %+v", r)
+ }
+ checked = true
+ }
+ if _, err := f.root.Serve(t.Context(), f.peer, f.req); err != nil {
+ t.Fatal(err)
+ }
+ if !checked {
+ t.Fatal("spawn boundary was not checked")
+ }
+ r := readGenerationLaunch(t, d)
+ if r.Process.PID != f.units.started.MainPID || r.Process.TenantPID != f.kernel.observed.TenantPID || r.Process.GroupIdentity.Inode != f.kernel.observed.Group.Inode {
+ t.Fatalf("missing process evidence: %+v", r)
+ }
+ if err := f.root.stop(t.Context(), f.peer, *f.req.Reservation); err != nil {
+ t.Fatal(err)
+ }
+ if after := readGenerationLaunch(t, d); after != r {
+ t.Fatal("unit settlement retired the generation pin")
+ }
+}
+
+func TestPlan14GenerationLaunchRefusals(t *testing.T) {
+ for _, attack := range []string{"open", "sync", "no-lock", "unsigned", "wrong-account", "wrong-digest", "tree-replaced", "record-changed", "reservation-erased"} {
+ t.Run(attack, func(t *testing.T) {
+ f, d, rec := generationLaunchFixture(t)
+ switch attack {
+ case "open":
+ f.root.generations.open = func() (rootstate.Store, error) { return nil, os.ErrPermission }
+ case "sync":
+ d.fail = 1
+ case "no-lock":
+ f.root.history = nil
+ case "unsigned":
+ f.root.generations.pub = func() (ed25519.PublicKey, error) { p, _, e := ed25519.GenerateKey(nil); return p, e }
+ case "wrong-account":
+ rec.Account = "bap"
+ plan14PutRecord(t, f.root.generations.store, rec)
+ if err := f.root.generations.store.Delete(t.Context(), provision.RecordKey("bab", rec.App, rec.Host)); err != nil {
+ t.Fatal(err)
+ }
+ case "wrong-digest":
+ if err := os.WriteFile(f.req.Executable, []byte("replaced"), 0755); err != nil {
+ t.Fatal(err)
+ }
+ case "tree-replaced":
+ f.units.beforeCheck = func() {
+ dir := filepath.Dir(f.req.Executable)
+ if err := os.Rename(dir, dir+".old"); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Mkdir(dir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Rename(filepath.Join(dir+".old", filepath.Base(f.req.Executable)), f.req.Executable); err != nil {
+ t.Fatal(err)
+ }
+ }
+ case "record-changed":
+ f.units.beforeCheck = func() { rec.Environment = "changed"; plan14PutRecord(t, f.root.generations.store, rec) }
+ case "reservation-erased":
+ f.units.beforeCheck = func() {
+ names, err := d.Names()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, n := range names {
+ if err := d.Remove(n); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ }
+ if _, err := f.root.Serve(t.Context(), f.peer, f.req); err == nil {
+ t.Fatal("unreserved launch succeeded")
+ }
+ if n, _ := f.units.counts(); n != 0 {
+ t.Fatalf("refused launch started %d units", n)
+ }
+ })
+ }
+}
+
+func TestPlan14GenerationStartedSyncFailureRetainsPin(t *testing.T) {
+ f, d, _ := generationLaunchFixture(t)
+ f.kernel.observe = func(instancectl.ClaimUnitStarted) (ClaimLaunchObserved, error) {
+ d.fail = 1
+ return f.kernel.observed, nil
+ }
+ if _, err := f.root.Serve(t.Context(), f.peer, f.req); err == nil {
+ t.Fatal("unsynced launch reported success")
+ }
+ if n, stops := f.units.counts(); n != 1 || stops != 1 {
+ t.Fatalf("starts=%d stops=%d", n, stops)
+ }
+ r := readGenerationLaunch(t, d)
+ if r.ID == "" || r.Binding.Path != f.req.Executable {
+ t.Fatal("lost failed launch pin")
+ }
+ d.fail = 0
+ if _, err := (&release.RootGenerations{Store: d}).Read(r.Binding); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestPlan14GenerationReservationProcessCrash(t *testing.T) {
+ if boundary := os.Getenv("PLAN14_GENERATION_CRASH"); boundary != "" {
+ f, d, _ := generationLaunchFixture(t)
+ d.dir = os.Getenv("PLAN14_GENERATION_DIR")
+ d.afterWrite = func(_ string, raw []byte) {
+ var r release.GenerationReservation
+ if err := json.Unmarshal(raw, &r); err != nil {
+ t.Fatal(err)
+ }
+ if boundary == "reserved" && r.Revision == 1 || boundary == "observed" && r.Revision == 2 {
+ os.Exit(73)
+ }
+ }
+ if boundary == "before-spawn" {
+ f.units.beforeCheck = func() { os.Exit(73) }
+ }
+ _, err := f.root.Serve(t.Context(), f.peer, f.req)
+ t.Fatal("crash boundary not reached", err)
+ }
+ for _, boundary := range []string{"reserved", "before-spawn", "observed"} {
+ t.Run(boundary, func(t *testing.T) {
+ dir := t.TempDir()
+ cmd := exec.Command(os.Args[0], "-test.run=^TestPlan14GenerationReservationProcessCrash$", "-test.count=1")
+ cmd.Env = append(os.Environ(), "PLAN14_GENERATION_CRASH="+boundary, "PLAN14_GENERATION_DIR="+dir)
+ out, err := cmd.CombinedOutput()
+ var exit *exec.ExitError
+ if !errors.As(err, &exit) || exit.ExitCode() != 73 {
+ t.Fatalf("child: %v %s", err, out)
+ }
+ d := &generationDisk{rollbackDisk: &rollbackDisk{dir: dir}}
+ r := readGenerationLaunch(t, d)
+ got, err := (&release.RootGenerations{Store: d}).Read(r.Binding)
+ if err != nil || got != r || r.ID == "" || r.Binding.Executable.Inode == 0 || r.Binding.TreeIdentity.Inode == 0 {
+ t.Fatalf("crash lost pin: %+v %v", got, err)
+ }
+ if (r.Process.PID > 0) != (boundary == "observed") {
+ t.Fatalf("wrong process evidence: %+v", r)
+ }
+ })
+ }
+}
+
+func TestPlan14GenerationProductionWiring(t *testing.T) {
+ f, _, _ := generationLaunchFixture(t)
+ root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }, Coordinator: ClaimStopCoordinator{Kernel: stopKernelFunc(nil), Guard: f.root.Guard}}}
+ assembly := &ClaimAssembly{Claims: f.root.generations.store, ManagedRoots: map[string]string{"opt": f.root.generations.optDir, "data": "/data"}, Local: f.root.Local}
+ ctx := context.WithValue(t.Context(), claimEntryKey{}, claimEntry{Enabled: true, FlagRead: true, Assembly: assembly})
+ handler, err := WithProductionClaimLaunch(ctx, root)
+ if err != nil {
+ t.Fatal(err)
+ }
+ launch := handler.(ClaimRootWithLaunch).Launch
+ if launch.generations == nil || launch.generations.open == nil || launch.generations.openFile == nil || launch.generations.pub == nil || launch.generations.store != assembly.Claims || launch.history == nil {
+ t.Fatal("production launch lacks a mandatory root reservation")
+ }
+}
diff --git a/internal/host/rootgenerations_launch.go b/internal/host/rootgenerations_launch.go
new file mode 100644
index 000000000..3750ee2ec
--- /dev/null
+++ b/internal/host/rootgenerations_launch.go
@@ -0,0 +1,167 @@
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+type rootGenerationSource struct {
+ store release.Store
+ optDir string
+ pub func() (ed25519.PublicKey, error)
+ open func() (rootstate.Store, error)
+ openFile func(string) (*os.File, error)
+}
+
+func productionRootGenerations(store release.Store, optDir string) *rootGenerationSource {
+ return &rootGenerationSource{store: store, optDir: optDir,
+ pub: func() (ed25519.PublicKey, error) {
+ return release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ },
+ open: func() (rootstate.Store, error) { return rootstate.Open(release.RootGenerationsDir, true, false) },
+ openFile: rootstate.OpenTrustedFile}
+}
+
+// resolve runs under release and host exclusion. The request nominates a build;
+// root binds it to its installation, the deployment record and signed manifest.
+func (s *rootGenerationSource) resolve(ctx context.Context, h *ClaimLaunchRoot, peer StopPeer, q hostbox.RoleLaunchRequest) (release.GenerationBinding, error) {
+ var b release.GenerationBinding
+ if s == nil || s.store == nil || s.pub == nil || s.openFile == nil || h.Guard.Root == nil || q.Reservation == nil || q.Spec == nil {
+ return b, os.ErrPermission
+ }
+ t := q.Reservation.Target
+ v, err := h.Guard.Root.View(ctx, t.Host, hostguard.Captured{Incarnation: t.Incarnation, Stamp: t.Stamp})
+ if err != nil {
+ return b, err
+ }
+ in := v.Installation
+ if in.Host != t.Host || in.Incarnation != t.Incarnation || in.Stamp != t.Stamp || in.Box != t.Box || in.Enrollment != t.Enrollment || in.InventoryRevision == 0 || in.Record != provision.RecordKey(in.Account, in.App, in.Host) {
+ return b, os.ErrPermission
+ }
+ rec, etag, err := provision.GetRecordETag(ctx, s.store, in.Account, in.App, in.Host)
+ if err != nil {
+ return b, err
+ }
+ inc, err := hostclaim.Incarnation(rec.Account, rec.App, rec.Host, rec.CreatedAt)
+ if err != nil || inc != in.Incarnation || rec.Account != in.Account || rec.App != in.App || rec.Host != in.Host || etag == "" {
+ return b, os.ErrPermission
+ }
+ rel, err := filepath.Rel(filepath.Join(s.optDir, "versions"), q.Executable)
+ if err != nil || !filepath.IsLocal(rel) {
+ return b, os.ErrPermission
+ }
+ version, _, ok := strings.Cut(rel, string(filepath.Separator))
+ if !ok || !release.ValidVersion(version) || q.Executable != release.VersionPath(s.optDir, in.Account, version, in.App) {
+ return b, os.ErrPermission
+ }
+ pub, err := s.pub()
+ if err != nil {
+ return b, err
+ }
+ m, err := release.AuthorizedManifest(ctx, s.store, pub, in.Account, in.App, version)
+ if err != nil {
+ return b, err
+ }
+ if m.Kind != "binary" || m.Artifacts[release.AdoptPlatform()].SHA256 != q.Spec.Digest {
+ return b, os.ErrPermission
+ }
+ f, err := s.openFile(q.Executable)
+ if err != nil {
+ return b, err
+ }
+ defer f.Close()
+ id, err := rootstate.Identity(f)
+ if err != nil {
+ return b, err
+ }
+ if id.Device != q.Reservation.Executable.Device || id.Inode != q.Reservation.Executable.Inode {
+ return b, os.ErrPermission
+ }
+ hash := sha256.New()
+ if _, err := io.Copy(hash, f); err != nil {
+ return b, err
+ }
+ if hex.EncodeToString(hash.Sum(nil)) != q.Spec.Digest {
+ return b, os.ErrPermission
+ }
+ // OpenTrustedFile already checked this ancestry; release exclusion prevents
+ // another root release writer replacing it while the identities are captured.
+ tree := filepath.Dir(q.Executable)
+ d, err := os.Open(tree)
+ if err != nil {
+ return b, err
+ }
+ defer d.Close()
+ treeID, err := rootstate.Identity(d)
+ if err != nil {
+ return b, err
+ }
+ b = release.GenerationBinding{Binding: release.HistoryBinding{Host: in.Host, Account: in.Account, App: in.App},
+ Target: release.GenerationOwner{Host: t.Host, Incarnation: t.Incarnation, Stamp: t.Stamp, Session: t.Session, Box: t.Box, Enrollment: t.Enrollment, BootID: peer.BootID, ControllerID: peer.ControllerID},
+ Installation: rootHistoryInstallation(in), Record: in.Record, RecordETag: etag,
+ Attempt: q.Reservation.ID, Inputs: q.Reservation.Inputs, Invocation: q.Reservation.InvocationID, ControllerPID: peer.PID, ControllerStart: peer.ProcessStart,
+ Version: version, Digest: q.Spec.Digest, Path: q.Executable, Tree: tree, Executable: id, TreeIdentity: treeID}
+ return b, nil
+}
+
+type generationLaunchPermit struct {
+ ledger *release.RootGenerations
+ reservation release.GenerationReservation
+ checkBinding func() (release.GenerationBinding, error)
+}
+
+func (s *rootGenerationSource) reserve(ctx context.Context, h *ClaimLaunchRoot, peer StopPeer, q hostbox.RoleLaunchRequest) (*generationLaunchPermit, error) {
+ if s == nil || s.open == nil {
+ return nil, os.ErrPermission
+ }
+ b, err := s.resolve(ctx, h, peer, q)
+ if err != nil {
+ return nil, err
+ }
+ store, err := s.open()
+ if err != nil {
+ return nil, err
+ }
+ g := &release.RootGenerations{Store: store}
+ r, err := g.Reserve(b)
+ if err != nil {
+ store.Close()
+ return nil, err
+ }
+ return &generationLaunchPermit{ledger: g, reservation: r, checkBinding: func() (release.GenerationBinding, error) { return s.resolve(ctx, h, peer, q) }}, nil
+}
+
+func (p *generationLaunchPermit) check() error {
+ if p == nil {
+ return nil
+ }
+ b, err := p.checkBinding()
+ if err != nil {
+ return err
+ }
+ if b != p.reservation.Binding {
+ return os.ErrPermission
+ }
+ return p.ledger.Check(p.reservation)
+}
+
+func (p *generationLaunchPermit) started(grant hostbox.RoleLaunchGrant, observed ClaimLaunchObserved) error {
+ if p == nil {
+ return nil
+ }
+ return p.ledger.Started(p.reservation, release.GenerationProcess{PID: grant.PID, Start: grant.ProcessStart, TenantPID: observed.TenantPID, TenantStart: observed.TenantStart,
+ Group: grant.Group.RelativePath, Invocation: grant.UnitInvocationID, GroupIdentity: rootstate.FileIdentity{Device: observed.Group.Device, Inode: observed.Group.Inode}})
+}
diff --git a/internal/provision/plan9_b8_census_test.go b/internal/provision/plan9_b8_census_test.go
index e09e62dc7..5d55256e0 100644
--- a/internal/provision/plan9_b8_census_test.go
+++ b/internal/provision/plan9_b8_census_test.go
@@ -473,6 +473,7 @@ func Reviewed(ctx context.Context, s Store, k string) error {
// BEGIN REVIEWED INVENTORY
var plan9B8CensusInventory = []censusEntry{
+ {"internal/host/rootgenerations_launch.go", "rootGenerationSource.resolve", "key-site", "key:RecordKey", "1d66e9ffa6aca3ea4a2d370c04f782b01c8a15d9ae6ca93a9356632d2b032977"},
{"internal/host/rootinventory.go", "ClaimRootHTTP.recoverRootInventory", "key-site", "key:RecordKey", "10d09b5ab6d07d316437233c9a65bf0bcdfe1083db4d0769fa8303b255dfd0ac"},
{"internal/host/proofnamed.go", "proofBindingSource.record", "key-site", "key:RecordKey", "10a99ff4b355907831a904010b7019d480ce93f2f595e99e076d3c702d10ca2b"},
{"internal/host/rootrecovery_rebind.go", "type recoveryRebindRequest", "key-site", "key:RecordKey", "b5db3e4dcd4807edea1d053eb1787fbf222be97d3f892e2ab66771457a51dc3d"},
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index 9123f2813..b49cfde76 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -54,8 +54,8 @@ 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 602c4df9eb0b2143825502d87198495d19f4a4bfcc378a925b7816d474507560
-@coverage internal/release a69c59eb1ff514d78b138292d8fe617887993c0be43bd9132394a95243ac8d3c
+@coverage internal/host aa5107fd5f62f16db48f9f788c22c2df3279b5ba0740b8d3978bd1593ff42f92
+@coverage internal/release b3659b929285d525c77f4e298fc1eb588d696b37f16c5dd3a77e231012bc29ce
@coverage internal/rootstate 6338f74a6b2059cf77cf478bd6111b37b441eb9d65a309464c378359d4bdac05
internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154
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
@@ -159,10 +159,6 @@ internal/host/rootrestart_requests_source.go rootRestartRequestSource.inventory
internal/host/rootrestart_requests_source.go rootRestartRequestSource.target authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. registry:Unit@1211,whole:Route@891 301b62efbf0281f713cbed7fb15cd244b7d2c479882f4ad930c8d482b51177ff
internal/host/rootrestart_requests_source.go type rootRestartRequestSource authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. db7ff5c46b6db3fe88657fddf8b4e17324b896fce9b21042d7f0f63140c1d776
internal/release/rootrestart_history.go RootRestarts.Bindings authority E1 E15: census-only root unit-restart consumption uses retained installations, canonical records, exact requests, invocation evidence and admission after durable intent under release exclusion. No-census effects use the original consumer. Fixtures: TestPlan14RootRestartProductionConsumption, TestPlan14RootRestartProductionHeldRollover, TestPlan14RootRestartProductionAdmissionAfterIntent, TestPlan14RootRestartProductionUnknown, TestPlan14RootRestartProductionSyncFailure, TestPlan14RootRestartProductionCrash, TestPlan14RootRestartRequestCensusGate. file:Read@324 d22b7e6b5710ca0025639395101933bec5ce0bd38768ebcf44a0805f1f159b08
-internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. eddb5f39afb0a4a7e94f8063955159d105f38574e600f15fda8d39c6837183a0
-internal/host/claimlaunch_root.go ClaimLaunchRoot.start authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. fe4bea8c5fff3656ca8e1c42eec41e0d2eedd3a4a3b2f00e1203740cb8ebc4f7
-internal/host/claimlaunch_root.go ClaimLaunchRoot.startRecorded authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. file:Read@2599 1cdb9e6359ed4994f2a7ebd12390f84f3f85c944e2c003cc6911ceae93f201d9
-internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. b01a2842411ca0ef5d5b0d5b189dc6300e1ac8c62e9a5b907a1afe79ca2e66c7
internal/host/claimretire_transport.go type ClaimRootHTTP authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. e87bb168975b9a7c4334de1c1fc027c556d69597122a6134770e770c719a45be
internal/host/claimunit.go claimUnitContent authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. 38dcfeb754659895bc28b024bc3186c6b12edeb17d087546507fa1299eb8e331
internal/host/roothistory_creation.go productionCreationHistory authority E1 E16: root first-registration creation evidence under release exclusion; exact installation identity is required for first-program adoption and is consumed before an attempt. Installed-program history comes from the verified claim launch. Fixtures: TestPlan14FreshInstallRegistration, TestPlan14FreshInstallProgramGate, TestPlan14FreshInstallCanonicalIdentity, TestPlan14FreshInstallLaunchObservation, TestPlan14InstalledLaunchProgramEvidence, TestPlan14FreshInstallProcessCrash, TestPlan14FreshInstallVisibleSync, TestClaimControlUnitInstallers. file:Open@295,file:Open@374 b4f4f82422cf0b1d874be7a0d9ee4a2032a4704b022bf73b8264097c2a53705c
@@ -188,3 +184,30 @@ proof_cli.go runProof authority E1 E17: operator saves exact signed authorizatio
internal/historyreadback/readback.go RequestStatus authority E1 E17: operator saves exact signed authorization and challenge before delivery, resumes without renewal, and verifies enrolled-root exact completion. Existing proof branches are unchanged. Tests: TestPlan14HistoryOperatorWorkflow, TestPlan14HistoryOperatorConflicts, TestPlan14HistoryOperatorExactCompletion, TestPlan14HistoryWorkflowProcessCrash, TestPlan14HistoryCommandResume, TestPlan14HistorySavedFiles, TestPlan14HistoryCLIDispatch. d89052b0cdd12c8c8dcbc353e9ae995005caeb88d8e4c70974cb9e6f4bfd46d7
@coverage . 85921915751b89a3baf64b24fefeb4c036fa056819801d4d3950629a9616d605
@coverage internal/historyreadback eaca4d4caacf499db8a4c4e8044d565eec5bb42e0fc765b66f6c7559fbb04842
+internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 183f783e1998ffbe49fec813501dfcb9238ffae3b8a09dc545cf88d1b1609dd0
+internal/host/claimlaunch_root.go ClaimLaunchRoot.launchLocked authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@2340,file:Read@2499,file:Read@707 ab5d6ab6c237ed569904210a69ce0f2bdef564bbd8ff1a65f427c503f52abb48
+internal/host/claimlaunch_root.go ClaimLaunchRoot.start authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 78796fbb8f2516f96d1de84cfdd97f294cbcbd21ee63e8b47aa4e2fc2bad3980
+internal/host/claimlaunch_root.go ClaimLaunchRoot.startRecorded authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@2813 f24033673d1d9b8c36304c204a271a7729e94b0e61620f3df6ea1f80537da4c2
+internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 14f50101580ce4b094e0a321db3db200994da9c6708745c2f7b3a8fd3409adfd
+internal/host/rootgenerations_launch.go generationLaunchPermit.check authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 788e600dd37fec60c5b98dc42b23c182c337df064088a041e2e20b68573942b5
+internal/host/rootgenerations_launch.go generationLaunchPermit.started authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 8f8b0ab8295f39fd54dd8a7d78ef2653eb7bc3b2ac581c47f8657ed59de84326
+internal/host/rootgenerations_launch.go productionRootGenerations authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Open@346 a1132403ace7213de9e12720d5a393a3bb85045d1d2c53f0c37ea2ae8e933b04
+internal/host/rootgenerations_launch.go rootGenerationSource.reserve authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. c4269e65bcac751cd1a51a439e48eda77b055320554b3489d605fea47fa7f3a8
+internal/host/rootgenerations_launch.go rootGenerationSource.resolve authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Open@2514 1d66e9ffa6aca3ea4a2d370c04f782b01c8a15d9ae6ca93a9356632d2b032977
+internal/host/rootgenerations_launch.go type generationLaunchPermit authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 52b30a79d7ced7875f435eff76d3c924ff4e460d3a2360cda5a9f12c407a2442
+internal/host/rootgenerations_launch.go type rootGenerationSource authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 59a336027ef7750d36f1a36a27cf3d0449395493c05789291c4ed9bc16724e5f
+internal/release/rootgenerations.go GenerationBinding.valid authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 636d5eb63f80ec3f3f2b87b859c96ce09700948ea3f3a5d58f04e5b1cf5ac3d3
+internal/release/rootgenerations.go GenerationProcess.valid authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 6032100296677f5397c44226c3158eb75f336f0317af6994c4f3ebbfb2894861
+internal/release/rootgenerations.go RootGenerations.Check authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@78 5bb55d8607353739eb9a8c96a7795ba0f9a0bcf6fc59974657620a313e26ce01
+internal/release/rootgenerations.go RootGenerations.Read authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@206,file:Read@26 d77a3e6d7f8bdc2c16973910b3089aa7fb7c3c40b2d0b94f2ca7cdf32f9463b6
+internal/release/rootgenerations.go RootGenerations.Reserve authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@397,file:Read@99 be082a4f3529a2dffc21dd5110db36581e0e1e4853ec63c6176a877bfbfaefae
+internal/release/rootgenerations.go RootGenerations.Started authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Read@147 c1e96eedb87a4baf5e87e65cf0b63511e7e4cc1b8a71d882cd2cd2a880c370b8
+internal/release/rootgenerations.go RootGenerations.write authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 72f8b4aa9f0487ee8b26843b5cbdc76aa5447d60535766cd4e4ce32f4baa5a2a
+internal/release/rootgenerations.go const RootGenerationsDir authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. e235f24f29b1f8511349cae97fbbc3f3472b49fdc6781c781d5b6f93ff3d45ff
+internal/release/rootgenerations.go generationKey authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. cdd7e0d8cdff508772ab54e421a4ab914dbb15ea5d97847f35a93544f0a9259a
+internal/release/rootgenerations.go type GenerationBinding authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 9bd76e1f6ef4d809df1a59719a1880215c910b0cab78449ad8cd79a678da401a
+internal/release/rootgenerations.go type GenerationOwner authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 6a6c3576622656c2bc4b65075c8f5d70b29968203101a1916926adc48b20bce5
+internal/release/rootgenerations.go type GenerationProcess authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. eeaafe23c23e4c95e58c45797065baa9ca6c784fab593e361e0d647ddaaa2957
+internal/release/rootgenerations.go type GenerationReservation authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 4d1b20a5f99117b6bc2e16c6a60033496f99796c47e682ab19bbddbdb6cc35e4
+internal/release/rootgenerations.go type RootGenerations authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 240860014efc91f51ca7e3e920ffb5cfa98076340dc0c9e9972c35051e5eca84
+internal/release/rootgenerations.go validGeneration authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 7fa482be0515f5b94acd6fe5e3072b2a45e89c043961e30ab764d0f719b9a7cd
diff --git a/internal/release/plan14_root_generations_test.go b/internal/release/plan14_root_generations_test.go
new file mode 100644
index 000000000..955cf2374
--- /dev/null
+++ b/internal/release/plan14_root_generations_test.go
@@ -0,0 +1,171 @@
+package release
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func generationFixture(t *testing.T) (*RootGenerations, *healthyRetryStore, GenerationBinding) {
+ t.Helper()
+ s := &healthyRetryStore{dir: t.TempDir(), fault: errors.New("directory sync failed")}
+ b := GenerationBinding{Binding: HistoryBinding{Host: "shop.example", Account: "bab", App: "shop"},
+ Installation: "installation", Record: "record", RecordETag: "etag", Attempt: "attempt:main", Inputs: "inputs", Invocation: "invocation",
+ Target: GenerationOwner{Host: "shop.example", Incarnation: "inc", Stamp: 1, Session: 2, Box: "box", Enrollment: "enrollment", BootID: "boot", ControllerID: "controller"},
+ ControllerPID: 42, ControllerStart: "start", Version: "v1", Digest: strings.Repeat("a", 64),
+ Path: "/opt/carlos/versions/v1/bab/shop", Tree: "/opt/carlos/versions/v1/bab",
+ Executable: rootstate.FileIdentity{Device: 1, Inode: 2}, TreeIdentity: rootstate.FileIdentity{Device: 1, Inode: 3}}
+ return &RootGenerations{Store: s}, s, b
+}
+
+func TestPlan14GenerationReservationDurability(t *testing.T) {
+ g, s, b := generationFixture(t)
+ s.fail = 1
+ if _, err := g.Reserve(b); err == nil {
+ t.Fatal("directory sync failure granted a reservation")
+ }
+ raw, err := s.Read(generationKey(b))
+ if err != nil {
+ t.Fatal(err)
+ }
+ s.fail = 1
+ if _, err := g.Reserve(b); err == nil {
+ t.Fatal("visible reservation bypassed directory sync retry")
+ }
+ r, err := g.Reserve(b)
+ if err != nil || r.ID == "" || r.Binding != b || r.Revision != 1 {
+ t.Fatalf("reserve: %+v %v", r, err)
+ }
+ after, err := s.Read(generationKey(b))
+ if err != nil || string(raw) != string(after) {
+ t.Fatalf("retry changed reservation identity: %v", err)
+ }
+ for _, attack := range []string{"empty", "missing", "rename"} {
+ t.Run(attack, func(t *testing.T) {
+ dir := t.TempDir()
+ path := GenerationsPath(dir)
+ if err := WriteGenerations(path, Generations{Hosts: map[string][]string{b.Binding.Host: {b.Version}}}); err != nil {
+ t.Fatal(err)
+ }
+ switch attack {
+ case "empty":
+ if err := WriteGenerations(path, Generations{}); err != nil {
+ t.Fatal(err)
+ }
+ case "missing":
+ if err := os.Remove(path); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Remove(GenerationsSeqPath(path)); err != nil {
+ t.Fatal(err)
+ }
+ case "rename":
+ if err := os.Rename(filepath.Dir(path), filepath.Dir(path)+".old"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ got, err := g.Read(b)
+ if err != nil || got != r {
+ t.Fatalf("legacy edit changed root reservation: %+v %v", got, err)
+ }
+ })
+ }
+}
+
+func TestPlan14GenerationReservationConflicts(t *testing.T) {
+ g, _, b := generationFixture(t)
+ r, err := g.Reserve(b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for name, change := range map[string]func(*GenerationBinding){
+ "account": func(b *GenerationBinding) { b.Binding.Account = "bap" },
+ "app": func(b *GenerationBinding) { b.Binding.App = "other" },
+ "version": func(b *GenerationBinding) { b.Version = "v2" },
+ "digest": func(b *GenerationBinding) { b.Digest = strings.Repeat("b", 64) },
+ "inode": func(b *GenerationBinding) { b.Executable.Inode++ },
+ "tree": func(b *GenerationBinding) { b.TreeIdentity.Inode++ },
+ "installation": func(b *GenerationBinding) { b.Installation = "replacement" },
+ "process": func(b *GenerationBinding) { b.ControllerStart = "replacement" },
+ "inputs": func(b *GenerationBinding) { b.Inputs = "replacement" },
+ } {
+ t.Run(name, func(t *testing.T) {
+ other := b
+ change(&other)
+ if _, err := g.Reserve(other); err == nil {
+ t.Fatal("rebound existing reservation")
+ }
+ got, err := g.Read(b)
+ if err != nil || got != r {
+ t.Fatalf("conflict changed old pin: %+v %v", got, err)
+ }
+ })
+ }
+}
+
+func TestPlan14GenerationObservationKeepsReservation(t *testing.T) {
+ g, s, b := generationFixture(t)
+ r, err := g.Reserve(b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o := GenerationProcess{PID: 51, Start: "51", TenantPID: 52, TenantStart: "52", Group: "system.slice/run.service", GroupIdentity: rootstate.FileIdentity{Device: 2, Inode: 3}, Invocation: "unit-invocation"}
+ s.fail = 1
+ if err := g.Started(r, o); err == nil {
+ t.Fatal("unsynced started record succeeded")
+ }
+ if err := g.Started(r, o); err != nil {
+ t.Fatal(err)
+ }
+ got, err := g.Read(b)
+ if err != nil || got.Binding != b || got.ID != r.ID || got.Process != o || got.Revision != 2 {
+ t.Fatalf("started: %+v %v", got, err)
+ }
+ o.PID++
+ if err := g.Started(r, o); err == nil {
+ t.Fatal("changed running lifetime")
+ }
+ if _, err := g.Reserve(b); err == nil {
+ t.Fatal("running reservation authorized another launch")
+ }
+}
+
+func TestPlan14GenerationRecordRefusals(t *testing.T) {
+ for _, attack := range []string{"missing", "malformed", "unknown field", "oversize"} {
+ t.Run(attack, func(t *testing.T) {
+ g, s, b := generationFixture(t)
+ if _, err := g.Reserve(b); err != nil {
+ t.Fatal(err)
+ }
+ key := generationKey(b)
+ raw, err := s.Read(key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ switch attack {
+ case "missing":
+ if err := os.Remove(filepath.Join(s.dir, key)); err != nil {
+ t.Fatal(err)
+ }
+ case "malformed":
+ raw = []byte("{")
+ case "unknown field":
+ raw = append(raw[:len(raw)-1], []byte(`,"complete":true}`)...)
+ case "oversize":
+ raw = []byte(strings.Repeat(" ", rootstate.MaxRecord+1))
+ }
+ if attack != "missing" {
+ if err := s.Write(key, raw); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if _, err := g.Read(b); err == nil {
+ t.Fatal("unavailable reservation granted a launch")
+ }
+ })
+ }
+}
diff --git a/internal/release/rootgenerations.go b/internal/release/rootgenerations.go
new file mode 100644
index 000000000..1d8d081a8
--- /dev/null
+++ b/internal/release/rootgenerations.go
@@ -0,0 +1,166 @@
+package release
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+const RootGenerationsDir = "/var/lib/carlos-release/private/generations"
+
+type GenerationOwner struct {
+ Host, Incarnation, Box, Enrollment, BootID, ControllerID string
+ Stamp, Session uint64
+}
+
+type GenerationBinding struct {
+ Binding HistoryBinding
+ Target GenerationOwner
+ Installation, Attempt, Inputs, Invocation string
+ Record, RecordETag string
+ ControllerPID int
+ ControllerStart string
+ Version, Digest, Path, Tree string
+ Executable, TreeIdentity rootstate.FileIdentity
+}
+
+type GenerationProcess struct {
+ PID, TenantPID int
+ Start, TenantStart, Group, Invocation string
+ GroupIdentity rootstate.FileIdentity
+}
+
+type GenerationReservation struct {
+ Schema int
+ Revision uint64
+ ID string
+ Binding GenerationBinding
+ Process GenerationProcess
+}
+
+// RootGenerations records additional pins under the caller's release exclusion.
+// A reservation is never retired by unit settlement or an agent publication.
+// These records alone cannot establish a complete generation census.
+type RootGenerations struct{ Store rootstate.Store }
+
+func generationKey(b GenerationBinding) string {
+ raw, _ := json.Marshal([]any{b.Target, b.Attempt})
+ sum := sha256.Sum256(raw)
+ return hex.EncodeToString(sum[:]) + ".json"
+}
+
+func (b GenerationBinding) valid() bool {
+ t := b.Target
+ digest, err := hex.DecodeString(b.Digest)
+ return b.Binding.valid() && t.Host == b.Binding.Host && t.Incarnation != "" && t.Box != "" && t.Enrollment != "" && t.BootID != "" && t.ControllerID != "" && t.Stamp > 0 && t.Session > 0 &&
+ b.Record != "" && b.RecordETag != "" && b.Installation != "" && b.Attempt != "" && b.Inputs != "" && b.Invocation != "" && b.ControllerPID > 0 && b.ControllerStart != "" &&
+ ValidVersion(b.Version) && err == nil && len(digest) == sha256.Size && hex.EncodeToString(digest) == b.Digest &&
+ filepath.IsAbs(b.Path) && filepath.Clean(b.Path) == b.Path && b.Tree == filepath.Dir(b.Path) &&
+ b.Executable.Device > 0 && b.Executable.Inode > 0 && b.TreeIdentity.Device > 0 && b.TreeIdentity.Inode > 0
+}
+
+func (p GenerationProcess) valid() bool {
+ return p.PID > 0 && p.TenantPID > 0 && p.PID != p.TenantPID && p.Start != "" && p.TenantStart != "" &&
+ filepath.IsLocal(p.Group) && p.Group != "." && p.Invocation != "" && p.GroupIdentity.Device > 0 && p.GroupIdentity.Inode > 0
+}
+
+func validGeneration(r GenerationReservation) bool {
+ id, err := hex.DecodeString(r.ID)
+ return r.Schema == 1 && err == nil && len(id) == 32 && hex.EncodeToString(id) == r.ID && r.Binding.valid() &&
+ ((r.Revision == 1 && r.Process == (GenerationProcess{})) || (r.Revision == 2 && r.Process.valid()))
+}
+
+func (g *RootGenerations) Read(b GenerationBinding) (GenerationReservation, error) {
+ var r GenerationReservation
+ if g == nil || g.Store == nil || !b.valid() {
+ return r, os.ErrPermission
+ }
+ raw, err := g.Store.Read(generationKey(b))
+ if err != nil {
+ return r, err
+ }
+ if len(raw) > rootstate.MaxRecord || strictCanonicalJSON(raw, &r) != nil || !validGeneration(r) || r.Binding != b {
+ return GenerationReservation{}, os.ErrPermission
+ }
+ // Recommit before relying on a rename a prior process may not have synced.
+ if err := g.Store.Write(generationKey(b), raw); err != nil {
+ return GenerationReservation{}, err
+ }
+ return r, nil
+}
+
+func (g *RootGenerations) write(r GenerationReservation) error {
+ if g == nil || g.Store == nil || !validGeneration(r) {
+ return os.ErrPermission
+ }
+ raw, err := json.Marshal(r)
+ if err != nil {
+ return err
+ }
+ if len(raw) > rootstate.MaxRecord {
+ return os.ErrPermission
+ }
+ return g.Store.Write(generationKey(r.Binding), raw)
+}
+
+// Reserve reuses an exact pending request after a lost response. Root allocates
+// the ID. A changed request or an already observed launch cannot reserve again.
+func (g *RootGenerations) Reserve(b GenerationBinding) (GenerationReservation, error) {
+ r, err := g.Read(b)
+ if err == nil {
+ if r.Process != (GenerationProcess{}) {
+ return GenerationReservation{}, os.ErrPermission
+ }
+ return r, nil
+ }
+ if !errors.Is(err, os.ErrNotExist) || g == nil || g.Store == nil || !b.valid() {
+ return GenerationReservation{}, err
+ }
+ var id [32]byte
+ if _, err := rand.Read(id[:]); err != nil {
+ return GenerationReservation{}, err
+ }
+ r = GenerationReservation{Schema: 1, Revision: 1, ID: hex.EncodeToString(id[:]), Binding: b}
+ if err := g.write(r); err != nil {
+ return GenerationReservation{}, err
+ }
+ return r, nil
+}
+
+func (g *RootGenerations) Check(r GenerationReservation) error {
+ got, err := g.Read(r.Binding)
+ if err != nil {
+ return err
+ }
+ if got != r || r.Process != (GenerationProcess{}) {
+ return os.ErrPermission
+ }
+ return nil
+}
+
+func (g *RootGenerations) Started(r GenerationReservation, p GenerationProcess) error {
+ if !p.valid() {
+ return os.ErrPermission
+ }
+ got, err := g.Read(r.Binding)
+ if err != nil {
+ return err
+ }
+ if got.ID != r.ID {
+ return os.ErrPermission
+ }
+ if got.Process == p {
+ return nil
+ }
+ if got != r || got.Process != (GenerationProcess{}) {
+ return os.ErrPermission
+ }
+ got.Process, got.Revision = p, 2
+ return g.write(got)
+}
diff --git a/proof_cli.go b/proof_cli.go
index 5c0108c48..a8e287f4a 100644
--- a/proof_cli.go
+++ b/proof_cli.go
@@ -28,7 +28,7 @@ import (
"github.com/carlosframework/platform/internal/release"
)
-const proofUsage = "usage: carlos proof keygen [--out <file>] | get --app <a> --sha256 <hex> [--prover <name>] | put --app <a> <file>"
+const proofUsage = "usage: carlos proof history <status|reconstruct|retire|resume> [flags] | keygen [--out <file>] | get --app <a> --sha256 <hex> [--prover <name>] | put --app <a> <file>"
func runProof(args []string) {
if len(args) == 0 {
@@ -36,6 +36,8 @@ func runProof(args []string) {
}
ctx := context.Background()
switch args[0] {
+ case "history":
+ runHistory(args[1:])
case "keygen":
fs := flag.NewFlagSet("proof keygen", flag.ExitOnError)
out := fs.String("out", "/etc/carlos/proving.key", "where to write the private key (root only, mode 0600)")