carlos / platform Public

claim: ClaimLaunchDigest covers Instance only when set (home part 2, 2b r3)

The moves launch source captures the instance's layer names with the
env (ExecBackend.LaunchLayers) into ClaimLaunchInputs.Instance. The
reservation digest now covers them, sorted, only when there are any:
a launch without them keeps main's digest byte for byte (pinned in
TestClaimLaunchDigestCoversInstanceOnlyWhenSet), and a changed Instance
changes it. Census: envelope only, ruling owed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a0d787e4718b211c597188e2cc6713f7f619d544 parent b59c5fb
3 files changed, +67 −6
  • internal/host/claimlaunchdigest_instance_test.go +45 −0
  • internal/host/claimoperations.go +20 −5
  • internal/provision/testdata/plan14-root-reads-home-part2.tsv +2 −1
diff --git a/internal/host/claimlaunchdigest_instance_test.go b/internal/host/claimlaunchdigest_instance_test.go
new file mode 100644
index 000000000..6e9d93779
--- /dev/null
+++ b/internal/host/claimlaunchdigest_instance_test.go
@@ -0,0 +1,45 @@
+package host
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostwrite"
+)
+
+// ClaimLaunchDigest and the instance's layer names (home part 2, Task 2b
+// fix round 3). Instance is captured with Env by the moves launch source
+// (ExecBackend.LaunchLayers); the digest covers it only when it is
+// non-empty, so every launch that carries none (serving, relocation, and
+// every reservation already journalled) keeps main's digest byte for byte.
+func TestClaimLaunchDigestCoversInstanceOnlyWhenSet(t *testing.T) {
+ base := ClaimLaunchInputs{Identity: hostwrite.Identity{Config: "launch-env:sha256:c", Release: "v7"}, Executable: "/opt/carlos/live/a.example.com", ExecutableSHA256: "aaaa",
+ Args: []string{"--socket", "/run/a.sock"}, Env: []string{"A=1", "HOSTNAME=x"}}
+ // main's digest of these inputs, computed by main's ClaimLaunchDigest.
+ const mainDigest = "47246370546f9bec4ae2541bace116bd102be9655ab1b376de990d5374f26bbe"
+ for name, instance := range map[string][]string{"nil": nil, "empty": {}} {
+ in := base
+ in.Instance = instance
+ if d, err := ClaimLaunchDigest(in); err != nil || d != mainDigest {
+ t.Fatalf("%s Instance: %s %v, want main's %s", name, d, err, mainDigest)
+ }
+ }
+ with := func(names ...string) string {
+ in := base
+ in.Instance = names
+ d, err := ClaimLaunchDigest(in)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return d
+ }
+ a := with("A", "HOSTNAME")
+ if a == mainDigest {
+ t.Fatal("a non-empty Instance left the digest unchanged")
+ }
+ if with("A") == a || with("A", "HOSTNAME", "B") == a {
+ t.Fatal("a changed Instance left the digest unchanged")
+ }
+ if with("HOSTNAME", "A") != a {
+ t.Fatal("the digest depends on the order of the names")
+ }
+}
diff --git a/internal/host/claimoperations.go b/internal/host/claimoperations.go
index de67e118a..c577e4cb6 100644
--- a/internal/host/claimoperations.go
+++ b/internal/host/claimoperations.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"os/exec"
+ "slices"
"time"
"github.com/carlosframework/platform/internal/activator"
@@ -67,12 +68,26 @@ type ClaimProbeReadiness func(ctx context.Context, cmd *exec.Cmd, rt registry.Ro
// ClaimLaunchDigest names the launch inputs an attempt reservation binds: the
// identity, executable, digest, arguments and environment, never the
// admission. Serving retries and operation attempts use the same digest.
+// The instance's layer names (Instance, captured with Env by the moves
+// launch source) are covered only when there are any, sorted: a launch
+// that carries none keeps the digest's original form byte for byte.
func ClaimLaunchDigest(in ClaimLaunchInputs) (string, error) {
- raw, err := json.Marshal(struct {
- Identity hostwrite.Identity
- Executable, Digest string
- Args, Env []string
- }{in.Identity, in.Executable, in.ExecutableSHA256, in.Args, in.Env})
+ var raw []byte
+ var err error
+ if len(in.Instance) == 0 {
+ raw, err = json.Marshal(struct {
+ Identity hostwrite.Identity
+ Executable, Digest string
+ Args, Env []string
+ }{in.Identity, in.Executable, in.ExecutableSHA256, in.Args, in.Env})
+ } else {
+ raw, err = json.Marshal(struct {
+ Identity hostwrite.Identity
+ Executable, Digest string
+ Args, Env []string
+ Instance []string
+ }{in.Identity, in.Executable, in.ExecutableSHA256, in.Args, in.Env, slices.Sorted(slices.Values(in.Instance))})
+ }
if err != nil {
return "", err
}
diff --git a/internal/provision/testdata/plan14-root-reads-home-part2.tsv b/internal/provision/testdata/plan14-root-reads-home-part2.tsv
index 46fb5e4fb..c44fff582 100644
--- a/internal/provision/testdata/plan14-root-reads-home-part2.tsv
+++ b/internal/provision/testdata/plan14-root-reads-home-part2.tsv
@@ -2,6 +2,7 @@
# Task 1: moves.CompareEnv takes targetHome, and T3 (internal/host checkEnv) passes rec.Move.To == "" for it: a home target must set no CARLOS_REGION and fails region-present when it does. Task 2b and its fix round 2: internal/moves owns the move-portable env rule once (AgentOwnedVars, BoxVars, Pinned: agent-owned variables never pinned, a box variable pinned only when the instance's own layers supplied it; ReportLaunch, PortableDigest), and LocalDigest, the whole composed env for a check made on the box that recorded it. The composer reports the instance's layer names (activator ExecBackend.LaunchLayers, LaunchEnvironment's composition moved into its own unit). The source pin, the cut's currentInputs and the probe owners' checks hash the portable env; the recovery records recoveryInputs and checks localEnvDigest, the whole local env.
# Env digests, by where they are compared. Across boxes, the move-portable env: the source pin writeSourceReport sr.Env.Digest (host/movesphase1.go:875) copied to the target report's Inputs.EnvReport (host/movesreport.go:276) and checked on the target by the probe owners' Select and Materialize (host/movesprobeowners.go envDigest) and by the committed wake (committedLaunchIdentity against pinnedLaunchIdentity, selectCommitted and InputPolicy); the cut's currentInputs (host/movescut.go), compared on the source with that pin. Same box, the whole local env: the recovery's recoveryInputs (host/movescut.go, recorded through host/movesrecoveryprobe.go:139-146 on the recovering box) checked by the recovery owners' Select and Materialize (host/movesrecoveryowners.go localEnvDigest). Same box, full composed env kept and unchanged: ClaimLaunchDigest (host/claimoperations.go:70, host/claimowners.go:400), compared only with the attempt's own box, boot and controller (host/movesprobeattempts.go:312 and :353, host/relocationattempts.go:265, host/claimlaunch_authority.go:89-96, host/claimwakestop.go:162, activator/servingretry.go:188 and :222, activator/rolelaunch_linux.go:37 and :135, activator/rolelaunch_root_linux.go:41); snapshot.Identity.Config (host/claimmoveaccess.go:191), which the moves owners never read (they set their own identity from the pins). movesReadLaunch (host/moveswakesource.go:207) discards the launch source's EnvReport, not its identity.
# Re-frozen units, every one at main's disposition (class, task and basis unchanged; kinds and digest refreshed). Authority rows, read set unchanged (only the source offsets moved): host/claimmoveaccess.go claimMoveLaunchSource (authority C,G,J1; it now calls LaunchLayers and returns the instance's layer names) and host/movespass.go armPhase1 (authority B2,C; its LaunchEnv hook now returns the layer names). Legacy rows: activator ExecBackend.LaunchEnvironment (now a wrapper of LaunchLayers) and the new ExecBackend.LaunchLayers (LaunchEnvironment's composition and reads, moved; LaunchEnvironment's disposition); host movesWorker.writeSourceReport (ReportLaunch with the layer names); movesProbeOwners.Select, Materialize, selectCommitted and materializeCommitted (envDigest with the layer names); movesRecoveryOwners.Select and Materialize (localEnvDigest). No new read and no root decision. Nine frozen declarations are added.
+# ClaimLaunchDigest (host/claimoperations.go, fix round 3): envelope only, it reads nothing and is no root-read unit. It covers ClaimLaunchInputs.Instance, sorted, only when non-empty, so every launch without the instance's layer names keeps main's digest byte for byte (TestClaimLaunchDigestCoversInstanceOnlyWhenSet pins main's bytes). Its comparisons stay same-box (listed above).
@legacy 849ce111c61c3dcedd80595348d0a26c67fb47462d497fba63d52d2c94e1d5d0 internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113
internal/activator/backend_exec.go ExecBackend.LaunchEnvironment legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route whole:Route@139 e8853e15b427b80a701b5e10b565f7d4f0b778e0c8185da63266e2408f6b4113
@legacy 1c98a7ecd93fe84486af8cbc09bd1cb31915c69c16785c3fd3bcdd6d21eb6849 internal/activator/backend_exec.go ExecBackend.LaunchLayers legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:Socket@689,whole:Route@179,whole:Route@685 a82c0f1740d9242da29248be3a009ab34a528df105e3a269b91b49e40c839261
@@ -23,5 +24,5 @@ internal/host/movesrecoveryowners.go movesRecoveryOwners.Materialize legacy acce
@legacy 98403405dd4f1aa000814d268c71477fd7b3f54fe1921270f8ad3ffc41ec7c54 internal/host/movesrecoveryowners.go movesRecoveryOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1753,registry:DBPath@792,whole:Route@1008,whole:Route@514 35686d5e12ca3008cfefe0e8a15fcfc8d1e7c120adf94619a55fa948f4554921
internal/host/movesrecoveryowners.go movesRecoveryOwners.Select legacy accepted-legacy accepted-legacy;rev3=J1;basis=root-route registry:DBPath@1753,registry:DBPath@792,whole:Route@1008,whole:Route@514 35686d5e12ca3008cfefe0e8a15fcfc8d1e7c120adf94619a55fa948f4554921
@coverage internal/activator 4b4b243c37f98df546ac893baaf98a80ae805705492dcd0792f532d41976766c
-@coverage internal/host 572809ffe0af5a194c7816de18098eba21a991154a7c8af79a6ad4621ad7fbdb
+@coverage internal/host e4e29379586d2c258da32909370f909a2134a87226968785bdfea4babe6f789e
@coverage internal/moves 14b012bafa4965182c173593563004a1d872436fdfcf03d55bd0b49a0bde85d2