carlos / platform Public

claim-prev-rehearsal fix round 2: always finish carlos.prev's judgement, Windows test build, approved copy

- The --policy already-installed path always runs carlos.prev's
  judgement again and fails on its error; completePrev writes the
  record, then judges, so a retry finishes whichever step was cut off.
- The Linux-only policy-open test moves to installchannel_policy_linux_test.go.
- The unfinished-update reason drops guard-arm's floor prefix.
- The census-box root test checks the verdict names the replaced release.
- Paul approved all nine strings (2026-10-18). The docs paragraph is
  applied to cli.md, and RHB-C19's row carries the re-approved usage,
  pinned whole by the 2b gate.
Census overlay claim-prev-rehearsal-fix2 (envelope only) PROPOSED.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 5d43820c34b6aed72602802adba35078aabf916b parent 4592399
10 files changed, +207 −40
  • approved_region_ha_2b_copy_test.go +9 −1
  • docs/site/cli.md +2 −0
  • docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md +12 −0
  • docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md +1 −1
  • internal/host/installchannel.go +26 −7
  • internal/host/installchannel_policy_linux_test.go +46 −0
  • internal/host/installchannel_policy_rootrunner_linux_test.go +7 −1
  • internal/host/installchannel_policy_test.go +93 −30
  • internal/provision/plan14_root_reads_test.go +8 −0
  • internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv +3 −0
