carlos / platform Public

docs: region HA stage B plan rev 4.1 adopts the instance-key plan rev 3's O2' exactly: its pin.go diff, record, item, parser and 17-case test; O3' and O5 by instancekey; N10 and the KeyEvidence reader deleted; ruling 55 cites instance-key rulings 19 to 24; dependencies IK-1, O1', O6, O7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 9f7fae6846c1cfc9069007a6ff80542cd42f1f52 parent ca6b39c
1 file changed, +395 −258
  • docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md +395 −258
diff --git a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md
index 0f469f758..aa55528a6 100644
--- a/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md
+++ b/docs/superpowers/plans/2026-10-10-region-ha-1b-next-stage-b.md
@@ -1,4 +1,4 @@
-# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 4)
+# Region HA 1b-next Stage B: Relocation Wired Into the Claim Assembly Implementation Plan (rev 4.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.
@@ -12,11 +12,128 @@
---
+## Rev 4.1: O2′ aligned to the instance-key plan rev 3 (3947e1d9)
+
+The instance-key plan rev 3 is committed at 3947e1d9 (`docs/superpowers/plans/2026-10-10-instance-key-provision.md`, "O2' for stage B"). Its planner compiled and ran its O2′ against stage B at 919662d2: a 17-case evidence test plus every existing pinner test. Rev 4.1 adopts that O2′ exactly, and deletes everything in stage B that differed.
+
+**Adopted, exactly as in the instance-key plan:**
+- **The `pin.go` diff:**
+ - `PinObject.InstanceKey *KeyPin {Source, SHA256}`;
+ - `pinnedWriter(c, gen)`, the writer whose data is pinned: the holder's own session for its generation, else the matching admission;
+ - `adoptedKey`.
+- **The record and the item:** `instancekey.Adoption` (v 2, `var`, host, incarnation, session, generation, `source`, `sha256`), parsed strictly by `instancekey.ParseAdoption` and bound by `Binds`, in the platform item `instancekey.AdoptionItem` = `instance-key-adoption.json`.
+- **The reader interface:** none. The evidence is in the pinned manifest, so the pinner needs no outside reader and no wiring.
+- **The shared env parser:** `instancekey.ParseEnv` and `Effective`. The activator delivers by the same rule under that plan, so `export CARLOS_INSTANCE_KEY=` is not the variable.
+- **The test:** `TestStorePinnerKeyEvidence` with its 17 cases, in place of rev 4's `TestStorePinnerNeedsTheWritersAdoptionOfTheDeliveredKey`.
+
+**Deleted from stage B:**
+- rev 4's ruling 65 and its `FilesPin.KeySHA256`, `KeyEvidence` (with its `ConfiguredEnv` reader), `KeyQuestion`, `AdoptionRecord`, `AdoptionItem` constant, `EffectiveInstanceKey` and `KeyDigest`;
+- N10's activator export (ruling 69);
+- rev 3's `KeySource`, `KeyConfig`, `envHasInstanceKey` and its `export`-accepting parse, already gone in rev 4.
+
+None of these was ever committed as code on this branch: every revision changed only the plan document.
+
+**O3′ (Task 8c install).** When `pin.InstanceKey` is set, the install checks the delivered key after the env file and the held render are on disk, and before anything launches. It calls `instancekey.VerifyDelivered(rendered config env, restored own env, pin.InstanceKey.SHA256)` and refuses on error. The adoption item is never restored. A deleted or absent `EnvItem` removes the local env file (ruling 68).
+
+**O5 (Task 8a `ReleaseFlushFiles`).** In the same commit as the env item, the flush stages the local `<host>` + `instancekey.AdoptionSuffix` record as `AdoptionItem` when it parses and binds exactly the releasing writer, and otherwise stages its deletion. `TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption` is written and passes, with four cases: the writer's record, a stale session's record, a malformed record, none.
+
+**Dependencies** (dependency table):
+- **IK-1**, `internal/instancekey` on main (the instance-key plan's Task 1). Stage B's pinner, install and flush import it, so it lands after that plan merges.
+- **O1′**, C8's `Establish`: publishes the env item, and starts each admission with no adoption item.
+- **O6**, the claim launch's adoption verb: gates platform-key and configured-key relocation (instance-key ruling 23).
+- **O7**, moves retiring the flat record (instance-key ruling 24).
+
+**Compile record.** A throwaway copy of ca6b39c9 had rev 4's code restored from this plan, `internal/instancekey/instancekey.go` taken from the instance-key plan, and the instance-key plan's `pin.go` diff applied as given. In that copy:
+- `go build ./...` is clean;
+- `go vet` on host, hostrelocate and instancekey is clean;
+- darwin, windows and linux/arm64 builds of `./internal/...` are clean, and `gofmt -l` is empty;
+- `hostrelocate` passes in full, including all 17 subtests of `TestStorePinnerKeyEvidence` and every existing pinner test, and so does `hostclaim`;
+- the host release, flush, install-policy, attempt, census, `EnsureLocal` and `VerifyReceipt` tests pass, as does the new O5 test.
+
+`internal/host/relocationflush_adoption_test.go` (Task 8a, O5; written, passes):
+
+```go
+package host
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// O5 (instance-key plan rev 3): the release flush publishes the adoption
+// record in the same commit as the env item only when it binds exactly the
+// releasing writer; a stale record, or none, publishes none.
+func TestReleaseFlushPublishesOnlyTheReleasingWritersAdoption(t *testing.T) {
+ ctx := context.Background()
+ const host = "shop.example"
+ writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 6, Generation: "g6"}
+ for _, tc := range []struct {
+ name string
+ record []byte // nil is no file
+ want bool
+ }{
+ {"the releasing writer's record", instancekey.AdoptionRecord(writer, instancekey.SourcePlatform, instancekey.Digest("k1")), true},
+ {"a stale record from session 5", instancekey.AdoptionRecord(instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 5, Generation: "g5"}, instancekey.SourcePlatform, instancekey.Digest("k1")), false},
+ {"a malformed record", []byte(`{"v":1}`), false},
+ {"no record", nil, false},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ data := release.NewDirStore(t.TempDir())
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ if err := reg.Upsert(registry.Route{Host: host, Kind: "service", Addr: "10.0.0.1:3000", Status: "ready", Hibernate: true}); err != nil {
+ t.Fatal(err)
+ }
+ dir, envDir := t.TempDir(), t.TempDir()
+ if err := os.WriteFile(filepath.Join(envDir, host+".env"), []byte("CARLOS_INSTANCE_KEY=k1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if tc.record != nil {
+ if err := os.WriteFile(filepath.Join(envDir, host+instancekey.AdoptionSuffix), tc.record, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f := ReleaseFlushFiles{Data: data, Registry: reg, FilesDir: func(string) string { return dir }, EnvPath: func(h string) string { return filepath.Join(envDir, h+".env") }}
+ obs := hostclaim.Observed{Host: host, Claim: &hostclaim.Claim{Host: host, Holder: &hostclaim.Holder{Location: "instances/" + host + "/inc", Incarnation: "inc-1", Session: 6, Generation: "g6"}}}
+ seq, err := f.Flush(ctx, obs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ prefix := "instances/" + host + "/inc/files/g6/"
+ m, ok, err := hostfiles.Load(ctx, data, prefix)
+ if err != nil || !ok || m.Seq != seq {
+ t.Fatalf("manifest: %v %v", ok, err)
+ }
+ got, err := hostfiles.ReadPlatform(ctx, data, prefix, m, instancekey.AdoptionItem, nil)
+ if tc.want && (err != nil || string(got) != string(tc.record)) {
+ t.Fatalf("the writer's record was not published: %q %v", got, err)
+ }
+ if !tc.want && err == nil {
+ t.Fatalf("a record not the releasing writer's was published: %q", got)
+ }
+ })
+ }
+}
+```
+
+---
+
## Rev 4: round 3 dispositions
Astra's round 3 (`CODEX-PLAN-REGION-HA-STAGE-B-R3.md`) raised 3 P1s and 2 P2s and found the plan converging: N6 is judged sound and C11's fallback safe. Every cited path was read at 49277296, and all five findings reproduce. Counts: **accepted 5, accepted-changed 0, declined 0**.
-**Instance-key plan rev 3 was not committed when this revision was written.** Its branch `instance-key-build` stands at f3ac9ee3 (round 2 review), so O2′ here is written from the coordinator's description of its ruling R-B. That makes it a named dependency: **IK-R-B**, owned by the instance-key plan. The adoption record's fields, the platform item's name (`instance-key-adoption`) and the configured-env reader must match that plan's rev 3 when it lands. Until then `StorePinner.KeyEvidence` is nil, and only `instance.key` evidences a nonempty database's key (ruling 25 stands).
+*(Rev 4.1: the instance-key plan rev 3 is now committed at 3947e1d9, and O2′ is aligned to it; see the Rev 4.1 section.)* **Instance-key plan rev 3 was not committed when rev 4 was written.** Its branch `instance-key-build` stands at f3ac9ee3 (round 2 review), so O2′ here is written from the coordinator's description of its ruling R-B. That makes it a named dependency: **IK-R-B**, owned by the instance-key plan. The adoption record's fields, the platform item's name (`instance-key-adoption`) and the configured-env reader must match that plan's rev 3 when it lands. Until then `StorePinner.KeyEvidence` is nil, and only `instance.key` evidences a nonempty database's key (ruling 25 stands).
All rev 4 code was assembled in a throwaway `git archive 49277296`, with rev 3's code restored from this plan; it is deleted. The checks run there:
@@ -28,7 +145,7 @@ All rev 4 code was assembled in a throwaway `git archive 49277296`, with rev 3's
| # | Task | Finding | Disposition | Change | Test |
|---|---|---|---|---|---|
-| 1 (P1) | 8b, 8c | Rev 3's O2′ accepted any non-empty env key with an adoption source, never comparing the record's digest to the key; `KeyConfig` read no env at all; the custom parser accepted `export CARLOS_INSTANCE_KEY=`, which the launch parser does not treat as that variable. | accepted | Ruling 65, which supersedes 55. O2′ has exactly two sources, per instance-key rev 3 R-B: (a) `instance.key`; or (b′) the pinned writer's own adoption record (host, incarnation, session, generation all equal to the pinned holder's) whose `key_sha256` is the digest of the exact key the target will deliver. That key is the pinned env item over the configured env, parsed and layered by the activator's own launch code (`activator.ParseEnv`, `MergeEnv`: named change N10, an unexported-to-exported wrapper; no custom parser). Source (c) is dropped. The digest is pinned (`FilesPin.KeySHA256`), and the install derives the key again from the restored env and the config it has just rendered, refusing on any difference. | `TestStorePinnerNeedsTheWritersAdoptionOfTheDeliveredKey` (written, passes; 15 cases). The cases: the nil-reader unused key; a platform key with no record; a mismatched adoption; another session's and another generation's record; a configured key overridden by the env (refuses), and the overriding env key adopted (passes); a configured key adopted; an unreadable env item (an error, never "unevidenced"); a duplicate assignment (the last wins, as at launch); an `export` line, which is not the variable; an empty key. Plus `TestRelocationInstallRefusesAKeyChangedSinceThePin` (Task 8c, specified: configuration changed between pin and install, so the install refuses). |
+| 1 (P1) | 8b, 8c | *(Change superseded in rev 4.1 by the instance-key plan rev 3's O2′, adopted exactly.)* Rev 3's O2′ accepted any non-empty env key with an adoption source, never comparing the record's digest to the key; `KeyConfig` read no env at all; the custom parser accepted `export CARLOS_INSTANCE_KEY=`, which the launch parser does not treat as that variable. | accepted | Ruling 65, which supersedes 55. O2′ has exactly two sources, per instance-key rev 3 R-B: (a) `instance.key`; or (b′) the pinned writer's own adoption record (host, incarnation, session, generation all equal to the pinned holder's) whose `key_sha256` is the digest of the exact key the target will deliver. That key is the pinned env item over the configured env, parsed and layered by the activator's own launch code (`activator.ParseEnv`, `MergeEnv`: named change N10, an unexported-to-exported wrapper; no custom parser). Source (c) is dropped. The digest is pinned (`FilesPin.KeySHA256`), and the install derives the key again from the restored env and the config it has just rendered, refusing on any difference. | `TestStorePinnerNeedsTheWritersAdoptionOfTheDeliveredKey` (written, passes; 15 cases). The cases: the nil-reader unused key; a platform key with no record; a mismatched adoption; another session's and another generation's record; a configured key overridden by the env (refuses), and the overriding env key adopted (passes); a configured key adopted; an unreadable env item (an error, never "unevidenced"); a duplicate assignment (the last wins, as at launch); an `export` line, which is not the variable; an empty key. Plus `TestRelocationInstallRefusesAKeyChangedSinceThePin` (Task 8c, specified: configuration changed between pin and install, so the install refuses). |
| 2 (P1) | 9 | Completion released the hold and lifted the fence without closing the participation. An immediate request could be admitted (`writer.Admit` and `AfterAdmission` run before `persistPrelaunch` selects work, claimwakeassembly.go:311, :529), moving the holder off the probe session, after which C10's `completedPrior` refuses for good. | accepted | Ruling 66. `relocationReleaseHold` first calls `cfg.Discharge` = `productionClaimLocal.DischargeCompleted` (N1's third accessor). That is main's own `Prepare` on the serving observation, which closes the slot through C10's discharge and clears `.hostexec`, succeeding only when neither a slot nor a record is left. Only then does it release the hold and lift the activator fence. Until that point the held row keeps the edge from routing, and the fence keeps the activator from waking, so no serving admission is possible. | **Test 1**, `TestRelocationCompletionServesOnlyAfterDischarge` (harness, Task 9): the participation loop paused; a request immediately after the serving CAS is refused with the row held and nothing admitted; one pass discharges, then releases; the next request is admitted through N6 with no serving-shaped fence. Mutation: releasing the hold before the discharge fails it. Plus `TestRelocationReleaseHoldWaitsForDischarge` (unit, specified: a refusing `Discharge` leaves `ReleaseHold` and `LiftFence` uncalled). |
| 3 (P1) | 9 | After a crash between completion and cleanup, `clearUntracked` passes the current serving observation to `cleanup` (claimparticipation_stale.go:31). C10 rejected it for not being a captured relocating claim, so cleanup ran a serving-shaped stop whose `BeginFence` replaces the probe's fence, and `BeginColdHandover` then refuses N6's receipt (hostguard/coldhandover.go:42). | accepted | Ruling 66. C10's text changes: the discharge is keyed by the persisted participation binding (the slot's `Config.Binding`, or after a restart the `.hostexec` record's `state.Binding`), not by any observation. It runs first in every cleanup path, so no ordinary cleanup, and no serving-shaped stop, runs for a completed relocation. `productionClaimLocal.cleanup` takes the binding (three call sites: the slot's `Cleanup`, `prepareWith`'s close, `clearUntracked`). | **Test 2**, `TestRelocationRestartAfterServingCASRecoversParticipation` (harness): the agent crashes after the serving CAS and before `ClearStale`. On restart the first participation pass runs `clearUntracked`, then C10 by binding, then `ClearStale`; the guard journal shows no new fence. The pass releases the hold, and the first serving request's N6 handover is accepted by `BeginColdHandover` with the original probe receipt. Main's `TestPlan9ParticipationCleanup*` pass with an empty binding (C10 falls through). |
| 4 (P2) | 9 | With `probe` journaled and no `probe_stop`, the worker returned before any deadline; a lease lost after `RecordProbe` left a live target in `probing` indefinitely. | accepted | Ruling 67. `Probe.At` records when the probe passed (`RecordProbe` from the participant's clock). `ReleaseDeadline` after it with no stop receipt, the worker marks the op `stuck` for an operator, releasing nothing (denial, hold and session stay). A probe without `At` counts from `probing_since + ProbeDeadline`. | `TestWorkerBoundsAPassedProbeAwaitingItsStop` (written, passes): the lease lost right after `RecordProbe`, and a persistent stop refusal. Not stuck at the bound, stuck one second after; still relocating, exec unchanged, no `probe_stop`. `TestWorkerBoundsAnInstallThatNeverProbes` (ruling 60, written, passes). |
@@ -40,15 +157,15 @@ All rev 4 code was assembled in a throwaway `git archive 49277296`, with rev 3's
### Rulings, rev 4 (65 to 69)
-65. **O2′ is exactly (a) or (b′)** (finding 1; supersedes 55; dependency IK-R-B). (a) The pinned manifest holds `instance.key`. (b′) The pinned manifest's `instance-key-adoption` platform item is a strictly decoded `AdoptionRecord`: v 1, host, incarnation, session and generation equal to the pinned holder's, and `key_sha256` equal to `sha256` of `EffectiveInstanceKey(configured env, pinned env item)`, which is `activator.MergeEnv(activator.ParseEnv(config), activator.ParseEnv(env))["CARLOS_INSTANCE_KEY"]`, the launch's own reading and layering. The configured env comes from `KeyEvidence.ConfiguredEnv`, rendered for the pinned release. Whichever source yields the key, it is accepted only through the writer's own adoption of it, and configuration is never evidence by itself. The digest is pinned as `FilesPin.KeySHA256`. After the held render the install computes the key again from the restored `<host>.env` and the rendered `<host>.config.env`, and refuses unless it has that digest. An unreadable env or adoption item is an error, never "no evidence". *Cost if wrong:* a host whose writer has not yet reported adoption (each new admission starts without a record) is `stuck` until its app restarts once, which is ruling 25's behaviour.
+65. *Withdrawn in rev 4.1: replaced by the instance-key plan rev 3's O2′ exactly (rewritten ruling 55).* **O2′ is exactly (a) or (b′)** (finding 1; supersedes 55; dependency IK-R-B). (a) The pinned manifest holds `instance.key`. (b′) The pinned manifest's `instance-key-adoption` platform item is a strictly decoded `AdoptionRecord`: v 1, host, incarnation, session and generation equal to the pinned holder's, and `key_sha256` equal to `sha256` of `EffectiveInstanceKey(configured env, pinned env item)`, which is `activator.MergeEnv(activator.ParseEnv(config), activator.ParseEnv(env))["CARLOS_INSTANCE_KEY"]`, the launch's own reading and layering. The configured env comes from `KeyEvidence.ConfiguredEnv`, rendered for the pinned release. Whichever source yields the key, it is accepted only through the writer's own adoption of it, and configuration is never evidence by itself. The digest is pinned as `FilesPin.KeySHA256`. After the held render the install computes the key again from the restored `<host>.env` and the rendered `<host>.config.env`, and refuses unless it has that digest. An unreadable env or adoption item is an error, never "no evidence". *Cost if wrong:* a host whose writer has not yet reported adoption (each new admission starts without a record) is `stuck` until its app restarts once, which is ruling 25's behaviour.
66. **Participation is discharged before any serving admission is possible, by binding, in every cleanup path** (findings 2 and 3; C10's text changed). Completion's hold release waits for `DischargeCompleted`, and the activator fence lifts after it. C10 keys the discharge by the persisted binding and runs it before ordinary cleanup, so after a restart `clearUntracked` discharges the completed relocation and never asks a serving-shaped stop that would replace the probe's fence. *Cost if wrong:* if C10 is refused, the hold stays and the host is unserved on the target; the pass alerts once (the gate, as before).
67. **A passed probe waiting for its stop is bounded** (finding 4). `ReleaseDeadline` after `Probe.At`, the op is `stuck` for an operator with everything held. *Cost if wrong:* an operator acts on a session whose stop root could not certify, which is the outcome Astra asks for.
68. **A deleted `EnvItem` installs as none** (finding 5). Install and repair remove any stale local env file. *Cost if wrong:* none.
-69. **N10: the activator's env parser and layering are exported unchanged** (`ParseEnv`, `MergeEnv` in admintoken.go). They are the one implementation the pinner and the install use; the activator is not claim-owned code. *Cost if wrong:* none.
+69. *Withdrawn in rev 4.1: the shared parser is `instancekey.ParseEnv`.* **N10: the activator's env parser and layering are exported unchanged** (`ParseEnv`, `MergeEnv` in admintoken.go). They are the one implementation the pinner and the install use; the activator is not claim-owned code. *Cost if wrong:* none.
### Rev 4 changes by task
-- **Task 8b.** Step 0 is O2′ (b′): `pin.go` and `pin_envkey_test.go` as below, with N10's activator export. `StorePinner.KeyEvidence` is `*KeyEvidence` (`ConfiguredEnv`), nil until IK-R-B lands.
+- **Task 8b.** Step 0 is O2′ as in the instance-key plan rev 3 (rev 4.1): its `pin.go` diff and `TestStorePinnerKeyEvidence`. There is no reader and no activator export.
- **Task 8c.** `relocationinstall.go` (below) handles a deleted `EnvItem` as none, and checks the delivered key against `FilesPin.KeySHA256` after the render.
- **Task 9.**
- `relocationowners.go`'s `relocationReleaseHold` discharges first.
@@ -61,28 +178,9 @@ All rev 4 code was assembled in a throwaway `git archive 49277296`, with rev 3's
### Rev 4 code
-N10, `internal/activator/admintoken.go`:
+N10 (withdrawn in rev 4.1):
-```diff
---- a/internal/activator/admintoken.go
-+++ b/internal/activator/admintoken.go
-@@ -255,3 +255,15 @@
- }
- return m
- }
-+
-+// ParseEnv is the launch path's EnvironmentFile parser, exported unchanged
-+// (region HA N10) so relocation reads <host>.env and <host>.config.env
-+// exactly as a launch does: KEY=VALUE lines, blank lines and # comments
-+// skipped, the last assignment of a key winning, and an "export KEY=..."
-+// line naming the key "export KEY", never KEY.
-+func ParseEnv(raw []byte) map[string]string { return parseEnvBytes(raw) }
-+
-+// MergeEnv is the launch path's layering, exported unchanged (N10): the
-+// bucket-derived config env under the instance's own <host>.env, which
-+// wins every conflict.
-+func MergeEnv(base, overlay map[string]string) map[string]string { return mergeEnv(base, overlay) }
-```
+*(Withdrawn in rev 4.1: N10 is not needed; the shared parser is `instancekey.ParseEnv`, which the activator itself delegates to under the instance-key plan.)*
C10 (rev 4, for the claim session; not committed by stage B):
@@ -350,7 +448,7 @@ All rev 3 code was assembled in a throwaway `git archive 7a41192c` under a uniqu
## Rulings, rev 3 (55 to 64)
-55. *Superseded in rev 4 by ruling 65 (Astra round 3 finding 1; instance-key rev 3 R-B: source (c) dropped, (b) bound to the pinned writer and the delivered key).* **O2 is withdrawn; the pinner implements O2'** (finding 1; withdraws 54). Ruling 25's refusal stands unless the pinned manifest has the `instance.key` file, or `StorePinner.KeyEvidence` (O2' from the instance-key plan rev 2) reports the bucket-held config's key, or reports an adoption record *and* the pinned env carries a non-empty `CARLOS_INSTANCE_KEY`. The adoption record is the agent's durable record, written when the app's helper reports it used `$CARLOS_INSTANCE_KEY` for this incarnation. `KeyEvidence` is nil until that plan lands. The question names host, account, app, incarnation and the pinned release, so the evidence can be version-bound (migration evidence is O2''s to define). A reader error refuses. *Cost if wrong:* an app that really uses the env key but has no adoption record yet goes `stuck`, as ruling 25 already does.
+55. **The instance key is evidenced by (a) `instance.key` or (b′) an adoption record bound to the pinned writer for the key the target delivers, pinned by digest** (rewritten in rev 4.1; instance-key plan rev 3, 3947e1d9, rulings 19 to 24). Ruling 19: the record is a trusted-helper assertion, stopping accidental loss, never a boundary between tenants. Ruling 20: a nonempty database pins only with (a) or (b′). (b′) is the `instance-key-adoption.json` platform item binding exactly the pinned writer (`pinnedWriter`: host, incarnation, session, generation), whose digest is the key the target delivers. A `platform` record must match the env item's own key. A `config` record needs an env item with no own key. Source (c) is gone, and every new admission starts with no record. Ruling 21: the agent writes the record from its own launch state. Ruling 22: it is published on the running session. Ruling 23: O6 gates platform-key and configured-key relocation, and refuses until then. Ruling 24: O7 retires the flat record with moves. The env item is parsed with `instancekey.ParseEnv`. The key is pinned (`PinObject.InstanceKey`) and verified again at install (O3′, `instancekey.VerifyDelivered`). *Cost if wrong:* a host whose writer's report has not landed is `stuck`, not relocated. That is safe and visible.
56. **Completion hands over from the probe's existing receipt, and discharges its participation with it** (findings 5 and 6; C7 withdrawn, C10 raised). `CompleteRelocation` journals `completed_relocation` on the serving op: the op, the probe stop (box, session, fence id, receipt digest), the stamp, phase and generation of the stopped session. On the first serving cold wake on that box, N6 does three things. Before admission, `relocationCompletedHandover.Before` finds the exact receipt in this box's guard journal (`CompletedWriterStops` with matching fence id, obligation `writer-session-S`, phase and digest) and verifies it, in place of `Wake.Stop`, which would ask a second, serving-shaped fence that S's producers reject. After admission, `coldHandoverWith` posts that receipt's target as `Prior` to root's `/claim/handover`. Root's `ColdHandover.Run` takes `prior` as it is: it checks the successor's stamp and session above the prior's, same box and enrollment, the receipt and the prior's exact inventory, and never matches op ids. The participation slot opened at acknowledgement is discharged by the same evidence (C10). *Cost if wrong:* if C10 is refused, the completed target's slot denies serving; C10 is an activation gate, and the flags stay off.
57. **The instance directory is made by main's `PrepareState`** (finding 3). The install reads the claim fresh after `MintHeld`, checks its authority, and calls `ClaimRoleAssembly.PrepareState(ctx, installed, work, {DBPath})` under the install work, then restores into the materialized directory. On a non-Linux build `PrepareState` refuses, so the install refuses there; relocation is Linux-only. *Cost if wrong:* none beyond main's protocol.
58. **A later probe attempt is made only when root can launch it** (finding 4). The reader validates a later attempt at the current writer stamp. Main's `claimLaunchAuthority` still pins `install_stamp`, so until C11 `Preflight` answers `ErrLaterAttemptUnlaunchable` once P has opened, and the participant makes `probe-failed` at once. *Cost if wrong:* one probe attempt per target until C11; a failed P opening goes `probe-failed` sooner.
@@ -1165,9 +1263,11 @@ Each is modelled as a dependency with a fallback. None is assumed. Rev 2 added C
| **C6** | claim | A kind-scoped CC9 discharge for a relocation target the op excluded (`replaceTargetSlot` removes it from participants, relocation.go:1091) while its unopened install and held row remain. | **Rev 3, ruling 63: two safe outcomes.** If the slot was captured at the target's own install stamp, main's `CheckLocal` finds the complete installation and cleanup succeeds, so `ClearStale` removes the denial while the inert, held, never-reactivated installation waits for its `park-local`. If it was captured earlier, the census refuses the row and the denial stands until `park-local`. No writer can start there either way. The ported lifecycle property covers both. | rulings 42, 63 |
| ~~C7~~ | claim | **Withdrawn in rev 3 (ruling 56).** `ColdHandover.Run` accepts the probe stop's exact receipt as prior beside a validated cold serving successor without matching op ids; the agent supplies it through N6. Nothing is re-stopped or relabelled. | none | ruling 56 |
| **C10** | claim | **Text changed in rev 4** (see "C10's text on #236"): a CC9 discharge of a relocation participation keyed by the persisted binding and the exact completed probe evidence, first in every cleanup path, the slot's close and `clearUntracked` alike. | **None that serves: an activation gate.** Region HA releases the hold and the fence only after the discharge (ruling 66), so without C10 the target stays held and unserved, alerting once; nothing unsafe is admitted. | rulings 56, 66, Task 9 |
-| **IK-R-B** | instance-key plan rev 3 | O2′ (b′): the adoption record (`instance-key-adoption` platform item: host, incarnation, session, generation, `key_sha256`), written by the agent from its launch state when rastrillo's helper reports `$CARLOS_INSTANCE_KEY` resolved at a process start, published continuously, none for a new admission; and `KeyEvidence.ConfiguredEnv`, the configured env rendered for the pinned release. Not committed when rev 4 was written; field names to be matched on landing. | `KeyEvidence` nil: only `instance.key` evidences a nonempty database's key (ruling 25); such a host is `stuck` rather than relocated. | ruling 65, Task 8b |
+| **IK-1** | instance-key plan rev 3, Task 1 | `internal/instancekey` on main (`ParseEnv`, `Effective`, `Digest`, `Binding`, `Adoption`, `AdoptionRecord`, `ParseAdoption`, `Binds`, `VerifyDelivered`, `AdoptionItem`, `AdoptionSuffix`). | Stage B's pinner, install and flush import it, so stage B merges after it; until then relocation is not built. | rulings 55, 68 |
+| **O6** | instance-key plan rev 3 (claim Plan 9 Group D's launcher) | Claim launches bind a control socket and mount the adoption verb, with `CARLOS_INSTANCE_KEY_SOURCE` (instance-key ruling 23). | **An explicit refusal.** An app whose key is not its own `instance.key` cannot report adoption on a claim box, so its relocation refuses (`stuck`). It is not HA-supported until O6. | ruling 55, checklist item 9 |
+| **O7** | instance-key plan rev 3 (moves) | Moves' journalled artifact lists retire the flat `<host>.instance-key-adoption.json` (instance-key ruling 24). O6 does not ship before O7. | None needed before O6: no record exists anywhere until O6 ships. | ruling 55 |
| **C11** | claim | `claimLaunchAuthority` (claimlaunch_root.go:129-132) pins a relocating launch to `install_stamp` only for P's opening, and validates a later attempt at the admitted writer's stamp through root's attempt reader. | **One launchable attempt per target.** `Preflight` answers `ErrLaterAttemptUnlaunchable` once P has opened, and the participant makes `probe-failed` at once (no attempts spent on refusals). `RelocationAssemblyConfig.LaterProbeAttempts` is set when C11 lands. | ruling 58, Task 8d |
-| C8 + **O1** | moves (platform-d8, #236) | The production `ClaimFilesManifest.Establish` provider and its `RegisterProductionClaimMoveSources` caller (none on this branch). O1: `Establish` publishes `<host>.env`'s exact bytes before it returns, and an error fails the wake. | **Relocation stays unbound** (ruling 53). `RelocationSource.Open` returns the zero owner set while `ClaimServingSources.Files` is nil, `claimRelocationC1` answers (false, nil), relocating is refused as before Plan 11, and the runtime is not built. | ruling 53, Tasks 8d and 10 |
+| C8 + **O1′** | moves (platform-d8, #236) | The production `ClaimFilesManifest.Establish` provider and its `RegisterProductionClaimMoveSources` caller (none on this branch). O1′ (instance-key plan rev 3): `Establish` publishes `<host>.env`'s exact bytes, starts each admission with no adoption item (`StagePlatformDelete`) and removes the local record under the start's work, before it returns; an error fails the wake. | **Relocation stays unbound** (ruling 53). `RelocationSource.Open` returns the zero owner set while `ClaimServingSources.Files` is nil, `claimRelocationC1` answers (false, nil), relocating is refused as before Plan 11, and the runtime is not built. | ruling 53, Tasks 8d and 10 |
| **C9** | claim | The claim's `Policy` admits relocating `install`, `held-config` and `held-release` for `op.exec` in `installed`, and in `probing` before P opens, with the hold absent or the op's own. Its `WithRefusals` then also run for them. | `relocationInstallPolicy` on main's WorkSource (ruling 46): the same `Guard`, `Prepare` and `Local` main uses, so the claim's execution, deadline and quarantine checks run on every `With`; the policy itself is region HA's, followed by region HA's `Policy.Relocation` owner. | ruling 46, Task 8c |
| M1 | moves | Close the move record after a converted landing (`released_move`) | Region HA's half is built here (Task 9): `CompleteRelocation` journals `released_move` when `conversion == committed-landing`. Until moves relaxes `closeMove`, a converted host cannot move again: operator path. | Task 9 |
| M2 | moves | `sourceOf` accepting a converted claim | Nothing to build here. The source's `park-local` waits; the row is held (`RowRetiring`). | none |
@@ -1819,7 +1919,7 @@ func (e *RelocationEffects) EndProbe(ctx context.Context, obs hostclaim.Observed
```diff
--- a/internal/host/relocationflush.go
+++ b/internal/host/relocationflush.go
-@@ -7,6 +7,7 @@
+@@ -7,10 +7,12 @@
"fmt"
"os"
"path/filepath"
@@ -1827,7 +1927,35 @@ func (e *RelocationEffects) EndProbe(ctx context.Context, obs hostclaim.Observed
"github.com/carlosframework/platform/internal/controlname"
"github.com/carlosframework/platform/internal/hostclaim"
-@@ -93,24 +94,26 @@
+ "github.com/carlosframework/platform/internal/hostfiles"
++ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+ )
+@@ -73,6 +75,22 @@
+ default:
+ return 0, err
+ }
++ // O5 (instance-key plan rev 3): the adoption record beside the env
++ // file is staged in the same commit when it parses and binds exactly
++ // the releasing writer; otherwise its deletion is staged, so a stale
++ // record, or none, is never published as this writer's.
++ writer := instancekey.Binding{Host: obs.Host, Incarnation: h.Incarnation, Session: h.Session, Generation: h.Generation}
++ rec, err := os.ReadFile(filepath.Join(filepath.Dir(f.EnvPath(obs.Host)), obs.Host+instancekey.AdoptionSuffix))
++ if err != nil && !errors.Is(err, os.ErrNotExist) {
++ return 0, err
++ }
++ if a, perr := instancekey.ParseAdoption(rec); err == nil && perr == nil && a.Binds(writer) {
++ if err := p.StagePlatform(ctx, instancekey.AdoptionItem, rec); err != nil {
++ return 0, err
++ }
++ } else if err := p.StagePlatformDelete(instancekey.AdoptionItem); err != nil {
++ return 0, err
++ }
+ st, err := hostfiles.SyncDir(ctx, p, f.FilesDir(obs.Host), f.Skip)
+ if err != nil {
+ return 0, err
+@@ -93,24 +111,26 @@
ReleasePosition
}
@@ -1861,7 +1989,7 @@ func (e *RelocationEffects) EndProbe(ctx context.Context, obs hostclaim.Observed
if err != nil {
return err
}
-@@ -158,7 +161,7 @@
+@@ -158,7 +178,7 @@
}
func (f ReleasePositionsFile) Recorded(_ context.Context, r StopRequest) (ReleasePosition, bool, error) {
@@ -2113,213 +2241,194 @@ The pinner implements O2' from the instance-key plan rev 2 (`instance-key-build`
```diff
--- a/internal/hostrelocate/pin.go
+++ b/internal/hostrelocate/pin.go
-@@ -1,12 +1,14 @@
- package hostrelocate
-
- import (
-+ "bytes"
- "context"
- "crypto/sha256"
- "encoding/hex"
- "encoding/json"
- "errors"
- "fmt"
-+ "github.com/carlosframework/platform/internal/activator"
- "os"
- "sort"
- "strings"
-@@ -54,6 +56,12 @@
- // reads: P is written from this copy, never from the source again.
- // Empty for a host that never published files.
- Retained string `json:"retained,omitempty"`
-+ // KeySHA256 is the digest of the instance key the target will deliver,
-+ // pinned when O2' (b') evidenced it (ruling 65): the install derives the
-+ // key again from the restored env and the config it renders, and
-+ // refuses unless it has this digest. Empty when instance.key is the
-+ // evidence, or no key is needed.
-+ KeySHA256 string `json:"key_sha256,omitempty"`
- }
+@@ -15,6 +15,7 @@
+ "github.com/carlosframework/platform/internal/genselect"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostfiles"
++ "github.com/carlosframework/platform/internal/instancekey"
+ "github.com/carlosframework/platform/internal/release"
+ )
- // RetainedManifestKey is where the pinner keeps the pinned manifest's
-@@ -88,6 +96,10 @@
- // Stage B wires it to the controller's registry. Nil is ErrUnsupported;
- // an empty version refuses the pin, never a guess.
- Release func(ctx context.Context, host string) (string, error)
-+ // KeyEvidence is O2' (b')'s configured-env reader from the instance-key
-+ // plan rev 3 (region HA ruling 65): nil until that plan lands, and then
-+ // only the instance.key file evidences a nonempty database's key.
-+ KeyEvidence *KeyEvidence
+@@ -37,6 +38,18 @@
+ // Release is the app release version the relocation installs, read
+ // at pinning from StorePinner.Release (R9); a reuse carries it.
+ Release string `json:"release"`
++ // InstanceKey is the key a nonempty database is sealed with when the
++ // pinned manifest evidences it by an adoption record rather than by
++ // instance.key (O2', instance-key plan rev 3): its digest and source.
++ // The install delivers exactly this key or refuses (O3').
++ InstanceKey *KeyPin `json:"instance_key,omitempty"`
++}
++
++// KeyPin is a pinned instance key: the SHA-256 of the key and the file
++// that supplies it at launch (instancekey.SourcePlatform or SourceConfig).
++type KeyPin struct {
++ Source string `json:"source"`
++ SHA256 string `json:"sha256"`
}
- func (p StorePinner) Pin(ctx context.Context, obs hostclaim.Observed, now time.Time) (hostclaim.RecoveryPin, error) {
-@@ -142,7 +154,7 @@
+ // FilesPin names one generation's files manifest by prefix, sequence and
+@@ -142,11 +155,15 @@
// that writer's instance.key: the pinned manifest must carry it (rev
// 18, round 17 finding 1).
needKey := c.Holder.Session != 0 && obj.DB != nil && obj.DB.MaxTXID > 0
- files, retained, err := p.filesPin(ctx, c.Holder, released && rel.ManifestSeq == 0, needKey)
-+ files, retained, err := p.filesPin(ctx, c.Host, version, c.Holder, released && rel.ManifestSeq == 0, needKey)
++ // The writer whose data is pinned: the session admitted as the pinned
++ // database's generation (O2', instance-key plan ruling 20). Asked only
++ // when instance.key is missing.
++ writer := func() (instancekey.Binding, error) { return pinnedWriter(c, obj.DB.Generation) }
++ files, keyPin, retained, err := p.filesPin(ctx, writer, c.Holder, released && rel.ManifestSeq == 0, needKey)
if err != nil {
return hostclaim.RecoveryPin{}, err
}
-@@ -221,7 +233,7 @@
+- obj.Files = files
++ obj.Files, obj.InstanceKey = files, keyPin
+ if released && obj.Files.Seq < rel.ManifestSeq {
+ return hostclaim.RecoveryPin{}, fmt.Errorf("%w: the files manifest is at %d, the release at %d", ErrNotYet, obj.Files.Seq, rel.ManifestSeq)
+ }
+@@ -221,7 +238,7 @@
// stays restorable at g10. Every referenced object must lie under this
// holder's location (its incarnation) in this region's data bucket, at
// files/<generation>/objects/; any other reference refuses the pin.
-func (p StorePinner) filesPin(ctx context.Context, h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, []byte, error) {
-+func (p StorePinner) filesPin(ctx context.Context, host, release string, h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, []byte, error) {
++func (p StorePinner) filesPin(ctx context.Context, writer func() (instancekey.Binding, error), h *hostclaim.Holder, releasedEmpty, needKey bool) (FilesPin, *KeyPin, []byte, error) {
chain := []string{h.Generation}
for i := len(h.Admissions) - 1; i >= 0; i-- {
chain = append(chain, h.Admissions[i].Generation, h.Admissions[i].Predecessor)
-@@ -247,18 +259,23 @@
- if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion {
- return FilesPin{}, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err)
+@@ -241,24 +258,33 @@
+ continue
+ }
+ if err != nil {
+- return FilesPin{}, nil, err
++ return FilesPin{}, nil, nil, err
}
+ var m hostfiles.Manifest // decoded from the bytes the digest names
+ if err := json.Unmarshal(raw, &m); err != nil || m.V != hostfiles.ManifestVersion {
+- return FilesPin{}, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err)
+- }
- if needKey && !liveEntry(m, InstanceKeyPath) {
- // The manifest was copied before the app started and the
- // key it minted afterwards never published (§5.6's
- // continuous-sync window, L21): installing this set would mint
- // a new key over data sealed with the old one.
- return FilesPin{}, nil, fmt.Errorf("%w: %s's pinned manifest has no %s for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath)
-- }
- if origins, err = referencedOrigins(h.Location, origins, m); err != nil {
- return FilesPin{}, nil, err
++ return FilesPin{}, nil, nil, errors.Join(fmt.Errorf("%w: files manifest %s", ErrRefused, prefix), err)
}
-+ keySHA := ""
+ if origins, err = referencedOrigins(h.Location, origins, m); err != nil {
+- return FilesPin{}, nil, err
++ return FilesPin{}, nil, nil, err
++ }
++ var keyPin *KeyPin
+ if needKey && !liveEntry(m, InstanceKeyPath) {
-+ // Ruling 25 stands: a nonempty database is pinned only with
-+ // positive evidence of the key that sealed it. Without the
-+ // instance.key file that is O2' (b'), instance-key plan rev 3
-+ // ruling R-B (region HA ruling 65): the pinned writer's own
-+ // adoption record for the exact key the target will deliver.
-+ var err error
-+ if keySHA, err = p.adoptedKey(ctx, host, release, h, prefix, m, origins); err != nil {
-+ return FilesPin{}, nil, err
++ // (a) failed: no instance.key. (b'): an adoption record bound
++ // to the pinned writer, for the key the target will deliver
++ // (O2', instance-key plan rev 3). Without it the manifest may
++ // have been copied before the app minted a key of its own
++ // (§5.6's continuous-sync window), and installing this set
++ // would mint a new key over data sealed with the old one.
++ w, err := writer()
++ if err != nil {
++ return FilesPin{}, nil, nil, err
+ }
-+ }
++ if keyPin, err = p.adoptedKey(ctx, w, prefix, m, origins); err != nil {
++ return FilesPin{}, nil, nil, err
++ }
+ }
sum := sha256.Sum256(raw)
- return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, raw, nil
-+ return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins, KeySHA256: keySHA}, raw, nil
++ return FilesPin{Prefix: prefix, Seq: m.Seq, SHA256: hex.EncodeToString(sum[:]), Origins: origins}, keyPin, raw, nil
}
// No manifest anywhere in the chain. An empty files pin needs the
// same kind of evidence an empty database pin does (ruling 15): no
-@@ -395,3 +412,118 @@
+@@ -272,14 +298,14 @@
+ // 17, finding F2), never an install that mints a fresh key over data
+ // sealed with the old one.
+ if h.Session != 0 && !releasedEmpty {
+- return FilesPin{}, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location)
++ return FilesPin{}, nil, nil, fmt.Errorf("%w: %s has admitted a writer but no files manifest exists in its chain", ErrFilesUnevidenced, h.Location)
+ }
+ gen := h.Generation
+ if gen == "" {
+ gen = hostclaim.RootGeneration
+ }
+ prefix := h.Location + "/files/" + gen + "/"
+- return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil
++ return FilesPin{Prefix: prefix, Origins: []string{prefix}}, nil, nil, nil
+ }
+
+ // referencedOrigins adds to origins the files prefix of every generation
+@@ -395,3 +421,72 @@
}
return pinned, nil
}
+
-+// InstanceKeyEnv is the platform-provisioned instance key (L21): a
-+// variable in <host>.env, the file that also carries CARLOS_ADMIN_TOKEN,
-+// published as the manifest's platform EnvItem.
-+const InstanceKeyEnv = "CARLOS_INSTANCE_KEY"
-+
-+// AdoptionItem is the manifest's platform item holding the adoption record
-+// (instance-key plan rev 3, ruling R-B), published continuously beside
-+// EnvItem.
-+const AdoptionItem = "instance-key-adoption"
-+
-+// AdoptionRecord is O2' (b'), as instance-key plan rev 3's agent writes it
-+// from its own launch state when rastrillo's helper reports, at a process
-+// start, that it resolved $CARLOS_INSTANCE_KEY: bound to one writer
-+// session of one installation, never carried to another. Every new writer
-+// admission starts with none.
-+type AdoptionRecord struct {
-+ V int `json:"v"`
-+ Host string `json:"host"`
-+ Incarnation string `json:"incarnation"`
-+ Session uint64 `json:"session"`
-+ Generation string `json:"generation"`
-+ KeySHA256 string `json:"key_sha256"`
-+}
-+
-+// KeyQuestion is what the pinner asks for the configured env.
-+type KeyQuestion struct {
-+ Host, Account, App, Incarnation string
-+ // Release is the pinned release the target will run.
-+ Release string
-+}
-+
-+// KeyEvidence is O2' (b')'s inputs beyond the manifest: the host's
-+// configured env (<host>.config.env bytes) as the target would render it
-+// now, the instance-key plan's reader. Nil KeyEvidence: only instance.key.
-+type KeyEvidence struct {
-+ ConfiguredEnv func(context.Context, KeyQuestion) ([]byte, error)
-+}
-+
-+// EffectiveInstanceKey is the key a launch delivers: the activator's own
-+// parser and layering (activator.ParseEnv, MergeEnv, N10), <host>.env over
-+// <host>.config.env. "" is none.
-+func EffectiveInstanceKey(configEnv, hostEnv []byte) string {
-+ return activator.MergeEnv(activator.ParseEnv(configEnv), activator.ParseEnv(hostEnv))[InstanceKeyEnv]
-+}
-+
-+// KeyDigest is sha256 of the key, hex, as the adoption record holds it.
-+func KeyDigest(key string) string {
-+ sum := sha256.Sum256([]byte(key))
-+ return hex.EncodeToString(sum[:])
-+}
-+
-+// livePlatform reads a platform item that is present and not deleted; ok
-+// false when absent or a tombstone. A named item that cannot be read is an
-+// error, never "absent".
-+func (p StorePinner) livePlatform(ctx context.Context, prefix string, m hostfiles.Manifest, name string, origins []string) ([]byte, bool, error) {
-+ e, ok := m.Platform[name]
-+ if !ok || e.Deleted {
-+ return nil, false, nil
++// pinnedWriter is the writer whose data a pin of generation gen holds: the
++// holder's own session when gen is the holder's generation, else the
++// admitted session recorded for gen. A generation no admission names has
++// no writer to bind evidence to, and refuses.
++func pinnedWriter(c *hostclaim.Claim, gen string) (instancekey.Binding, error) {
++ h := c.Holder
++ w := instancekey.Binding{Host: c.Host, Incarnation: h.Incarnation, Generation: gen}
++ if gen == h.Generation {
++ w.Session = h.Session
+ }
-+ raw, err := hostfiles.ReadPlatform(ctx, p.Data, prefix, m, name, origins)
-+ if err != nil {
-+ return nil, false, err
++ for i := len(h.Admissions) - 1; w.Session == 0 && i >= 0; i-- {
++ if h.Admissions[i].Generation == gen {
++ w.Session = h.Admissions[i].Session
++ }
+ }
-+ return raw, true, nil
++ if w.Session == 0 || w.Incarnation == "" || gen == "" {
++ return instancekey.Binding{}, fmt.Errorf("%w: no admitted writer for generation %q", ErrFilesUnevidenced, gen)
++ }
++ return w, nil
+}
+
-+// adoptedKey is O2' (b'): the pinned manifest's adoption record names the
-+// pinned writer (host, incarnation, session, generation) and the digest of
-+// the exact key the target will deliver, which is the pinned env item's
-+// key over the configured env's (the launch's layering). It returns that
-+// digest, pinned as FilesPin.KeySHA256 for the install to verify again.
-+func (p StorePinner) adoptedKey(ctx context.Context, host, release string, h *hostclaim.Holder, prefix string, m hostfiles.Manifest, origins []string) (string, error) {
-+ refuse := func(why string) (string, error) {
-+ return "", fmt.Errorf("%w: %s's pinned manifest has no %s, and %s, for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, why)
-+ }
-+ if p.KeyEvidence == nil {
-+ return refuse("no adoption evidence is wired (instance-key plan rev 3)")
++// adoptedKey is O2' source (b') (instance-key plan rev 3, ruling 20): the
++// pinned manifest's adoption record must bind exactly the pinned writer,
++// and name the key the target will deliver. A platform-sourced record must
++// match the env item's own key, which the install restores byte for byte.
++// A config-sourced record needs an env item with no own key (one would
++// override the configuration at launch); its digest is pinned, and the
++// install verifies the configuration it renders still delivers it (O3').
++// The env item is parsed with instancekey.ParseEnv, the activator's own
++// rule. A named item that cannot be read is an error, never "no evidence".
++func (p StorePinner) adoptedKey(ctx context.Context, writer instancekey.Binding, prefix string, m hostfiles.Manifest, origins []string) (*KeyPin, error) {
++ read := func(name string) ([]byte, bool, error) {
++ if e, ok := m.Platform[name]; !ok || e.Deleted {
++ return nil, false, nil
++ }
++ raw, err := hostfiles.ReadPlatform(ctx, p.Data, prefix, m, name, origins)
++ return raw, err == nil, err
+ }
-+ raw, ok, err := p.livePlatform(ctx, prefix, m, AdoptionItem, origins)
++ rec, ok, err := read(instancekey.AdoptionItem)
+ if err != nil {
-+ return "", err
++ return nil, err
++ }
++ refuse := func(why string) (*KeyPin, error) {
++ return nil, fmt.Errorf("%w: %s's pinned manifest has no %s and %s, for a nonempty database", ErrFilesUnevidenced, prefix, InstanceKeyPath, why)
+ }
+ if !ok {
+ return refuse("no adoption record")
+ }
-+ var rec AdoptionRecord
-+ d := json.NewDecoder(bytes.NewReader(raw))
-+ d.DisallowUnknownFields()
-+ if err := d.Decode(&rec); err != nil || rec.V != 1 {
-+ return "", errors.Join(fmt.Errorf("%w: adoption record in %s", ErrRefused, prefix), err)
-+ }
-+ if rec.Host != host || rec.Incarnation != h.Incarnation || rec.Session != h.Session || rec.Generation != h.Generation {
-+ return refuse("the adoption record is not the pinned writer's")
-+ }
-+ env, _, err := p.livePlatform(ctx, prefix, m, hostfiles.EnvItem, origins)
++ a, err := instancekey.ParseAdoption(rec)
+ if err != nil {
-+ return "", err
++ return refuse("its adoption record is malformed")
+ }
-+ var config []byte
-+ if p.KeyEvidence.ConfiguredEnv != nil {
-+ if config, err = p.KeyEvidence.ConfiguredEnv(ctx, KeyQuestion{Host: host, Account: h.Account, App: h.App, Incarnation: h.Incarnation, Release: release}); err != nil {
-+ return "", err
-+ }
++ if !a.Binds(writer) {
++ return refuse("its adoption record is not the pinned writer's")
+ }
-+ key := EffectiveInstanceKey(config, env)
-+ if key == "" {
-+ return refuse("the target would deliver no instance key")
++ env, _, err := read(hostfiles.EnvItem)
++ if err != nil {
++ return nil, err
+ }
-+ if digest := KeyDigest(key); digest != rec.KeySHA256 {
-+ return refuse("the adoption record is for another key than the target would deliver")
-+ } else {
-+ return digest, nil
++ own, _ := instancekey.Effective(nil, instancekey.ParseEnv(env))
++ switch {
++ case a.Source == instancekey.SourcePlatform && own != "" && instancekey.Digest(own) == a.SHA256:
++ case a.Source == instancekey.SourceConfig && own == "":
++ default:
++ return refuse("its adoption record does not name the key the env delivers")
+ }
++ return &KeyPin{Source: a.Source, SHA256: a.SHA256}, nil
+}
```
@@ -2338,96 +2447,122 @@ import (
"testing"
"time"
+ "github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfiles"
+ "github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/release"
)
-// O2' (b') (instance-key plan rev 3, ruling R-B; region HA ruling 65):
-// without the instance.key file, a nonempty database pins only when the
-// pinned writer's own adoption record names the digest of the exact key
-// the target will deliver: the pinned env item over the configured env,
-// parsed and layered by the activator's own launch code.
-func TestStorePinnerNeedsTheWritersAdoptionOfTheDeliveredKey(t *testing.T) {
+// O2' (instance-key plan rev 3, replacing stage B's O2 and ruling 55's
+// KeyEvidence reader): a nonempty database's pinned manifest evidences its
+// key by (a) instance.key, or (b') an adoption record bound to the pinned
+// writer for the key the target will deliver. Presence of a key, in the env
+// or in the configuration, is never evidence (Astra rounds 1 and 2).
+func TestStorePinnerKeyEvidence(t *testing.T) {
ctx := context.Background()
at := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
- adopt := func(key string) *AdoptionRecord {
- return &AdoptionRecord{V: 1, Host: host, Incarnation: "inc", Session: 3, Generation: "g3", KeySHA256: KeyDigest(key)}
- }
- config := func(env string) *KeyEvidence {
- return &KeyEvidence{ConfiguredEnv: func(context.Context, KeyQuestion) ([]byte, error) { return []byte(env), nil }}
+ const key = "q+/Zk3vJ0aW1Yx9sR7tUe2oPl4mN8bC6dF5gH0iJ1kA="
+ writer := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 3, Generation: "g3"}
+ earlier := instancekey.Binding{Host: host, Incarnation: "inc-1", Session: 2, Generation: "g2"}
+ envWithKey := "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=" + key + "\n"
+ rec := func(b instancekey.Binding, source, k string) string {
+ return string(instancekey.AdoptionRecord(b, source, instancekey.Digest(k)))
}
for _, tc := range []struct {
- name string
- env string // "" is no env item
- unreadable bool // the env item is named but its object is missing
- record *AdoptionRecord
- evidence *KeyEvidence
- wantErr error
- wantKey string // the pinned key's digest source, on success
+ name string
+ appKey bool // (a) instance.key in the manifest
+ env, adoption string // "" is no item
+ unreadable string // a named item whose object is missing
+ holder func(*hostclaim.Holder)
+ want error
+ pinned *KeyPin
}{
- {name: "an unused platform key and an unpublished app key (no reader)", env: "CARLOS_INSTANCE_KEY=k1\n", record: nil, evidence: nil, wantErr: ErrFilesUnevidenced},
- {name: "the platform key, no adoption record", env: "CARLOS_INSTANCE_KEY=k1\n", evidence: config(""), wantErr: ErrFilesUnevidenced},
- {name: "the writer adopted the env key", env: "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=k1\n", record: adopt("k1"), evidence: config(""), wantKey: "k1"},
- {name: "a mismatched adoption", env: "CARLOS_INSTANCE_KEY=k1\n", record: adopt("k2"), evidence: config(""), wantErr: ErrFilesUnevidenced},
- {name: "another session's adoption", env: "CARLOS_INSTANCE_KEY=k1\n", record: &AdoptionRecord{V: 1, Host: host, Incarnation: "inc", Session: 2, Generation: "g3", KeySHA256: KeyDigest("k1")}, evidence: config(""), wantErr: ErrFilesUnevidenced},
- {name: "another generation's adoption", env: "CARLOS_INSTANCE_KEY=k1\n", record: &AdoptionRecord{V: 1, Host: host, Incarnation: "inc", Session: 3, Generation: "g2", KeySHA256: KeyDigest("k1")}, evidence: config(""), wantErr: ErrFilesUnevidenced},
- {name: "a configured key overridden by the env", env: "CARLOS_INSTANCE_KEY=k2\n", record: adopt("c1"), evidence: config("CARLOS_INSTANCE_KEY=c1\n"), wantErr: ErrFilesUnevidenced},
- {name: "the env key that overrides the configured one, adopted", env: "CARLOS_INSTANCE_KEY=k2\n", record: adopt("k2"), evidence: config("CARLOS_INSTANCE_KEY=c1\n"), wantKey: "k2"},
- {name: "a configured key, adopted, no env key", env: "CARLOS_ADMIN_TOKEN=t\n", record: adopt("c1"), evidence: config("CARLOS_INSTANCE_KEY=c1\n"), wantKey: "c1"},
- {name: "an unreadable env item", env: "CARLOS_INSTANCE_KEY=k1\n", unreadable: true, record: adopt("k1"), evidence: config("")},
- {name: "a duplicate assignment: the last wins, as at launch", env: "CARLOS_INSTANCE_KEY=a\nCARLOS_INSTANCE_KEY=b\n", record: adopt("a"), evidence: config(""), wantErr: ErrFilesUnevidenced},
- {name: "a duplicate assignment adopted as launched", env: "CARLOS_INSTANCE_KEY=a\nCARLOS_INSTANCE_KEY=b\n", record: adopt("b"), evidence: config(""), wantKey: "b"},
- {name: "an export line is not the variable at launch", env: "export CARLOS_INSTANCE_KEY=k1\n", record: adopt("k1"), evidence: config(""), wantErr: ErrFilesUnevidenced},
- {name: "an export line, the configured key delivered and adopted", env: "export CARLOS_INSTANCE_KEY=k1\n", record: adopt("c1"), evidence: config("CARLOS_INSTANCE_KEY=c1\n"), wantKey: "c1"},
- {name: "an empty key", env: "CARLOS_INSTANCE_KEY=\n", record: adopt(""), evidence: config(""), wantErr: ErrFilesUnevidenced},
+ {name: "(a) the app's own instance.key", appKey: true, env: envWithKey, want: nil},
+ {name: "(b') the platform key, adopted by the pinned writer", env: envWithKey, adoption: rec(writer, "platform", key), pinned: &KeyPin{Source: "platform", SHA256: instancekey.Digest(key)}},
+ {name: "(b') a configured key, adopted by the pinned writer", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}},
+ {name: "(b') a configured key under an empty override", env: "CARLOS_ADMIN_TOKEN=t\nCARLOS_INSTANCE_KEY=\n", adoption: rec(writer, "config", "configured-A"), pinned: &KeyPin{Source: "config", SHA256: instancekey.Digest("configured-A")}},
+ {name: "a backed-up platform key, the app's own key missing", env: envWithKey, want: ErrFilesUnevidenced},
+ {name: "configured A, the app's own unpublished B: no report, no record", env: "CARLOS_ADMIN_TOKEN=t\n", want: ErrFilesUnevidenced},
+ {name: "a stale record from a previous release", env: envWithKey, adoption: rec(earlier, "platform", key), want: ErrFilesUnevidenced},
+ {name: "a rollback: a new session, the old session's record", env: envWithKey, adoption: rec(writer, "platform", key),
+ holder: func(h *hostclaim.Holder) {
+ h.Session, h.Generation = 4, "g4"
+ h.Admissions = append(h.Admissions, hostclaim.AdmissionRecord{Generation: "g4", Predecessor: "g3", Session: 4})
+ }, want: ErrFilesUnevidenced},
+ {name: "a record from another installation", env: envWithKey, adoption: rec(instancekey.Binding{Host: host, Incarnation: "inc-0", Session: 3, Generation: "g3"}, "platform", key), want: ErrFilesUnevidenced},
+ {name: "a platform record for another key", env: envWithKey, adoption: rec(writer, "platform", "another-key"), want: ErrFilesUnevidenced},
+ {name: "a config record while the env's own key overrides it", env: envWithKey, adoption: rec(writer, "config", key), want: ErrFilesUnevidenced},
+ {name: "a platform record without the platform key", env: "CARLOS_ADMIN_TOKEN=t\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced},
+ {name: "an exported key line is not what the app receives", env: "export CARLOS_INSTANCE_KEY=" + key + "\n", adoption: rec(writer, "platform", key), want: ErrFilesUnevidenced},
+ {name: "a malformed record", env: envWithKey, adoption: `{"v":1}`, want: ErrFilesUnevidenced},
+ {name: "no admitted writer for the pinned generation", env: envWithKey, adoption: rec(writer, "platform", key), holder: func(h *hostclaim.Holder) { h.Incarnation = "" }, want: ErrFilesUnevidenced},
+ {name: "a named adoption item that cannot be read", env: envWithKey, unreadable: instancekey.AdoptionItem, want: errUnreadable},
+ {name: "a named env item that cannot be read", adoption: rec(writer, "config", "configured-A"), unreadable: hostfiles.EnvItem, want: errUnreadable},
} {
t.Run(tc.name, func(t *testing.T) {
claims, data := release.NewDirStore(t.TempDir()), release.NewDirStore(t.TempDir())
+ obs := pinClaim()
+ obs.Claim.Holder.Incarnation = "inc-1"
+ gen := "g3"
+ if tc.holder != nil {
+ tc.holder(obs.Claim.Holder)
+ gen = obs.Claim.Holder.Generation
+ }
m := hostfiles.Manifest{V: hostfiles.ManifestVersion, Seq: 4, Entries: map[string]hostfiles.Entry{}, Platform: map[string]hostfiles.Entry{}}
- put := func(item string, body []byte, store bool) {
- sum := sha256.Sum256(body)
+ if tc.appKey {
+ m = keyed(m)
+ }
+ item := func(name, body string, put bool) {
+ sum := sha256.Sum256([]byte(body))
sha := hex.EncodeToString(sum[:])
- key := pinLocation + "/files/g3/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
- if store {
- if _, err := data.Put(ctx, key, body, ""); err != nil {
+ obj := pinLocation + "/files/" + gen + "/objects/" + fmt.Sprintf("%016d-%s", 4, sha)
+ if put {
+ if _, err := data.Put(ctx, obj, []byte(body), ""); err != nil {
t.Fatal(err)
}
}
- m.Platform[item] = hostfiles.Entry{Object: key, SHA256: sha, Size: int64(len(body)), Mode: 0o600, Seq: 4}
+ m.Platform[name] = hostfiles.Entry{Object: obj, SHA256: sha, Size: int64(len(body)), Mode: 0o600, Seq: 4}
}
if tc.env != "" {
- put(hostfiles.EnvItem, []byte(tc.env), !tc.unreadable)
+ item(hostfiles.EnvItem, tc.env, true)
}
- if tc.record != nil {
- raw, _ := json.Marshal(tc.record)
- put(AdoptionItem, raw, true)
+ if tc.adoption != "" {
+ item(instancekey.AdoptionItem, tc.adoption, true)
+ }
+ if tc.unreadable != "" {
+ item(tc.unreadable, "never stored\n", false)
}
- putManifest(t, data, "g3", m)
- o := pinClaim()
- o.Claim.Holder.Incarnation = "inc"
- p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41), KeyEvidence: tc.evidence}
- ref, err := p.Pin(ctx, o, at)
+ putManifest(t, data, gen, m)
+ p := StorePinner{Claims: claims, Data: data, Release: routeRelease, Bucket: "data", Region: "ie", SelectDB: selectAt(41)}
+ ref, err := p.Pin(ctx, obs, at)
switch {
- case tc.unreadable:
+ case tc.want == errUnreadable:
if err == nil || errors.Is(err, ErrFilesUnevidenced) {
- t.Fatalf("an unreadable env item is an error, never no evidence: %v", err)
+ t.Fatalf("got %v, want a read error, never a refusal for no evidence", err)
}
return
- case tc.wantErr != nil:
- if !errors.Is(err, tc.wantErr) {
- t.Fatalf("got %v, want %v", err, tc.wantErr)
- }
+ case !errors.Is(err, tc.want):
+ t.Fatalf("got %v, want %v", err, tc.want)
+ }
+ if err != nil {
return
- case err != nil:
+ }
+ raw, _, err := claims.Get(ctx, ref.Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var obj PinObject
+ if err := json.Unmarshal(raw, &obj); err != nil {
t.Fatal(err)
}
- obj, err := ReadPin(ctx, claims, ref)
- if err != nil || obj.Files.KeySHA256 != KeyDigest(tc.wantKey) {
- t.Fatalf("pinned key digest %q, want %s's: %v", obj.Files.KeySHA256, tc.wantKey, err)
+ if (obj.InstanceKey == nil) != (tc.pinned == nil) || (tc.pinned != nil && *obj.InstanceKey != *tc.pinned) {
+ t.Fatalf("pinned key %+v, want %+v", obj.InstanceKey, tc.pinned)
}
})
}
}
+
+var errUnreadable = errors.New("sentinel: any read error")
```
Run: `nice -n 19 go test ./internal/hostrelocate/ -run TestStorePinner -count=1`. Expected: PASS (rev 3's scratch build ran it with every existing pinner test).
@@ -2834,6 +2969,7 @@ import (
"github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/hostrelocate"
"github.com/carlosframework/platform/internal/hostwrite"
+ "github.com/carlosframework/platform/internal/instancekey"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -3205,23 +3341,24 @@ func (a ClaimAssembly) relocationInstall(cfg RelocationAssemblyConfig) func(cont
return hostclaim.LocalInstall{}, err
}
journal.Steps["render"] = rendered.SHA256
- // O2' (b') at install (ruling 65): the key this install will
- // deliver, the restored env over the config just rendered, must be
- // the one pinned by digest; a configuration change since the pin
- // makes the install refuse rather than deliver another key.
- if pin.Files.KeySHA256 != "" {
+ // O3' (instance-key plan rev 3; ruling 55): when the pin carries the
+ // key by an adoption record, the env files this install will launch
+ // with must deliver exactly that key; a configuration changed since
+ // the pin refuses here, before anything launches. The adoption item
+ // itself is never restored: the target's sessions are new writers.
+ if pin.InstanceKey != nil {
configEnv, err := os.ReadFile(rendered.Path)
if err != nil {
return hostclaim.LocalInstall{}, err
}
- hostEnv, err := os.ReadFile(paths.EnvPath)
+ ownEnv, err := os.ReadFile(paths.EnvPath)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return hostclaim.LocalInstall{}, err
}
- if key := hostrelocate.EffectiveInstanceKey(configEnv, hostEnv); key == "" || hostrelocate.KeyDigest(key) != pin.Files.KeySHA256 {
- return hostclaim.LocalInstall{}, fmt.Errorf("%w: the instance key this install would deliver is not the pinned key", hostrelocate.ErrRefused)
+ if err := instancekey.VerifyDelivered(configEnv, ownEnv, pin.InstanceKey.SHA256); err != nil {
+ return hostclaim.LocalInstall{}, errors.Join(hostrelocate.ErrRefused, err)
}
- journal.Steps["key"] = pin.Files.KeySHA256
+ journal.Steps["key"] = pin.InstanceKey.SHA256
}
// The pinned release (R9): install_release, never the route's
// version, through the claim-aware held stage (never MaterializeHeld,
@@ -5715,7 +5852,7 @@ Before `CARLOS_REGION_HA` is set on any box with a second box in its region, on
6. **M3.** Root's held-stage relay serves a relocating request (the target's `live/<host>` names `install_release`). Until then relocation waits in `installed`.
7. **C1.** Drains stay off until the claim answers C1 (ruling 33). Test failover first: terminate the executor of one host; watch `starting -> pinning -> installing -> installed -> probing -> serving`, one cold restart on the target, the source's `park-local` left open (no `enrolled_at` discharge yet unless the roster has it).
8. **J3.** A committed landing whose target is terminated converts; moves' M1/M2 close it.
-9. **L21.** The instance-key plan has landed with O2' wired, or every app in the region writes `instance.key` before its first manifest (rev 3, ruling 55: a platform key alone is not evidence).
+9. **L21.** The instance-key plan is rolled to the region's boxes, and O1′, O2′, O3′, O5 and O7 are in. **O6 gates platform-key and configured-key relocation:** until claim launches bind a control socket and mount the adoption verb, an app whose key is not its own `instance.key` cannot report adoption on a claim box, and its relocation refuses (stuck). That is an explicit refusal, not HA support for those apps. Apps must also run a rastrillo release with the adoption call (#66 ask and addendum).
10. **Capabilities.** Every box and edge publishes `region-ha-1` (spec §8 step 3).
11. **C6.** An excluded target's participation ends in one of ruling 63's two safe outcomes. Check that no writer starts on it and that its `park-local` retires the inert installation.
12. **C10 (gate).** `TestRelocationFromAnEmptyTargetToTheServingWake` passes on the branch and on dev with C10 on main: after a failover the first request after completion is served on the target from a cold serving wake, handed over from the probe's receipt (N6), with no agent restart.
@@ -5723,7 +5860,7 @@ Before `CARLOS_REGION_HA` is set on any box with a second box in its region, on
14. **W4.** Root's relocating attempt reader is live: `carlos-instance-control` answers the relocating capability, and a probe's first launch is authorized from `<DataDir>/relocate/ops/<op>/attempts.json`.
15. **Census.** After a relocation completes on dev, `find /data -path '*<host>*'` on the target lists only the host's instance directory and the census-opaque `relocate/` (N9), and on the source nothing outside them after its `park-local`; within a pass of completion `relocate/` holds nothing of that op.
16. **C11.** Until it lands each target gets one launchable probe attempt; set `LaterProbeAttempts` only after it does.
-17. **O2′ (IK-R-B).** The instance-key plan rev 3's adoption record is published by the C8 provider and `KeyEvidence.ConfiguredEnv` is wired into `StorePinner`; until then only `instance.key` evidences a nonempty database's key. On dev: a relocation of an app on `carlos.InstanceKey` pins with `FilesPin.KeySHA256`, and changing its configured key between pin and install makes the install refuse.
+17. **O2′ on dev.** A relocation of an app on its own `instance.key` pins with no `InstanceKey`. After O6, a relocation of a platform-key app pins `InstanceKey`, and changing a configured key between pin and install makes the install refuse (`VerifyDelivered`).
---