carlos / platform Public

Instance key provision plan rev 3.1: answers Astra round 3

A replica launch is handed no CARLOS_INSTANCE_KEY on any path (ruling 25,
fileEnvFor; new Task 3b), tested with real replica launches carrying a
configured key and a pre-existing env key. The follow-ups now ship O7
before O6. O2' and everything stage B consumes are unchanged. Report R3
gains a rev 3.1 section.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 0317f5d1f260ea2b32d84164ea3aaecf0674e2b8 parent 794c45e
2 files changed, +327 −12
  • docs/superpowers/plans/2026-10-10-instance-key-provision.md +306 −12
  • docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md +21 −0
diff --git a/docs/superpowers/plans/2026-10-10-instance-key-provision.md b/docs/superpowers/plans/2026-10-10-instance-key-provision.md
index 4de56f122..0ae36c7e3 100644
--- a/docs/superpowers/plans/2026-10-10-instance-key-provision.md
+++ b/docs/superpowers/plans/2026-10-10-instance-key-provision.md
@@ -1,4 +1,4 @@
-# Instance Key Provision (Region HA L21) Implementation Plan, rev 3
+# Instance Key Provision (Region HA L21) Implementation Plan, rev 3.1
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
@@ -13,7 +13,20 @@
- Region HA spec `docs/superpowers/specs/2026-10-04-region-ha-box-placement-design.md`: H6 (line 131) and §5.6 (line 539): `instance.key`, `report.token`, `<host>.env` are always continuous.
- Framework contract: rastrillo/rastrillo discussion #66 (agreed; landed on rastrillo main as e3ea08d). `carlos.InstanceKey(own string) (string, error)` resolves, in order: (1) `own`, the app's own key; (2) `$CARLOS_INSTANCE_KEY`, verbatim; (3) on CARLOS (`carlos.Running()`, which checks `$CARLOS_CONTROL_SOCKET`) with no key, `ErrNoInstanceKey`; (4) off CARLOS, `ErrNotOnCarlos`, today's behaviour. Read at e3ea08de in `~/amadan.net/rastrillo/rastrillo` (`carlos/instancekey.go`, `carlos/schedule.go:28`).
- Stage B plan `docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md` on `region-ha-1b-next-build` (919662d2): rulings 25 and 55, Task 8b step 0 (its O2' variant with a `KeyEvidence` reader) and Task 8c (O3). This plan's O2' replaces that step.
-- Review: Astra round 1, `docs/superpowers/plans/CODEX-PLAN-INSTANCE-KEY-R1.md`, and round 2, `CODEX-PLAN-INSTANCE-KEY-R2.md` (verdict both times: not ready). Their findings are answered in the two tables below, round 2's first.
+- Review: Astra rounds 1 to 3, `docs/superpowers/plans/CODEX-PLAN-INSTANCE-KEY-R1.md`, `-R2.md` and `-R3.md` (verdict each time: not ready; round 3 found no P1). Their findings are answered in the three tables below, newest first.
+
+## Rev 3.1: round 3 dispositions
+
+Astra's round 3 (`CODEX-PLAN-INSTANCE-KEY-R3.md`, reviewed at f649d972) found no P1. It closed round 2's five findings at the contract level and judged the `source` check sound. Two fixes remain.
+
+| # | Finding | Disposition | Change | Test |
+|---|---|---|---|---|
+| R3-1 | P2. The replica exclusion stops minting but not delivery. A replica whose configuration supplies B, or whose own env already holds a key, is handed it, and the helper's step 2 returns it over the home's replicated data. The adoption 409 cannot stop that, because the helper ignores failures. | **Accepted** (ruling 25). Verified: `InstanceKeyReplica` only skipped the mint; `mergeEnv` still delivered the configured or existing key. | No launch of a replica row is handed `CARLOS_INSTANCE_KEY`: `fileEnvFor` strips it from the composed env on every launch path (Start, Seed, the nested prepare, `LaunchEnvironment`, the claim snapshot). With the variable absent, the helper's step 2 has nothing to return. No rastrillo change. | `TestReplicaLaunchIsHandedNoInstanceKey` (a real replica Start and Seed: a configured key, a pre-existing env key, both; the home row delivers the key as the control case); `TestNestedReplicaPrepareIsHandedNoInstanceKey` (run and seed roles); `TestClaimReplicaLaunchIsHandedNoInstanceKey` (the claim launch env and the snapshot) |
+| R3-2 | P3. The follow-ups said "O6 and O7, in that order", contradicting ruling 24. | **Accepted.** | The follow-up now says O7 ships before O6, and why. | none |
+
+**Counts:** 2 accepted, 0 declined.
+
+**Effect on stage B and O2':** none. O2' and its test are unchanged. Stage B consumes `instancekey`, `ClaimLaunchSnapshot` and `PinObject` as before. One behaviour visible elsewhere: the moves env report for a replica (`LaunchEnvironment`) no longer lists `CARLOS_INSTANCE_KEY`, on the source and the target alike, so the two still compare equal.
## Rev 3: round 2 dispositions
@@ -196,7 +209,7 @@ The agent sets `CARLOS_CONTROL_SOCKET` only for instance children (`agentOwnedVa
**16. Claim retries reuse their inputs (new; question 5's premise corrected).** On a claim box every serving start is a claim wake. The launch source, which mints, runs in `Select`. A session's retries reuse the captured inputs and call `Materialize`, and so `Establish`, before every attempt. The legacy spawn path does not serve a claim host, and production claim swaps refuse. No claim attempt can run with a key minted after its files manifest was established. Any future path that mints before an asynchronous publish needs a synchronous publication gate. *Cost if wrong:* `TestStartAdmittedRetriesReuseTheirInputsAndMaterializeFirst` fails on the change that breaks it.
-**17. Replicas are excluded from platform-key adoption (new; open question 1, provisional default, Paul not yet confirmed).** A replica (#162) mirrors its home's data, which is sealed under the home's key. A replica's own platform key would let an adopting app seal under a different key, silently. So no path mints on a replica row (`InstanceKeyReplica`), the backfill skips replicas, and the adoption verb answers 409 on one. An app with replica placements passes its own key as `own`, or refuses to start on the replica (`ErrNoInstanceKey`), loudly. *Cost if wrong:* a replica of an adopting app with no own key does not start until the follow-up gives replicas their home's key.
+**17. Replicas are excluded from platform-key adoption (new; open question 1, provisional default, Paul not yet confirmed).** A replica (#162) mirrors its home's data, which is sealed under the home's key. A replica's own platform key would let an adopting app seal under a different key, silently. So no path mints on a replica row (`InstanceKeyReplica`), no launch of one is handed a key at all (ruling 25), the backfill skips replicas, and the adoption verb answers 409 on one. An app with replica placements passes its own key as `own`, or refuses to start on the replica (`ErrNoInstanceKey`), loudly. *Cost if wrong:* a replica of an adopting app with no own key does not start until the follow-up gives replicas their home's key.
**18. The claim early return syncs without writing (new; finding 2).** When the token and the key are both present, the claim launch source writes nothing and needs no authority, as before. It still opens the env file read-only, checks the mode, fsyncs the file and fsyncs its directory before it answers. fsync changes no byte, mode or name, so it is not a host write and runs outside the claim's guard. A file whose mode is not 0600 needs a write, so it takes the guarded path, whose ensure sets it. *Cost if wrong:* if fsync were judged a host write, this step would move under `authorize`, which re-reads the claim on every selection.
@@ -225,6 +238,12 @@ Source (c) and `ConfiguredInstanceKey` are gone: a configured key is covered by
**24. The move lifecycle includes the record (rev 3; Paul's R-E; finding R2-5).** The record is an installation artifact. Nested, it lives in the instance directory and is retired with it. Flat, it must be in moves' journalled artifact lists, so a move away retires it and a later empty-installation census on that box finds nothing of the host's. That is O7, with the exact lists. The writer (O6) does not ship before O7. The census classification does not change: the record stays an unclassified host artifact, so a leftover still refuses, which is the check that keeps O7 honest. *Cost if wrong:* a host that moved away could not be installed on its old box again until an operator removed the file.
+**25. A replica is handed no key at all (rev 3.1; Astra round 3 finding 1).** Ruling 17 stopped the mint, but a replica could still be handed a configured key, or a key already in its own env file, and the helper's step 2 would return it over the home's data. So `fileEnvFor`, the one composition every launch path uses (`instanceEnv` for Start, Seed and the nested prepare; `LaunchEnvironment`; the claim snapshot), drops `CARLOS_INSTANCE_KEY` for a replica row. The helper then has nothing at step 2.
+- On a launch with a control socket (`carlos.Running()`), an app with no key of its own gets `ErrNoInstanceKey` and refuses to start, loudly. That is ruling 17's promise.
+- On a claim launch before O6 there is no socket, so the app sees step 4 (`ErrNotOnCarlos`) and does what it did before L21. The platform still hands it nothing.
+
+This option was chosen over the other two. A replica marker would need a rastrillo change. Refusing the launch while the keys differ would need the home's key on the replica's box, which is the deferred follow-up itself. Stripping needs neither, and it keeps the deferral honest: the platform never hands a replica a key, so it never hands one different from its home's. *Cost if wrong:* an app that deliberately configured a key for its replicas (the only way a replica could share the home's key today) loses it on the replica, and must pass it as `own` instead. Until the follow-up, that is the supported way to run an adopting app on a replica, as rollout step 6 already says.
+
## Obligations on other plans
Rev 3 rewrites O1', O2', O3', O4, O5 and O6, and adds O7.
@@ -627,11 +646,12 @@ Two changes to the ask above, and only the second is new:
- Create `internal/instancekey/instancekey.go` and `instancekey_test.go`. They hold the parser, `Effective`, `Digest`, the sources, the writer-bound adoption record (`Binding`, `Adoption`, `AdoptionRecord`, `ParseAdoption`, `Binds`) and `VerifyDelivered`. Standard library only.
- Modify `internal/activator/admintoken.go` (`parseEnvBytes` calls `instancekey.ParseEnv`) and `internal/activator/backend_exec.go` (`mergeEnv`, `LaunchEnvironment`'s shadow list).
- Create `internal/activator/instancekey.go`: the mint (`EnsureInstanceKey`, `EnsureInstanceKeyBeneath`, `ensureInstanceKeyFor`, `BackfillInstanceKey`, `settleClaimInstanceEnv`, `InstanceKeyVar`, `InstanceKeyOutcome`).
-- Create `internal/activator/instancekey_test.go`, `instancekey_linux_test.go`, `claim_instancekey_test.go` and `claimretry_instancekey_linux_test.go`.
+- Create `internal/activator/instancekey_test.go`, `instancekey_linux_test.go`, `replica_instancekey_test.go`, `replica_instancekey_linux_test.go`, `claim_instancekey_test.go` and `claimretry_instancekey_linux_test.go`.
+- Modify `internal/activator/backend_exec.go` (`instanceEnv` and `LaunchEnvironment` through `fileEnvFor`, Task 3b).
- Modify `internal/activator/backend_exec.go` (`seedChild` :831, `spawnChildFrom` :1369), `internal/activator/prepare.go` (:60) and `internal/activator/claim_snapshot.go` (the snapshot's fields, `EnsureClaimAdminToken` :40-67, `SnapshotClaimLaunch`'s env read).
- Create `internal/activator/keyadoption.go`, `keyadoption_test.go` and `keyadoption_linux_test.go`: the adoption verb, mounted by O6.
- Create `internal/hostfiles/relay.go` and `relay_test.go`: `PlatformRelay`.
-- Modify `internal/host/claimowners.go` (:125-138), for O1' on `ClaimFilesManifest`. Create `internal/host/claim_instancekey_test.go` and `instancekey_adoption_test.go`.
+- Modify `internal/host/claimowners.go` (:125-138), for O1' on `ClaimFilesManifest`. Create `internal/host/claim_instancekey_test.go`, `claim_replica_instancekey_test.go` and `instancekey_adoption_test.go`.
- Create `internal/host/instancekeys.go` and `instancekeys_test.go`: the backfill. Modify `internal/host/agent.go` (:476-484).
- Create `internal/appconfig/syncexec_instancekey_test.go`, `internal/moves/envreport_instancekey_test.go` and `internal/moves/archive/instancekey_test.go`.
@@ -2130,12 +2150,240 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
---
+### Task 3b: A replica is handed no key (ruling 25)
+
+**Files:**
+- Modify: `internal/activator/backend_exec.go:1824-1830` (`instanceEnv`), `:1879-1905` (`LaunchEnvironment`)
+- Test: `internal/activator/replica_instancekey_test.go`, `internal/activator/replica_instancekey_linux_test.go` (create both)
+
+**Interfaces:**
+- Consumes: Task 1's `mergeEnv` rule; test helpers `testExecBackend`, `stageRecordingLiveFullEnv`, `waitForFile`, `tmpSock`, `nestedBackend`.
+- Produces: `fileEnvFor(rt, cfgEnv, ownEnv)`: `mergeEnv` with `CARLOS_INSTANCE_KEY` dropped for a replica row. Task 4's snapshot uses it.
+
+- [ ] **Step 1: Write the failing tests**
+
+Create `internal/activator/replica_instancekey_test.go`:
+
+```go
+package activator
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// startRecordedRoute is startRecorded for a route the test builds: it
+// starts rt with the recording binary and returns the environment the
+// child saw, or nil when no child ran within a second.
+func startRecordedRoute(t *testing.T, b *ExecBackend, rt registry.Route) map[string]string {
+ t.Helper()
+ _, envPath := stageRecordingLiveFullEnv(t, b, rt.Host, "v1")
+ rt.Socket = tmpSock(t)
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt) // the fake binary never binds: the health wait fails
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+ deadline := time.Now().Add(time.Second)
+ for time.Now().Before(deadline) {
+ if raw, err := os.ReadFile(envPath); err == nil {
+ child := map[string]string{}
+ for _, line := range strings.Split(string(raw), "\n") {
+ if k, v, ok := strings.Cut(line, "="); ok {
+ child[k] = v
+ }
+ }
+ return child
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ return nil
+}
+
+// Ruling 25 (Astra round 3 finding 1): a replica launch is handed no
+// CARLOS_INSTANCE_KEY, whether its configuration supplies one or a key is
+// already in its own env file, so the helper's step 2 has nothing to
+// return. Its seed is handed none either, LaunchEnvironment reports none,
+// nothing is minted, and the own file is left as it was. The same files on
+// a home row deliver the key, as the control case.
+func TestReplicaLaunchIsHandedNoInstanceKey(t *testing.T) {
+ tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ for _, tc := range []struct {
+ name, own, config, home string
+ }{
+ {"a configured key", tok, InstanceKeyVar + "=configured-B\n", "configured-B"},
+ {"a pre-existing env key", tok + InstanceKeyVar + "=" + strings.Repeat("A", 43) + "=\n", "", strings.Repeat("A", 43) + "="},
+ {"both", tok + InstanceKeyVar + "=" + strings.Repeat("A", 43) + "=\n", InstanceKeyVar + "=configured-B\n", strings.Repeat("A", 43) + "="},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ for _, replica := range []bool{true, false} {
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: replica}
+ own := filepath.Join(b.DataDir, rt.Host+".env")
+ if err := os.WriteFile(own, []byte(tc.own), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.config != "" {
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ child := startRecordedRoute(t, b, rt)
+ if child == nil {
+ t.Fatal("no child ran")
+ }
+ got, handed := child[InstanceKeyVar]
+ if !replica {
+ if got != tc.home {
+ t.Fatalf("home row: the child was handed %q, want %q", got, tc.home)
+ }
+ continue
+ }
+ if handed {
+ t.Fatalf("a replica launch was handed %s (%d bytes)", InstanceKeyVar, len(got))
+ }
+ if raw, _ := os.ReadFile(own); string(raw) != tc.own {
+ t.Fatal("a replica launch changed its own env file")
+ }
+ env, _, err := b.LaunchEnvironment(rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, ok := env[InstanceKeyVar]; ok {
+ t.Fatal("LaunchEnvironment reports a key for a replica")
+ }
+ rt.Seed = `["seed"]`
+ _, seedEnv := stageRecordingLiveFullEnv(t, b, rt.Host, "v2")
+ if err := b.Seed(context.Background(), rt); err != nil {
+ t.Fatalf("Seed: %v", err)
+ }
+ if strings.Contains(waitForFile(t, seedEnv, time.Second), InstanceKeyVar+"=") {
+ t.Fatal("a replica's seed was handed the key")
+ }
+ }
+ })
+ }
+}
+```
+
+Create `internal/activator/replica_instancekey_linux_test.go`:
+
+```go
+package activator
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Ruling 25 on the nested layout: a replica's prepared env, which the
+// launch file carries to the trampoline, holds no CARLOS_INSTANCE_KEY even
+// when its configuration supplies one, for the run role and the seed role.
+func TestNestedReplicaPrepareIsHandedNoInstanceKey(t *testing.T) {
+ for _, role := range []instancectl.Role{instancectl.RoleRun, instancectl.RoleSeed} {
+ t.Run(string(role), func(t *testing.T) {
+ b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{})
+ rt.Replica = true
+ dir := provision.InstanceDir(b.DataDir, rt.Host)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, rt.Host+".config.env"), []byte(InstanceKeyVar+"=configured-B\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ _, ctl, logs, env, err := b.prepare(rt, role)
+ if err != nil {
+ t.Fatalf("prepare: %v", err)
+ }
+ logs.Close()
+ if ctl != nil {
+ ctl.Close()
+ }
+ if _, ok := env[InstanceKeyVar]; ok {
+ t.Fatal("a nested replica's prepared env carries the key")
+ }
+ })
+ }
+}
+```
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run (sandbox off: they start children and bind sockets): `nice -n 19 go test ./internal/activator/ -run 'TestReplicaLaunchIsHandedNoInstanceKey|TestNestedReplicaPrepareIsHandedNoInstanceKey' -count=1 -v`
+Expected: FAIL for every case: the replica's child, seed and prepared env carry the configured or existing key. (Checked in the scratch build with the strip removed.)
+
+- [ ] **Step 3: Compose every launch's env through `fileEnvFor`**
+
+In `internal/activator/backend_exec.go`, `instanceEnv` returns through the new function, which follows it:
+
+```go
+ return fileEnvFor(rt, cfgEnv, ownEnv), nil
+}
+
+// fileEnvFor is mergeEnv for rt's launch. A replica row is handed no
+// CARLOS_INSTANCE_KEY from either file (instance-key plan ruling 25): a
+// replica mirrors its home's data, sealed with the home's key, and until a
+// replica can be given that key (open question 1) any key it is handed is
+// a different one. With the variable absent, an app on rastrillo's
+// InstanceKey and no key of its own refuses to start (step 3) wherever the
+// agent gave it a control socket. Every launch composes its env through
+// here: Start, Seed, the nested prepare, LaunchEnvironment and the claim
+// snapshot.
+func fileEnvFor(rt registry.Route, cfgEnv, ownEnv map[string]string) map[string]string {
+ env := mergeEnv(cfgEnv, ownEnv)
+ if rt.Replica {
+ delete(env, instancekey.Var)
+ }
+ return env
+}
+```
+
+In `LaunchEnvironment`, a replica's key shadows nothing, and its env is composed the same way:
+
+```go
+ if k == instancekey.Var && (ownEnv[k] == "" || rt.Replica) {
+ continue // an empty own key, or a replica's, shadows nothing (fileEnvFor)
+ }
+```
+
+```go
+ fileEnv := fileEnvFor(rt, cfgEnv, ownEnv)
+```
+
+- [ ] **Step 4: Run the tests, then the launch tests around them**
+
+Run (sandbox off): the Step 2 command, then `nice -n 19 go test ./internal/activator/ -run 'Replica|InstanceKey|Seed|Start|Prepare|LaunchEnvironment' -count=1`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/activator
+git add internal/activator/backend_exec.go internal/activator/replica_instancekey_test.go internal/activator/replica_instancekey_linux_test.go
+git commit -m "activator: a replica launch is handed no CARLOS_INSTANCE_KEY (L21)
+
+Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
+```
+
+---
+
### Task 4: The claim's launch source mints under the guard, syncs before it answers, and names the key it delivers
**Files:**
- Modify: `internal/activator/claim_snapshot.go:17-25` (`ClaimLaunchSnapshot`), `:40-67` (`EnsureClaimAdminToken`), `:69-85` (`SnapshotClaimLaunch`'s env read)
- Modify: `internal/host/claimowners.go:125-138` (doc on `ClaimFilesManifest`, O1')
-- Test: `internal/activator/claim_instancekey_test.go`, `internal/activator/claimretry_instancekey_linux_test.go`, `internal/host/claim_instancekey_test.go` (create all three)
+- Test: `internal/activator/claim_instancekey_test.go`, `internal/activator/claimretry_instancekey_linux_test.go`, `internal/host/claim_instancekey_test.go`, `internal/host/claim_replica_instancekey_test.go` (create all four)
**Interfaces:**
- Consumes: Task 1's `instancekey.Effective`, `Digest`, `SourceOf`; Task 2's `ensureInstanceKeyFor`, `settleClaimInstanceEnv`, `errInstanceEnvNeedsWrite`, `probeInstanceKeySync`; `instanceEnvParts` (backend_exec.go:1836); `claimServingLaunchSource` (claimproduction.go:421), `claimServingOwners` (claimowners.go:293) with `Select` (:375) and `Materialize` (:425); test fixture `newAdminTokenFixture`, `adminTokenHost`, `f.envPath()`, `f.token()` (claimproduction_admintoken_test.go:20-118); `leasetest.NewVersionedBucket`; `Service.startAdmitted` (claimwake.go:330) as `claimloop_linux_test.go` drives it.
@@ -2427,9 +2675,55 @@ func TestClaimSnapshotNamesTheDeliveredKeyAndItsSource(t *testing.T) {
}
```
+Create `internal/host/claim_replica_instancekey_test.go`, ruling 25 on the claim path:
+
+```go
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/activator"
+)
+
+// Ruling 25 on the claim path: a replica's claim launch is handed no
+// CARLOS_INSTANCE_KEY, from its configuration or a key already in its own
+// env file, and the snapshot names none for O6 to accept a report against.
+func TestClaimReplicaLaunchIsHandedNoInstanceKey(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ f.rt.Replica = true
+ own := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n" + activator.InstanceKeyVar + "=" + strings.Repeat("A", 43) + "=\n"
+ if err := os.WriteFile(f.envPath(), []byte(own), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(activator.InstanceKeyVar+"=configured-B\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ launch, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, kv := range launch.Env {
+ if strings.HasPrefix(kv, activator.InstanceKeyVar+"=") {
+ t.Fatal("a replica's claim launch env carries the key")
+ }
+ }
+ snap, err := f.backend.SnapshotClaimLaunch(t.Context(), f.rt)
+ if err != nil || snap.InstanceKeySHA256 != "" || snap.InstanceKeySource != "" {
+ t.Fatalf("snapshot names a key for a replica: %q %q, %v", snap.InstanceKeySource, snap.InstanceKeySHA256, err)
+ }
+ if raw, _ := os.ReadFile(f.envPath()); string(raw) != own {
+ t.Fatal("a replica's claim launch changed its own env file")
+ }
+}
+```
+
- [ ] **Step 2: Run them to verify they fail**
-Run: `nice -n 19 go test ./internal/activator/ -run 'TestEnsureClaimAdminTokenSyncs|TestStartAdmittedRetriesReuse' -count=1 -v && nice -n 19 go test ./internal/host/ -run 'TestClaimFirstWakeKey|TestClaimLaunchMintsAKey|TestClaimSnapshotNames' -count=1 -v`
+Run: `nice -n 19 go test ./internal/activator/ -run 'TestEnsureClaimAdminTokenSyncs|TestStartAdmittedRetriesReuse' -count=1 -v && nice -n 19 go test ./internal/host/ -run 'TestClaimFirstWakeKey|TestClaimLaunchMintsAKey|TestClaimSnapshotNames|TestClaimReplicaLaunch' -count=1 -v`
Expected, as checked in the scratch build:
- `TestEnsureClaimAdminTokenSyncsAPresentKeyBeforeItAnswers` fails: the early return answers without a sync.
- `TestStartAdmittedRetriesReuseTheirInputsAndMaterializeFirst` passes: it pins existing behaviour (ruling 16).
@@ -2485,21 +2779,21 @@ Replace the body of `EnsureClaimAdminToken` from the early return to its end:
}
```
-In `SnapshotClaimLaunch`, read the two layers, keep `instanceEnv`'s merge, and record the delivered key. The identity is computed from `Env` as before, so it does not change:
+In `SnapshotClaimLaunch`, read the two layers, compose them as `instanceEnv` does (`fileEnvFor`, Task 3b), and record the delivered key, never a replica's. The identity is computed from `Env` as before, so it does not change:
```go
cfgEnv, ownEnv, err := b.instanceEnvParts(rt)
if err != nil {
return out, err
}
- vars := mergeEnv(cfgEnv, ownEnv) // instanceEnv, with its two layers kept
+ vars := fileEnvFor(rt, cfgEnv, ownEnv) // instanceEnv, with its two layers kept
if vars["CARLOS_ADMIN_TOKEN"] == "" {
return out, hostwrite.ErrUnavailable
}
// The key this launch delivers and the file that supplied it: what the
// process's adoption report is checked against (ruling 21, O6). Not in
// the identity: the env already is.
- if key, layer := instancekey.Effective(cfgEnv, ownEnv); key != "" {
+ if key, layer := instancekey.Effective(cfgEnv, ownEnv); key != "" && !rt.Replica {
out.InstanceKeySHA256, out.InstanceKeySource = instancekey.Digest(key), instancekey.SourceOf(layer)
}
```
@@ -2537,7 +2831,7 @@ Expected: PASS, including `TestEnsureClaimAdminTokenInspectsSidecarBeforeAuthori
```bash
gofmt -l internal/activator internal/host
-git add internal/activator/claim_snapshot.go internal/activator/claim_instancekey_test.go internal/activator/claimretry_instancekey_linux_test.go internal/host/claimowners.go internal/host/claim_instancekey_test.go
+git add internal/activator/claim_snapshot.go internal/activator/claim_instancekey_test.go internal/activator/claimretry_instancekey_linux_test.go internal/host/claimowners.go internal/host/claim_instancekey_test.go internal/host/claim_replica_instancekey_test.go
git commit -m "claim: the launch source mints the instance key under the guard, syncs it before Establish, and names it (L21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
@@ -3582,5 +3876,5 @@ Question 5 (restarts inside a claim session) is withdrawn: its premise was wrong
## Follow-ups this plan does not build
-- **O6 and O7** are other plans' work, in that order (rulings 23 and 24).
+- **O7, then O6.** Both are other plans' work. O7 (moves' artifact lifecycle) ships first; O6 (the writer, on claim launches) ships only after it (ruling 24), so no record is ever written that moves cannot retire.
- **Low-entropy configured keys.** A `config` record publishes the SHA-256 of a configured key in the data bucket (ruling 9). For a random key that reveals nothing. For a short key an owner typed by hand, a data-bucket reader could test guesses against it. Rastrillo's docs could say that a configured `CARLOS_INSTANCE_KEY` should be random.
diff --git a/docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md b/docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md
index 1eb5718d6..6e843ed2e 100644
--- a/docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md
+++ b/docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md
@@ -52,3 +52,24 @@ The helper reports on every process start (at most once per process), and the bo
3. **A configured key's digest is published.** A `config` record puts the SHA-256 of a configured secret in the data bucket. That reveals nothing for a random key; a weak, hand-typed key could be guessed against it. It is listed as a follow-up.
4. **Stage B has its own diverging O2' in plan text.** Its rev 3 (919662d2) has the `KeyEvidence` reader, with presence-only config evidence and the `export` parser. Stage B's planner must replace that step with this plan's O2' and rewrite its ruling 55, as O2' says.
5. **One report lost to box loss inside the publisher's bound.** A box lost within that bound (a second by default) after the first adoption loses that report. The pin then refuses until a later session re-reports. That is safe, but worth knowing.
+
+## Rev 3.1 (Astra round 3)
+
+Round 3 found no P1. Rev 3.1 fixes the two remaining findings; both are accepted.
+
+- **R3-1 (P2), the replica exclusion at delivery.** Ruling 25: `fileEnvFor`, the composition every launch path uses, drops `CARLOS_INSTANCE_KEY` for a replica row. That covers Start, Seed, the nested prepare, `LaunchEnvironment` and the claim snapshot. The helper's step 2 then has nothing to return, so no rastrillo change is needed. It is in the new Task 3b; Task 4's snapshot uses it and names no key for a replica.
+- **R3-2 (P3), the dependency order.** The follow-ups now say O7 ships before O6.
+- **Effect on stage B:** O2' and its test are unchanged, and nothing stage B consumes changes. The moves env report for a replica no longer lists the key, on the source and the target alike.
+
+**How it was checked:**
+- The plan's Go blocks were extracted from the document, rebuilt into a tree and compared byte for byte with the tested scratch tree. They matched.
+- `go vet` and gofmt were clean. Builds for darwin, windows and linux/arm64 were clean.
+- The new tests were run, sandbox off for sockets:
+ - `TestReplicaLaunchIsHandedNoInstanceKey`: real replica Start and Seed, with a configured key, a pre-existing env key and both, plus a home-row control case;
+ - `TestNestedReplicaPrepareIsHandedNoInstanceKey`: run and seed roles;
+ - `TestClaimReplicaLaunchIsHandedNoInstanceKey`.
+
+ They pass with the activator and host launch families. With the strip removed, the replica launch test fails in all three cases.
+- Scratch copies are deleted, and `$TMPDIR/gocache` is kept.
+
+**Residual:** a replica on a claim launch before O6 has no control socket. It sees step 4, as before L21, and is still handed no key.