carlos / platform Public

Instance key provision plan rev 4: a replica gets its home's key (ruling 26)

Reverts rev 3.1's replica strip. A replica launch is gated by the agent
(replicaKeyGate) on every path: a delivered key must be the home's by
digest, and a keyless replica starts only with a control socket, so the
helper can never fall back to minting. A pre-roll check records verified
home keys in CARLOS_REPLICA_HOME_KEYS. Delivering the home key's value to
a keyless replica is new obligation O8 (replica placements, #162), with
refusal as the fallback. Tested through real launches running rastrillo's
resolution. O2' unchanged. Answers Astra round 4.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 3e42e8e7f0cadf2e7048e8562c03c3d3634411dc parent 5d90d85
2 files changed, +677 −181
  • docs/superpowers/plans/2026-10-10-instance-key-provision.md +654 −181
  • docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md +23 −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 0ae36c7e3..d1649d58e 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.1
+# Instance Key Provision (Region HA L21) Implementation Plan, rev 4
> **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,25 @@
- 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 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.
+- Review: Astra rounds 1 to 4, `docs/superpowers/plans/CODEX-PLAN-INSTANCE-KEY-R1.md` to `-R4.md` (verdict each time: not ready; rounds 3 and 4 found no P1). Their findings are answered in the four tables below, newest first.
+
+## Rev 4: round 4 dispositions
+
+Astra's round 4 (`CODEX-PLAN-INSTANCE-KEY-R4.md`, reviewed at cba8ae4e) found two P2s in the rev 3.1 replica fix. Paul decided the follow-up instead of deferring it: **a replica gets its home's key** (ruling 26, which replaces rulings 17 and 25).
+
+| # | Finding | Disposition | Change | Test |
+|---|---|---|---|---|
+| R4-1 | P2. The unconditional strip breaks existing replicas that share a configured key matching the home's. "Runs everywhere" is wrong. | **Accepted** (ruling 26). | The strip is reverted: Task 3b's `fileEnvFor` is gone, and `mergeEnv` composes a replica's env as any other. A replica whose key is exactly its home's keeps it. One whose key differs is refused with a log line. A pre-roll check (rollout step 1) lists every replica's key against its home's before any box is upgraded, and records the matches in `CARLOS_REPLICA_HOME_KEYS`. The wording at rollout step 6 is corrected. | `TestReplicaLaunchWithoutAControlSocket` (the home's configured key runs; a pre-existing env key that is the home's runs; a differing key is refused; an unverifiable key is refused); `TestClaimReplicaLaunchIsGated`; `TestNestedReplicaPrepareIsGated`; `TestParseHomeKeys`, `TestReplicaHomeKeysFromTheEnvironment` |
+| R4-2 | P2. Removing the variable does not refuse on a claim launch: with no control socket, `InstanceKey("")` returns `ErrNotOnCarlos` and the app mints its own key over the home's data. | **Accepted** (ruling 26). Verified: rastrillo e3ea08de's step 4, and `SnapshotClaimLaunch`'s env has no socket (claim_snapshot.go:116). | An agent-side refusal, `replicaKeyGate`, runs on every replica launch before anything starts. A replica with no key of its home's starts only with a control socket, where the helper refuses at step 3. Without one (a seed, a claim launch before O6, a failed bind) the agent refuses the launch itself. | `TestReplicaLaunchWithoutAControlSocket` and `TestReplicaSeedIsGated` run the app as rastrillo's resolution. A keyless replica is refused and no app runs; with the gate disabled the app reports "minted". `TestKeylessReplicaWithAControlSocketLeavesTheHelperToRefuse` (the helper reports `ErrNoInstanceKey`) |
+
+**Counts:** 2 accepted, 0 declined.
+
+**New obligation O8 (replica placements, #162).** The platform has no channel today for a replica to read its home's key. A replica record names no home: `provision.Replica` copies the app, account, pool and channel, not the home's host. The one-way mirror between home and replica is the app's own (titogo's), not the platform's. A home's `<host>.env` is published only on claim boxes, once O1' is in, into the home region's data bucket. So obtaining the home key's *value* for a replica is O8, with this plan's fallback: a replica that cannot be handed its home's key gets none, and refuses to start reliably (ruling 26). Until O8, verification uses the digests the pre-roll check records.
+
+**Effect on stage B and O2':**
+- O2' and its test are unchanged.
+- `SnapshotClaimLaunch` can now return `activator.ErrReplicaKey` for a replica row. A relocated replica's launch passes the same gate, so it needs its home's key, verified, or O6's socket.
+- `LaunchEnvironment` is as in rev 3 again, so the moves env report lists a replica's key like any other.
## Rev 3.1: round 3 dispositions
@@ -197,7 +215,7 @@ Each ruling ends with its cost if wrong. Rulings 1 to 12 are rev 1's, amended wh
- unit-backed tenants: their env is `/etc/carlos/apps/<host>.env`, root-only and unreadable to the agent (backend_unit.go:174-178);
- TCP-addr routes: an app supervisor, not the agent, starts them;
- sidecars and workers: they have their own rows and env files (open question 4, provisional default: a follow-up);
-- replica rows: ruling 17.
+- replica rows: ruling 26 (only the home's key, never a minted one).
The agent sets `CARLOS_CONTROL_SOCKET` only for instance children (`agentOwnedVars`), so `carlos.Running()` is false for unit tenants, TCP-addr routes, sidecars and workers, and the helper keeps today's behaviour. *Cost if wrong:* an excluded process that runs with `CARLOS_CONTROL_SOCKET` set and adopts the helper refuses to start. That is loud, not silent.
@@ -209,7 +227,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`), 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.
+**17. Replicas are excluded from platform-key adoption (rev 2; superseded by ruling 26).** Rev 2 deferred a replica's key and minted none; ruling 26 decides it.
**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.
@@ -238,15 +256,27 @@ 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.
+**25. A replica is handed no key at all (rev 3.1; superseded by ruling 26).** Rev 3.1 stripped `CARLOS_INSTANCE_KEY` from every replica launch. Astra's round 4 showed that broke replicas that already share their home's configured key, and did not refuse on a launch without a control socket. The strip is reverted.
-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.
+**26. A replica gets exactly its home's key (rev 4; Paul's decision, 2026-10-11; replaces rulings 17 and 25; Astra round 4).** A replica (#162) mirrors its home's data, which is sealed under the home's effective key. A replica is handed exactly that key or none, and it never mints.
+- **Never minted.** No path mints on a replica row (`InstanceKeyReplica`), and the backfill skips replicas.
+- **The gate, `replicaKeyGate`,** runs on every replica launch before anything starts: Start, Seed, the nested prepare's run and seed roles, and the claim launch source.
+ - If the replica's env files deliver a key, the replica starts only when that key's digest is its home's (`ExecBackend.ReplicaHomeKeys`). A key that differs is refused, with the log line `activator: replica <host> not started: CARLOS_INSTANCE_KEY is not its home's key`. A key that cannot be checked is refused too.
+ - If the env files deliver no key, the replica starts only with a bound control socket. There an app on rastrillo's `InstanceKey` with no key of its own gets `ErrNoInstanceKey` and refuses (step 3), and an app that never asks for a key runs as before. Without a socket (every seed, every claim launch before O6, a flat launch whose bind failed) the helper would fall to step 4 and mint over the home's data. So the agent refuses the launch itself: it does not leave the refusal to the helper.
+- **Where the home's key comes from.** The home's key reaches a replica through O8: a replica record that names its home, and a read of the home's published env item, verified by digest against the home's adoption record or env item. Until O8, nothing can hand a keyless replica its home's key, so it gets none and is gated as above. Verification uses the digests the pre-roll check records in the box's `CARLOS_REPLICA_HOME_KEYS` (`replica-host=<sha256>`, comma-separated, read by `replicaHomeKeys`). A value that does not parse verifies nothing.
+- **No regression on upgrade.** A replica that shares a configured key that is its home's effective key keeps it: the pre-roll check lists it, and the gate passes it. A replica whose configured key differs is refused, and the pre-roll check shows it before any box is upgraded (rollout step 1).
+- **The adoption verb** still answers 409 on a replica: a replica's record would bind a writer whose data is the home's.
+
+*Cost if wrong:*
+- A keyless replica of an app with a seed command cannot seed until O8 or a verified configured key, because seeds have no control socket.
+- A keyless replica on a claim box does not start before O6. Claim launches start nothing before Group D anyway.
+- A flat replica whose control bind fails does not start.
+- A replica whose key the operator never listed is refused after the roll. That is loud and fixed by the list.
+- Until O8, the list is a snapshot. If a home's effective key changes after the check while its replica keeps the old one, the replica still passes until the list is refreshed. Under rulings 5 and 13 that happens only when a home's own `<host>.env` is edited by hand, or when the key is configured per host (an instance overlay) on one side only. The rollout says to re-run the check after either. A shared app-level configured key changes on both sides at once, and the replica is then refused until its list entry is refreshed, which is safe.
## Obligations on other plans
-Rev 3 rewrites O1', O2', O3', O4, O5 and O6, and adds O7.
+Rev 3 rewrites O1', O2', O3', O4, O5 and O6, and adds O7. Rev 4 adds O8.
- **O1' (the C8 files-manifest provider, `ClaimFilesManifest`):** `Establish` leaves a committed manifest whose `hostfiles.EnvItem` holds exactly the bytes of `<host>.env` when it runs. A new admission starts with no adoption record (ruling 20): the new generation's manifest carries no `instancekey.AdoptionItem` (`StagePlatformDelete`), and `Establish` removes the local `<host>.instance-key-adoption.json` under the start's captured work. It never writes `<host>.env`. Any error (store unreachable, `If-Match` lost) is returned, and the wake fails before the app executes. Tests in the provider's plan, run against the provider itself rather than a fake:
- `TestEstablishPublishesTheEnvFileWithItsInstanceKey`: a dir store, a predecessor manifest whose env item lacks the key and which carries an adoption item, and a local `<host>.env` with the key plus a local record. After `Establish`, `ReadPlatform(EnvItem)` equals the local bytes, the new manifest has no adoption item, the local record is gone, and the manifest is the committed revision.
@@ -265,6 +295,7 @@ Rev 3 rewrites O1', O2', O3', O4, O5 and O6, and adds O7.
- `Publish` is a `hostfiles.PlatformRelay` over the session's files publisher, whose `hostfiles.Run` loop runs for the session.
- The record's write runs under the claim's guard (a hostwrite selection for the host, as the token's mint does).
- The launch env gains the agent-owned `CARLOS_INSTANCE_KEY_SOURCE` (`platform` or `config`, from `InstanceKeySource`), set only when the socket is bound. It is stripped from tenant files like `CARLOS_CONTROL_SOCKET` (`agentOwnedVars`) and added to appconfig's `agentOwned` list.
+ - `SnapshotClaimLaunch`'s replica gate (ruling 26) then takes the bound socket as `haveControl`, so a keyless replica runs and its helper refuses at step 3.
- **O6 does not ship before O7.** Until O6, ruling 23's refusal stands.
- **O7 (moves, the journalled artifact lifecycle; ruling 24).** These lists change:
- `installArtifactPaths` (internal/host/movesinstall.go:751-754): append `host + instancekey.AdoptionSuffix` after `host + ".env"`. It flows into the dirty return's pre-move park (movesinstall.go:852), into `sourceArtifactPaths` (movesfinal.go:586-587) for the source finalization's park (movesfinal.go:527), and into the rollback's candidates (movesrollback.go:1314).
@@ -274,6 +305,14 @@ Rev 3 rewrites O1', O2', O3', O4, O5 and O6, and adds O7.
- Journals: the lists are journalled in order, and a journal written before O7 keeps its recorded list. No record exists anywhere before O6 ships, so an older journal cannot miss one.
- Tests (in the moves change): `TestMoveAwayRetiresTheAdoptionRecordSoTheHostCanReturn`. A flat source holds a record; the move completes; the source finalization parks the record; `EmptyInstallationCensus.CheckLocal` for a reinstall of the host on the source box passes; a move back lands. Also the dirty-return variant: a return over a source that still holds a stale record parks it before placing anything.
+- **O8 (replica placements, #162; ruling 26).** It hands a replica its home's key. Today's fallback: a replica that cannot be handed its home's key gets none, and refuses to start reliably (`replicaKeyGate`).
+ - The replica record names its home. `provision.Replica` and the console's create door record the home's host (and so its incarnation, through the home's claim) in an immutable field. Today a replica record names only the app, account, region and role.
+ - The replica's box reads the home's published `EnvItem` from the home's files manifest, in the home region's data bucket (O1' publishes it). That needs read access for the replica's region to the home region's bucket, which no box has today.
+ - It verifies the env item's effective key by digest against the home's adoption record (`instancekey.Binds` to the home's current writer) or the env item itself.
+ - It writes the key, create-only, into the replica's own `<host>.env` (or keeps a configured key that matches), and supplies `ExecBackend.ReplicaHomeKeys` from the same read, replacing `CARLOS_REPLICA_HOME_KEYS`.
+ - A home with a configured key is delivered the same way: the digest is of the home's effective key, whatever its source.
+ - Test, in O8's plan: a keyless replica of an adopting app starts with exactly its home's key, and its helper resolves it; a home whose env item is unreadable or unverifiable leaves the replica refused.
+
### O2' for stage B
Apply to `internal/hostrelocate/pin.go` on `region-ha-1b-next-build` after merging main, which brings `internal/instancekey`. It goes in place of Task 8b step 0's diff, and that step's `KeyEvidence` reader goes with it. It was verified in a scratch copy of `origin/region-ha-1b-next-build` (919662d2) with `internal/instancekey` added: `go build ./internal/...` and `go vet` clean, and `go test ./internal/hostrelocate/ -count=1` passes, every existing pinner test included.
@@ -640,6 +679,7 @@ Two changes to the ask above, and only the second is new:
4. **Adoption, then box loss.** The record is in the bucket with no admission or release after it (`TestAdoptionSurvivesBoxLossWithoutAnotherAdmissionOrRelease`).
5. **A failed sync, then a retry.** The retry keeps the bytes, redoes the fchmod and both syncs, and only then succeeds; no child starts in between (Tasks 2 to 4).
6. **An empty `CARLOS_INSTANCE_KEY=` in `<host>.env` with a configured key.** The child gets the configured key and nothing is minted (Task 3).
+7. **A replica without a control socket.** A keyless replica is refused by the agent, before its app could mint; a replica whose key is not its home's is refused; its home's key runs (Task 3b, through the helper's real resolution).
## File structure
@@ -647,7 +687,7 @@ Two changes to the ask above, and only the second is new:
- 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`, `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).
+- Create `internal/activator/replicakey.go` (`replicaKeyGate`, `ErrReplicaKey`) and `internal/host/replicahomekeys.go` (`replicaHomeKeys`), with `internal/host/replicahomekeys_test.go`. Modify `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys`; the gate in `seedChild` and `spawnChildFrom`), `internal/activator/prepare.go` and `internal/host/agent.go` (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`.
@@ -671,7 +711,7 @@ Two changes to the ask above, and only the second is new:
- Modify: `internal/activator/admintoken.go:245-257` (`parseEnvBytes`), `internal/activator/backend_exec.go:1879-1905` (`LaunchEnvironment`), `:1980-1990` (`mergeEnv`)
**Interfaces:**
-- Produces: `instancekey.Var`, `AdoptionSuffix`, `AdoptionItem`, `ParseEnv`, `Layer` (`LayerNone`, `LayerOwn`, `LayerConfig`), `Effective`, `Digest`, `SourcePlatform`, `SourceConfig`, `SourceOf`, `Binding`, `Adoption`, `AdoptionRecord`, `ParseAdoption`, `(Adoption).Binds`, `VerifyDelivered`, `ValidDigest`.
+- Produces: `instancekey.Var`, `AdoptionSuffix`, `AdoptionItem`, `ParseEnv`, `Layer` (`LayerNone`, `LayerOwn`, `LayerConfig`), `Effective`, `Digest`, `SourcePlatform`, `SourceConfig`, `SourceOf`, `Binding`, `Adoption`, `AdoptionRecord`, `ParseAdoption`, `(Adoption).Binds`, `VerifyDelivered`, `ValidDigest`, `ParseHomeKeys` (ruling 26, for Task 3b).
- [ ] **Step 1: Write the failing tests**
@@ -771,6 +811,24 @@ func TestVerifyDeliveredRefusesAnyOtherKey(t *testing.T) {
}
}
}
+
+// Ruling 26: the pre-roll check's verified home keys, as the box reads
+// them. A list that may be wrong verifies nothing.
+func TestParseHomeKeys(t *testing.T) {
+ a, b := Digest("home-key-a"), Digest("home-key-b")
+ got, err := ParseHomeKeys(" shop-au.example.com=" + a + ", shop-uk.example.com=" + b + ",")
+ if err != nil || len(got) != 2 || got["shop-au.example.com"] != a || got["shop-uk.example.com"] != b {
+ t.Fatalf("ParseHomeKeys = %v, %v", got, err)
+ }
+ if got, err := ParseHomeKeys(""); err != nil || len(got) != 0 {
+ t.Fatalf("empty = %v, %v", got, err)
+ }
+ for _, bad := range []string{"shop-au.example.com", "shop-au.example.com=abc", "=" + a, "shop-au.example.com=" + a + ",shop-au.example.com=" + b, "a b=" + a} {
+ if _, err := ParseHomeKeys(bad); err == nil {
+ t.Errorf("%q parsed", bad)
+ }
+ }
+}
```
- [ ] **Step 2: Run them to verify they fail**
@@ -978,6 +1036,33 @@ func ValidDigest(s string) bool {
}
return true
}
+
+// ParseHomeKeys reads CARLOS_REPLICA_HOME_KEYS, the box's list of verified
+// home keys for its replica rows (instance-key plan ruling 26):
+// "replica-host=sha256,replica-host=sha256", each digest the home's
+// effective key as the pre-roll check computed it. Whitespace around
+// entries is ignored and "" is an empty list. A malformed entry, or a host
+// named twice, fails the whole value: a list that may be wrong is not a
+// list to verify keys against.
+func ParseHomeKeys(s string) (map[string]string, error) {
+ out := map[string]string{}
+ for _, e := range strings.Split(s, ",") {
+ e = strings.TrimSpace(e)
+ if e == "" {
+ continue
+ }
+ host, sha, ok := strings.Cut(e, "=")
+ host, sha = strings.TrimSpace(host), strings.TrimSpace(sha)
+ if !ok || host == "" || strings.ContainsAny(host, " /") || !ValidDigest(sha) {
+ return nil, fmt.Errorf("instancekey: home key entry %q: want replica-host=<sha256 hex>", host)
+ }
+ if _, dup := out[host]; dup {
+ return nil, fmt.Errorf("instancekey: home key for %q named twice", host)
+ }
+ out[host] = sha
+ }
+ return out, nil
+}
```
- [ ] **Step 4: One parser, and the merge follows the rule**
@@ -1378,9 +1463,8 @@ func TestEnsureInstanceKeyRedoesAFailedSyncBeforeReportingSuccess(t *testing.T)
}
}
-// Open question 1, provisional default: a replica row gets no platform key
-// on any path, so an app on a replica that adopts the helper refuses to
-// start rather than seal with a key its home does not hold.
+// Ruling 26: a replica row is never minted a key, on any path; the only
+// key it may hold is its home's (replicaKeyGate).
func TestReplicaRowsGetNoPlatformKey(t *testing.T) {
b := &ExecBackend{DataDir: t.TempDir()}
rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Socket: "/run/carlos/shop-au.sock", Replica: true}
@@ -1477,9 +1561,8 @@ const (
// has never been started here. Nothing was written; its first start
// mints the key.
InstanceKeyNoEnvFile
- // InstanceKeyReplica: a replica row (#162). The platform mints no key
- // for a replica until replicas can carry their home's key (open
- // question 1, provisional default).
+ // InstanceKeyReplica: a replica row (#162). A replica is never minted
+ // a key: it may hold only its home's (ruling 26, replicaKeyGate).
InstanceKeyReplica
)
@@ -2150,18 +2233,21 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
---
-### Task 3b: A replica is handed no key (ruling 25)
+### Task 3b: A replica gets exactly its home's key, or none and a refusal (ruling 26)
**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)
+- Create: `internal/activator/replicakey.go`, `internal/host/replicahomekeys.go`
+- Modify: `internal/activator/backend_exec.go` (`ExecBackend.ReplicaHomeKeys`; the gate in `seedChild` and `spawnChildFrom`), `internal/activator/prepare.go` (`prepareAt`), `internal/host/agent.go` (the wiring)
+- Test: `internal/activator/replica_instancekey_test.go`, `internal/activator/replica_instancekey_linux_test.go`, `internal/host/replicahomekeys_test.go`
**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.
+- Consumes: Task 1's `instancekey.Digest` and `ParseHomeKeys`; Task 2's `InstanceKeyReplica` (never minted); test helpers `testExecBackend`, `tmpSock`, `nestedBackend`, `readEnvFile`.
+- Produces: `ExecBackend.ReplicaHomeKeys func(host string) (sha256 string, ok bool)`; `(*ExecBackend).replicaKeyGate(rt, env, haveControl) error`; `activator.ErrReplicaKey`; `host.replicaHomeKeys(getenv)`, which reads `CARLOS_REPLICA_HOME_KEYS`. Task 4's snapshot calls the gate.
- [ ] **Step 1: Write the failing tests**
+The app these tests launch is the test binary itself, running `TestInstanceKeyHelperProcess`: rastrillo e3ea08de's resolution and its documented caller. So each case records what an adopting app actually did ("key <sha256>", "refused" or "minted"), not only which variables it was handed. "No control socket" is a socket path too long to bind, so the launch withholds `CARLOS_CONTROL_SOCKET`, exactly as a claim launch before O6 does.
+
Create `internal/activator/replica_instancekey_test.go`:
```go
@@ -2169,104 +2255,224 @@ package activator
import (
"context"
+ "errors"
+ "fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/instancekey"
"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 {
+// The app the replica tests launch is this test binary, running
+// TestInstanceKeyHelperProcess: rastrillo e3ea08de's carlos.InstanceKey
+// and its documented caller, so a test sees what an adopting app does, not
+// only which variables it was handed.
+const helperOutEnv = "GO_TEST_INSTANCE_KEY_HELPER_OUT"
+
+var (
+ errHelperNoInstanceKey = errors.New("ErrNoInstanceKey")
+ errHelperNotOnCarlos = errors.New("ErrNotOnCarlos")
+)
+
+// helperInstanceKey is rastrillo e3ea08de's InstanceKey(own) with Running():
+// own, then $CARLOS_INSTANCE_KEY verbatim, then ErrNoInstanceKey when
+// $CARLOS_CONTROL_SOCKET is set, else ErrNotOnCarlos.
+func helperInstanceKey(own string) (string, error) {
+ if own != "" {
+ return own, nil
+ }
+ if k := os.Getenv(InstanceKeyVar); k != "" {
+ return k, nil
+ }
+ if os.Getenv("CARLOS_CONTROL_SOCKET") != "" {
+ return "", errHelperNoInstanceKey
+ }
+ return "", errHelperNotOnCarlos
+}
+
+// TestInstanceKeyHelperProcess is the app, not a test: with no output path
+// set it does nothing. It resolves its key as an adopting app with no key
+// of its own does, records the outcome, and exits:
+// - "key <sha256>": it sealed with the key it was handed;
+// - "refused": ErrNoInstanceKey, it did not start;
+// - "minted": ErrNotOnCarlos, and the documented caller minted its own.
+func TestInstanceKeyHelperProcess(t *testing.T) {
+ out := os.Getenv(helperOutEnv)
+ if out == "" {
+ return
+ }
+ var outcome string
+ switch key, err := helperInstanceKey(""); {
+ case err == nil:
+ outcome = "key " + instancekey.Digest(key)
+ case errors.Is(err, errHelperNoInstanceKey):
+ outcome = "refused"
+ default:
+ outcome = "minted"
+ }
+ _ = os.WriteFile(out+".tmp", []byte(outcome), 0o600)
+ _ = os.Rename(out+".tmp", out)
+ os.Exit(0)
+}
+
+// stageHelperApp makes this test binary host's live build, running only
+// TestInstanceKeyHelperProcess, and returns where its outcome lands.
+func stageHelperApp(t *testing.T, b *ExecBackend, host, ver 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())
+ self, err := os.Executable()
+ if err != nil {
+ t.Fatal(err)
}
- deadline := time.Now().Add(time.Second)
+ out := filepath.Join(t.TempDir(), "outcome")
+ t.Setenv(helperOutEnv, out)
+ vdir := filepath.Join(b.OptDir, "versions", ver)
+ if err := os.MkdirAll(vdir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ bin := filepath.Join(vdir, "app")
+ script := fmt.Sprintf("#!/bin/sh\nexec '%s' -test.run='^TestInstanceKeyHelperProcess$'\n", self)
+ if err := os.WriteFile(bin, []byte(script), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(b.OptDir, "live"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ os.Remove(b.liveLink(host))
+ if err := os.Symlink(bin, b.liveLink(host)); err != nil {
+ t.Fatal(err)
+ }
+ return out
+}
+
+// outcomeOf waits up to two seconds for the helper's outcome; "" is no app
+// ran at all.
+func outcomeOf(path string) string {
+ deadline := time.Now().Add(2 * 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
+ if raw, err := os.ReadFile(path); err == nil {
+ return string(raw)
}
time.Sleep(10 * time.Millisecond)
}
- return nil
+ return ""
+}
+
+// noControlSocket is a socket path too long to bind, so the launch's
+// control listener fails and CARLOS_CONTROL_SOCKET is withheld: the same
+// launch a claim box runs before O6, and a flat launch whose bind failed.
+func noControlSocket(t *testing.T) string {
+ return filepath.Join(t.TempDir(), strings.Repeat("s", 120)+".sock")
+}
+
+type replicaCase struct {
+ name string
+ replica bool
+ own, config string // env file bodies ("" = no config file)
+ homeKey string // the home's effective key the box lists ("" = none listed)
+ start string // "refused" (no app runs) or the helper outcome wanted
+ wantKeyFromOwnOK bool // a home row: the outcome is the key minted into <host>.env
}
-// 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) {
+// Ruling 26 through a real launch and the helper (Astra round 4): a
+// replica runs only with exactly its home's key, or with none and a control
+// socket its helper refuses on. Every launch here has no control socket,
+// which is where the helper would otherwise mint.
+func TestReplicaLaunchWithoutAControlSocket(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) + "="},
+ homeKey := strings.Repeat("H", 43) + "="
+ for _, tc := range []replicaCase{
+ {name: "a keyless replica is refused by the agent, not left to mint", replica: true, own: tok, start: "refused"},
+ {name: "a configured key that is the home's runs and seals with it", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, start: "key " + instancekey.Digest(homeKey)},
+ {name: "a pre-existing env key that is the home's runs", replica: true, own: tok + InstanceKeyVar + "=" + homeKey + "\n", homeKey: homeKey, start: "key " + instancekey.Digest(homeKey)},
+ {name: "a configured key that differs is refused", replica: true, own: tok, config: InstanceKeyVar + "=configured-B\n", homeKey: homeKey, start: "refused"},
+ {name: "a configured key with no verified home key is refused", replica: true, own: tok, config: InstanceKeyVar + "=" + homeKey + "\n", start: "refused"},
+ {name: "a home row mints and its app seals with the minted key", own: tok, wantKeyFromOwnOK: true},
} {
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 {
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: tc.replica, Socket: noControlSocket(t)}
+ 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)
}
- 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 tc.homeKey != "" {
+ b.ReplicaHomeKeys = func(h string) (string, bool) {
+ return instancekey.Digest(tc.homeKey), h == rt.Host
}
- if handed {
- t.Fatalf("a replica launch was handed %s (%d bytes)", InstanceKeyVar, len(got))
+ }
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, err := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+ got := outcomeOf(out)
+ if tc.wantKeyFromOwnOK {
+ env, _ := readEnvFile(own)
+ if got != "key "+instancekey.Digest(env[InstanceKeyVar]) {
+ t.Fatalf("home row: outcome %q, want the minted key", got)
}
- if raw, _ := os.ReadFile(own); string(raw) != tc.own {
- t.Fatal("a replica launch changed its own env file")
+ return
+ }
+ if tc.start == "refused" {
+ if !errors.Is(err, ErrReplicaKey) || got != "" {
+ t.Fatalf("Start = %v, app outcome %q; want ErrReplicaKey and no app", err, got)
}
- env, _, err := b.LaunchEnvironment(rt)
- if err != nil {
+ } else if got != tc.start {
+ t.Fatalf("app outcome %q, want %q (Start: %v)", got, tc.start, err)
+ }
+ if raw, _ := os.ReadFile(own); string(raw) != tc.own {
+ t.Fatal("a replica launch changed its own env file: a replica never mints")
+ }
+ })
+ }
+}
+
+// A replica's seed has no control socket: a keyless replica's seed is
+// refused before it runs; one handed its home's key runs.
+func TestReplicaSeedIsGated(t *testing.T) {
+ tok := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ homeKey := strings.Repeat("H", 43) + "="
+ for _, tc := range []struct {
+ name, config string
+ want string
+ }{
+ {"keyless", "", ""},
+ {"the home's key", InstanceKeyVar + "=" + homeKey + "\n", "key " + instancekey.Digest(homeKey)},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Seed: `["seed"]`}
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte(tok), 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)
}
- 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")
+ }
+ b.ReplicaHomeKeys = func(h string) (string, bool) { return instancekey.Digest(homeKey), true }
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ err := b.Seed(context.Background(), rt)
+ got := outcomeOf(out)
+ if tc.want == "" {
+ if !errors.Is(err, ErrReplicaKey) || got != "" {
+ t.Fatalf("Seed = %v, outcome %q; want ErrReplicaKey and no seed process", err, got)
}
+ return
+ }
+ if err != nil || got != tc.want {
+ t.Fatalf("Seed = %v, outcome %q; want %q", err, got, tc.want)
}
})
}
@@ -2279,30 +2485,95 @@ Create `internal/activator/replica_instancekey_linux_test.go`:
package activator
import (
+ "context"
+ "errors"
+ "net"
"os"
"path/filepath"
+ "strings"
"testing"
+ "time"
"github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
)
-// 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) {
+// socketsAllowed reports whether this process may bind a unix socket (the
+// sandbox refuses it).
+func socketsAllowed(t *testing.T) bool {
+ dir, err := os.MkdirTemp("", "ik") // short: a test's own temp dir can exceed a socket path's limit
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(dir)
+ l, err := net.Listen("unix", filepath.Join(dir, "p.sock"))
+ if err != nil {
+ return false
+ }
+ l.Close()
+ return true
+}
+
+// Ruling 26 with a control socket: a keyless replica runs, and the app's
+// helper refuses at step 3 (ErrNoInstanceKey) instead of minting.
+func TestKeylessReplicaWithAControlSocketLeavesTheHelperToRefuse(t *testing.T) {
+ if !socketsAllowed(t) {
+ t.Skip("needs unix sockets (run with the sandbox off)")
+ }
+ b := testExecBackend(t)
+ rt := registry.Route{Host: "shop-au.example.com", Kind: "instance", Replica: true, Socket: tmpSock(t)}
+ if err := os.WriteFile(filepath.Join(b.DataDir, rt.Host+".env"), []byte("CARLOS_ADMIN_TOKEN="+strings.Repeat("c", 64)+"\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ out := stageHelperApp(t, b, rt.Host, "v1")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ proc, _, _ := b.Start(ctx, rt)
+ if proc != nil {
+ defer proc.Stop(context.Background())
+ }
+ if got := outcomeOf(out); got != "refused" {
+ t.Fatalf("app outcome %q, want the helper's refusal", got)
+ }
+}
+
+// Ruling 26 on the nested layout: the seed role has no control socket, so
+// a keyless replica's seed is refused at prepare; a run role handed a key
+// that is not the home's is refused too; one handed the home's key is
+// prepared with it.
+func TestNestedReplicaPrepareIsGated(t *testing.T) {
+ homeKey := strings.Repeat("H", 43) + "="
+ for _, tc := range []struct {
+ name, config string
+ role instancectl.Role
+ refused bool
+ }{
+ {"a keyless seed", "", instancectl.RoleSeed, true},
+ {"a run role with another key", InstanceKeyVar + "=configured-B\n", instancectl.RoleRun, true},
+ {"a run role with the home's key", InstanceKeyVar + "=" + homeKey + "\n", instancectl.RoleRun, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
b, rt := nestedBackend(t, &fakeControl{inv: "inv1"}, &fakeUnit{})
rt.Replica = true
+ b.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), 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)
+ if tc.config != "" {
+ if err := os.WriteFile(filepath.Join(dir, rt.Host+".config.env"), []byte(tc.config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, ctl, logs, env, err := b.prepare(rt, tc.role)
+ if tc.refused {
+ if !errors.Is(err, ErrReplicaKey) {
+ t.Fatalf("prepare = %v, want ErrReplicaKey", err)
+ }
+ return
}
- _, ctl, logs, env, err := b.prepare(rt, role)
if err != nil {
t.Fatalf("prepare: %v", err)
}
@@ -2310,68 +2581,228 @@ func TestNestedReplicaPrepareIsHandedNoInstanceKey(t *testing.T) {
if ctl != nil {
ctl.Close()
}
- if _, ok := env[InstanceKeyVar]; ok {
- t.Fatal("a nested replica's prepared env carries the key")
+ if env[InstanceKeyVar] != homeKey {
+ t.Fatal("the prepared env does not carry the home's key")
}
})
}
}
```
+Create `internal/host/replicahomekeys_test.go`:
+
+```go
+package host
+
+import (
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+)
+
+func TestReplicaHomeKeysFromTheEnvironment(t *testing.T) {
+ sha := instancekey.Digest("home-key")
+ env := map[string]string{"CARLOS_REPLICA_HOME_KEYS": "shop-au.example.com=" + sha}
+ keys := replicaHomeKeys(func(k string) string { return env[k] })
+ if got, ok := keys("shop-au.example.com"); !ok || got != sha {
+ t.Fatalf("listed replica = %q, %v", got, ok)
+ }
+ if _, ok := keys("shop-uk.example.com"); ok {
+ t.Fatal("an unlisted replica verified")
+ }
+ env["CARLOS_REPLICA_HOME_KEYS"] = "shop-au.example.com=" + sha + ",shop-au.example.com=" + sha
+ if _, ok := replicaHomeKeys(func(k string) string { return env[k] })("shop-au.example.com"); ok {
+ t.Fatal("a malformed list verified a 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.)
+Run: `nice -n 19 go test ./internal/activator/ -run 'TestReplicaLaunchWithoutAControlSocket|TestReplicaSeedIsGated|TestKeylessReplica|TestNestedReplicaPrepareIsGated' -count=1 -v; nice -n 19 go test ./internal/host/ -run TestReplicaHomeKeys -count=1`
+Expected: build failures, `undefined: ErrReplicaKey` (and `ReplicaHomeKeys`, `replicaHomeKeys`). In the scratch build, with the gate present but disabled, the keyless and differing-key cases failed: the app reported "minted", or ran with the wrong key. That is Astra's failure.
-- [ ] **Step 3: Compose every launch's env through `fileEnvFor`**
+- [ ] **Step 3: Write the gate**
-In `internal/activator/backend_exec.go`, `instanceEnv` returns through the new function, which follows it:
+Create `internal/activator/replicakey.go`:
```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)
+package activator
+
+// A replica gets its home's key (instance-key plan ruling 26). A replica
+// (#162) mirrors its home's data, sealed with the home's key, so the only
+// key it may be handed is exactly the home's effective key. The platform
+// never mints one on a replica row (ensureInstanceKeyFor), and every
+// replica launch passes replicaKeyGate before anything runs:
+//
+// - a replica whose env files deliver a key starts only when that key's
+// digest is its home's (ReplicaHomeKeys). A key that differs, or one
+// that cannot be checked, refuses the launch with a log line;
+// - a replica whose env files deliver no key starts only with a control
+// socket. There an app on rastrillo's InstanceKey with no key of its
+// own gets ErrNoInstanceKey and refuses (step 3), and an app that never
+// asks for a key runs as before. Without a socket (a seed, a claim
+// launch before O6, a control bind that failed) the helper would fall
+// to step 4 and mint its own key over the home's data, so the agent
+// refuses the launch itself rather than leave that to the helper.
+//
+// Delivering the home's key to a replica that has none is O8's: until a
+// replica can read its home's published env item, a keyless replica gets
+// nothing.
+
+import (
+ "errors"
+ "fmt"
+ "log"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+)
+
+// ErrReplicaKey is a replica launch refused by replicaKeyGate.
+var ErrReplicaKey = errors.New("activator: replica launch refused: its instance key is not its home's")
+
+// replicaKeyGate is ruling 26's check for a launch of rt with env (the
+// composed env files) and, when haveControl, a bound control socket. It is
+// nil for every row that is not a replica. Its errors name the host and
+// the reason, never a key or a digest.
+func (b *ExecBackend) replicaKeyGate(rt registry.Route, env map[string]string, haveControl bool) error {
+ if !rt.Replica {
+ return nil
+ }
+ refuse := func(why string) error {
+ log.Printf("activator: replica %s not started: %s", rt.Host, why)
+ return fmt.Errorf("%w: %s: %s", ErrReplicaKey, rt.Host, why)
}
- return env
+ key := env[instancekey.Var]
+ if key == "" {
+ if haveControl {
+ return nil
+ }
+ return refuse("it has no key of its home's and no control socket, so an app on the instance-key helper would mint its own")
+ }
+ var home string
+ var ok bool
+ if b.ReplicaHomeKeys != nil {
+ home, ok = b.ReplicaHomeKeys(rt.Host)
+ }
+ switch {
+ case !ok:
+ return refuse(instancekey.Var + " cannot be checked against its home's key (no entry in CARLOS_REPLICA_HOME_KEYS; O8)")
+ case instancekey.Digest(key) != home:
+ return refuse(instancekey.Var + " is not its home's key")
+ }
+ return nil
}
```
-In `LaunchEnvironment`, a replica's key shadows nothing, and its env is composed the same way:
+In `internal/activator/backend_exec.go`, add the field to `ExecBackend`, after `Region`:
+
+```go
+ // ReplicaHomeKeys answers, for a replica row's host, the SHA-256 of its
+ // home's effective CARLOS_INSTANCE_KEY (instance-key plan ruling 26).
+ // O8 will supply it from the home's published env item; until then
+ // the agent reads CARLOS_REPLICA_HOME_KEYS, which the pre-roll check
+ // fills. Nil, or false for a host, means the home key cannot be
+ // verified, and a replica launch that would be handed a key is refused
+ // (replicaKeyGate).
+ ReplicaHomeKeys func(host string) (sha256 string, ok bool)
+```
+
+In `seedChild`, after the env is read:
+
+```go
+ fileEnv, err := b.instanceEnv(rt)
+ if err != nil {
+ return err
+ }
+ // A seed has no control socket, so a replica's seed is gated as a
+ // launch without one (ruling 26).
+ if err := b.replicaKeyGate(rt, fileEnv, false); err != nil {
+ return err
+ }
+ dbPath := paths.dbPath
+```
+
+In `spawnChildFrom`, once the control listener is bound or has failed, before the child's env is composed:
```go
- if k == instancekey.Var && (ownEnv[k] == "" || rt.Replica) {
- continue // an empty own key, or a replica's, shadows nothing (fileEnvFor)
+ // A replica is started only with its home's key, verified, or with
+ // none and a control socket its helper can refuse on (ruling 26).
+ if err := b.replicaKeyGate(rt, fileEnv, ctl != nil); err != nil {
+ if ctl != nil {
+ ctl.Close()
+ }
+ return nil, "", err
+ }
+ childVars := b.agentOwnedVars(rt, controlSocket, fileEnv, ctl != nil)
+```
+
+In `internal/activator/prepare.go`, `prepareAt`, before the log listener:
+
+```go
+ // A replica's run and seed roles are gated like a flat launch: the
+ // seed has no control socket, the run role has the one just bound or
+ // none (ruling 26). The sidecar role reads no instance env.
+ if role == instancectl.RoleRun || role == instancectl.RoleSeed {
+ if err := b.replicaKeyGate(rt, env, ctl != nil); err != nil {
+ if ctl != nil {
+ ctl.Close()
}
+ return p, nil, nil, nil, err
+ }
+ }
+ logs, err := listenLogs(b.logSocketFor(rt.Host, role, gen), b.logEmitter(rt))
```
+Create `internal/host/replicahomekeys.go`:
+
```go
- fileEnv := fileEnvFor(rt, cfgEnv, ownEnv)
+package host
+
+import (
+ "log"
+
+ "github.com/carlosframework/platform/internal/instancekey"
+)
+
+// replicaHomeKeys is ExecBackend.ReplicaHomeKeys from the box's
+// CARLOS_REPLICA_HOME_KEYS (instance-key plan ruling 26): the pre-roll
+// check's verified home-key digests for this box's replica rows. A value
+// that does not parse is logged and verifies nothing, so every replica
+// launch that would be handed a key is refused until it is fixed.
+func replicaHomeKeys(getenv func(string) string) func(host string) (string, bool) {
+ keys, err := instancekey.ParseHomeKeys(getenv("CARLOS_REPLICA_HOME_KEYS"))
+ if err != nil {
+ log.Printf("agent: CARLOS_REPLICA_HOME_KEYS: %v (no replica home key verifies until it is fixed)", err)
+ keys = nil
+ }
+ return func(host string) (string, bool) {
+ sha, ok := keys[host]
+ return sha, ok
+ }
+}
+```
+
+In `internal/host/agent.go`, after the `ExecBackend` literal (beside `eb.Logs`):
+
+```go
+ // The verified home keys of this box's replica rows (L21 ruling 26),
+ // until O8 reads them from each home's published env item.
+ eb.ReplicaHomeKeys = replicaHomeKeys(os.Getenv)
```
- [ ] **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.
+Run (sandbox off: the control-socket case and the nested prepare bind sockets): the Step 2 commands, then `nice -n 19 go test ./internal/activator/ -run 'Replica|InstanceKey|Seed|Start|Prepare|LaunchEnvironment|Control' -count=1`
+Expected: PASS. (With the sandbox on, the control-socket case skips and the nested run-role case cannot bind its log socket.)
- [ ] **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)
+gofmt -l internal/activator internal/host
+git add internal/activator/replicakey.go internal/activator/backend_exec.go internal/activator/prepare.go internal/activator/replica_instancekey_test.go internal/activator/replica_instancekey_linux_test.go internal/host/replicahomekeys.go internal/host/replicahomekeys_test.go internal/host/agent.go
+git commit -m "activator: a replica launches only with its home's key, verified, or none and a socket its helper refuses on (L21)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
@@ -2675,48 +3106,66 @@ func TestClaimSnapshotNamesTheDeliveredKeyAndItsSource(t *testing.T) {
}
```
-Create `internal/host/claim_replica_instancekey_test.go`, ruling 25 on the claim path:
+Create `internal/host/claim_replica_instancekey_test.go`, ruling 26 on the claim path:
```go
package host
import (
+ "errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/instancekey"
)
-// 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")
+// Ruling 26 on the claim path, which has no control socket before O6: a
+// keyless replica is never selected (its helper would mint), a key that is
+// not the home's is refused, and the home's key is selected and named.
+func TestClaimReplicaLaunchIsGated(t *testing.T) {
+ homeKey := strings.Repeat("H", 43) + "="
+ for _, tc := range []struct {
+ name, config string
+ refused bool
+ }{
+ {"keyless", "", true},
+ {"another key", activator.InstanceKeyVar + "=configured-B\n", true},
+ {"the home's key", activator.InstanceKeyVar + "=" + homeKey + "\n", false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ f.rt.Replica = true
+ f.backend.ReplicaHomeKeys = func(string) (string, bool) { return instancekey.Digest(homeKey), true }
+ own := "CARLOS_ADMIN_TOKEN=" + strings.Repeat("c", 64) + "\n"
+ if err := os.WriteFile(f.envPath(), []byte(own), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.config != "" {
+ if err := os.WriteFile(filepath.Join(f.data, adminTokenHost+".config.env"), []byte(tc.config), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ launch, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt)
+ if tc.refused {
+ if !errors.Is(err, activator.ErrReplicaKey) {
+ t.Fatalf("launch source = %v, want ErrReplicaKey", err)
+ }
+ } else {
+ found := false
+ for _, kv := range launch.Env {
+ found = found || kv == activator.InstanceKeyVar+"="+homeKey
+ }
+ if err != nil || !found {
+ t.Fatalf("launch source = %v, key delivered %v", err, found)
+ }
+ }
+ if raw, _ := os.ReadFile(f.envPath()); string(raw) != own {
+ t.Fatal("a replica's claim launch changed its own env file: a replica never mints")
+ }
+ })
}
}
```
@@ -2750,7 +3199,7 @@ Replace the body of `EnsureClaimAdminToken` from the early return to its end:
// The instance key is initialized the same way and in the same
// authorized write (L21). vars is the effective env, so a key the
// rendered configuration supplies counts as present, and a replica
- // gets none (open question 1). When both are present nothing is
+ // gets none (ruling 26). When both are present nothing is
// written, but the file is still made durable before the launch
// source answers: a key whose mint failed its sync is never launched
// with until a sync succeeds (settleClaimInstanceEnv). A file that
@@ -2779,21 +3228,26 @@ Replace the body of `EnsureClaimAdminToken` from the early return to its end:
}
```
-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:
+In `SnapshotClaimLaunch`, read the two layers, keep `instanceEnv`'s merge, pass a replica through Task 3b's gate (a claim launch has no control socket before O6), and record the delivered key. 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 := fileEnvFor(rt, cfgEnv, ownEnv) // instanceEnv, with its two layers kept
+ vars := mergeEnv(cfgEnv, ownEnv) // instanceEnv, with its two layers kept
if vars["CARLOS_ADMIN_TOKEN"] == "" {
return out, hostwrite.ErrUnavailable
}
+ // A claim launch has no control socket until O6, so a replica is
+ // selected only with its home's key, verified (ruling 26).
+ if err := b.replicaKeyGate(rt, vars, false); err != nil {
+ return out, err
+ }
// 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 != "" && !rt.Replica {
+ if key, layer := instancekey.Effective(cfgEnv, ownEnv); key != "" {
out.InstanceKeySHA256, out.InstanceKeySource = instancekey.Digest(key), instancekey.SourceOf(layer)
}
```
@@ -3043,7 +3497,7 @@ func TestInstanceKeyAdoptionRecordsTheLaunchesWriter(t *testing.T) {
}
// No launch state (a legacy launch) is the 404 an agent without the
- // verb answers; a replica refuses (ruling 17).
+ // verb answers; a replica refuses (ruling 26).
none := newAdoptionFixture(t, rt, tok, key, instancekey.SourcePlatform)
none.hasLaunch = false
replicaRT := rt
@@ -3389,7 +3843,7 @@ type AdoptionLaunch struct {
// - 204 once the record for the launch's writer is on disk, 0600, file
// and directory fsynced, and handed to the session's publisher;
// - 409 when the digest or the source is not the launch's (the app holds
-// some other key), or for a replica row (ruling 17); nothing recorded;
+// some other key), or for a replica row (ruling 26); nothing recorded;
// - 404 with no launch state, 400 for a malformed body (a session or
// generation in it included), 401 without the bearer, 500 when the
// durable write fails (nothing published).
@@ -3612,8 +4066,8 @@ type instanceKeyBackfiller interface {
// instanceKeyRoute selects the rows the backfill writes for: ready,
// activator-spawned instances, the rows execSyncable delivers config to,
-// without sidecars and without replicas (open question 1, provisional
-// default). A held row (moving, landing, restoring, releasing) belongs to
+// without sidecars and without replicas (a replica is never minted a key,
+// ruling 26). A held row (moving, landing, restoring, releasing) belongs to
// its operation and is skipped, as is a provisioning row whose first start
// will mint.
func instanceKeyRoute(rt registry.Route) bool {
@@ -3850,24 +4304,42 @@ Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
## Rollout
-1. **Merge and release.** Merging is Paul's call, as a squash on the hub, after CI is green on the tip. Cut a platform release by the README's "Releasing the platform" recipe.
-2. **Ship to every box, every region.** Use the platform roll recipe (build arm64, upload, `update-platform-binary.sh` per box, `roll-console.sh` after). The console needs nothing new, but rolls with the release as usual.
-3. **Verify the backfill on each box.** `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`. For each failure, read the per-host line above it, fix the file, and restart the instance (its start mints the key, or fails loudly with the same error). `never started here` hosts mint at their first start. Claim boxes print no backfill line: their instances mint at their next cold admission.
-4. **Post the rastrillo ask and its addendum** (above) on #66, and wait for a rastrillo release that makes the call with `source`.
-5. **Only then tell app owners to adopt** that rastrillo release: `carlos.InstanceKey(own)`. Before a box has this build, an adopting app with no key of its own refuses to start on it. An app on the platform or a configured key runs everywhere, but does not relocate until O6 and O7 are in (step 7).
-6. **App owners, before adopting:**
+1. **Pre-roll check: list every replica's key against its home's, before any box is upgraded** (ruling 26). On every box with a replica row (`sqlite3 <data>/registry.db "select host from routes where replica=1"`) and on each replica's home box, compute the effective key's digest with this function. Use the data directory for a flat row, or the instance directory for a nested one. It applies `instancekey.Effective`'s rule: the last `CARLOS_INSTANCE_KEY` line of `<host>.env` when non-empty, else of `<host>.config.env`.
+
+ ```sh
+ eff_key_sha() { # eff_key_sha DIR HOST
+ val() { [ -f "$1" ] && sed -n 's/^[[:space:]]*CARLOS_INSTANCE_KEY[[:space:]]*=[[:space:]]*\(.*\)$/\1/p' "$1" | sed 's/[[:space:]]*$//' | tail -n 1; }
+ own=$(val "$1/$2.env"); cfg=$(val "$1/$2.config.env"); key=${own:-$cfg}
+ [ -n "$key" ] && printf %s "$key" | sha256sum | cut -d' ' -f1
+ }
+ ```
+
+ For each replica:
+ - **Its home has a key and its digest equals the replica's:** add `replica-host=<digest>` to the replica box's `CARLOS_REPLICA_HOME_KEYS`, in the agent's environment drop-in, before that box is upgraded.
+ - **The two digests differ:** the replica will be refused after the roll. Tell the owner, who sets the replica's key to the home's, or accepts the refusal.
+ - **The replica has no key:** list nothing. It starts as before wherever it has a control socket. An adopting app without its own key refuses there itself (`ErrNoInstanceKey`) until O8.
+
+ Re-run the check whenever a home's own `<host>.env` is edited by hand, or a per-host key is configured on either side. The digests go only into the agent's environment, never into a log.
+2. **Merge and release.** Merging is Paul's call, as a squash on the hub, after CI is green on the tip. Cut a platform release by the README's "Releasing the platform" recipe.
+3. **Ship to every box, every region.** Use the platform roll recipe (build arm64, upload, `update-platform-binary.sh` per box, `roll-console.sh` after). The console needs nothing new, but rolls with the release as usual.
+4. **Verify on each box.**
+ - `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`. For each failure, read the per-host line above it, fix the file, and restart the instance (its start mints the key, or fails loudly with the same error). `never started here` hosts mint at their first start. Claim boxes print no backfill line: their instances mint at their next cold admission.
+ - `grep 'activator: replica .* not started'` must name only the replicas step 1 expected to be refused.
+5. **Post the rastrillo ask and its addendum** (above) on #66, and wait for a rastrillo release that makes the call with `source`.
+6. **Only then tell app owners to adopt** that rastrillo release: `carlos.InstanceKey(own)`. Before a box has this build, an adopting app with no key of its own refuses to start on it. After the roll, an app on the platform key or a configured key runs on its home, and on every other host where it is delivered a key. A replica runs only with its home's key, verified (ruling 26). None of these apps relocates until O6 and O7 are in (step 8).
+7. **App owners, before adopting:**
- An app with its own key passes it as `own`: its explicit setting, or the key file it already has, read without minting. It keeps that key, and nothing changes. Its relocation evidence is its `instance.key`, source (a).
- An app that ran on CARLOS with a **development fallback key** moves to the platform key when it adopts. Anything sealed under the old key stops reading: sessions, sealed cookies, sign-in challenges, sealed columns. Check what is sealed under the old key first. Then either pass the old key as `own` and keep it, or accept the switch knowing what it invalidates.
- - An app with replica placements passes its own key as `own` (ruling 17, provisional): the platform mints no key on a replica, and an adopting app without its own key refuses to start there.
-7. **Region HA.** Do not enable relocation on a region until this plan is rolled there and O1', O2', O3', O5 and O7 are in. Platform-key and configured-key apps relocate only after O6 as well (rulings 23 and 24); until then their relocation refuses, explicitly. This is stage B Task 10's checklist item 9, as O4 rewrites it.
+ - **An app with replica placements must not adopt with an empty `own` and only the platform key until O8.** Its replicas would have no key: an adopting replica refuses (`ErrNoInstanceKey`) wherever it has a control socket, and the agent refuses it where it has none. Such an app either passes its own key as `own` everywhere, or configures one key at app level (not per host). The pre-roll check then lists that key, and its replicas run with it.
+8. **Region HA.** Do not enable relocation on a region until this plan is rolled there and O1', O2', O3', O5 and O7 are in. Platform-key and configured-key apps relocate only after O6 as well (rulings 23 and 24); until then their relocation refuses, explicitly. This is stage B Task 10's checklist item 9, as O4 rewrites it.
-**Rollback:** roll the previous binary. Keys already written stay. That is harmless: no app reads the key until it adopts, and the next forward roll finds it present. No adoption record is written before O6.
+**Rollback:** roll the previous binary. Keys already written stay. That is harmless: no app reads the key until it adopts, and the next forward roll finds it present. No adoption record is written before O6. `CARLOS_REPLICA_HOME_KEYS` is ignored by an older agent.
## Open questions for Paul
-Questions 1 to 4 have provisional defaults, which this plan builds on. **Paul has not yet confirmed them.**
+Question 1 is decided (Paul, 2026-10-11). Questions 2 to 4 have provisional defaults, which this plan builds on. **Paul has not yet confirmed them.**
-1. **Replicas.** *Provisional default:* deferred to a follow-up; replicas are excluded from platform-key adoption until then (ruling 17). The follow-up gives a replica its home's key, which needs a way to copy the key between boxes.
+1. **Replicas.** *Decided (ruling 26):* a replica gets exactly its home's key, verified, and never mints; it refuses to start when it cannot. Delivering the key's value to a keyless replica is O8.
2. **A key set in configuration.** *Provisional default:* configuration wins (ruling 5); the name is not reserved.
3. **`report.token`.** *Provisional default:* not provisioned. The decision covers the key only.
4. **Workers and sidecars.** *Provisional default:* a follow-up; out of scope here (ruling 12).
@@ -3876,5 +4348,6 @@ Question 5 (restarts inside a claim session) is withdrawn: its premise was wrong
## Follow-ups this plan does not build
+- **O8, the home's key for replicas** (replica placements, #162). Until it lands, replicas run only with a verified configured key, or keyless with a control socket.
- **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 6e843ed2e..4cc875827 100644
--- a/docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md
+++ b/docs/superpowers/plans/INSTANCE-KEY-PLAN-REPORT-R3.md
@@ -73,3 +73,26 @@ Round 3 found no P1. Rev 3.1 fixes the two remaining findings; both are accepted
- 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.
+
+## Rev 4 (Astra round 4, Paul's ruling 26)
+
+Round 4 found two P2s in rev 3.1's replica strip; both are accepted. Paul decided that a replica gets its home's key (ruling 26, which replaces rulings 17 and 25).
+
+- **The strip is reverted.** A replica's env is composed like any other, so a replica sharing its home's configured key keeps it.
+- **`replicaKeyGate`,** an agent-side check, runs on every replica launch (Start, Seed, the nested prepare and the claim launch source):
+ - a delivered key must match the home key's digest;
+ - a keyless replica starts only with a control socket, where the helper refuses at step 3;
+ - with no socket, the agent refuses the launch itself, so the app never reaches the helper's mint fallback.
+- **Pre-roll check.** Rollout step 1 is new: it compares each replica's key digest with its home's before any box is upgraded, and records matches in `CARLOS_REPLICA_HOME_KEYS`. Rollout step 6's wording is corrected.
+- **New obligation O8 (replica placements, #162).** A replica record names no home, the home-to-replica mirror is the app's own, and no box can read another region's data bucket. So handing a keyless replica its home key's value needs a placement change. Until O8 lands, the fallback is the gate's reliable refusal.
+- **Effect on stage B:** O2' and its test are unchanged. `SnapshotClaimLaunch` can now return `ErrReplicaKey` for a replica, and `LaunchEnvironment` is as in rev 3 again.
+
+**How it was checked:**
+- **Code blocks:** the plan's blocks, rebuilt from the document, match the tested scratch tree byte for byte. `go vet` and gofmt are clean, and builds for darwin, windows and linux/arm64 are clean.
+- **Tests through a real launch:** the replica tests run the test binary as the app, executing rastrillo e3ea08de's resolution and its documented caller.
+ - With no control socket: a keyless replica is refused and no app runs; the home's key runs and seals with it; a differing or unlisted key is refused.
+ - With a socket (sandbox off): the helper refuses with `ErrNoInstanceKey`.
+ - With the gate disabled, the app reports "minted". That is Astra's failure, so the test catches it.
+- **Other paths tested:** the nested prepare (run and seed), the claim launch source, and the env-var parser.
+- **Regression runs:** the activator launch families, run with the sandbox off, pass. So do the targeted host tests and all of `instancekey` and `hostfiles`.
+- Scratch copies are deleted, and `$TMPDIR/gocache` is kept.