carlos / platform Public

plan: claim Plan 9 rev 6 (review round 2: sealed SQL handles before commit, uncertain journal replay, first-wake admin token, D4 task order)

Co-Authored-By: Codex <noreply@openai.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
codex agent, operated by paul@keymail.dev pushed by claude agent, operated by paul@keymail.dev 6b31d8b91576e3456119e11ba39fe18714d331f4 parent 4dba3af
1 file changed, +1492 −114
  • docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md +1492 −114

CI passed — run details

diff --git a/docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md b/docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md
index 82016a04a..6b4a884be 100644
--- a/docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md
+++ b/docs/superpowers/plans/2026-10-08-host-lifecycle-claim-9-activation-readiness.md
@@ -1,6 +1,8 @@
# Host lifecycle claim, Plan 9: activation readiness
-Implementation plan, 2026-10-08, rev 5. Base: `origin/main` at **4f26f15cb41ecfdd9214b49393c228399f34490b**. The remote main ref was checked without fetching or writing git state. Spec: `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev **6.30**. Scope: amadan discussion **#250**, including its Plan 5, Plan 6b and integration additions. Plans 1 through 8b, 6b, and Plan 5 Tasks 1 through 9 are already on this base; do not replay them.
+Implementation plan, 2026-10-08, rev 6. Base: `origin/main` at **4f26f15cb41ecfdd9214b49393c228399f34490b**. The remote main ref was checked without fetching or writing git state. Spec: `docs/superpowers/specs/2026-10-02-host-lifecycle-claim-design.md`, rev **6.30**. Scope: amadan discussion **#250**, including its Plan 5, Plan 6b and integration additions. Plans 1 through 8b, 6b, and Plan 5 Tasks 1 through 9 are already on this base; do not replay them.
+
+**Rev 6 (2026-10-08).** Answers review round 2. B4 seals public SQL handles before joining and committing, with separate internal commit authorization and a retained write queued across COMMIT. B7 preserves uncertainty when receipt lookup fails after client restart; 409 requires a proven conflict. B3 initializes a first wake's admin token under claim authority before taking the read-only launch snapshot. D4 updates the constructor callback signature in the same task as the interface, and D5 consumes that signature. Ordered verification also moves B6's census-key regression out of A2. The invariant is: **no statement runs under a transaction permit after its commit begins.** The Rev 6 verification note records the task-by-task checks and supersedes Rev 5's assembled-only verification for these findings. No rollout or git write is authorized by this revision.
**Rev 5 (2026-10-08).** Paul accepted all six recommended decisions. A1 adds root recovery for a provably dead pending controller; A3 separates root fence credentials and includes Carloku's private-root migration; B3 defines the signed reset request/receipt path and wires production owners; B4 admits shared ingests with local transaction permits and durable SQL settlement; B7 moves journals behind root's authenticated typed service. C2 now uses Carloku's real configured roles and the ruled order: prepare the floor, arm every actor, publish after readiness, then enable the flag. No design ruling remains open among these six. Group D signatures remain unchanged. Rollout milestones still need separate Paul approvals; this revision applies nothing. The final verification note supersedes earlier revisions' open-decision and test limitations.
@@ -24,16 +26,17 @@ The task groups retain the discussion's order. Every task states the authority o
Use a clean, later implementation checkout based on the pinned main or a reviewed descendant. If main moved, refresh the exact patches and repeat this document's replay checks; do not force a failed hunk. This authoring session performs no git writes. Implementation must later use the repository's reviewed amadan branch workflow, all required gates, and authority review before landing.
-Save the following as `$CLAIM9_SCRATCH/claim9-apply.py`. The original decided tasks and Group D form the foundation; the rev 5 amendments are exact patches against that assembled foundation. This ordering keeps Group D's published signatures and original executable blocks intact while adding their production consumers. There are four phases: `tests`, `implementation`, `revision-tests`, and `revision-implementation`. For each task, apply its tests first, record the stated failure, then apply its implementation and repeat the tests. Never apply a patch with fuzz or drop a failing test. B4 and B7 now contain complete ruled amendments, not intentionally red landing exclusions.
+Save the following as `$CLAIM9_SCRATCH/claim9-apply.py`. The original decided tasks and Group D form the foundation. The sections headed `Rev 5 amendments` contain the ruled implementations, including Rev 6's corrections, and apply after that foundation in the order below. D4 now changes its constructor signature; D5 expects the updated signature. For each task, apply its tests first, record the stated failure, then apply its implementation and run `check` (or `revision-check`). Each check builds every repository package and runs every test declared by that task's Go blocks and its named existing regressions before the next task starts. Collect the complete-package commands into one serial final gate after these ordered checks. Native module checks run for tasks with HCL test blocks. Never apply a patch with fuzz or drop a failing test. B4 and B7 are required passing tasks.
```python
+import os
import pathlib
import re
import subprocess
import sys
plan, wanted, phase = sys.argv[1:]
-if phase not in {"tests", "implementation", "revision-tests", "revision-implementation"}:
+if phase not in {"tests", "implementation", "check", "revision-tests", "revision-implementation", "revision-check"}:
raise SystemExit("invalid phase")
revision = phase.startswith("revision-")
tests = phase.endswith("tests")
@@ -54,6 +57,39 @@ for task, section in zip(sections[1::2], sections[2::2]):
blocks = re.findall(
r"^#### (New file|Patch): `([^`]+)`\n\n```(?:go|diff|hcl)\n(.*?)^```$",
section, re.M | re.S)
+ if phase.endswith("check"):
+ lock = pathlib.Path(os.environ["CLAIM9_SCRATCH"]) / "tests.lock"
+ prefix = ["flock", "-o", str(lock), "nice", "-n", "10"]
+ subprocess.run(prefix + ["go", "build", "-p", "1", "./..."], check=True)
+ packages = sorted({"./" + str(pathlib.Path(name).parent)
+ for _, name, _ in blocks if name.endswith(".go")})
+ packages = ["." if p == "./." else p for p in packages]
+ names = sorted(set(re.findall(
+ r"^[ +]?func (Test\w+)\(",
+ "\n".join(content for _, name, content in blocks
+ if name.endswith("_test.go")), re.M)))
+ patterns = ["^(" + "|".join(names) + ")$"] if names else []
+ # Include the task's named existing regressions and their packages.
+ for commands in re.findall(r"^```bash\n(.*?)^```$", section, re.M | re.S):
+ for command in commands.splitlines():
+ match = re.search(r"\bgo test\b.*? -run '([^']+)'", command)
+ if match:
+ patterns.append(match[1])
+ packages.extend(re.findall(r"(?<!\S)\./[\w/]+(?=\s|$)", command))
+ if re.search(r"(?<!\S)\.(?=\s|$)", command):
+ packages.append(".")
+ if packages and patterns:
+ subprocess.run(prefix + ["go", "test", "-timeout", "30m", "-p", "1",
+ "-count=1", "-run", "|".join(patterns)]
+ + sorted(set(packages)), check=True)
+ modules = sorted({str(pathlib.Path(name).parent.parent)
+ for _, name, _ in blocks if name.endswith(".tftest.hcl")})
+ for module in modules:
+ subprocess.run(prefix + ["tofu", "-chdir=" + module, "init",
+ "-backend=false", "-input=false"], check=True)
+ subprocess.run(prefix + ["tofu", "-chdir=" + module, "test"], check=True)
+ subprocess.run(prefix + ["tofu", "fmt", "-check", module], check=True)
+ continue
for kind, name, content in blocks:
path = pathlib.Path(name)
if path.is_absolute() or ".." in path.parts:
@@ -79,7 +115,7 @@ Prepare a fresh main export in a later implementation checkout without modifying
```bash
set -euo pipefail
test "$(git rev-parse origin/main)" = "$(git ls-remote origin refs/heads/main | cut -f1)"
-CLAIM9_SCRATCH=$(mktemp -d /home/paulca/.cache/tmp/claude-1001/claim9-replay-XXXXXX)
+export CLAIM9_SCRATCH=$(mktemp -d /home/paulca/.cache/tmp/claude-1001/claim9-replay-XXXXXX)
mkdir "$CLAIM9_SCRATCH/overlay"
mkdir -m 0700 /tmp/g9
git archive origin/main | tar -x -C "$CLAIM9_SCRATCH/overlay"
@@ -95,15 +131,17 @@ for task in A1 A2 A3 B1 B2 B3 B5 B6 B8 D1 D2 D3 D4 D5; do
python3 "$CLAIM9_SCRATCH/claim9-apply.py" "$CLAIM9_SCRATCH/plan.md" "$task" tests
# Run this task's failing-first command before its implementation.
python3 "$CLAIM9_SCRATCH/claim9-apply.py" "$CLAIM9_SCRATCH/plan.md" "$task" implementation
+ python3 "$CLAIM9_SCRATCH/claim9-apply.py" "$CLAIM9_SCRATCH/plan.md" "$task" check
done
for task in A1 A3 B4 B7 B3; do
python3 "$CLAIM9_SCRATCH/claim9-apply.py" "$CLAIM9_SCRATCH/plan.md" "$task" revision-tests
# Run this task's failing-first command before its implementation.
python3 "$CLAIM9_SCRATCH/claim9-apply.py" "$CLAIM9_SCRATCH/plan.md" "$task" revision-implementation
+ python3 "$CLAIM9_SCRATCH/claim9-apply.py" "$CLAIM9_SCRATCH/plan.md" "$task" revision-check
done
```
-Run Go checks serially under `nice -n 10`, `go test -p 1`, and a single shared `flock -o "$CLAIM9_SCRATCH/tests.lock"` when more than one authoring process can request tests. The final complete-package commands are recorded in the Rev 5 verification note. Once all processes have exited, remove only the newly created overlay/scratch and `/tmp/g9`; retain the shared Go cache. No checkout, index, branch or ref is written by this replay.
+Run Go checks serially under `nice -n 10`, `go test -p 1`, and a single shared `flock -o "$CLAIM9_SCRATCH/tests.lock"` when more than one authoring process can request tests. The final complete-package commands and ordered replay results are recorded in the verification notes, with Rev 6 authoritative for this revision. Once all processes have exited, remove only the newly created overlay/scratch and `/tmp/g9`; retain the shared Go cache. No checkout, index, branch or ref is written by this replay.
## Group A: arming blockers
@@ -2393,96 +2431,6 @@ func (s plan9KeyFailStore) Get(ctx context.Context, key string) ([]byte, string,
// stay behind the published floor (older binaries strict-decode the roster
// and would refuse census_public_key) and bind only an active, unfenced
// enrollment, read fresh rather than from a caller-supplied copy.
-func TestPlan9BindCensusKeyRequiresPublishedFloorAndActiveEnrollment(t *testing.T) {
- for _, tc := range []string{"published-active", "unpublished", "preparing", "floor-mismatch", "quarantined", "retired", "fenced", "fence-unreadable", "stale-observed", "forged-observed"} {
- t.Run(tc, func(t *testing.T) {
- ctx := context.Background()
- w, _ := plan9Fixture(t, func(r *Roster) {
- r.Activations = map[string]Reservation{}
- switch tc {
- case "unpublished":
- r.Publication, r.FloorSHA256 = "", ""
- case "preparing":
- r.Publication = "preparing"
- case "floor-mismatch":
- r.FloorSHA256 = strings.Repeat("f", 64)
- case "quarantined":
- plan9Quarantine(r, "b", "loss-b")
- case "retired":
- plan9Quarantine(r, "b", "loss-b")
- b := r.Boxes["b"]
- b.State = "retired"
- r.Boxes["b"] = b
- }
- })
- base := w.d.Store
- switch tc {
- case "fenced":
- w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
- case "fence-unreadable":
- w.d.Store = brokenFenceStore{w.d.Store}
- }
- before, err := w.d.Read(ctx)
- if err != nil {
- t.Fatal(err)
- }
- pub, key, err := ed25519.GenerateKey(nil)
- if err != nil {
- t.Fatal(err)
- }
- binding := CensusKeyBinding{Bucket: "bucket", Box: "b", Enrollment: before.Roster.Boxes["b"].Enrollment, PublicKey: pub}
- raw, err := json.Marshal(binding)
- if err != nil {
- t.Fatal(err)
- }
- input := before
- switch tc {
- case "stale-observed":
- // A concurrent writer moved the roster after the caller read it.
- next := Clone(before.Roster)
- next.RequestLimitNS = 120000000000
- if _, err := w.d.Replace(ctx, w.sg, before, next); err != nil {
- t.Fatal(err)
- }
- case "forged-observed":
- // The caller's copy claims b is active while the signed roster
- // says otherwise; only the signed roster decides.
- o, err := w.d.Read(ctx)
- if err != nil {
- t.Fatal(err)
- }
- r := Clone(o.Roster)
- plan9Quarantine(&r, "b", "loss-b")
- signed, err := release.SignRootEnvelope(w.sg, r)
- if err != nil {
- t.Fatal(err)
- }
- etag, err := w.d.Store.Put(ctx, Key, signed, o.ETag)
- if err != nil {
- t.Fatal(err)
- }
- input = Observed{Roster: before.Roster, ETag: etag}
- }
- got, err := w.d.BindCensusKey(ctx, w.sg, input, binding, ed25519.Sign(key, raw))
- if tc == "published-active" {
- if err != nil || !ed25519.PublicKey(got.Roster.Boxes["b"].CensusPublicKey).Equal(pub) {
- t.Fatalf("bind = %v", err)
- }
- return
- }
- if err == nil {
- t.Fatalf("%s: census key bound", tc)
- }
- after, readErr := (Directory{Store: base, PublicKey: w.d.PublicKey, Bucket: "bucket"}).Read(ctx)
- if readErr != nil {
- t.Fatal(readErr)
- }
- if len(after.Roster.Boxes["b"].CensusPublicKey) != 0 {
- t.Fatalf("%s: census key written despite refusal", tc)
- }
- })
- }
-}
```
#### New file: `internal/hostfence/plan9_b_test.go`
@@ -5220,6 +5168,240 @@ Implementation.
#### Rev 5 amendments
+**Rev 6, round 2 P2.** Initialize a missing admin token through the production serving launch source before `SnapshotClaimLaunch`. The initialization uses fresh serving-claim and actor checks under the host guard, including the exact holder, session, generation, executor, quiet-after and hold policy. With no installation record, use `WithNoInstallation`; an existing record must match the captured incarnation and stamp. Missing, stale or foreign authority refuses before the env write. Snapshot and input-policy checks remain read-only; retrying an existing token never rotates it. The first-wake regression begins without an env file, selects the launch, checks the token in the snapshot, and repeats before and after activation. Refusal cases and a snapshot-only test prove the write remains scoped.
+
+
+#### New file: `internal/host/claimproduction_admintoken_test.go`
+
+```go
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const adminTokenHost = "shop.bab.oncarlos.com"
+
+type adminTokenFixture struct {
+ t *testing.T
+ claims release.Store
+ data string
+ backend *activator.ExecBackend
+ tokens claimAdminTokens
+ rt registry.Route
+ actor hostbox.Actor
+ verify error
+}
+
+// newAdminTokenFixture is a first unscheduled wake on box-1: a serving claim
+// whose executor is execBox, no local hostguard record yet (Activate runs at
+// Admit, after selection), a staged live release and no <host>.env at all.
+func newAdminTokenFixture(t *testing.T, execBox string) *adminTokenFixture {
+ t.Helper()
+ ctx := t.Context()
+ st := release.NewDirStore(t.TempDir())
+ now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
+ in := hostclaim.NewHolder{Family: "instance", Key: "apps/bab/jam/instances/" + adminTokenHost + ".json", Account: "bab", App: "jam", Host: adminTokenHost, Region: "ie", RecordRegion: "ie", CreatedAt: now}
+ body := []byte(`{"schema":1,"host":"shop.bab.oncarlos.com","account":"bab","app":"jam","region":"ie","created_at":"2026-10-06T12:00:00Z"}`)
+ absent, err := hostclaim.Read(ctx, st, adminTokenHost)
+ if err != nil {
+ t.Fatal(err)
+ }
+ o, err := hostclaim.Update(ctx, st, absent, hostclaim.BeginCreate(in, body, "create", "console", now))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err = hostclaim.Update(ctx, st, o, hostclaim.CompleteCreate("create", execBox)); err != nil {
+ t.Fatal(err)
+ }
+ root := t.TempDir()
+ data, opt := filepath.Join(root, "data"), filepath.Join(root, "opt")
+ for _, dir := range []string{filepath.Join(data, "hostguard"), filepath.Join(opt, "versions", "v1", "bab"), filepath.Join(opt, "live")} {
+ if err = os.MkdirAll(dir, 0o770); err != nil {
+ t.Fatal(err)
+ }
+ }
+ bin := filepath.Join(opt, "versions", "v1", "bab", "jam")
+ if err = os.WriteFile(bin, []byte("#!/bin/sh\n"), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err = os.Symlink(bin, filepath.Join(opt, "live", adminTokenHost)); err != nil {
+ t.Fatal(err)
+ }
+ reg, err := registry.Open(filepath.Join(root, "registry.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { reg.Close() })
+ f := &adminTokenFixture{t: t, claims: st, data: data, backend: &activator.ExecBackend{DataDir: data, OptDir: opt}, actor: hostbox.Actor{Box: "box-1", Region: "ie", Enrollment: "enr-1"}}
+ f.rt = registry.Route{Host: adminTokenHost, Socket: filepath.Join(root, "run", "app.sock"), DBPath: filepath.Join(data, adminTokenHost+".db")}
+ local := &productionClaimLocal{Resources: ClaimRuntimeResources{DataDir: data}, slots: map[string]*claimParticipation{}}
+ f.tokens = claimAdminTokens{
+ Guard: hostguard.Guard{DataDir: data, LockDir: t.TempDir(), Claims: hostguard.StoreReader{Store: st}},
+ Claims: st,
+ Verify: f.verifyActor,
+ Local: func(ctx context.Context, c *hostclaim.Claim, s hostbox.Selection) error {
+ a, err := f.verifyActor(ctx)
+ if err != nil {
+ return err
+ }
+ if s.Box != a.Box || s.Region != a.Region {
+ return hostbox.ErrChanged
+ }
+ return local.Check(ctx, a, c, s)
+ },
+ Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(reg)}.Check,
+ Now: time.Now,
+ }
+ return f
+}
+
+func (f *adminTokenFixture) verifyActor(context.Context) (hostbox.Actor, error) {
+ return f.actor, f.verify
+}
+
+func (f *adminTokenFixture) envPath() string { return filepath.Join(f.data, adminTokenHost+".env") }
+
+func (f *adminTokenFixture) token() string {
+ f.t.Helper()
+ raw, err := os.ReadFile(f.envPath())
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ var tok string
+ for _, line := range strings.Split(string(raw), "\n") {
+ if v, ok := strings.CutPrefix(line, "CARLOS_ADMIN_TOKEN="); ok {
+ if tok != "" {
+ f.t.Fatalf("env file holds two tokens: %q", raw)
+ }
+ tok = v
+ }
+ }
+ return tok
+}
+
+func envHasToken(env []string, tok string) bool {
+ for _, kv := range env {
+ if kv == "CARLOS_ADMIN_TOKEN="+tok {
+ return true
+ }
+ }
+ return false
+}
+
+// B3 regression: the claim path bypasses the legacy launch preparation that
+// mints CARLOS_ADMIN_TOKEN. The production serving launch source must
+// initialize the token under the claim's own serving authority before the
+// read-only snapshot, and every later selection must see the same token.
+func TestClaimProductionFirstWakeInitializesAdminToken(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ launch := claimServingLaunchSource(f.backend, f.tokens)
+ first, err := launch(t.Context(), f.rt)
+ if err != nil {
+ t.Fatalf("first unscheduled wake: launch selection failed: %v", err)
+ }
+ tok := f.token()
+ if len(tok) != 64 {
+ t.Fatalf("minted token %q, want 64 hex characters", tok)
+ }
+ if !envHasToken(first.Env, tok) || first.Identity.Config == "" || first.Identity.Release == "" {
+ t.Fatalf("snapshot does not carry the minted token: %+v", first)
+ }
+ // Retry selection, still before Admit: no rotation, identical identity.
+ again, err := launch(t.Context(), f.rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.token() != tok || again.Identity != first.Identity {
+ t.Fatalf("retry rotated the token or changed the launch identity: %q→%q %+v→%+v", tok, f.token(), first.Identity, again.Identity)
+ }
+ // After Admit activates the local record, the token is still stable.
+ if err = f.tokens.Guard.Activate(t.Context(), adminTokenHost, "box-1", nil); err != nil {
+ t.Fatal(err)
+ }
+ after, err := launch(t.Context(), f.rt)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.token() != tok || after.Identity != first.Identity {
+ t.Fatalf("post-admission selection rotated the token: %q→%q", tok, f.token())
+ }
+}
+
+// With a local record in place and the token removed, the token is minted
+// again only through the guarded installation path (Guard.With).
+func TestClaimProductionAdminTokenUnderInstalledRecord(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ if err := f.tokens.Guard.Activate(t.Context(), adminTokenHost, "box-1", nil); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt); err != nil {
+ t.Fatalf("installed serving holder: %v", err)
+ }
+ if len(f.token()) != 64 {
+ t.Fatal("no token minted under the installed record")
+ }
+}
+
+// Missing or stale authority refuses the wake before any file is written.
+func TestClaimProductionAdminTokenRefusesWithoutAuthority(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ setup func(*adminTokenFixture)
+ }{
+ {"other executor", func(f *adminTokenFixture) {}},
+ {"identity unverifiable", func(f *adminTokenFixture) { f.verify = errors.New("enrollment revoked") }},
+ {"no authorizer", func(f *adminTokenFixture) { f.tokens = claimAdminTokens{} }},
+ {"stale local record", func(f *adminTokenFixture) {
+ // A record from an earlier incarnation is not this holder's installation.
+ if err := os.WriteFile(filepath.Join(f.data, "hostguard", adminTokenHost+".json"), []byte(`{"v":1,"host":"`+adminTokenHost+`","incarnation":"old","active_stamp":1,"retired_stamps":[]}`), 0o600); err != nil {
+ f.t.Fatal(err)
+ }
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ box := "box-1"
+ if tc.name == "other executor" {
+ box = "box-2"
+ }
+ f := newAdminTokenFixture(t, box)
+ tc.setup(f)
+ if _, err := claimServingLaunchSource(f.backend, f.tokens)(t.Context(), f.rt); err == nil {
+ t.Fatal("launch selection succeeded without serving authority")
+ }
+ if _, err := os.Stat(f.envPath()); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("token initialization wrote %s without authority: %v", f.envPath(), err)
+ }
+ })
+ }
+}
+
+// The snapshot itself stays read-only: it refuses a missing token and never
+// creates one, so the InputPolicy re-check under hostguard cannot mint.
+func TestSnapshotClaimLaunchNeverMintsAdminToken(t *testing.T) {
+ f := newAdminTokenFixture(t, "box-1")
+ if _, err := f.backend.SnapshotClaimLaunch(t.Context(), f.rt); err == nil {
+ t.Fatal("snapshot accepted a missing token")
+ }
+ if _, err := os.Stat(f.envPath()); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("snapshot wrote %s: %v", f.envPath(), err)
+ }
+}
+```
+
Invariant: a reset changes only the registered holder's retry episode after a positive stop proof for its exact observed attempt. The console can request a stop using its existing deployment control signer. Only root instance-control holds the box evidence key and signs the result. A lost response, expiry, cancellation, empty listing or controller error grants no reset authority.
Retain the original Task B3 R2 implementation and tests, including `plan9_c2_test.go`, the writer-issued registration kind and incarnation, and comparison of the entire reset-shaped transition. The new grant fields stay private and other registration kinds keep their existing behavior. Retain the existing R4 signer assertion and 5-M10 signed census reader assertions. The code below extends those patches; it does not replace them with the transport alone.
@@ -6658,12 +6840,51 @@ type ClaimLaunchSnapshot struct {
Limit cgroup.Limit
}
+// claimLaunchRoute is the route shape the claim launch path serves.
+func claimLaunchRoute(rt registry.Route) bool {
+ return rt.Host != "" && rt.Socket != "" && filepath.IsAbs(rt.DBPath) && !rt.IsSidecar() && rt.Unit == "" && rt.Backing == "" && rt.Tunnel == ""
+}
+
+// EnsureClaimAdminToken is the claim path's half of what startChild and
+// seedChild do before reading the env: a first wake has no
+// CARLOS_ADMIN_TOKEN until something mints it, and the claim path never
+// runs the legacy launch preparation. A token already present is left
+// exactly as it is and authorize is not called, so a retry never rotates
+// it. Only a missing token is minted, and only inside authorize, which must
+// run the write under the claim's current serving authority and refuse
+// otherwise. SnapshotClaimLaunch stays read-only.
+func (b *ExecBackend) EnsureClaimAdminToken(ctx context.Context, rt registry.Route, authorize func(context.Context, func() error) error) error {
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+ if !claimLaunchRoute(rt) {
+ return hostwrite.ErrUnavailable
+ }
+ vars, err := b.instanceEnv(rt)
+ if err != nil {
+ return err
+ }
+ if vars["CARLOS_ADMIN_TOKEN"] != "" {
+ return nil
+ }
+ if authorize == nil {
+ return hostwrite.ErrUnavailable
+ }
+ paths := b.pathsFor(rt)
+ return authorize(ctx, func() error {
+ if paths.layout == LayoutNested {
+ return EnsureAdminTokenBeneath(paths.dataDir, rt.Host+".env")
+ }
+ return EnsureAdminToken(paths.envFile)
+ })
+}
+
func (b *ExecBackend) SnapshotClaimLaunch(ctx context.Context, rt registry.Route) (ClaimLaunchSnapshot, error) {
var out ClaimLaunchSnapshot
if ctx.Err() != nil {
return out, ctx.Err()
}
- if rt.Host == "" || rt.Socket == "" || !filepath.IsAbs(rt.DBPath) || rt.IsSidecar() || rt.Unit != "" || rt.Backing != "" || rt.Tunnel != "" {
+ if !claimLaunchRoute(rt) {
return out, hostwrite.ErrUnavailable
}
vars, err := b.instanceEnv(rt)
@@ -7655,6 +7876,7 @@ import (
"github.com/carlosframework/platform/internal/hostbox"
"github.com/carlosframework/platform/internal/hostclaim"
"github.com/carlosframework/platform/internal/hostfloor"
+ "github.com/carlosframework/platform/internal/hostguard"
"github.com/carlosframework/platform/internal/hostops"
"github.com/carlosframework/platform/internal/hostwrite"
"github.com/carlosframework/platform/internal/lease"
@@ -7769,10 +7991,10 @@ func (ProductionClaimOwnerProvider) OpenClaimOwners(ctx context.Context, store r
local := &productionClaimLocal{Resources: r, Identity: out.Identity, Directory: directory, Store: store, slots: map[string]*claimParticipation{}}
out.Local = local
backend := &activator.ExecBackend{DataDir: r.DataDir, OptDir: r.OptDir, Cgroups: r.Manager}
- out.Serving.Launch = func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) {
- snapshot, err := backend.SnapshotClaimLaunch(ctx, rt)
- return ClaimServingLaunch{Identity: snapshot.Identity, Executable: snapshot.Executable, ExecutableSHA256: snapshot.SHA256, Args: snapshot.Args, Env: snapshot.Env, Version: snapshot.Version, Limit: snapshot.Limit}, err
- }
+ // The token mint is the only write this source makes. It runs under the
+ // same guard and claim store the assembly's WorkSource uses.
+ tokens := claimAdminTokens{Guard: hostguard.Guard{DataDir: r.DataDir, Claims: hostguard.StoreReader{Store: store}}, Claims: store, Verify: out.Identity.Verify, Local: out.Identity.Local(local), Policy: hostbox.Policy{Held: hostbox.RegistryHoldPolicy(r.Registry)}.Check, Now: time.Now}
+ out.Serving.Launch = claimServingLaunchSource(backend, tokens)
out.Serving.Markers = marker
claimMoveSources.Lock()
external := claimMoveSources.Source
@@ -7941,9 +8163,84 @@ func (p productionClaimRegional) Authorize(ctx context.Context, o hostclaim.Obse
}
return p.Local.Prepare(ctx, o)
}
-```
-#### New file: `internal/host/claimproduction_linux.go`
+// claimAdminTokens authorizes a first wake's CARLOS_ADMIN_TOKEN mint. The
+// legacy launch preparation mints it before reading the env. The claim path
+// never runs that preparation, and SnapshotClaimLaunch refuses a missing
+// token, so selection would refuse every first wake. The mint is a host
+// write, so it runs only under the claim's current serving holder for this
+// box. The checks are the ones a selected "spawn" Work makes (hostbox
+// WorkSource): exact op, holder stamp, session and generation; quiet-after;
+// local authority; and the serving policy with a zero identity. Before
+// Admit there is no local hostguard record, so the write runs under
+// Guard.WithNoInstallation. After Admit it runs under Guard.With at the
+// holder's captured stamp. A record for any other incarnation or stamp
+// refuses the write.
+type claimAdminTokens struct {
+ Guard hostguard.Guard
+ Claims release.Store
+ Verify func(context.Context) (hostbox.Actor, error)
+ Local func(context.Context, *hostclaim.Claim, hostbox.Selection) error
+ Policy func(context.Context, *hostclaim.Claim, hostbox.Selection, hostwrite.Identity) error
+ Now func() time.Time
+}
+
+func (t claimAdminTokens) With(ctx context.Context, host string, effect func() error) error {
+ if ctx == nil || effect == nil || t.Guard.DataDir == "" || t.Guard.Claims == nil || t.Claims == nil || t.Verify == nil || t.Local == nil || t.Policy == nil || t.Now == nil {
+ return hostwrite.ErrUnavailable
+ }
+ o, err := hostclaim.Read(ctx, t.Claims, host)
+ if err != nil {
+ return err
+ }
+ if o.Absent || o.Claim == nil || o.Claim.Holder == nil || o.Claim.Host != host || o.Claim.Op.Kind != hostclaim.KindServing || o.Claim.Holder.Stamp == 0 {
+ return hostbox.ErrChanged
+ }
+ actor, err := t.Verify(ctx)
+ if err != nil {
+ return err
+ }
+ c, h := o.Claim, o.Claim.Holder
+ sel := hostbox.Selection{Host: host, Action: "spawn", Box: actor.Box, Region: actor.Region, Captured: hostguard.Captured{Incarnation: h.Incarnation, Stamp: h.Stamp}, Kind: c.Op.Kind, Op: c.Op.ID, Phase: c.Op.Phase, Generation: h.Generation, Session: h.Session}
+ check := func(c *hostclaim.Claim) error {
+ if c.Op.Kind != sel.Kind || c.Op.ID != sel.Op || c.Op.Phase != sel.Phase || c.Holder.Incarnation != sel.Captured.Incarnation || c.Holder.Stamp != sel.Captured.Stamp || c.Holder.Session != sel.Session || c.Holder.Generation != sel.Generation {
+ return hostbox.ErrChanged
+ }
+ if until, ok := c.QuietAfter[sel.Region]; ok && !t.Now().After(until) {
+ return hostbox.ErrChanged
+ }
+ if err := t.Local(ctx, c, sel); err != nil {
+ return err
+ }
+ return t.Policy(ctx, c, sel, hostwrite.Identity{})
+ }
+ captured, err := hostguard.Capture(t.Guard.DataDir, host)
+ if errors.Is(err, hostguard.ErrNoRecord) {
+ return t.Guard.WithNoInstallation(ctx, host, sel.Captured.Incarnation, check, effect)
+ }
+ if err != nil {
+ return err
+ }
+ if captured != sel.Captured {
+ return hostbox.ErrChanged
+ }
+ return t.Guard.With(ctx, host, captured, check, effect)
+}
+
+// claimServingLaunchSource is the production serving launch source: the
+// authorized token initialization, then the read-only snapshot.
+func claimServingLaunchSource(backend *activator.ExecBackend, tokens claimAdminTokens) func(context.Context, registry.Route) (ClaimServingLaunch, error) {
+ return func(ctx context.Context, rt registry.Route) (ClaimServingLaunch, error) {
+ if err := backend.EnsureClaimAdminToken(ctx, rt, func(ctx context.Context, effect func() error) error { return tokens.With(ctx, rt.Host, effect) }); err != nil {
+ return ClaimServingLaunch{}, err
+ }
+ snapshot, err := backend.SnapshotClaimLaunch(ctx, rt)
+ return ClaimServingLaunch{Identity: snapshot.Identity, Executable: snapshot.Executable, ExecutableSHA256: snapshot.SHA256, Args: snapshot.Args, Env: snapshot.Env, Version: snapshot.Version, Limit: snapshot.Limit}, err
+ }
+}
+```
+
+#### New file: `internal/host/claimproduction_linux.go`
```go
//go:build linux
@@ -9108,6 +9405,298 @@ func CheckRouteReleaseIdentity(ctx context.Context, s Store, pub ed25519.PublicK
#### Rev 5 amendments
+**Rev 6, round 2 P1.** No statement runs under a transaction permit after its commit begins. Seal the callback binding before closing or joining handles; the driver also checks the seal for operations already queued on its connection. Only the internal commit authorization may check the transaction's local authority after sealing. Close unfinished rows before COMMIT. Recheck handle registration under the view mutex so a late prepare or begin cannot escape the handle-closure pass. If a remaining child transaction forces rollback, return an error and retain any rollback error; never report success without a commit. Keep retained views, prepared statements, transactions and row iteration closed through unbind and pool reuse. The queued-write regression holds the connection in the commit check and queues direct and prepared-statement operations across COMMIT. It also checks the retained view’s fast refusal. Every operation must return `ErrClaimSQLRevoked`, leave no late write and retain the committed receipt. Separate deterministic regressions seal during handle registration and leave a child transaction open at commit; the latter must report rollback, with no write or receipt. Run it against the previous implementation before applying the fix.
+
+
+#### New file: `internal/console/plan9_sql_seal_test.go`
+
+```go
+package console
+
+import (
+ "context"
+ "database/sql"
+ "database/sql/driver"
+ "errors"
+ "github.com/carlosframework/platform/internal/hostops"
+ "runtime"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// plan9SealRetainedStore is a retained *Store from a batch callback writing
+// after the callback returned.
+func plan9SealRetainedStore(v *Store, out chan<- error) {
+ _, err := v.db.Exec(`UPDATE audit SET action='late-store' WHERE account_id=7 AND app='blog'`)
+ out <- err
+}
+
+// plan9SealQueuedDriver is an operation that already passed the view's public
+// check and is queued on the held driver connection.
+func plan9SealQueuedDriver(d *claimBoundDB, out chan<- error) {
+ _, err := d.conn.ExecContext(context.Background(), `UPDATE audit SET action='late-driver' WHERE account_id=7 AND app='blog'`)
+ out <- err
+}
+
+// plan9SealQueuedStmtExec and plan9SealQueuedStmtQuery run an escaped prepared
+// statement: one that was never registered with the view, as if the statement
+// had been prepared before the callback returned.
+func plan9SealQueuedStmtExec(st *sql.Stmt, out chan<- error) {
+ _, err := st.ExecContext(context.Background())
+ out <- err
+}
+
+func plan9SealQueuedStmtQuery(st *sql.Stmt, out chan<- error) {
+ rows, err := st.QueryContext(context.Background())
+ if err == nil {
+ for rows.Next() {
+ }
+ err = rows.Err()
+ rows.Close()
+ }
+ out <- err
+}
+
+// waitPlan9SealQueued returns once the goroutine running fn has finished
+// (done has a value) or is parked on the database/sql driver-connection lock,
+// which the batch commit holds.
+func waitPlan9SealQueued(t *testing.T, fn string, done chan error) {
+ t.Helper()
+ deadline := time.Now().Add(10 * time.Second)
+ buf := make([]byte, 1<<20)
+ for time.Now().Before(deadline) {
+ if len(done) > 0 {
+ return
+ }
+ n := runtime.Stack(buf, true)
+ for _, g := range strings.Split(string(buf[:n]), "\n\n") {
+ onDriver := strings.Contains(g, "database/sql.withLock(") || strings.Contains(g, "database/sql.resultFromStatement(") || strings.Contains(g, "database/sql.rowsiFromStatement(")
+ if strings.Contains(g, "console."+fn+"(") && onDriver && strings.Contains(g, "sync.(*Mutex).lockSlow(") {
+ return
+ }
+ }
+ runtime.Gosched()
+ }
+ t.Fatalf("%s neither finished nor queued on the driver connection", fn)
+}
+
+// TestPlan9SQLBatchSealsRetainedWriteQueuedAcrossCommit checks the view fast
+// path and queues driver and prepared-statement writes across COMMIT. None
+// may run under the transaction permit once its commit begins.
+func TestPlan9SQLBatchSealsRetainedWriteQueuedAcrossCommit(t *testing.T) {
+ s := openGatedStore(t)
+ seedGateApp(t, s)
+ r := receiptFixture(t, s)
+ var view *Store
+ var returned atomic.Bool
+ store, driver := make(chan error, 1), make(chan error, 1)
+ stmtExec, stmtQuery := make(chan error, 1), make(chan error, 1)
+ var escapedExec, escapedQuery *sql.Stmt
+ local := func(ctx context.Context, _ claimSQLScope) error {
+ if !returned.CompareAndSwap(true, false) {
+ return ctx.Err()
+ }
+ // The commit authorization: endBatch holds the driver connection.
+ go plan9SealRetainedStore(view, store)
+ waitPlan9SealQueued(t, "plan9SealRetainedStore", store)
+ go plan9SealQueuedDriver(view.db.(*claimBoundDB), driver)
+ waitPlan9SealQueued(t, "plan9SealQueuedDriver", driver)
+ go plan9SealQueuedStmtExec(escapedExec, stmtExec)
+ waitPlan9SealQueued(t, "plan9SealQueuedStmtExec", stmtExec)
+ go plan9SealQueuedStmtQuery(escapedQuery, stmtQuery)
+ waitPlan9SealQueued(t, "plan9SealQueuedStmtQuery", stmtQuery)
+ return nil
+ }
+ err := s.withPreparedClaimSQL(gateCtx(t), []claimSQLScope{gateScope}, allowAllClaimSQL, local, []hostops.SQLReceiptScope{r}, true, func(v *Store) error {
+ view = v
+ // Prepared on the raw sql.Conn, bypassing the view's registration, so
+ // these statements are past it and only the driver gate can stop them.
+ d := v.db.(*claimBoundDB)
+ var perr error
+ if escapedExec, perr = d.conn.PrepareContext(context.Background(), `UPDATE audit SET action='late-stmt-exec' WHERE account_id=7 AND app='blog'`); perr != nil {
+ return perr
+ }
+ if escapedQuery, perr = d.conn.PrepareContext(context.Background(), `UPDATE audit SET action='late-stmt-query' WHERE account_id=7 AND app='blog' RETURNING id`); perr != nil {
+ return perr
+ }
+ _, err := v.db.Exec(`UPDATE audit SET action='batch' WHERE account_id=7 AND app='blog'`)
+ returned.Store(true)
+ return err
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if escapedExec != nil {
+ defer escapedExec.Close()
+ }
+ if escapedQuery != nil {
+ defer escapedQuery.Close()
+ }
+ if returned.Load() {
+ t.Fatal("commit authorization never ran the local check")
+ }
+ for name, ch := range map[string]chan error{
+ "retained store (view fast path)": store,
+ "queued driver": driver,
+ "escaped stmt Exec": stmtExec,
+ "escaped stmt QueryContext": stmtQuery,
+ } {
+ select {
+ case werr := <-ch:
+ if !errors.Is(werr, ErrClaimSQLRevoked) {
+ t.Errorf("%s write across COMMIT: err=%v, want ErrClaimSQLRevoked", name, werr)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatalf("%s write never finished", name)
+ }
+ }
+ if n := countWhere(t, s, "audit", "action LIKE 'late-%'"); n != 0 {
+ t.Errorf("%d retained writes landed outside the receipted transaction", n)
+ }
+ if countWhere(t, s, "audit", "action='batch'") != 1 {
+ t.Error("batch write did not commit")
+ }
+ var state string
+ if err := s.rawBoot.QueryRow(`SELECT state FROM claim_sql_receipts WHERE effect=?`, r.Effect).Scan(&state); err != nil || state != "committed" {
+ t.Fatalf("receipt=%q err=%v", state, err)
+ }
+}
+
+// TestPlan9SQLEndBatchLeftoverTxRefusesCommit drives the driver directly: a
+// transaction that registered after the handles were joined forces the
+// requested commit to roll back, and endBatch must say so instead of
+// reporting success with nothing committed.
+func TestPlan9SQLEndBatchLeftoverTxRefusesCommit(t *testing.T) {
+ s := openGatedStore(t)
+ seedGateApp(t, s)
+ r := receiptFixture(t, s)
+ ctx := gateCtx(t)
+ conn, err := s.rawPool.Conn(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer conn.Close()
+ sqids := make([]string, 1)
+ if s.gate.needSqid() {
+ if err := conn.QueryRowContext(ctx, `SELECT sqid FROM accounts WHERE id = ?`, gateScope.AccountID).Scan(&sqids[0]); err != nil {
+ t.Fatal(err)
+ }
+ }
+ scopes := []claimSQLScope{gateScope}
+ b := &claimBinding{ctx: ctx, scopes: scopes, check: func(ctx context.Context, _ claimSQLScope) error { return ctx.Err() }, deadline: time.Now().Add(hostops.SQLTransactionLifetime), batch: true, receipts: []hostops.SQLReceiptScope{r}, dead: make([]bool, 1)}
+ var endErr error
+ err = conn.Raw(func(dc any) error {
+ c := dc.(*claimConn)
+ if err := c.bindScopes(ctx, b, sqids); err != nil {
+ return err
+ }
+ defer func() { _ = c.unbind(b) }()
+ if err := c.beginBatch(ctx, b); err != nil {
+ return err
+ }
+ if _, err := c.ExecContext(ctx, `UPDATE audit SET action='batch' WHERE account_id=7 AND app='blog'`, nil); err != nil {
+ return err
+ }
+ if _, err := c.BeginTx(ctx, driver.TxOptions{}); err != nil {
+ return err
+ }
+ endErr = c.endBatch(b, true)
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := conn.Close(); err != nil {
+ t.Fatal(err)
+ }
+ if endErr == nil {
+ t.Fatal("endBatch reported success although a leftover transaction forced the commit to roll back")
+ }
+ if !errors.Is(endErr, ErrClaimSQLScope) {
+ t.Errorf("endBatch err=%v, want ErrClaimSQLScope", endErr)
+ }
+ var n int
+ if err := s.rawBoot.QueryRow(`SELECT count(*) FROM claim_sql_receipts WHERE effect=?`, r.Effect).Scan(&n); err != nil || n != 0 {
+ t.Errorf("receipt rows=%d err=%v, want none", n, err)
+ }
+ if got := countWhere(t, s, "audit", "action='batch'"); got != 0 {
+ t.Errorf("batch write present (%d rows) after forced rollback", got)
+ }
+}
+
+// Seal while a handle has left the driver but has not entered the view's
+// registry. It must be closed by the registering goroutine, not escape the
+// closeHandles snapshot. Holding mu makes this boundary deterministic.
+func TestPlan9SQLSealDuringHandleRegistration(t *testing.T) {
+ for _, method := range []string{"PrepareContext", "BeginTx"} {
+ t.Run(method, func(t *testing.T) {
+ s := openGatedStore(t)
+ seedGateApp(t, s)
+ r := receiptFixture(t, s)
+ err := s.withPreparedClaimSQL(gateCtx(t), []claimSQLScope{gateScope}, allowAllClaimSQL, allowAllClaimSQL, []hostops.SQLReceiptScope{r}, true, func(v *Store) error {
+ d := v.db.(*claimBoundDB)
+ d.mu.Lock()
+ locked := true
+ defer func() {
+ if locked {
+ d.mu.Unlock()
+ }
+ }()
+ done := make(chan error, 1)
+ go func() {
+ if method == "PrepareContext" {
+ st, err := d.PrepareContext(context.Background(), "SELECT 1")
+ if st != nil {
+ _ = st.Close()
+ }
+ done <- err
+ } else {
+ tx, err := d.BeginTx(context.Background(), nil)
+ if tx != nil {
+ _ = tx.Rollback()
+ }
+ done <- err
+ }
+ }()
+ deadline := time.Now().Add(10 * time.Second)
+ buf := make([]byte, 1<<20)
+ waiting := false
+ for time.Now().Before(deadline) && !waiting {
+ n := runtime.Stack(buf, true)
+ for _, g := range strings.Split(string(buf[:n]), "\n\n") {
+ if strings.Contains(g, "console.(*claimBoundDB)."+method+"(") && strings.Contains(g, "sync.(*Mutex).lockSlow(") {
+ waiting = true
+ break
+ }
+ }
+ runtime.Gosched()
+ }
+ if !waiting {
+ t.Fatal("handle never reached registration")
+ }
+ d.b.sealed.Store(true)
+ d.mu.Unlock()
+ locked = false
+ select {
+ case err := <-done:
+ if !errors.Is(err, ErrClaimSQLRevoked) {
+ t.Errorf("late registration = %v, want revoked", err)
+ }
+ case <-time.After(10 * time.Second):
+ t.Fatal("late registration did not join")
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+}
+```
+
Invariant: one admitted SQL batch writes only its exact account/app/app_id/epoch under one issued effect. Admission can close and the registration can expire after SQL preparation; neither event grants teardown authority while that effect remains issued or uncertain. Every SQL write and commit checks local request cancellation, a monotonic deadline and the unrevoked issued-effect handle. No remote authority read, renewal or settlement holds the SQLite connection.
Paul selected option 1 in rev 5. This task implements that ruling. It does not change the Group D public interfaces or mail delivery's separate non-expiring operation.
@@ -10392,6 +10981,7 @@ import (
"encoding/hex"
"encoding/json"
"errors"
+ "fmt"
"github.com/carlosframework/platform/internal/hostops"
"time"
)
@@ -10552,13 +11142,30 @@ func (c *claimConn) endBatch(b *claimBinding, commit bool) error {
if b.outer == nil {
return hostops.ErrContract
}
+ // The caller sealed every public handle; this runs under the driver lock,
+ // so nothing else is in flight. Finish what the handles left behind.
+ b.sealed.Store(true)
+ c.closeRows()
if c.tx != nil {
c.tx.abort()
- commit = false
+ if commit {
+ // A transaction left open means the requested commit cannot be
+ // honoured: roll back and say so, never report success.
+ err := fmt.Errorf("%w: transaction still open at batch commit", ErrClaimSQLScope)
+ if rerr := b.outer.Rollback(); rerr != nil {
+ c.bad = true
+ err = errors.Join(err, rerr)
+ }
+ b.outer = nil
+ return err
+ }
}
if commit {
- if err := b.authorize(b.ctx, c); err != nil {
- _ = b.outer.Rollback()
+ if err := b.authorizeCommit(b.ctx, c); err != nil {
+ if rerr := b.outer.Rollback(); rerr != nil {
+ c.bad = true
+ err = errors.Join(err, rerr)
+ }
b.outer = nil
return err
}
@@ -11513,6 +12120,373 @@ func validIngestRegistration(id string, r Registration, appID string, epoch uint
```
+#### Patch: `internal/console/store_gate.go`
+
+```diff
+--- a/internal/console/store_gate.go
++++ b/internal/console/store_gate.go
+@@ -818,14 +818,58 @@
+
+ // claimConn wraps one sqlite connection. database/sql serializes calls into
+ // it, so its fields need no lock of their own.
++//
++// sealed outlives the binding: a public operation that was already queued on
++// this connection when its scope ended is refused until the pool resets the
++// connection for its next user.
+ type claimConn struct {
+ g *claimGate
+ inner claimInnerConn
+ trusted bool
+ armed bool
+ bad bool
++ sealed bool
+ tx *claimTx
+ bind atomic.Pointer[claimBinding]
++ rows map[*claimRows]struct{}
++}
++
++// usable is the public-operation gate. Every statement, prepare, begin,
++// prepared execution and row step that reaches the driver passes it under the
++// driver-connection lock, so a handle sealed before a commit can never run
++// after that commit begins.
++func (c *claimConn) usable() error {
++ if c.bad {
++ return driver.ErrBadConn
++ }
++ if c.sealed {
++ return ErrClaimSQLRevoked
++ }
++ if b := c.bind.Load(); b != nil && b.closed() {
++ return ErrClaimSQLRevoked
++ }
++ return nil
++}
++
++// trackRows wraps rows opened while a binding is attached, so the batch
++// commit and unbind can finish every statement still in progress.
++func (c *claimConn) trackRows(r driver.Rows) driver.Rows {
++ if c.bind.Load() == nil {
++ return r
++ }
++ w := &claimRows{c: c, inner: r}
++ if c.rows == nil {
++ c.rows = map[*claimRows]struct{}{}
++ }
++ c.rows[w] = struct{}{}
++ return w
++}
++
++// closeRows finishes every tracked statement. Called under the driver lock.
++func (c *claimConn) closeRows() {
++ for r := range c.rows {
++ r.closeInner()
++ }
+ }
+
+ func (c *claimConn) vet(q string) (claimSQLInfo, error) {
+@@ -879,8 +923,8 @@
+ }
+
+ func (c *claimConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
+- if c.bad {
+- return nil, driver.ErrBadConn
++ if err := c.usable(); err != nil {
++ return nil, err
+ }
+ if err := c.ensureArmed(ctx); err != nil {
+ return nil, err
+@@ -905,8 +949,8 @@
+ }
+
+ func (c *claimConn) PrepareContext(ctx context.Context, q string) (driver.Stmt, error) {
+- if c.bad {
+- return nil, driver.ErrBadConn
++ if err := c.usable(); err != nil {
++ return nil, err
+ }
+ info, err := c.vet(q)
+ if err != nil {
+@@ -923,8 +967,8 @@
+ }
+
+ func (c *claimConn) ExecContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Result, error) {
+- if c.bad {
+- return nil, driver.ErrBadConn
++ if err := c.usable(); err != nil {
++ return nil, err
+ }
+ info, err := c.vet(q)
+ if err != nil {
+@@ -940,8 +984,8 @@
+ }
+
+ func (c *claimConn) QueryContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Rows, error) {
+- if c.bad {
+- return nil, driver.ErrBadConn
++ if err := c.usable(); err != nil {
++ return nil, err
+ }
+ info, err := c.vet(q)
+ if err != nil {
+@@ -953,7 +997,11 @@
+ if err := c.authorizeWrite(ctx, info.write); err != nil {
+ return nil, err
+ }
+- return c.inner.QueryContext(ctx, q, args)
++ rows, err := c.inner.QueryContext(ctx, q, args)
++ if err != nil {
++ return nil, err
++ }
++ return c.trackRows(rows), nil
+ }
+
+ // ResetSession runs when the pool hands the connection out again. A
+@@ -963,6 +1011,8 @@
+ if c.bad || c.bind.Load() != nil {
+ return driver.ErrBadConn
+ }
++ // A new user: handles of the previous scope are closed by now.
++ c.sealed = false
+ if r, ok := c.inner.(driver.SessionResetter); ok {
+ return r.ResetSession(ctx)
+ }
+@@ -1063,6 +1113,8 @@
+ // is poisoned and the pool discards it.
+ func (c *claimConn) unbind(b *claimBinding) error {
+ b.revoked.Store(true)
++ c.sealed = true
++ c.closeRows()
+ if t := c.tx; t != nil {
+ t.abort()
+ }
+@@ -1131,17 +1183,32 @@
+ check func(context.Context, claimSQLScope) error
+
+ revoked atomic.Bool
+- mu sync.Mutex
+- dead []bool
+-}
++ // sealed refuses every public handle of the scope while the internal
++ // batch commit still runs; only authorizeCommit passes it.
++ sealed atomic.Bool
++ mu sync.Mutex
++ dead []bool
++}
++
++func (b *claimBinding) closed() bool { return b.revoked.Load() || b.sealed.Load() }
+
+ // authorize runs the check callback for every live scope. The first refusal
+ // kills that scope (its permit row goes) and refuses the statement; once every
+ // scope is dead nothing is written at all.
+ func (b *claimBinding) authorize(ctx context.Context, c *claimConn) error {
++ return b.admit(ctx, c, false)
++}
++
++// authorizeCommit is the batch commit's own authorization: the same checks,
++// but it runs after the public handles were sealed.
++func (b *claimBinding) authorizeCommit(ctx context.Context, c *claimConn) error {
++ return b.admit(ctx, c, true)
++}
++
++func (b *claimBinding) admit(ctx context.Context, c *claimConn, commit bool) error {
+ b.mu.Lock()
+ defer b.mu.Unlock()
+- if b.revoked.Load() {
++ if b.revoked.Load() || (!commit && b.sealed.Load()) {
+ return ErrClaimSQLRevoked
+ }
+ if ctx == nil {
+@@ -1208,7 +1275,7 @@
+ return sql.ErrTxDone
+ }
+ if b := t.bind; b != nil {
+- if b.revoked.Load() {
++ if b.closed() {
+ t.abortAndPurge()
+ return ErrClaimSQLRevoked
+ }
+@@ -1295,6 +1362,9 @@
+ }
+
+ func (s *claimStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) {
++ if err := s.c.usable(); err != nil {
++ return nil, err
++ }
+ if err := s.c.ensureArmed(ctx); err != nil {
+ return nil, err
+ }
+@@ -1312,20 +1382,67 @@
+ }
+
+ func (s *claimStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) {
++ if err := s.c.usable(); err != nil {
++ return nil, err
++ }
+ if err := s.c.ensureArmed(ctx); err != nil {
+ return nil, err
+ }
+ if err := s.c.authorizeWrite(ctx, s.write); err != nil {
+ return nil, err
+ }
++ var rows driver.Rows
++ var err error
+ if q, ok := s.inner.(driver.StmtQueryContext); ok {
+- return q.QueryContext(ctx, args)
+- }
+- vals, err := claimNamedToValues(args)
+- if err != nil {
+- return nil, err
+- }
+- return s.inner.Query(vals) //nolint:staticcheck // legacy driver path
++ rows, err = q.QueryContext(ctx, args)
++ } else {
++ vals, verr := claimNamedToValues(args)
++ if verr != nil {
++ return nil, verr
++ }
++ rows, err = s.inner.Query(vals) //nolint:staticcheck // legacy driver path
++ }
++ if err != nil {
++ return nil, err
++ }
++ return s.c.trackRows(rows), nil
++}
++
++// claimRows is a result set opened under a binding. A step after the scope
++// is sealed is refused, and the commit finishes the statement first.
++type claimRows struct {
++ c *claimConn
++ inner driver.Rows
++ closed bool
++}
++
++func (r *claimRows) Columns() []string { return r.inner.Columns() }
++
++func (r *claimRows) Next(dest []driver.Value) error {
++ if r.closed {
++ return ErrClaimSQLRevoked
++ }
++ if err := r.c.usable(); err != nil {
++ r.closeInner()
++ return err
++ }
++ return r.inner.Next(dest)
++}
++
++func (r *claimRows) Close() error {
++ if r.closed {
++ return nil
++ }
++ return r.closeInner()
++}
++
++func (r *claimRows) closeInner() error {
++ if r.closed {
++ return nil
++ }
++ r.closed = true
++ delete(r.c.rows, r)
++ return r.inner.Close()
+ }
+
+ // ---- the bound view ---------------------------------------------------------
+@@ -1339,9 +1456,10 @@
+ life context.Context
+ cancel context.CancelFunc
+
+- mu sync.Mutex
+- txs []*sql.Tx
+- stmts []*sql.Stmt
++ mu sync.Mutex
++ closed bool
++ txs []*sql.Tx
++ stmts []*sql.Stmt
+ }
+
+ // opCtx lets revoke cancel an operation whose result outlives the call
+@@ -1358,7 +1476,7 @@
+ }
+
+ func (d *claimBoundDB) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
+- if d.b.revoked.Load() {
++ if d.b.closed() {
+ return nil, ErrClaimSQLRevoked
+ }
+ return d.conn.ExecContext(ctx, q, args...)
+@@ -1369,7 +1487,7 @@
+ }
+
+ func (d *claimBoundDB) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
+- if d.b.revoked.Load() {
++ if d.b.closed() {
+ return nil, ErrClaimSQLRevoked
+ }
+ return d.conn.QueryContext(d.opCtx(ctx), q, args...)
+@@ -1386,14 +1504,21 @@
+ func (d *claimBoundDB) Begin() (*sql.Tx, error) { return d.BeginTx(context.Background(), nil) }
+
+ func (d *claimBoundDB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
+- if d.b.revoked.Load() {
++ if d.b.closed() {
+ return nil, ErrClaimSQLRevoked
+ }
+ tx, err := d.conn.BeginTx(d.opCtx(ctx), opts)
+ if err != nil {
+ return nil, err
+ }
++ // Registration window: a handle created after closeHandles took its
++ // snapshot is never joined, so release it here.
+ d.mu.Lock()
++ if d.closed || d.b.closed() {
++ d.mu.Unlock()
++ _ = tx.Rollback()
++ return nil, ErrClaimSQLRevoked
++ }
+ d.txs = append(d.txs, tx)
+ d.mu.Unlock()
+ return tx, nil
+@@ -1404,7 +1529,7 @@
+ }
+
+ func (d *claimBoundDB) PrepareContext(ctx context.Context, q string) (*sql.Stmt, error) {
+- if d.b.revoked.Load() {
++ if d.b.closed() {
+ return nil, ErrClaimSQLRevoked
+ }
+ st, err := d.conn.PrepareContext(ctx, q)
+@@ -1412,6 +1537,11 @@
+ return nil, err
+ }
+ d.mu.Lock()
++ if d.closed || d.b.closed() {
++ d.mu.Unlock()
++ _ = st.Close()
++ return nil, ErrClaimSQLRevoked
++ }
+ d.stmts = append(d.stmts, st)
+ d.mu.Unlock()
+ return st, nil
+@@ -1423,6 +1553,7 @@
+ // gone, and the connection goes back to the pool.
+ func (d *claimBoundDB) closeHandles() {
+ d.mu.Lock()
++ d.closed = true
+ txs, stmts := d.txs, d.stmts
+ d.txs, d.stmts = nil, nil
+ d.mu.Unlock()
+@@ -1547,6 +1678,10 @@
+ }
+ err = fn(&vs)
+ if batch {
++ // Seal before joining: from here no public handle reaches the driver,
++ // whether retained, concurrent or already queued on the connection.
++ // endBatch then commits under authorizeCommit alone.
++ b.sealed.Store(true)
+ view.closeHandles()
+ err = errors.Join(err, conn.Raw(func(dc any) error { return dc.(*claimConn).endBatch(b, err == nil) }))
+ }
+```
+
### Task B5: keep claim responses appropriate to each door (5-M7)
Invariant: an accepted browser operation renders HTML with its operation ID and phase, while API clients retain JSON; a dependency error cannot expose a bucket key, signed object, SQL error or backend credential. A 202 still means the request's own durable operation, never a foreign operation inferred from a claim.
@@ -11908,16 +12882,127 @@ func (s *Server) renderClaimAccepted(w http.ResponseWriter, r *http.Request, sub
Invariant: a census public key is bound once by proof of possession to a currently active, unfenced enrollment on the exact published floor and current signed roster, never to stale, preparing or quarantined authority.
-**I2 is still open on 76f728a9.** Main validates the proof and enrollment but lacks this complete publication/floor/active check. The regression was added with A2's shared hostactors test file. Run it red now, then apply the patch. Re-read the signed roster and require the caller's exact ETag/revision; verify the remote floor, published digest and active state; refuse any own fence. Keep first binding write-once and exact replay idempotent.
+**I2 is still open on 76f728a9.** Main validates the proof and enrollment but lacks this complete publication/floor/active check. Add the regression below at B6, using A2's shared fixture helpers. Run it red now, then apply the patch. Re-read the signed roster and require the caller's exact ETag/revision; verify the remote floor, published digest and active state; refuse any own fence. Keep first binding write-once and exact replay idempotent.
```bash
nice -n 10 go test -p 1 ./internal/hostactors -run '^TestPlan9BindCensusKeyRequiresPublishedFloorAndActiveEnrollment$' -count=1
```
-**First-review M5 remains a rollout requirement:** main already has `Holder.Acq` and strict readers. It cannot prove the deployed reader set understands `acq`; C1 certifies that before acquisition/backfill writes. Do not remove the field or treat source compilation as fleet readiness.
-
-Separately, the integration report's flag-off `StartHostReports` release observation is intentional pre-activation authority today: arming needs reports and floor updates need reservations before the claim flag. Do not gate all reporting/release on `HostClaim` and wedge armed updates. Keep its signed-floor/reservation/report checks. This is the existing pre-flag authority path, and this plan preserves it; no legacy reporter start condition changes.
-
+**First-review M5 remains a rollout requirement:** main already has `Holder.Acq` and strict readers. It cannot prove the deployed reader set understands `acq`; C1 certifies that before acquisition/backfill writes. Do not remove the field or treat source compilation as fleet readiness.
+
+Separately, the integration report's flag-off `StartHostReports` release observation is intentional pre-activation authority today: arming needs reports and floor updates need reservations before the claim flag. Do not gate all reporting/release on `HostClaim` and wedge armed updates. Keep its signed-floor/reservation/report checks. This is the existing pre-flag authority path, and this plan preserves it; no legacy reporter start condition changes.
+
+
+Tests first. The fixture helpers come from A2; this regression belongs to B6 so A2 can pass independently.
+
+#### New file: `internal/hostactors/plan9_b6_test.go`
+
+```go
+package hostactors
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/json"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestPlan9BindCensusKeyRequiresPublishedFloorAndActiveEnrollment(t *testing.T) {
+ for _, tc := range []string{"published-active", "unpublished", "preparing", "floor-mismatch", "quarantined", "retired", "fenced", "fence-unreadable", "stale-observed", "forged-observed"} {
+ t.Run(tc, func(t *testing.T) {
+ ctx := context.Background()
+ w, _ := plan9Fixture(t, func(r *Roster) {
+ r.Activations = map[string]Reservation{}
+ switch tc {
+ case "unpublished":
+ r.Publication, r.FloorSHA256 = "", ""
+ case "preparing":
+ r.Publication = "preparing"
+ case "floor-mismatch":
+ r.FloorSHA256 = strings.Repeat("f", 64)
+ case "quarantined":
+ plan9Quarantine(r, "b", "loss-b")
+ case "retired":
+ plan9Quarantine(r, "b", "loss-b")
+ b := r.Boxes["b"]
+ b.State = "retired"
+ r.Boxes["b"] = b
+ }
+ })
+ base := w.d.Store
+ switch tc {
+ case "fenced":
+ w.fence(t, "b", "w", w.now.Add(-time.Minute), "terminated")
+ case "fence-unreadable":
+ w.d.Store = brokenFenceStore{w.d.Store}
+ }
+ before, err := w.d.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, key, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ binding := CensusKeyBinding{Bucket: "bucket", Box: "b", Enrollment: before.Roster.Boxes["b"].Enrollment, PublicKey: pub}
+ raw, err := json.Marshal(binding)
+ if err != nil {
+ t.Fatal(err)
+ }
+ input := before
+ switch tc {
+ case "stale-observed":
+ // A concurrent writer moved the roster after the caller read it.
+ next := Clone(before.Roster)
+ next.RequestLimitNS = 120000000000
+ if _, err := w.d.Replace(ctx, w.sg, before, next); err != nil {
+ t.Fatal(err)
+ }
+ case "forged-observed":
+ // The caller's copy claims b is active while the signed roster
+ // says otherwise; only the signed roster decides.
+ o, err := w.d.Read(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := Clone(o.Roster)
+ plan9Quarantine(&r, "b", "loss-b")
+ signed, err := release.SignRootEnvelope(w.sg, r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ etag, err := w.d.Store.Put(ctx, Key, signed, o.ETag)
+ if err != nil {
+ t.Fatal(err)
+ }
+ input = Observed{Roster: before.Roster, ETag: etag}
+ }
+ got, err := w.d.BindCensusKey(ctx, w.sg, input, binding, ed25519.Sign(key, raw))
+ if tc == "published-active" {
+ if err != nil || !ed25519.PublicKey(got.Roster.Boxes["b"].CensusPublicKey).Equal(pub) {
+ t.Fatalf("bind = %v", err)
+ }
+ return
+ }
+ if err == nil {
+ t.Fatalf("%s: census key bound", tc)
+ }
+ after, readErr := (Directory{Store: base, PublicKey: w.d.PublicKey, Bucket: "bucket"}).Read(ctx)
+ if readErr != nil {
+ t.Fatal(readErr)
+ }
+ if len(after.Roster.Boxes["b"].CensusPublicKey) != 0 {
+ t.Fatalf("%s: census key written despite refusal", tc)
+ }
+ })
+ }
+}
+```
+
Implementation.
@@ -11989,6 +13074,245 @@ Implementation.
#### Rev 5 amendments
+**Rev 6, round 2 P2.** Receipt SELECT/decode errors return HTTP 503 and preserve `JournalUncertainError` on a fresh client. HTTP 409 requires a proven owner/request conflict; malformed requests return 400. Claim-read failures also return 503. The regression commits a producer completion, loses its HTTP reply, creates a fresh client, breaks receipt lookup, and requires uncertainty through the real Unix-socket transport. Restoring lookup must replay the original receipt without a second completion. The server never turns a failed read into evidence that the request was refused.
+
+
+#### New file: `internal/host/plan9_journal_lookup_test.go`
+
+```go
+package host
+
+import (
+ "bytes"
+ "database/sql"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "sync/atomic"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostguard"
+ _ "modernc.org/sqlite"
+)
+
+// A receipt lookup that fails says nothing about the request. A client that
+// restarted after a lost reply has no in-memory uncertainty, so a 409 here
+// would turn an already committed request into a definite refusal.
+func TestPlan9JournalReplayLookupFailureIsUncertain(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "root.db")
+ j, err := hostbox.OpenLaunchJournal(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ s, err := hostbox.NewJournalService(j)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}, ControllerPID: 17, ProcessStart: "23", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}}
+ q := plan9CanonicalJournalRequest(t, hostbox.JournalRequest{V: 1, Op: "create", Launch: in})
+ if _, err = s.Execute(t.Context(), "box/enroll", q); err != nil {
+ t.Fatal(err)
+ }
+ root := ClaimRootHTTP{Journals: s}
+ root.Reconcile.Box = "box"
+ root.Reconcile.Enrollment = "enroll"
+ peer := StopPeer{UID: 1001, PID: 17, ProcessStart: "23", BootID: "boot", ControllerID: "controller"}
+ // The restarted client rebuilds the byte-identical request and ID; it holds
+ // no memory that the first attempt's reply was lost.
+ replay := func() *httptest.ResponseRecorder {
+ fresh := plan9CanonicalJournalRequest(t, hostbox.JournalRequest{V: 1, Op: "create", Launch: in})
+ body, _ := json.Marshal(fresh)
+ response := httptest.NewRecorder()
+ root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer)
+ return response
+ }
+ // Break only the receipt lookup, from a second connection.
+ other, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer other.Close()
+ if _, err = other.Exec("ALTER TABLE journal_requests RENAME TO journal_requests_hidden"); err != nil {
+ t.Fatal(err)
+ }
+ // 400/403/409 are definite refusals to the client; only 5xx stays uncertain.
+ if response := replay(); response.Code < 500 {
+ t.Fatalf("failed receipt lookup reported as definite result %d: %s", response.Code, response.Body.String())
+ }
+ if _, err = other.Exec("ALTER TABLE journal_requests_hidden RENAME TO journal_requests"); err != nil {
+ t.Fatal(err)
+ }
+ var out hostbox.JournalResponse
+ response := replay()
+ if err = json.Unmarshal(response.Body.Bytes(), &out); response.Code != http.StatusOK || err != nil || out.Error != "" || out.ID != q.ID {
+ t.Fatalf("replay did not recover once lookup worked: %d %+v %v", response.Code, out, err)
+ }
+ var receipts int
+ if err = other.QueryRow("SELECT COUNT(*) FROM journal_requests").Scan(&receipts); err != nil || receipts != 1 {
+ t.Fatalf("duplicate effect or receipt: %d %v", receipts, err)
+ }
+}
+
+// A byte-different request under a stored ID is a proven conflict.
+func TestPlan9JournalReplayMismatchIsConflict(t *testing.T) {
+ j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ s, err := hostbox.NewJournalService(j)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}, ControllerPID: 17, ProcessStart: "23", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}}
+ q := plan9CanonicalJournalRequest(t, hostbox.JournalRequest{V: 1, Op: "create", Launch: in})
+ if _, err = s.Execute(t.Context(), "box/enroll", q); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err = s.Replay(t.Context(), "other/peer", q); !errors.Is(err, hostbox.ErrChanged) {
+ t.Fatalf("different owner not a proven mismatch: %v", err)
+ }
+}
+
+// A request committed, its reply is lost, and the client process restarts. The
+// new rootJournalClient has no memory of the first attempt. While the receipt
+// lookup fails it must report JournalUncertainError, never a definite refusal;
+// once the lookup works the same request replays without a second effect.
+func TestPlan9JournalRestartedClientLostReplyLookupFailure(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "root.db")
+ j, err := hostbox.OpenLaunchJournal(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ s, err := hostbox.NewJournalService(j)
+ if err != nil {
+ t.Fatal(err)
+ }
+ root := ClaimRootHTTP{Journals: s}
+ root.Reconcile.Box = "box"
+ root.Reconcile.Enrollment = "enroll"
+ peer := StopPeer{UID: 1001, PID: 17, ProcessStart: "23", BootID: "boot", ControllerID: "controller"}
+ in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}, ControllerPID: 17, ProcessStart: "23", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}}
+ if err = j.Create(t.Context(), in); err != nil {
+ t.Fatal(err)
+ }
+ if err = j.RecordProducer(t.Context(), in.Target, "issued"); err != nil {
+ t.Fatal(err)
+ }
+ var loseReply atomic.Bool
+ var requests atomic.Int32
+ handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ if !loseReply.Load() {
+ root.serveJournal(w, r, peer)
+ return
+ }
+ // Commit, then drop the connection before any reply is written.
+ root.serveJournal(httptest.NewRecorder(), r, peer)
+ if conn, _, err := w.(http.Hijacker).Hijack(); err == nil {
+ conn.Close()
+ }
+ })
+ countReceipts := func(db *sql.DB) int {
+ var n int
+ if err := db.QueryRow("SELECT COUNT(*) FROM journal_requests").Scan(&n); err != nil {
+ t.Fatal(err)
+ }
+ return n
+ }
+ other, err := sql.Open("sqlite", "file:"+path+"?_pragma=busy_timeout(5000)")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer other.Close()
+
+ // First client: the request commits and every reply is lost.
+ loseReply.Store(true)
+ first, _ := hostbox.RemoteJournals(plan9JournalSocket(t, handler))
+ var uncertain *hostbox.JournalUncertainError
+ if _, err = first.CompleteProducer(t.Context(), in.Target, "issued", hostbox.ProducerCompletion{}); !errors.As(err, &uncertain) {
+ t.Fatalf("lost reply was not uncertain: %v", err)
+ }
+ if n := countReceipts(other); n != 1 {
+ t.Fatalf("request did not commit exactly once: %d", n)
+ }
+
+ // Restarted client: receipt lookup fails on the server.
+ loseReply.Store(false)
+ if _, err = other.Exec("ALTER TABLE journal_requests RENAME TO journal_requests_hidden"); err != nil {
+ t.Fatal(err)
+ }
+ before := requests.Load()
+ restarted, _ := hostbox.RemoteJournals(plan9JournalSocket(t, handler))
+ _, err = restarted.CompleteProducer(t.Context(), in.Target, "issued", hostbox.ProducerCompletion{})
+ if !errors.As(err, &uncertain) {
+ t.Fatalf("failed lookup was not JournalUncertainError (definite refusal?): %v", err)
+ }
+ if requests.Load()-before != 2 {
+ t.Fatalf("restarted client attempts = %d, want 2", requests.Load()-before)
+ }
+
+ // Lookup restored: another fresh client replays the stored result.
+ if _, err = other.Exec("ALTER TABLE journal_requests_hidden RENAME TO journal_requests"); err != nil {
+ t.Fatal(err)
+ }
+ again, _ := hostbox.RemoteJournals(plan9JournalSocket(t, handler))
+ if _, err = again.CompleteProducer(t.Context(), in.Target, "issued", hostbox.ProducerCompletion{}); err != nil {
+ t.Fatalf("replay after restore failed: %v", err)
+ }
+ if n := countReceipts(other); n != 1 {
+ t.Fatalf("duplicate effect or receipt: %d", n)
+ }
+}
+
+// HTTP classification of Replay errors: a proven mismatch is 409, an invalid
+// request is 400, and only ErrChanged maps to 409.
+func TestPlan9JournalReplayErrorStatuses(t *testing.T) {
+ j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer j.Close()
+ s, err := hostbox.NewJournalService(j)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}, ControllerPID: 17, ProcessStart: "23", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}}
+ q := plan9CanonicalJournalRequest(t, hostbox.JournalRequest{V: 1, Op: "create", Launch: in})
+ if _, err = s.Execute(t.Context(), "box/enroll", q); err != nil {
+ t.Fatal(err)
+ }
+ peer := StopPeer{UID: 1001, PID: 17, ProcessStart: "23", BootID: "boot", ControllerID: "controller"}
+ serve := func(box string, q hostbox.JournalRequest) int {
+ root := ClaimRootHTTP{Journals: s}
+ root.Reconcile.Box = box
+ root.Reconcile.Enrollment = "enroll"
+ body, _ := json.Marshal(q)
+ response := httptest.NewRecorder()
+ root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer)
+ return response.Code
+ }
+ if got := serve("other", q); got != http.StatusConflict {
+ t.Fatalf("other owner under stored ID = %d, want 409", got)
+ }
+ tampered := q
+ tampered.Launch.ControllerPID++
+ if got := serve("box", tampered); got != http.StatusConflict {
+ t.Fatalf("changed bytes under stored ID = %d, want 409", got)
+ }
+ invalid := q
+ invalid.V = 0
+ if got := serve("box", invalid); got != http.StatusBadRequest {
+ t.Fatalf("invalid request = %d, want 400", got)
+ }
+}
+```
+
Invariant: every SQLite connection, delayed WAL/SHM open, replacement connection, checkpoint and close runs inside a namespace that the agent and tenant UIDs cannot modify or traverse. No copied agent-owned history becomes root stop evidence merely because its JSON parses. A request whose response was lost retains a recoverable identity across both client and root crashes.
**Decision settled.** Use the root-owned service and namespace. Production journal state is `/var/lib/carlos-claims/journals.db`, shared by root instance-control and root adopt through typed Go methods. Only the agent uses the existing root instance-control Unix socket, at `POST /claim/journal`. The existing SO_PEERCRED/MainPID/process-start/boot/controller authenticator remains the admission boundary; this adds no separately trusted listener, executable path, SQL operation or caller-chosen filename. Root adopt remains a trusted root caller and does not attempt to authenticate as the edge process.
@@ -13471,7 +14795,18 @@ func (h ClaimRootHTTP) serveJournal(w http.ResponseWriter, r *http.Request, peer
}
owner := h.Reconcile.Box + "/" + h.Reconcile.Enrollment
if out, found, err := h.Journals.Replay(r.Context(), owner, q); err != nil {
- w.WriteHeader(http.StatusConflict)
+ // Only a proven mismatch (other owner or bytes under a stored ID) is a
+ // conflict, and an invalid request is a plain bad request. A failed
+ // receipt lookup or an unreadable receipt says nothing about whether the
+ // request committed, so it stays retryable.
+ switch {
+ case errors.Is(err, hostbox.ErrChanged):
+ w.WriteHeader(http.StatusConflict)
+ case errors.Is(err, hostbox.ErrTypedJournal):
+ w.WriteHeader(http.StatusBadRequest)
+ default:
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }
return
} else if found {
w.Header().Set("Content-Type", "application/json")
@@ -13521,7 +14856,12 @@ func (h ClaimRootHTTP) serveJournal(w http.ResponseWriter, r *http.Request, peer
settlement := q.Op == "complete" || q.Op == "settled" || q.Op == "started"
if !settlement {
current, err := hostclaim.Read(r.Context(), h.Stop.Coordinator.Evidence, target.Host)
- if err != nil || current.Claim == nil || current.Claim.Holder == nil {
+ if err != nil {
+ // An unreadable claim proves nothing about its state.
+ w.WriteHeader(http.StatusServiceUnavailable)
+ return
+ }
+ if current.Claim == nil || current.Claim.Holder == nil {
w.WriteHeader(http.StatusConflict)
return
}
@@ -20612,6 +21952,8 @@ func claimProbeSocketReady(context.Context, *exec.Cmd, registry.Route, ClaimLaun
### Task D4: wire production local retirement for landing writers (H15)
+Rev 6 moves the constructor’s `RetireArtifacts` callback update into this task. Run the host tests and the repository build before applying D5; D5 now patches the four-argument constructor.
+
Invariant: a local landing writer can stop its own current session repeatedly without inventing or consuming a park-local chore. A superseded database and marker are parked only under the current guarded admission, with root's completed full-stop receipt for the earlier local writer. No stamp moves; the landing's live files directory and environment remain in place. A failed inventory CAS must be recovered before the successor materializes new bytes.
Authority: claim §6.4's `retire-artifacts(S)`, P6-D1's full root stop and P6-D2's recorded ownership before materialization, and moves §7.14's restore step. Root already accepts a nonempty operation-scope label from the current executor. `writer-session-<N>` narrows that existing scope: only a landing at probing, probed or committed, with this box as executor and writer, exactly one matching cold admission, the right region, and no colliding chore. Root still fences, drains, verifies, signs and journals the full stop. A wake uses this session label; `collectRetirementStop` continues to use its selected chore, so its acknowledgement cannot silently lose that chore.
@@ -21973,6 +23315,22 @@ func (g Guard) CompletedWriterStops(ctx context.Context, host string, captured C
}
```
+#### Patch: `internal/host/claimconstruction.go`
+
+```diff
+--- a/internal/host/claimconstruction.go
++++ b/internal/host/claimconstruction.go
+@@ -209,7 +209,7 @@
+ }
+ wake.Roles = &ClaimRoleAssembly{Manager: r.Manager, Journal: launches, Inventory: inventory, ManagedRoots: roots, Identity: owners.Identity, BootID: strings.TrimSpace(string(boot)), ControllerID: controller}
+ wake.AfterAdmission = coldAdmissionHook(guard, owners.Identity, controlsock.Path)
+- wake.RetireArtifacts = func(context.Context, hostclaim.Observed, string) error { return hostbox.ErrChanged }
++ wake.RetireArtifacts = func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error { return hostbox.ErrChanged }
+ wake.Retain = wake.retainCaptured
+ wake.ObserveLoss = func(o hostclaim.Observed, err error) { log.Printf("host claim lease loss for %s: %v", o.Host, err) }
+ wake.Retries = activator.ServingRetries{Claims: store, Box: actor.Box, Enrollment: actor.Enrollment, BootID: strings.TrimSpace(string(boot)), ControllerID: controller, Now: time.Now, NewID: newID}
+```
+
### Task D5: claim serving owners and the owner dispatcher by op kind (H16)
Invariant: every input, admission and publication call is routed by the observed claim's op kind. Serving goes to the claim's own serving owners. Moving, landing, reverting and restoring go to the moves owners, and only with both flags on and a complete moves owner set; otherwise they get main's typed refusal. Every other kind is unsupported. The moves owners never see a serving observation, and the serving owners refuse anything but plain serving with this box executing.
@@ -23134,9 +24492,8 @@ func (s *claimServingOwners) publicationEvidence(ctx context.Context, o hostclai
}
wake.Roles = &ClaimRoleAssembly{Manager: r.Manager, Journal: launches, Inventory: inventory, ManagedRoots: roots, Identity: owners.Identity, BootID: strings.TrimSpace(string(boot)), ControllerID: controller}
- wake.AfterAdmission = coldAdmissionHook(guard, owners.Identity, controlsock.Path)
-- wake.RetireArtifacts = func(context.Context, hostclaim.Observed, string) error { return hostbox.ErrChanged }
+ var crossBox func(context.Context, hostclaim.Observed, hostclaim.Observed) error
-+ wake.RetireArtifacts = func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error { return hostbox.ErrChanged }
+ wake.RetireArtifacts = func(context.Context, hostclaim.Observed, ClaimLaunchInputs, string) error { return hostbox.ErrChanged }
+ if movesC1 {
+ handover := claimCrossBoxHandover{Guard: guard, Evidence: store, Box: actor.Box}
+ crossBox = handover.After
@@ -24050,3 +25407,24 @@ The explicit writer census was reviewed rather than relaxed: termination still w
Explicit Opus authority reviews covered A1/A3, B4/B7 and B3, followed by a bounded integration review. Confirmed issues were corrected in the executable blocks: failed-Open cleanup, root lock ownership, periodic fence setup retry, stable filesystem receipt identity, unrelated recovery progress, durable journal replay/read handling, proof-read ordering, cleanup after roster withdrawal, stale-episode refusal, bounded dispatch and retained signed reset archives. The final Opus closure review accepted the CC9 cleanup and exact boot-link logic. It also caught an integration overwrite of B7's remote-aware `LaunchJournal.HasHost`; two existing complete-package tests reproduced it. That function was restored byte-for-byte from the reviewed B7 implementation, retaining its remote dispatch and transaction executor. The restored implementation passed both complete affected packages. All confirmed review findings are addressed; malformed control objects continue to refuse rather than being treated as stop evidence.
Local verification does not claim an effective IAM denial, real target-AMI reboot, distinct root/agent/tenant namespace witness, live systemd migration or production activation. C2 retains four separate Paul-approved milestones; its exact floor tag/protocol/hashes, live actor inventory and secret/runner details are confirm at approval. The target filesystem/kernel must support the full UUID ioctl and survive the reboot identity witness. Group D's G-1 through G-6 integration dependencies and Plan 5's session-zero positive stop witness remain explicit prerequisites, not new architecture decisions or permissive fallbacks. Nothing in this plan authorizes deleting uncertain effects, old journals or pending state by hand.
+
+## Rev 6 verification (2026-10-08)
+
+Round 2's four findings are addressed in the executable blocks. The base is still `origin/main` at `4f26f15cb41ecfdd9214b49393c228399f34490b`, verified against the remote with read-only `git ls-remote`. Authoring changed only this plan and made no git writes. All code and tests were applied in disposable exports under `/home/paulca/.cache/tmp/claude-1001/`; socket tests used the newly created `TMPDIR=/tmp/g9`. Go processes ran serially through one lock, under `nice -n 10` with `-p 1`. The default cache was read-only, so the existing shared disk-backed fallback was used and retained.
+
+The final fresh export ran the embedded checker at every task boundary: foundation `A1 A2 A3 B1 B2 B3 B5 B6 B8 D1 D2 D3 D4 D5`, then the ruled amendments `A1 A3 B4 B7 B3`. At all 19 boundaries, tests were applied first and the check failed before implementation; after implementation the repository build and the task's new and named existing regression tests passed before advancing. A3's native mock tests also passed at their boundaries. Group C contains rollout requirements, not implementation patches; no rollout was performed. All 216 paths produced by the final plan match the tested integrated overlay byte-for-byte.
+
+Ordered checking found one additional placement problem: A2 installed B6's census-key regression before B6 fixed the behavior. That regression now enters in B6 and still fails before its implementation. D4 builds and passes its host, hostguard and park checks with the constructor's four-argument callback in place, before any D5 block is applied. D5's patch expects that signature. No later task is needed to make either boundary compile.
+
+| Round 2 finding | Failing-first evidence and final result |
+| --- | --- |
+| B4, P1: retained SQL write across COMMIT | On the previous implementation, queued retained writes reached SQLite after COMMIT instead of returning `ErrClaimSQLRevoked`. Public view, direct connection and escaped prepared-statement executions now refuse after the seal; the batch write and receipt still commit. Internal commit authorization checks the local permit separately. No statement runs under a transaction permit after its commit begins. |
+| B7, P2: receipt-read failure after restart | A real Unix-socket client committed a producer completion and lost its reply. A fresh client replaying the same request while receipt SELECT failed received a definite refusal before the fix. It now receives `JournalUncertainError` through HTTP 503; restoring lookup replays the original receipt. Proven request/owner conflicts remain 409, malformed requests are 400. |
+| B3, P2: first unscheduled wake | With the snapshot-only production source, first selection without an admin token failed with `hostwrite: lifecycle provider unavailable`. The claim-authorized initialization makes selection pass, preserves the token across retries and activation, and refuses missing/stale/foreign authority without writing. Snapshot-only selection remains read-only. |
+| D4/D5, P2: intermediate constructor mismatch | The constructor update is now in D4. D4 and D5 each built and passed their tests in the fresh sequential replay, with D5 absent during D4's checks. |
+
+Opus reviewed B3/B4/B7 and found two further B4 issues: a leftover child transaction could roll back the batch while reporting success, and the queued-write test needed prepared-statement coverage. The corrected code returns an error for forced rollback, keeps rollback errors, and closes the handle-registration window under the view mutex. Deterministic tests reproduced the previous false success and successful late registration, then passed after the fixes. Prepared Exec and QueryContext calls now queue across COMMIT. The closure review found no remaining P1 or P2 in these changes.
+
+The complete gate passed all 21 packages: the 19 packages containing extracted Go changes, plus `internal/hostresolve` and `internal/genselect`. The first 20-package run passed 19 packages; console exposed a test synchronization mistake because prepared statements acquire the driver lock through different standard-library frames. After correcting that wait condition, the focused B4 tests passed and the complete console package passed in 277.203 seconds; genselect passed in 0.014 seconds. No failing test was excluded. The final checks also passed vet, compilation of every repository package, Darwin/arm64, Windows/amd64 and Linux/arm64 builds, whole-overlay gofmt, and both native mock module suites. The native suites use mocks and do not call AWS.
+
+The full package gate used the Rev 5 command above, followed by the complete console rerun and `internal/genselect`; the ordered `check` and `revision-check` commands at the start of this plan are now part of the replay, not comments asking the implementer to check later. The authoring overlays, review inputs/logs, owned scratch and `/tmp/g9` were removed after all processes exited. C2's approvals and the existing production witnesses remain prerequisites.