diff --git a/approved_region_ha_2b_copy_test.go b/approved_region_ha_2b_copy_test.go
index c36232651..d49cf5638 100644
--- a/approved_region_ha_2b_copy_test.go
+++ b/approved_region_ha_2b_copy_test.go
@@ -5,6 +5,7 @@ import (
"os"
"regexp"
"strconv"
+ "strings"
"testing"
)
@@ -66,5 +67,12 @@ func TestRegionHA2bBuiltCopyIsApproved(t *testing.T) {
}
func TestRegionHA2bBuiltCopyIsApplied(t *testing.T) {
- reportAll(t, checkRunApplied(builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt), "."))
+ rows := builtRows(mustParseRegionHA2bRecord(t), regionHA2bCopyBuilt)
+ // A table cell holds one line, so in this record a row's text writes a
+ // newline as \n: RHB-C19, the install-channel usage Paul re-approved on
+ // 2026-10-18 (claim-prev-rehearsal), is two lines and a blank one.
+ for i := range rows {
+ rows[i].Text = strings.ReplaceAll(rows[i].Text, `\n`, "\n")
+ }
+ reportAll(t, checkRunApplied(rows, "."))
}
diff --git a/docs/site/cli.md b/docs/site/cli.md
index b477aeeaa..8638a2d39 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -2286,6 +2286,8 @@ carlos system host-env write builds /etc/carlos/host.env from the deployment's p
carlos system install-channel installs the release the box's channel names, as both carlos and carlos.prev, once it passes the published host floor. A box that already runs a release is refused: carlos system update moves it.
+With --policy FILE, the armed policy carlos system claims run sign-policy wrote, it installs before the host floor is published. The policy's floor stands in for the published one, and once a floor is published the two must be the same. It also arms a box that carlos system update has already moved to the channel's release: carlos is left as it is, and carlos.prev, the release before, is replaced by it, so guard-arm finds the release in both. A box on another release, or with an armed guard, is still refused.
+
```sh
carlos system host-env write --deployment prod --region ie
carlos system install-channel
diff --git a/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md b/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md
index 8eea106d9..72e3bb247 100644
--- a/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md
+++ b/docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md
@@ -52,3 +52,15 @@ What changed:
| `completePrev`, `prevRecorded`, `newChannelInstall`, `install` | no read site of their own | envelope | the same root shell |
The internal/host envelope is re-pinned. The seal stands. No 9B8 entry: no new bucket writer. (On a census box, verifyPointer remembers the channel identity in root's guarded identities, as the update already does; that is a local root-state write, not a bucket write.)
+
+## Fix round 2 (PROPOSED)
+
+Overlay `internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv`, loaded after fix1. Envelope only: the internal/host envelope is re-pinned, and no root-read unit's digest or read sites change.
+
+What changed, all in the `--policy` path:
+
+- "Already installed" always runs carlos.prev's judgement again: `channelPrevJudge`, which is `notePrev` without a replace. It fails on the judgement's error, so a judgement that was cut off or failed is finished or reported, never answered over. `completePrev` ends with the same judgement, after the version record.
+- The unfinished-update refusal's reason drops guard-arm's floor prefix.
+- `installChannelUsage` is now one literal, of the same value, so the re-approved RHB-C19 can be pinned whole.
+
+The seal stands. No 9B8 entry.
diff --git a/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md b/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md
index fb4ca8457..7284ee8f6 100644
--- a/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md
+++ b/docs/superpowers/plans/2026-10-17-region-ha-2b-copy.md
@@ -79,7 +79,7 @@ as stable; `<platform>` such as linux-arm64; `<box>` a box's reference.
| id | file | text | status |
|---|---|---|---|
-| RHB-C19 | internal/host/installchannel.go (a fragment of the usage) | Install the release this box's channel names, as root, on a box that has none yet. | approved |
+| RHB-C19 | internal/host/installchannel.go (a fragment of the usage, re-approved by Paul 2026-10-18 (claim-prev-rehearsal)) | usage: carlos system install-channel [--policy FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet. | approved |
| RHB-C20 | internal/host/installchannel.go | carlos system install-channel runs as root: it installs the platform binary | approved |
| RHB-C21 | internal/host/installchannel.go | <variable>=<value> is not a channel, so nothing was installed | approved |
| RHB-C22 | internal/host/installchannel.go | <version> (<tag>) from the <channel> channel is already installed | approved |
diff --git a/internal/host/installchannel.go b/internal/host/installchannel.go
index c7860a94f..4e0aa52d4 100644
--- a/internal/host/installchannel.go
+++ b/internal/host/installchannel.go
@@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"runtime"
+ "strings"
"github.com/carlosframework/platform/internal/cliflag"
"github.com/carlosframework/platform/internal/hostfloor"
@@ -53,8 +54,7 @@ import (
// (refreshPrev), because guard-arm requires both slots to be the
// identity's release. Without --policy nothing here changes.
-const installChannelUsage = "usage: carlos system install-channel [--policy FILE]\n\n" +
- "Install the release this box's channel names, as root, on a box that has none yet.\n"
+const installChannelUsage = "usage: carlos system install-channel [--policy FILE]\n\nInstall the release this box's channel names, as root, on a box that has none yet.\n"
const installChannelPolicyFlag = "the armed policy carlos system claims run sign-policy wrote, used as the host floor until one is published"
@@ -271,6 +271,11 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error)
// refuses it: recovery owns carlos.prev until then (fix round 1, I1).
if policy != nil {
if err := guardArmIdle(systemGuardDir(a.optDir), systemRootDir(a.optDir)); err != nil {
+ // The reason is the journal or marker guard-arm names, without
+ // guard-arm's floor prefix: it is an unfinished update.
+ if errors.Is(err, hostfloor.ErrFloor) {
+ err = errors.New(strings.TrimPrefix(err.Error(), hostfloor.ErrFloor.Error()+": "))
+ }
return got, fmt.Errorf("an update on this box has not finished, so nothing was installed: %w", err)
}
}
@@ -282,10 +287,19 @@ func (in channelInstall) install(ctx context.Context) (channelInstalled, error)
// managed: its releases come through carlos system update.
if already {
// With --policy, "already installed" also needs carlos.prev's
- // version record to name this release; one a cut-off run left
- // missing or stale is completed first, or the run fails.
- if policy != nil && !prevRecorded(a.optDir, p.Current.Version) {
- if err := in.completePrev(ctx, p.Current.Version, false); err != nil {
+ // version record to name this release, and its judgement to have
+ // run. A record a cut-off run left missing or stale is completed
+ // first (which judges); otherwise the judgement runs again, as it
+ // is idempotent, so one a run left unfinished is finished. Either
+ // failing fails the run (fix round 2).
+ if policy != nil {
+ var err error
+ if !prevRecorded(a.optDir, p.Current.Version) {
+ err = in.completePrev(ctx, p.Current.Version, false)
+ } else {
+ err = channelPrevJudge(a, ctx)
+ }
+ if err != nil {
return got, err
}
}
@@ -473,9 +487,14 @@ func (in channelInstall) completePrev(ctx context.Context, version string, copyC
if err != nil {
return fmt.Errorf("the record of the release in carlos.prev could not be written, so the install is not complete. run it again: %w", err)
}
- return a.notePrev(ctx, false, "")
+ return channelPrevJudge(a, ctx)
}
+// channelPrevJudge is the floor's judgement of carlos.prev, as an update
+// makes it (notePrev without a replace); a seam for the tests of a
+// judgement that fails or is cut off.
+var channelPrevJudge = func(a *systemApplier, ctx context.Context) error { return a.notePrev(ctx, false, "") }
+
// refreshPrev is --policy's one admission of an updated box (applied.json
// names the channel release, and the slots and the identity are not yet
// install-channel's): carlos must already hold the channel release's
diff --git a/internal/host/installchannel_policy_linux_test.go b/internal/host/installchannel_policy_linux_test.go
new file mode 100644
index 000000000..a81b329e2
--- /dev/null
+++ b/internal/host/installchannel_policy_linux_test.go
@@ -0,0 +1,46 @@
+//go:build linux
+
+package host
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "testing"
+)
+
+// M3, Linux: a symlink, a FIFO, a directory and an oversized --policy file
+// are refused before anything is read; the FIFO's open does not wait for a
+// writer.
+func TestInstallChannelPolicyOpenRefusesWhatIsNotAPolicyFile(t *testing.T) {
+ dir := t.TempDir()
+ // M3: a symlink, a FIFO, a directory and an oversized file are refused
+ // before anything is read; the FIFO's open does not wait for a writer.
+ raw0 := signedPolicyBytes(t)
+ real := filepath.Join(dir, "real.json")
+ if err := os.WriteFile(real, raw0, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ link := filepath.Join(dir, "link.json")
+ if err := os.Symlink(real, link); err != nil {
+ t.Fatal(err)
+ }
+ fifo := filepath.Join(dir, "fifo.json")
+ if err := syscall.Mkfifo(fifo, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ big := filepath.Join(dir, "big.json")
+ if err := os.WriteFile(big, append(raw0, bytes.Repeat([]byte(" "), channelPolicyLimit)...), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ for _, path := range []string{link, fifo, dir, big} {
+ if _, err := readChannelPolicy(path); err == nil || !strings.HasPrefix(err.Error(), "the policy "+path+" could not be read, so nothing was installed: ") {
+ t.Fatalf("%s: %v", path, err)
+ }
+ }
+ if got, err := readChannelPolicy(real); err != nil || !bytes.Equal(got, raw0) {
+ t.Fatalf("the real file: %v", err)
+ }
+}
diff --git a/internal/host/installchannel_policy_rootrunner_linux_test.go b/internal/host/installchannel_policy_rootrunner_linux_test.go
index b19a094d0..719b7cd4b 100644
--- a/internal/host/installchannel_policy_rootrunner_linux_test.go
+++ b/internal/host/installchannel_policy_rootrunner_linux_test.go
@@ -6,6 +6,7 @@ import (
"bytes"
"context"
"encoding/base64"
+ "encoding/json"
"os"
"path/filepath"
"testing"
@@ -67,9 +68,14 @@ func TestRootInstallChannelPolicyCensusBox(t *testing.T) {
t.Fatal("the census box's root floor state", err)
}
defer floors.close()
- if verdict, err := floors.public.Read("prev.json"); err != nil || len(verdict) == 0 {
+ raw, err := floors.public.Read("prev.json")
+ if err != nil {
t.Fatal("the root moves floor did not judge the new carlos.prev", err)
}
+ var verdict rootFloorVerdict
+ if err := json.Unmarshal(raw, &verdict); err != nil || verdict.Identity != id || verdict.PrevAbsent || verdict.Prev.Version != "v1.4.0" || verdict.Prev.Digest != hostfloor.Digest(f.body) {
+ t.Fatalf("the judgement is not of the replaced carlos.prev (v1.4.0): %s %v", raw, err)
+ }
if again, err := f.in.run(context.Background()); err != nil || !again.Already {
t.Fatalf("rerun %+v %v", again, err)
}
diff --git a/internal/host/installchannel_policy_test.go b/internal/host/installchannel_policy_test.go
index 607e67814..3cad35a6f 100644
--- a/internal/host/installchannel_policy_test.go
+++ b/internal/host/installchannel_policy_test.go
@@ -9,7 +9,6 @@ import (
"os"
"path/filepath"
"strings"
- "syscall"
"testing"
"github.com/carlosframework/platform/internal/hostfloor"
@@ -341,7 +340,7 @@ func TestInstallChannelPolicyRefusals(t *testing.T) {
if err := os.WriteFile(f.a.file("journal.json"), []byte("{}"), 0o600); err != nil {
t.Fatal(err)
}
- }, "an update on this box has not finished, so nothing was installed: hostfloor: authenticated floor required: %opt%/system/router/journal.json is open; let the update finish or recover first"},
+ }, "an update on this box has not finished, so nothing was installed: %opt%/system/router/journal.json is open; let the update finish or recover first"},
{"an update-begun marker", func(t *testing.T, f *channelFixture) {
f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
if err := os.MkdirAll(systemGuardDir(f.a.optDir), 0o755); err != nil {
@@ -350,7 +349,7 @@ func TestInstallChannelPolicyRefusals(t *testing.T) {
if err := os.WriteFile(filepath.Join(systemGuardDir(f.a.optDir), updateBegunFile), []byte("{}"), 0o644); err != nil {
t.Fatal(err)
}
- }, "an update on this box has not finished, so nothing was installed: hostfloor: authenticated floor required: %opt%/system/guard/" + updateBegunFile + " is present; let the update finish or recover first"},
+ }, "an update on this box has not finished, so nothing was installed: %opt%/system/guard/" + updateBegunFile + " is present; let the update finish or recover first"},
{"a census that cannot be read", func(t *testing.T, f *channelFixture) {
f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
f.a.rootMoves = func() (rootFloorIdentity, bool, error) {
@@ -540,33 +539,6 @@ func TestInstallChannelPolicyFileIsReadUpFront(t *testing.T) {
t.Fatalf("%q: %v", body, err)
}
}
- // M3: a symlink, a FIFO, a directory and an oversized file are refused
- // before anything is read; the FIFO's open does not wait for a writer.
- raw0 := signedPolicyBytes(t)
- real := filepath.Join(dir, "real.json")
- if err := os.WriteFile(real, raw0, 0o600); err != nil {
- t.Fatal(err)
- }
- link := filepath.Join(dir, "link.json")
- if err := os.Symlink(real, link); err != nil {
- t.Fatal(err)
- }
- fifo := filepath.Join(dir, "fifo.json")
- if err := syscall.Mkfifo(fifo, 0o600); err != nil {
- t.Fatal(err)
- }
- big := filepath.Join(dir, "big.json")
- if err := os.WriteFile(big, append(raw0, bytes.Repeat([]byte(" "), channelPolicyLimit)...), 0o600); err != nil {
- t.Fatal(err)
- }
- for _, path := range []string{link, fifo, dir, big} {
- if _, err := readChannelPolicy(path); err == nil || !strings.HasPrefix(err.Error(), "the policy "+path+" could not be read, so nothing was installed: ") {
- t.Fatalf("%s: %v", path, err)
- }
- }
- if got, err := readChannelPolicy(real); err != nil || !bytes.Equal(got, raw0) {
- t.Fatalf("the real file: %v", err)
- }
_, key, _ := ed25519.GenerateKey(nil)
raw := signedPolicy(t, key, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
if err := os.WriteFile(missing, raw, 0o600); err != nil {
@@ -633,6 +605,97 @@ func TestInstallChannelPolicyTakesTheCensusBranch(t *testing.T) {
}
}
+// abaReturn is the fix round 2 box: installed at A (v1.4.0) with --policy,
+// updated to B and back to A, so A's channel identity is still held, with
+// a moves floor armed at A on the box so that judging carlos.prev writes a
+// verdict.
+func abaReturn(t *testing.T) *channelFixture {
+ t.Helper()
+ f := newChannelFixture(t, "")
+ f.in.policy = signedPolicy(t, f.priv, "bucket", hostfloor.Floor{Min: 1, MinRelease: "v1.4.0"})
+ if got, err := f.in.run(context.Background()); err != nil || got.Already || got.Replaced {
+ t.Fatalf("install A: %+v %v", got, err)
+ }
+ applyChannelUpdate(t, f, "v1.5.0", []byte("#!/bin/sh\necho carlos v1.5.0\n"), true)
+ applyChannelUpdate(t, f, "v1.4.0", f.body, false)
+ armLocalFloor(t, f.a.dataDir, "v1.4.0")
+ return f
+}
+
+// failJudge makes the floor's judgement of carlos.prev fail (as a cut-off
+// or failing judgement) until restored; calls counts every judgement.
+func failJudge(t *testing.T, calls *int) func() {
+ t.Helper()
+ real := channelPrevJudge
+ channelPrevJudge = func(a *systemApplier, ctx context.Context) error {
+ *calls++
+ return errors.New("judgement cut off")
+ }
+ restore := func() {
+ channelPrevJudge = func(a *systemApplier, ctx context.Context) error {
+ *calls++
+ return real(a, ctx)
+ }
+ }
+ t.Cleanup(func() { channelPrevJudge = real })
+ return restore
+}
+
+// Fix round 2: the refresh cut off after carlos.prev's record is written
+// and before its judgement finishes, over a matching identity. Binaries,
+// identity and record then all match A; the retry still judges, and only
+// then answers "already installed".
+func TestInstallChannelPolicyFinishesAJudgementCutOffAfterTheRecord(t *testing.T) {
+ f := abaReturn(t)
+ ctx := context.Background()
+ calls := 0
+ restore := failJudge(t, &calls)
+ if got, err := f.in.run(ctx); err == nil || err.Error() != "judgement cut off" || got.Already || got.Replaced {
+ t.Fatalf("the cut-off refresh: %+v %v", got, err)
+ }
+ if !prevRecorded(f.a.optDir, "v1.4.0") {
+ t.Fatal("the cut-off refresh did not write the record first")
+ }
+ if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !os.IsNotExist(err) {
+ t.Fatalf("a verdict before the judgement: %v", err)
+ }
+ if cur, prev := f.slots(t); !bytes.Equal(cur, f.body) || !bytes.Equal(prev, f.body) {
+ t.Fatal("slots")
+ }
+ restore()
+ got, err := f.in.run(ctx)
+ if err != nil || !got.Already || calls != 2 {
+ t.Fatalf("the retry: %+v %v after %d judgements", got, err, calls)
+ }
+ if v, ok := readLine(prevAllowedPath(f.a.optDir)); !ok || v != "v1.4.0" {
+ t.Fatalf("the retry did not finish the judgement: %q %v", v, ok)
+ }
+ // Every --policy "already installed" judges again.
+ if got, err := f.in.run(ctx); err != nil || !got.Already || calls != 3 {
+ t.Fatalf("a rerun: %+v %v after %d judgements", got, err, calls)
+ }
+}
+
+// Fix round 2: a judgement that keeps failing is reported every time, over
+// a record and an identity that match; never "already installed".
+func TestInstallChannelPolicyReportsAFailingJudgement(t *testing.T) {
+ f := abaReturn(t)
+ ctx := context.Background()
+ calls := 0
+ failJudge(t, &calls)
+ for i := 0; i < 3; i++ {
+ if got, err := f.in.run(ctx); err == nil || err.Error() != "judgement cut off" || got.Already {
+ t.Fatalf("run %d: %+v %v", i, got, err)
+ }
+ }
+ if calls != 3 || !prevRecorded(f.a.optDir, "v1.4.0") {
+ t.Fatalf("%d judgements", calls)
+ }
+ if _, err := os.Stat(prevAllowedPath(f.a.optDir)); !os.IsNotExist(err) {
+ t.Fatalf("a verdict from a failed judgement: %v", err)
+ }
+}
+
// floorUnreadable fails every read of the host floor's two objects.
type floorUnreadable struct{ release.Store }
diff --git a/internal/provision/plan14_root_reads_test.go b/internal/provision/plan14_root_reads_test.go
index f070466ef..d8c0abf63 100644
--- a/internal/provision/plan14_root_reads_test.go
+++ b/internal/provision/plan14_root_reads_test.go
@@ -875,6 +875,14 @@ func TestPlan14RootReadCensus(t *testing.T) {
if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix1.tsv"), &inv, true); err != nil {
t.Fatal(err)
}
+ // Census overlay claim-prev-rehearsal-fix2 (fix round 2: carlos.prev's
+ // judgement always finished on --policy's already-installed path),
+ // loaded last. Envelope only. PROPOSED
+ // (docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md,
+ // "Fix round 2"). The seal stands.
+ if err = loadRootReadInventory(filepath.Join(root, "internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv"), &inv, true); err != nil {
+ t.Fatal(err)
+ }
errs := append(compareRootReads(got, inv), checkRootReadLegacy(inv, seal)...)
errs = append(errs, checkRootReadDeclarations(got, inv)...)
if len(errs) > 0 {
diff --git a/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv
new file mode 100644
index 000000000..e7c547211
--- /dev/null
+++ b/internal/provision/testdata/plan14-root-reads-claim-prev-rehearsal-fix2.tsv
@@ -0,0 +1,3 @@
+# Census overlay claim-prev-rehearsal-fix2 (fix round 2: Astra's re-review and platform-00's minors, as ruled by the controller), loaded LAST, after claim-prev-rehearsal-fix1. PROPOSED, not ruled: docs/superpowers/notes/census-proposal-claim-prev-rehearsal.md, section "Fix round 2".
+# Envelope only. In the install-channel --policy path, "already installed" always runs carlos.prev's judgement again (channelPrevJudge, notePrev without a replace) and fails on its error; completePrev ends with the same judgement; the unfinished-update refusal's reason drops guard-arm's floor prefix; installChannelUsage is one literal of the same value (RHB-C19, re-approved). No root-read unit's digest or sites change: channelInstall.run, channelInstall.refreshPrev and openChannelPolicy stand at fix1's rows. The seal stands; the 9B8 inventory is unchanged.
+@coverage internal/host 33968e7c537bbb72b15c1c9641515921341507d38d4150589f27370e919ba36c