carlos / platform Public

plan: carlos system update, task by task

Sixteen tasks from the pointer to the docs, each with its tests, in
dependency order. Names real helpers found in the code: signedWrapper,
systemctlPlan, fastDeps, cliRoute, the console fixture.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
Paul Campbell pushed by paul@keymail.dev fc904cf04ff3f3850a4f0dd0e9efca2e7f60af62 parent a49b162
1 file changed, +5934 −0
  • docs/superpowers/plans/2026-09-06-carlos-system-update.md +5934 −0

CI passed — run details

diff --git a/docs/superpowers/plans/2026-09-06-carlos-system-update.md b/docs/superpowers/plans/2026-09-06-carlos-system-update.md
new file mode 100644
index 000000000..f904116b7
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-06-carlos-system-update.md
@@ -0,0 +1,5934 @@
+# `carlos system update` Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A fleet operator runs `carlos system update` and every box on the channel fetches, verifies, installs and health-gates the new platform binary itself, rolling back to the previous one if it does not come up.
+
+**Architecture:** A signed `SystemPointer` at `control/system/router/<channel>.json` names a version and its per-platform artifacts mirrored into the deployment bucket. A sandboxed agent leg notices the pointer on the serial poll, streams and verifies the artifact into `/data`, and touches a marker. A root oneshot (`carlos system apply`) re-verifies everything from root-owned copies, journals each step, swaps binary and units, restarts the edge, gates it, and rolls back to `carlos.prev` on failure; a boot-time unit recovers an interrupted apply. The console does the promote: fetch the public signed release, mirror, sign the pointer, bump the serial.
+
+**Tech Stack:** Go (stdlib, ed25519, net/http), systemd units embedded in the binary, S3/dir/fleet-door stores behind `release.Store`.
+
+**Spec:** `docs/superpowers/specs/2026-09-06-carlos-system-update-design.md` — read §3, §4, §6 before the task you are on. The spec wins where this plan and it disagree; say so in the commit if you find such a place.
+
+## Global Constraints
+
+- Go build cache is read-only in the sandbox: run every `go` command with `dangerouslyDisableSandbox: true`, and `export TMPDIR=/tmp/c` first (long socket paths break `internal/edge`/`internal/activator` tests).
+- Capture test exit codes explicitly: `out=$(go test ./pkg/... 2>&1); rc=$?; echo "$out" | tail -40; echo rc=$rc`. Never pipe `go test` into `grep`/`tail` and read the pipeline's status.
+- `-count=1` on every test run. `gofmt -l .` must print nothing before a commit.
+- Never run full `carlos bootstrap` on a live box path; the apply stages units into a temp root (`bootstrap -root <dir>`) and copies only `etc/`. Never copy a staging root's `data/`.
+- sysusers before tmpfiles, always, in every code path that installs units.
+- Root reads exactly one file under `/data` (`requested.json`), walks to it with `O_DIRECTORY|O_NOFOLLOW` per component, and derives every other path from a validated version string. Root never deletes under `/data`.
+- Apply never `systemctl start`s a `.service`; it calls `systemctlPlan`, which starts only `.timer`/`.path` units.
+- Every root state file is written temp → fsync → rename → directory fsync (`writeFileDurable`, Task 4).
+- The component name is `router`; channels are `canary` and `stable`. Platform strings are `<goos>-<goarch>` matching `^[a-z0-9]+-[a-z0-9]+$`.
+- Commit after every task with the attribution footer:
+ ```
+ Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+ Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+ ```
+- Work on branch `carlos-system-update` from forge `main`; land with `amadan branch merge`, never `gh`.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+|---|---|
+| `internal/release/system.go` (+`_test`) | `SystemPointer`, `SystemRelease`, `SystemArtifact`; key builders; validate/sign/verify (root key only); `GetSystemPointer`, `PromoteSystem`, `RollbackSystem` |
+| `internal/release/store.go`, `internal/fleetapi/fleetapi.go` (+tests) | `Opener` side-interface, `Open` on `DirStore`/`S3Store`/`prefixStore`/fleetapi, `OpenObject`, `FetchToFile` |
+| `internal/edge/storedoor.go` (+`_test`) | `aclRead` grants for the two new key shapes |
+| `internal/host/systemstate.go` (+`_test`) | On-box state types (request, result, seen, applied, journal), paths, `writeFileDurable`, outcome constants |
+| `internal/status/snapshot.go`, `internal/host/agent.go` | `Platform`, `SystemChannel`, `System` in `Probes`/`BoxMetrics`; filled in `statusLoop` |
+| `internal/host/units/carlos-system-update.path`, `carlos-system-update.service`, `carlos-system-recover.service`; `internal/host/bootstrap.go`; `internal/host/probes.go` | The three units, `enabledUnits`, `-plan-json`, the path-unit probe |
+| `internal/host/systemleg.go` (+`_test`) | `systemLeg`: `observe`/`tick`, fetch, request, marker re-touch; wired into `fastDeps` |
+| `internal/host/systemapply.go` (+`_test`) | `RunSystemApply`, `systemApplier` with an injectable `sysOps` seam: refuse path, install path, gate, rollback, recovery, lock |
+| `internal/host/agent.go` `RunAdopt` | The hourly belt: install and arm the three units if missing |
+| `internal/publicrelease/feed.go` (+`_test`) | `Feed`: latest tag, verified asset fetch — factored out of `update.go`, shared with the console |
+| `internal/console/apisystem.go` (+`_test`) | `/api/cli/system/{status,update,upload,jobs/{id},rollback}`; ops-owner gate; in-memory update job |
+| `system.go` (+`_test`, package main), `main.go` | `carlos system update|status|rollback|apply`, `carlos system router …` alias |
+| `docs/site/cli.md`, `AGENTS.md` | Operator docs; the env contract |
+
+Task order is dependency order. Tasks 1–3 are independent of each other and can run in parallel; 4 before 5/7/8; 6 before 8; 10 before 11; 11 before 12.
+
+---
+
+### Task 1: The system pointer (`internal/release/system.go`)
+
+**Files:**
+- Create: `internal/release/system.go`
+- Test: `internal/release/system_test.go`
+
+**Interfaces:**
+- Consumes: `signedWrapper`, `Signer` (fields `key`, `scope`, method `usable()`), `Promotion`, `historyMax`, `auditMax`, `prependCapped`, `ValidChannel`, `ValidVersion`, `ParsePublicKey`, `Store`, `ErrCASConflict` — all in package `release` (`release.go`, `signer.go`, `store.go`).
+- Produces (all exported, package `release`):
+ ```go
+ const SystemComponentRouter = "router"
+ func SystemPointerKey(component, channel string) string // control/system/<c>/<ch>.json
+ func SystemArtifactKey(version, platform string) string // bootstrap/router/<v>/carlos-<platform>
+ type SystemArtifact struct{ Key, SHA256 string }
+ type SystemRelease struct{ Version string; Artifacts map[string]SystemArtifact; Source string; ReleasedAt time.Time }
+ type SystemPointer struct{ Schema int; Component, Channel string; Current SystemRelease; History []SystemRelease; Spread string; PromotedAt time.Time; Audit []Promotion }
+ func (p *SystemPointer) SpreadDuration() time.Duration
+ func GetSystemPointer(ctx context.Context, s Store, pub ed25519.PublicKey, component, channel string) (*SystemPointer, string, error)
+ type SystemPromoteOptions struct{ Actor string; Spread time.Duration }
+ func PromoteSystem(ctx context.Context, s Store, sg Signer, component, channel string, rel SystemRelease, o SystemPromoteOptions) (*SystemPointer, error)
+ func RollbackSystem(ctx context.Context, s Store, sg Signer, component, channel, actor string) (*SystemPointer, error)
+ var ErrNoSystemHistory = errors.New("system pointer: nothing to roll back to")
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+ "time"
+)
+
+func testSystemRelease(v string) SystemRelease {
+ return SystemRelease{
+ Version: v,
+ Artifacts: map[string]SystemArtifact{
+ "linux-arm64": {Key: SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("ab", 32)},
+ },
+ Source: "release:" + v,
+ ReleasedAt: time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC),
+ }
+}
+
+func TestSystemPointerRoundTrip(t *testing.T) {
+ priv, pub := testKeys(t)
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, _, err := GetSystemPointer(ctx, s, pub, SystemComponentRouter, "stable"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("missing pointer: err = %v, want ErrNotExist", err)
+ }
+ p, err := PromoteSystem(ctx, s, RootSigner(priv), SystemComponentRouter, "stable", testSystemRelease("v0.18.0"),
+ SystemPromoteOptions{Actor: "paul", Spread: 5 * time.Minute})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Current.Version != "v0.18.0" || p.Spread != "5m0s" || p.Schema != 1 || p.PromotedAt.IsZero() {
+ t.Fatalf("promoted pointer = %+v", p)
+ }
+ got, etag, err := GetSystemPointer(ctx, s, pub, SystemComponentRouter, "stable")
+ if err != nil || etag == "" {
+ t.Fatalf("get: %v etag=%q", err, etag)
+ }
+ if got.Current.Artifacts["linux-arm64"].SHA256 != strings.Repeat("ab", 32) {
+ t.Fatalf("artifact lost: %+v", got.Current)
+ }
+ if got.SpreadDuration() != 5*time.Minute {
+ t.Fatalf("spread = %v", got.SpreadDuration())
+ }
+ if len(got.Audit) != 1 || got.Audit[0].Actor != "paul" || got.Audit[0].Version != "v0.18.0" {
+ t.Fatalf("audit = %+v", got.Audit)
+ }
+}
+
+func TestSystemPromoteKeepsWholeReleaseInHistoryAndRollbackRestoresIt(t *testing.T) {
+ priv, pub := testKeys(t)
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ sg := RootSigner(priv)
+ if _, err := PromoteSystem(ctx, s, sg, "router", "canary", testSystemRelease("v0.18.0"), SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ first, _, _ := GetSystemPointer(ctx, s, pub, "router", "canary")
+ time.Sleep(1100 * time.Millisecond) // PromotedAt is second-truncated; the rollback must be NEWER
+ if _, err := PromoteSystem(ctx, s, sg, "router", "canary", testSystemRelease("v0.19.0"), SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ p, _, _ := GetSystemPointer(ctx, s, pub, "router", "canary")
+ if len(p.History) != 1 || p.History[0].Version != "v0.18.0" || p.History[0].Artifacts["linux-arm64"].Key == "" {
+ t.Fatalf("history must carry the whole release: %+v", p.History)
+ }
+ time.Sleep(1100 * time.Millisecond)
+ rb, err := RollbackSystem(ctx, s, sg, "router", "canary", "paul")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if rb.Current.Version != "v0.18.0" || rb.Current.Artifacts["linux-arm64"].Key != SystemArtifactKey("v0.18.0", "linux-arm64") {
+ t.Fatalf("rollback current = %+v", rb.Current)
+ }
+ if !rb.PromotedAt.After(first.PromotedAt) || !rb.PromotedAt.After(p.PromotedAt) {
+ t.Fatalf("a rollback is a NEW promote: promoted_at %v must be newer than %v and %v", rb.PromotedAt, first.PromotedAt, p.PromotedAt)
+ }
+ if len(rb.History) != 0 {
+ t.Fatalf("history after rollback = %+v", rb.History)
+ }
+ if _, err := RollbackSystem(ctx, s, sg, "router", "canary", "paul"); !errors.Is(err, ErrNoSystemHistory) {
+ t.Fatalf("second rollback err = %v", err)
+ }
+}
+
+func TestSystemPromoteSameVersionDoesNotGrowHistory(t *testing.T) {
+ priv, pub := testKeys(t)
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ sg := RootSigner(priv)
+ for i := 0; i < 2; i++ {
+ if _, err := PromoteSystem(ctx, s, sg, "router", "stable", testSystemRelease("v1.0.0"), SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ p, _, _ := GetSystemPointer(ctx, s, pub, "router", "stable")
+ if len(p.History) != 0 {
+ t.Fatalf("re-promoting the running version must not push it into history: %+v", p.History)
+ }
+}
+
+func TestSystemPointerRefusesScopedSigner(t *testing.T) {
+ _, _, sg := scopedFixture(t, []string{RungCanary, RungEdge}, 30*24*time.Hour)
+ s := NewDirStore(t.TempDir())
+ _, err := PromoteSystem(context.Background(), s, sg, "router", "canary", testSystemRelease("v1.0.0"), SystemPromoteOptions{})
+ if err == nil || !strings.Contains(err.Error(), "root key") {
+ t.Fatalf("scoped signer signed a system pointer: err = %v", err)
+ }
+}
+
+func TestSystemPointerRefusesAGrantOnTheWire(t *testing.T) {
+ // A wrapper carrying a grant is refused BEFORE the signature is checked,
+ // like the production flag (production.go) and the pipeline.
+ priv, pub := testKeys(t)
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := PromoteSystem(ctx, s, RootSigner(priv), "router", "stable", testSystemRelease("v1.0.0"), SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ body, etag, _ := s.Get(ctx, SystemPointerKey("router", "stable"))
+ var sw signedWrapper
+ if err := json.Unmarshal(body, &sw); err != nil {
+ t.Fatal(err)
+ }
+ sw.Grant = &signedGrant{Payload: "x", Sig: "y"}
+ tampered, _ := json.Marshal(sw)
+ if _, err := s.Put(ctx, SystemPointerKey("router", "stable"), tampered, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := GetSystemPointer(ctx, s, pub, "router", "stable"); err == nil || !strings.Contains(err.Error(), "root key") {
+ t.Fatalf("grant-bearing wrapper accepted: err = %v", err)
+ }
+}
+
+func TestSystemPointerTamperAndCrossChannelRefused(t *testing.T) {
+ priv, pub := testKeys(t)
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := PromoteSystem(ctx, s, RootSigner(priv), "router", "canary", testSystemRelease("v1.0.0"), SystemPromoteOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ body, _, _ := s.Get(ctx, SystemPointerKey("router", "canary"))
+ // Cross-channel replay: the canary wrapper planted at the stable key.
+ if _, err := s.Put(ctx, SystemPointerKey("router", "stable"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := GetSystemPointer(ctx, s, pub, "router", "stable"); err == nil {
+ t.Fatal("a canary pointer verified as stable")
+ }
+ // Tampered signature.
+ var sw signedWrapper
+ _ = json.Unmarshal(body, &sw)
+ sw.Sig = "AA" + sw.Sig[2:]
+ bad, _ := json.Marshal(sw)
+ _, etag, _ := s.Get(ctx, SystemPointerKey("router", "canary"))
+ if _, err := s.Put(ctx, SystemPointerKey("router", "canary"), bad, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, _, err := GetSystemPointer(ctx, s, pub, "router", "canary"); err == nil {
+ t.Fatal("tampered signature verified")
+ }
+}
+
+func TestSystemPointerValidation(t *testing.T) {
+ priv, _ := testKeys(t)
+ sg := RootSigner(priv)
+ ctx := context.Background()
+ cases := map[string]SystemRelease{
+ "dev version": {Version: "dev", Artifacts: testSystemRelease("x").Artifacts},
+ "no artifacts": {Version: "v1.0.0"},
+ "bad platform": {Version: "v1.0.0", Artifacts: map[string]SystemArtifact{"linux_arm64": {Key: "bootstrap/router/v1.0.0/carlos-linux_arm64", SHA256: strings.Repeat("a", 64)}}},
+ "key not derived": {Version: "v1.0.0", Artifacts: map[string]SystemArtifact{"linux-arm64": {Key: "bootstrap/carlos", SHA256: strings.Repeat("a", 64)}}},
+ "short sha": {Version: "v1.0.0", Artifacts: map[string]SystemArtifact{"linux-arm64": {Key: SystemArtifactKey("v1.0.0", "linux-arm64"), SHA256: "abc"}}},
+ "version traversal": {Version: "../x", Artifacts: testSystemRelease("../x").Artifacts},
+ }
+ for name, rel := range cases {
+ s := NewDirStore(t.TempDir())
+ if _, err := PromoteSystem(ctx, s, sg, "router", "stable", rel, SystemPromoteOptions{}); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+ s := NewDirStore(t.TempDir())
+ if _, err := PromoteSystem(ctx, s, sg, "Router", "stable", testSystemRelease("v1.0.0"), SystemPromoteOptions{}); err == nil {
+ t.Error("bad component accepted")
+ }
+ if _, err := PromoteSystem(ctx, s, sg, "router", "stable", testSystemRelease("v1.0.0"), SystemPromoteOptions{Spread: 48 * time.Hour}); err == nil {
+ t.Error("48h spread accepted")
+ }
+}
+
+func TestSystemPromoteCASConflict(t *testing.T) {
+ priv, _ := testKeys(t)
+ ctx := context.Background()
+ inner := NewDirStore(t.TempDir())
+ // A store whose Put always says "someone else moved it".
+ s := conflictingStore{Store: inner}
+ if _, err := PromoteSystem(ctx, s, RootSigner(priv), "router", "stable", testSystemRelease("v1.0.0"), SystemPromoteOptions{}); !errors.Is(err, ErrCASConflict) {
+ t.Fatalf("err = %v, want ErrCASConflict", err)
+ }
+}
+
+type conflictingStore struct{ Store }
+
+func (c conflictingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) {
+ return "", ErrCASConflict
+}
+```
+
+- [ ] **Step 2: Run the tests to verify they fail**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/release/ -run 'TestSystem' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: build failure naming `SystemPointer`, `PromoteSystem`, etc. as undefined.
+
+- [ ] **Step 3: Implement `internal/release/system.go`**
+
+```go
+package release
+
+import (
+ "context"
+ "crypto/ed25519"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "time"
+)
+
+// SystemPointer is the platform's own channel pointer: what a ChannelPointer
+// is for an app, for the thing that does the adopting. It lives under
+// control/, not apps/, because the substrate is not an app (the
+// console-as-an-app spec's dividing line), and it is ROOT-KEY-ONLY: a scoped
+// grant's rungs name app channels, and a key that can roll the platform is a
+// bigger key than any app promoter should hold. Spec §3.
+type SystemPointer struct {
+ Schema int `json:"schema"`
+ Component string `json:"component"`
+ Channel string `json:"channel"`
+ Current SystemRelease `json:"current"`
+ History []SystemRelease `json:"history,omitempty"`
+ Spread string `json:"spread"`
+ PromotedAt time.Time `json:"promoted_at"` // of THIS write; the box's replay guard reads only this
+ Audit []Promotion `json:"audit,omitempty"`
+}
+
+// SystemRelease is everything a box needs to install one version. It travels
+// whole into History so a rollback re-installs what was there, not a version
+// string with someone else's artifacts.
+type SystemRelease struct {
+ Version string `json:"version"`
+ Artifacts map[string]SystemArtifact `json:"artifacts"` // keyed by "<goos>-<goarch>"
+ Source string `json:"source,omitempty"`
+ ReleasedAt time.Time `json:"released_at"` // audit only; never compared
+}
+
+// SystemArtifact is one mirrored binary. Its sha in the signed pointer IS
+// the artifact's signature: boxes need no .sig object.
+type SystemArtifact struct {
+ Key string `json:"key"`
+ SHA256 string `json:"sha256"`
+}
+
+// SystemComponentRouter is the one component today: the substrate binary
+// (edge + activator + adoption, one process). A split adds a name here.
+const SystemComponentRouter = "router"
+
+// ErrNoSystemHistory is RollbackSystem's answer when the pointer has never
+// moved: there is nothing older to go back to.
+var ErrNoSystemHistory = errors.New("system pointer: nothing to roll back to")
+
+const (
+ systemSpreadMax = 24 * time.Hour
+ systemSpreadDefault = 5 * time.Minute
+)
+
+var (
+ systemComponentRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`)
+ systemPlatformRe = regexp.MustCompile(`^[a-z0-9]+-[a-z0-9]+$`)
+ systemSHA256Re = regexp.MustCompile(`^[0-9a-f]{64}$`)
+)
+
+// SystemPointerKey is control/system/<component>/<channel>.json.
+func SystemPointerKey(component, channel string) string {
+ return "control/system/" + component + "/" + channel + ".json"
+}
+
+// SystemArtifactKey is bootstrap/router/<version>/carlos-<platform>: under
+// bootstrap/ because the store door already means "the platform's own
+// binary" by that name, per-version because canary and stable boxes must be
+// able to run different binaries at once.
+func SystemArtifactKey(version, platform string) string {
+ return "bootstrap/router/" + version + "/carlos-" + platform
+}
+
+// SpreadDuration is Spread parsed, or the default when it is unparseable —
+// validation refuses the unparseable case on the way in, so this only
+// defaults for a hand-edited or pre-field pointer.
+func (p *SystemPointer) SpreadDuration() time.Duration {
+ d, err := time.ParseDuration(p.Spread)
+ if err != nil || d < 0 || d > systemSpreadMax {
+ return systemSpreadDefault
+ }
+ return d
+}
+
+func validateSystemRelease(r SystemRelease) error {
+ if !ValidVersion(r.Version) || r.Version == "dev" {
+ return fmt.Errorf("version %q is not a valid platform version", r.Version)
+ }
+ if len(r.Artifacts) == 0 {
+ return fmt.Errorf("version %s names no artifacts", r.Version)
+ }
+ for plat, a := range r.Artifacts {
+ if !systemPlatformRe.MatchString(plat) {
+ return fmt.Errorf("platform %q is not <goos>-<goarch>", plat)
+ }
+ if a.Key != SystemArtifactKey(r.Version, plat) {
+ return fmt.Errorf("artifact key %q is not %s", a.Key, SystemArtifactKey(r.Version, plat))
+ }
+ if !systemSHA256Re.MatchString(a.SHA256) {
+ return fmt.Errorf("artifact %s: sha256 %q is not 64 hex characters", a.Key, a.SHA256)
+ }
+ }
+ return nil
+}
+
+func validateSystemPointer(p *SystemPointer, wantComponent, wantChannel string) error {
+ if p.Schema != 1 {
+ return fmt.Errorf("system pointer schema %d unsupported", p.Schema)
+ }
+ if p.Component != wantComponent || p.Channel != wantChannel {
+ return fmt.Errorf("system pointer is for %s/%s, not %s/%s", p.Component, p.Channel, wantComponent, wantChannel)
+ }
+ if !systemComponentRe.MatchString(p.Component) || !ValidChannel(p.Channel) {
+ return fmt.Errorf("system pointer %s/%s: bad component or channel", p.Component, p.Channel)
+ }
+ if err := validateSystemRelease(p.Current); err != nil {
+ return fmt.Errorf("system pointer %s/%s: %w", p.Component, p.Channel, err)
+ }
+ if d, err := time.ParseDuration(p.Spread); err != nil || d < 0 || d > systemSpreadMax {
+ return fmt.Errorf("system pointer %s/%s: spread %q must be a duration between 0 and %s", p.Component, p.Channel, p.Spread, systemSpreadMax)
+ }
+ if p.PromotedAt.IsZero() {
+ return fmt.Errorf("system pointer %s/%s: promoted_at unset", p.Component, p.Channel)
+ }
+ if len(p.History) > historyMax {
+ return fmt.Errorf("system pointer %s/%s: history longer than %d", p.Component, p.Channel, historyMax)
+ }
+ for _, h := range p.History {
+ if err := validateSystemRelease(h); err != nil {
+ return fmt.Errorf("system pointer %s/%s: history: %w", p.Component, p.Channel, err)
+ }
+ }
+ if len(p.Audit) > auditMax {
+ return fmt.Errorf("system pointer %s/%s: audit longer than %d", p.Component, p.Channel, auditMax)
+ }
+ return nil
+}
+
+// signSystemPointer mirrors signPointer with two differences: the signer
+// must be the root key, and the wrapper never carries a grant.
+func signSystemPointer(sg Signer, p *SystemPointer) ([]byte, error) {
+ if err := sg.usable(); err != nil {
+ return nil, err
+ }
+ if sg.scope != nil {
+ return nil, errors.New("system pointer: signed by a scoped key — the platform takes the root key only")
+ }
+ p.Schema = 1
+ p.PromotedAt = time.Now().UTC().Truncate(time.Second)
+ if err := validateSystemPointer(p, p.Component, p.Channel); err != nil {
+ return nil, err
+ }
+ payload, err := json.MarshalIndent(p, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ b64 := base64.StdEncoding.EncodeToString(payload)
+ sig := ed25519.Sign(sg.key, []byte(b64))
+ return json.MarshalIndent(signedWrapper{Payload: b64, Sig: base64.StdEncoding.EncodeToString(sig)}, "", " ")
+}
+
+// verifySystemPointer is the single parse site for system pointers. A grant
+// on the wire is refused before the signature is looked at (the production
+// flag's rule, production.go).
+func verifySystemPointer(pub ed25519.PublicKey, wrapper []byte, wantComponent, wantChannel string) (*SystemPointer, error) {
+ var sw signedWrapper
+ if err := json.Unmarshal(wrapper, &sw); err != nil {
+ return nil, fmt.Errorf("system pointer %s/%s: %w", wantComponent, wantChannel, err)
+ }
+ if sw.Grant != nil {
+ return nil, fmt.Errorf("system pointer %s/%s: signed by a scoped key — the platform takes the root key only", wantComponent, wantChannel)
+ }
+ sig, err := base64.StdEncoding.DecodeString(sw.Sig)
+ if err != nil {
+ return nil, fmt.Errorf("system pointer %s/%s: bad signature encoding", wantComponent, wantChannel)
+ }
+ if len(pub) != ed25519.PublicKeySize || !ed25519.Verify(pub, []byte(sw.Payload), sig) {
+ return nil, fmt.Errorf("system pointer %s/%s: signature does not verify", wantComponent, wantChannel)
+ }
+ payload, err := base64.StdEncoding.DecodeString(sw.Payload)
+ if err != nil {
+ return nil, fmt.Errorf("system pointer %s/%s: bad payload encoding", wantComponent, wantChannel)
+ }
+ var p SystemPointer
+ if err := json.Unmarshal(payload, &p); err != nil {
+ return nil, fmt.Errorf("system pointer %s/%s: %w", wantComponent, wantChannel, err)
+ }
+ if err := validateSystemPointer(&p, wantComponent, wantChannel); err != nil {
+ return nil, err
+ }
+ return &p, nil
+}
+
+// GetSystemPointer reads and verifies one channel's pointer. A missing key
+// is os.ErrNotExist, which is the normal state of every deployment before
+// its first `carlos system update`.
+func GetSystemPointer(ctx context.Context, s Store, pub ed25519.PublicKey, component, channel string) (*SystemPointer, string, error) {
+ body, etag, err := s.Get(ctx, SystemPointerKey(component, channel))
+ if err != nil {
+ return nil, "", err
+ }
+ p, err := verifySystemPointer(pub, body, component, channel)
+ if err != nil {
+ return nil, "", err
+ }
+ return p, etag, nil
+}
+
+// SystemPromoteOptions: who did it, and how long boxes spread their starts.
+type SystemPromoteOptions struct {
+ Actor string
+ Spread time.Duration // 0 = hotfix, every box at once; unset in the CLI means the default
+}
+
+// PromoteSystem writes the pointer for one channel, CAS-guarded. The
+// outgoing release goes whole into History unless it is the same version
+// (a re-promote of the running version is a no-op for boxes and must not
+// push that version into its own history).
+func PromoteSystem(ctx context.Context, s Store, sg Signer, component, channel string, rel SystemRelease, o SystemPromoteOptions) (*SystemPointer, error) {
+ key := SystemPointerKey(component, channel)
+ var old *SystemPointer
+ body, etag, err := s.Get(ctx, key)
+ switch {
+ case err == nil:
+ if old, err = verifySystemPointer(sg.verifyPub(), body, component, channel); err != nil {
+ return nil, err
+ }
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return nil, err
+ }
+ next := &SystemPointer{Component: component, Channel: channel, Current: rel, Spread: o.Spread.String()}
+ if rel.ReleasedAt.IsZero() {
+ next.Current.ReleasedAt = time.Now().UTC().Truncate(time.Second)
+ }
+ from := ""
+ if old != nil {
+ next.History, next.Audit = old.History, old.Audit
+ from = old.Current.Version
+ if old.Current.Version != rel.Version {
+ next.History = prependCapped(old.Current, old.History, historyMax)
+ }
+ }
+ next.Audit = prependCapped(Promotion{Version: rel.Version, From: from, To: channel, Actor: o.Actor, At: time.Now().UTC()}, next.Audit, auditMax)
+ wrapper, err := signSystemPointer(sg, next)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.Put(ctx, key, wrapper, etag); err != nil {
+ return nil, err
+ }
+ return next, nil
+}
+
+// RollbackSystem swaps Current with History[0] and writes a NEW pointer —
+// new PromotedAt — so every box's replay guard sees a promote, not a replay.
+func RollbackSystem(ctx context.Context, s Store, sg Signer, component, channel, actor string) (*SystemPointer, error) {
+ key := SystemPointerKey(component, channel)
+ body, etag, err := s.Get(ctx, key)
+ if err != nil {
+ return nil, err
+ }
+ old, err := verifySystemPointer(sg.verifyPub(), body, component, channel)
+ if err != nil {
+ return nil, err
+ }
+ if len(old.History) == 0 {
+ return nil, ErrNoSystemHistory
+ }
+ next := &SystemPointer{
+ Component: component, Channel: channel,
+ Current: old.History[0], History: old.History[1:], Spread: old.Spread,
+ Audit: prependCapped(Promotion{Version: old.History[0].Version, From: old.Current.Version, To: channel, Actor: actor, At: time.Now().UTC()}, old.Audit, auditMax),
+ }
+ wrapper, err := signSystemPointer(sg, next)
+ if err != nil {
+ return nil, err
+ }
+ if _, err := s.Put(ctx, key, wrapper, etag); err != nil {
+ return nil, err
+ }
+ return next, nil
+}
+```
+
+Note `sg.verifyPub()` is the existing unexported method on `Signer` (`signer.go:66`); for a root signer it returns the root public key.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/release/ -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: `ok`, rc=0. If `TestSystemPromoteCASConflict` fails because `conflictingStore` lacks a method, the `Store` interface grew — embed satisfies it; check the error text.
+
+- [ ] **Step 5: gofmt and commit**
+
+```bash
+gofmt -l internal/release/ && git add internal/release/system.go internal/release/system_test.go && git commit -q -F - <<'EOF'
+release: the platform's own channel pointer, root-key-only
+
+control/system/<component>/<channel>.json names a version and its
+per-platform artifacts. History carries whole releases so a rollback
+restores artifacts, and a rollback is a new promote (new PromotedAt).
+A grant on the wire is refused before the signature is read, like the
+production flag.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 2: Streaming reads (`Opener`, `FetchToFile`)
+
+**Files:**
+- Modify: `internal/release/store.go` (after the `SizeLister` block, ~line 190)
+- Modify: `internal/fleetapi/fleetapi.go` (after `Get`, ~line 156)
+- Create: `internal/release/fetch.go`
+- Test: `internal/release/fetch_test.go`, `internal/fleetapi/fleetapi_test.go` (add one test)
+
+**Interfaces:**
+- Consumes: `Store`, `DirStore.path`, `S3Store.Client/Bucket`, `s3NotFound`, `prefixStore.full`; fleetapi `store.newRequest`, `objectURL`.
+- Produces:
+ ```go
+ // package release
+ type Opener interface{ Open(ctx context.Context, key string) (io.ReadCloser, string, error) }
+ func OpenObject(ctx context.Context, s Store, key string) (io.ReadCloser, string, error)
+ var ErrTooLarge = errors.New("store: object larger than the caller's limit")
+ func FetchToFile(ctx context.Context, s Store, key, dst string, limit int64, wantSHA256 string) error
+ ```
+ `FetchToFile` writes `dst+".tmp"`, streams with a running sha, refuses more than `limit` bytes (`ErrTooLarge`), refuses a sha mismatch (error contains "sha256"), fsyncs, renames, fsyncs the directory, and removes the temp on any failure. Etag from `Open` may be `""` (DirStore).
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/release/fetch_test.go`:
+```go
+package release
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func shaOf(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) }
+
+func TestFetchToFileStreamsAndVerifies(t *testing.T) {
+ ctx := context.Background()
+ body := []byte(strings.Repeat("carlos", 10000))
+ for name, s := range map[string]Store{
+ "dir": NewDirStore(t.TempDir()),
+ "prefix": NewPrefixStore(NewDirStore(t.TempDir()), "fleets/pi/"),
+ } {
+ t.Run(name, func(t *testing.T) {
+ if _, err := s.Put(ctx, "bootstrap/router/v1/carlos-linux-arm64", body, ""); err != nil {
+ t.Fatal(err)
+ }
+ dst := filepath.Join(t.TempDir(), "carlos")
+ if err := FetchToFile(ctx, s, "bootstrap/router/v1/carlos-linux-arm64", dst, int64(len(body)), shaOf(body)); err != nil {
+ t.Fatal(err)
+ }
+ got, _ := os.ReadFile(dst)
+ if string(got) != string(body) {
+ t.Fatal("content differs")
+ }
+ if _, err := os.Stat(dst + ".tmp"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("temp file left behind")
+ }
+ // One byte over the limit is refused, and nothing is left on disk.
+ dst2 := filepath.Join(t.TempDir(), "carlos")
+ if err := FetchToFile(ctx, s, "bootstrap/router/v1/carlos-linux-arm64", dst2, int64(len(body))-1, shaOf(body)); !errors.Is(err, ErrTooLarge) {
+ t.Fatalf("over-limit err = %v", err)
+ }
+ if _, err := os.Stat(dst2); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("over-limit fetch left a file")
+ }
+ // A sha mismatch is refused and leaves nothing.
+ dst3 := filepath.Join(t.TempDir(), "carlos")
+ err := FetchToFile(ctx, s, "bootstrap/router/v1/carlos-linux-arm64", dst3, int64(len(body)), strings.Repeat("0", 64))
+ if err == nil || !strings.Contains(err.Error(), "sha256") {
+ t.Fatalf("mismatch err = %v", err)
+ }
+ if _, err := os.Stat(dst3); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("mismatch fetch left a file")
+ }
+ // Missing key is ErrNotExist.
+ if err := FetchToFile(ctx, s, "bootstrap/router/v9/carlos-linux-arm64", dst3, 10, shaOf(nil)); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("missing err = %v", err)
+ }
+ })
+ }
+}
+
+func TestPrefixStoreOpenRebasesTheKey(t *testing.T) {
+ // prefixStore embeds the Store INTERFACE, so a promoted Open would forward
+ // the raw key and silently escape the prefix. It must be written out.
+ ctx := context.Background()
+ inner := NewDirStore(t.TempDir())
+ if _, err := inner.Put(ctx, "fleets/pi/x", []byte("in"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := inner.Put(ctx, "x", []byte("OUT"), ""); err != nil {
+ t.Fatal(err)
+ }
+ rc, _, err := OpenObject(ctx, NewPrefixStore(inner, "fleets/pi/"), "x")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rc.Close()
+ b := make([]byte, 8)
+ n, _ := rc.Read(b)
+ if string(b[:n]) != "in" {
+ t.Fatalf("read %q through the prefix, want \"in\"", b[:n])
+ }
+}
+
+func TestOpenObjectFallsBackToGet(t *testing.T) {
+ // A Store that is not an Opener still works: OpenObject buffers via Get.
+ s := getOnly{NewDirStore(t.TempDir())}
+ if _, err := s.Put(context.Background(), "k", []byte("v"), ""); err != nil {
+ t.Fatal(err)
+ }
+ rc, _, err := OpenObject(context.Background(), s, "k")
+ if err != nil {
+ t.Fatal(err)
+ }
+ b := make([]byte, 4)
+ n, _ := rc.Read(b)
+ if string(b[:n]) != "v" {
+ t.Fatalf("got %q", b[:n])
+ }
+}
+
+type getOnly struct{ *DirStore }
+```
+(`getOnly` embeds `*DirStore`, so it has `Open` promoted — to make it a non-Opener, shadow it:)
+```go
+func (getOnly) Open() {} // different signature: getOnly is NOT an Opener
+```
+
+`internal/fleetapi/fleetapi_test.go`, add:
+```go
+func TestOpenStreamsThroughTheDoor(t *testing.T) {
+ d := newTestDoor(t)
+ body := bytes.Repeat([]byte("x"), 1<<20)
+ d.seed(t, "bootstrap/carlos", body)
+ c := d.box(t)
+ op, ok := c.(release.Opener)
+ if !ok {
+ t.Fatal("fleetapi store is not an Opener")
+ }
+ rc, _, err := op.Open(context.Background(), "bootstrap/carlos")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer rc.Close()
+ got, _ := io.ReadAll(rc)
+ if !bytes.Equal(got, body) {
+ t.Fatalf("read %d bytes, want %d", len(got), len(body))
+ }
+ if _, _, err := op.Open(context.Background(), "bootstrap/nothing"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("missing: %v", err)
+ }
+}
+```
+(`newTestDoor`, `seed`, `box` exist in that file, `fleetapi_test.go:52-105`; `bootstrap/carlos` is already readable by a box.)
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/release/ ./internal/fleetapi/ -run 'Fetch|Open' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: undefined `Opener`, `OpenObject`, `FetchToFile`, `ErrTooLarge`.
+
+- [ ] **Step 3: Implement**
+
+`internal/release/store.go`, after the `SizeLister` assertions (~line 190):
+```go
+// Opener is the optional streaming read. Store.Get returns the whole object
+// in memory, which is fine for a pointer and wrong for a platform binary on
+// a box under MemoryHigh=70%. Implemented by every production store; a test
+// fake that only has Get still works through OpenObject. The etag may be ""
+// (DirStore cannot know it without reading the file).
+type Opener interface {
+ Open(ctx context.Context, key string) (io.ReadCloser, string, error)
+}
+
+var (
+ _ Opener = (*DirStore)(nil)
+ _ Opener = (*S3Store)(nil)
+ _ Opener = prefixStore{}
+)
+
+// OpenObject streams key from s, buffering through Get when s cannot stream.
+func OpenObject(ctx context.Context, s Store, key string) (io.ReadCloser, string, error) {
+ if o, ok := s.(Opener); ok {
+ return o.Open(ctx, key)
+ }
+ b, etag, err := s.Get(ctx, key)
+ if err != nil {
+ return nil, "", err
+ }
+ return io.NopCloser(bytes.NewReader(b)), etag, nil
+}
+```
+`DirStore`:
+```go
+func (d *DirStore) Open(_ context.Context, key string) (io.ReadCloser, string, error) {
+ p, err := d.path(key)
+ if err != nil {
+ return nil, "", err
+ }
+ f, err := os.Open(p)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", fmt.Errorf("dirstore: %s: %w", key, os.ErrNotExist)
+ }
+ return nil, "", err
+ }
+ return f, "", nil
+}
+```
+`S3Store`:
+```go
+func (s *S3Store) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
+ out, err := s.Client.GetObject(ctx, &awss3.GetObjectInput{Bucket: &s.Bucket, Key: &key})
+ if err != nil {
+ if s3NotFound(err) {
+ return nil, "", fmt.Errorf("s3store: %s: %w", key, os.ErrNotExist)
+ }
+ return nil, "", err
+ }
+ return out.Body, aws.ToString(out.ETag), nil
+}
+```
+`prefixStore` (explicit, next to its `Get`):
+```go
+// Open is written out, not promoted: the embedded field is the interface,
+// and a promoted Open would forward the UNREBASED key past the prefix.
+func (p prefixStore) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
+ return OpenObject(ctx, p.Store, p.full(key))
+}
+```
+Add `"bytes"` and `"io"` to the imports if absent.
+
+`internal/fleetapi/fleetapi.go`, after `Get`:
+```go
+// Open streams one object. Unlike Get it does not buffer the body, which
+// is what a 25 MiB platform binary on a Pi needs; the 404 mapping is the
+// same as Get's so callers can errors.Is(err, os.ErrNotExist).
+func (s *store) Open(ctx context.Context, key string) (io.ReadCloser, string, error) {
+ req, err := s.newRequest(ctx, http.MethodGet, s.objectURL(key), nil)
+ if err != nil {
+ return nil, "", err
+ }
+ resp, err := s.client.Do(req)
+ if err != nil {
+ return nil, "", errorf("open %s: %w", key, err)
+ }
+ switch resp.StatusCode {
+ case http.StatusOK:
+ return resp.Body, resp.Header.Get("ETag"), nil
+ case http.StatusNotFound:
+ resp.Body.Close()
+ return nil, "", fmt.Errorf("fleetapi: %s: %w", key, os.ErrNotExist)
+ default:
+ resp.Body.Close()
+ return nil, "", errorf("open %s: unexpected status %d", key, resp.StatusCode)
+ }
+}
+
+var _ release.Opener = (*store)(nil)
+```
+
+`internal/release/fetch.go`:
+```go
+package release
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+)
+
+// ErrTooLarge: the object exceeded the caller's limit. The reader is cut at
+// limit+1 bytes, so an object EXACTLY at the limit is told from one over it.
+var ErrTooLarge = errors.New("store: object larger than the caller's limit")
+
+// FetchToFile streams key into dst with a running sha256, never holding the
+// object in memory: temp file, fsync, rename, directory fsync. Any failure
+// removes the temp and leaves dst untouched.
+func FetchToFile(ctx context.Context, s Store, key, dst string, limit int64, wantSHA256 string) (err error) {
+ rc, _, err := OpenObject(ctx, s, key)
+ if err != nil {
+ return err
+ }
+ defer rc.Close()
+ tmp := dst + ".tmp"
+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ f.Close()
+ os.Remove(tmp)
+ }
+ }()
+ h := sha256.New()
+ n, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(rc, limit+1))
+ if err != nil {
+ return fmt.Errorf("fetch %s: %w", key, err)
+ }
+ if n > limit {
+ return fmt.Errorf("fetch %s: %w (limit %d)", key, ErrTooLarge, limit)
+ }
+ if got := hex.EncodeToString(h.Sum(nil)); got != wantSHA256 {
+ return fmt.Errorf("fetch %s: sha256 %s does not match the signed %s", key, got, wantSHA256)
+ }
+ if err = f.Sync(); err != nil {
+ return err
+ }
+ if err = f.Close(); err != nil {
+ return err
+ }
+ if err = os.Rename(tmp, dst); err != nil {
+ return err
+ }
+ return syncDir(filepath.Dir(dst))
+}
+
+// syncDir makes a rename durable: the directory entry is what fsync(file)
+// does not cover.
+func syncDir(dir string) error {
+ d, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ return d.Sync()
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/release/ ./internal/fleetapi/ ./internal/edge/ -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: all `ok`. (`internal/edge` is included because its test fakes assign to `release.Store`; the interface did not change, so they still compile.)
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/ && git add internal/release/store.go internal/release/fetch.go internal/release/fetch_test.go internal/fleetapi/fleetapi.go internal/fleetapi/fleetapi_test.go && git commit -q -F - <<'EOF'
+release: stream an object to a file with a running sha
+
+Opener is a side-interface (48 test fakes implement Store; none break),
+implemented by DirStore, S3Store, prefixStore and the fleet door client.
+FetchToFile is what a box uses for a platform binary: limit+1 read, sha
+checked before rename, directory fsync after.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 3: Store-door grants for the pointer and artifact shapes
+
+**Files:**
+- Modify: `internal/edge/storedoor.go:383-397` (`aclRead`) and the doc comment above it (`:352-382`)
+- Modify: `internal/edge/storedoor_test.go` (`TestStoreDoorACLMatrix` rows at `:69+`, `TestStoreDoorGetServesOwnEstate` at `:365`, the prose in `TestDoneNeverServesCertMaterial`'s comment at `:519`)
+
+**Interfaces:**
+- Consumes: `aclRead(sqid, fleet, key string) bool`; `canonicalKey` already refuses any `..`/`.`/empty segment before the ACL runs, so traversal never reaches here.
+- Produces: `func systemReadKey(key string) bool` (unexported) — true for exactly `control/system/<c>/<ch>.json` and `bootstrap/router/<v>/<asset>`.
+
+- [ ] **Step 1: Add the failing matrix rows and GET cases**
+
+In `TestStoreDoorACLMatrix`'s table, after the `bootstrap/carlos.sig` row (`:84`):
+```go
+ {"control/system/router/stable.json", opRead, true, "the platform channel pointer (system update spec §3)"},
+ {"control/system/router/canary.json", opRead, true, "…and the canary one"},
+ {"control/system/router/stable.json", opWrite, false, "a box never writes a pointer"},
+ {"control/system/router", opRead, false, "the pointer shape is exactly three segments ending .json"},
+ {"control/system/router/stable.json/x", opRead, false, "…not four"},
+ {"control/system/router/stable.txt", opRead, false, "…and .json"},
+ {"bootstrap/router/v0.18.0/carlos-linux-arm64", opRead, true, "a mirrored platform artifact"},
+ {"bootstrap/router/v0.18.0/carlos-linux-arm64", opWrite, false, "a box must never publish the platform binary"},
+ {"bootstrap/router/v0.18.0", opRead, false, "the artifact shape is exactly three segments"},
+ {"bootstrap/router/v0.18.0/a/b", opRead, false, "…not four"},
+ {"bootstrap/router.sig", opRead, false, "nothing else under bootstrap/"},
+ {"bootstrap/router/../../x", opRead, false, "traversal is refused by canonicalKey before the ACL"},
+ {"control/system/../serial", opRead, false, "…on both prefixes"},
+```
+In `TestStoreDoorGetServesOwnEstate`, seed two more objects with `seedObject` and add their keys to the loop that expects 200:
+```go
+ seedObject(t, store, "control/system/router/stable.json", []byte(`{"payload":"x"}`))
+ seedObject(t, store, "bootstrap/router/v1/carlos-linux-arm64", []byte("ELF"))
+```
+Update the comment at `storedoor_test.go:519` (the allowlist prose) to add "`control/system/<c>/<ch>.json`, `bootstrap/router/<v>/<asset>`".
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/edge/ -run 'TestStoreDoorACLMatrix|TestStoreDoorGetServesOwnEstate' -count=1 2>&1); rc=$?; echo "$out" | tail -30; echo rc=$rc`
+Expected: FAIL on the four `want true` rows and the two new GET keys (404).
+
+- [ ] **Step 3: Implement**
+
+In `storedoor.go`, replace the `switch key` in `aclRead` with:
+```go
+ switch key {
+ case "control/serial", "bootstrap/carlos":
+ return true
+ }
+ if systemReadKey(key) {
+ return true
+ }
+```
+and add, below `aclRead`:
+```go
+// systemReadKey is the two shapes the system-update spec (§3) grants every
+// fleet box: its platform channel pointer and a mirrored platform artifact.
+// Shapes, not prefixes — exactly three segments each, so nothing else that
+// ever lands under control/ or bootstrap/ is readable by a box. Listing is
+// not granted (aclDir is unchanged), and canonicalKey has already refused
+// any "." or ".." segment before this runs.
+func systemReadKey(key string) bool {
+ if rest, ok := strings.CutPrefix(key, "control/system/"); ok {
+ comp, file, ok := strings.Cut(rest, "/")
+ return ok && comp != "" && file != "" && !strings.Contains(file, "/") &&
+ strings.HasSuffix(file, ".json") && len(file) > len(".json")
+ }
+ if rest, ok := strings.CutPrefix(key, "bootstrap/router/"); ok {
+ ver, asset, ok := strings.Cut(rest, "/")
+ return ok && ver != "" && asset != "" && !strings.Contains(asset, "/")
+ }
+ return false
+}
+```
+Extend the `aclRead` doc comment's allowlist enumeration (`:352-382`) with the two shapes and the sentence: "`bootstrap/carlos` stays until `update-platform-binary.sh` is retired (spec §10)."
+
+- [ ] **Step 4: Run the edge tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/edge/ -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: `ok`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/edge/ && git add internal/edge/storedoor.go internal/edge/storedoor_test.go && git commit -q -F - <<'EOF'
+edge: the store door grants a box its platform pointer and artifact
+
+Two exact shapes under control/system/ and bootstrap/router/, no
+listing, no writes. Without the pointer grant a door-backed fleet box
+would never see a platform promote.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+### Task 4: On-box state files (`internal/host/systemstate.go`)
+
+**Files:**
+- Create: `internal/host/systemstate.go`
+- Test: `internal/host/systemstate_test.go`
+
+**Interfaces:**
+- Consumes: nothing new.
+- Produces (package `host`, unexported unless noted):
+ ```go
+ const (
+ systemComponent = "router"
+ systemRequestMarker = "system-update-requested" // touched under dataDir
+ systemRequestEvery = time.Minute
+ systemArtifactLimit = int64(96 << 20)
+ systemOutcomeApplied = "applied"; systemOutcomeRolledBack = "rolled-back"; systemOutcomeRollbackFailed = "rollback-failed"
+ systemOutcomeRefused = "refused"; systemOutcomeDeferred = "deferred"; systemOutcomeNoted = "noted"
+ systemPhaseBackingUp = "backing-up"; systemPhaseInstalling = "installing"; systemPhaseInstalled = "installed"
+ systemPhaseUnits = "units"; systemPhaseRestarted = "restarted"; systemPhaseGated = "gated"
+ )
+ type systemRequest struct{ Version string; PromotedAt time.Time; NoOp bool; SeenAt time.Time }
+ type systemResult struct{ Channel, Version string; PromotedAt time.Time; Outcome, Reason string; At time.Time }
+ type systemApplied struct{ Version, Channel string; At time.Time }
+ type systemSeen map[string]time.Time
+ type systemJournal struct{ Phase, Target, PrevBuild string; UnitsAdded, UnitsRemoved []string; StartedAt time.Time }
+ func systemDataDir(dd string) string // <dd>/system/router
+ func systemRequestPath(dd string) string // <dd>/system/router/requested.json
+ func systemCachePath(dd, version string) string // <dd>/system/router/<version>/carlos
+ func systemRootDir(optDir string) string // <optDir>/system/router
+ func systemPrevPath(optDir string) string // <optDir>/carlos.prev
+ func systemBinaryPath(optDir string) string // <optDir>/carlos
+ func writeFileDurable(path string, data []byte, mode os.FileMode) error
+ func writeJSONDurable(path string, v any, mode os.FileMode) error
+ func readJSONFile(path string, v any) error // os.ErrNotExist passthrough
+ func readSystemResult(optDir string) (*systemResult, error)
+ func readSystemSeen(optDir string) (systemSeen, error) // missing file = empty map, nil error
+ func readSystemApplied(optDir string) (*systemApplied, error)
+ func readSystemJournal(optDir string) (*systemJournal, error)
+ func (r *systemResult) matches(channel, version string, promotedAt time.Time) bool
+ func (r *systemResult) terminal() bool // applied|rolled-back|rollback-failed|refused
+ func validSystemVersion(v string) bool // ^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$
+ ```
+ The root files are `result.json`, `seen.json`, `applied.json`, `journal.json` directly under `systemRootDir`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestSystemPathsAreDerivedNotTrusted(t *testing.T) {
+ if got := systemCachePath("/data", "v1.2.3"); got != "/data/system/router/v1.2.3/carlos" {
+ t.Fatalf("cache path = %s", got)
+ }
+ if got := systemRequestPath("/data"); got != "/data/system/router/requested.json" {
+ t.Fatalf("request path = %s", got)
+ }
+ if got := systemRootDir("/opt/carlos"); got != "/opt/carlos/system/router" {
+ t.Fatalf("root dir = %s", got)
+ }
+ for _, bad := range []string{"", ".", "..", "../x", "a/b", ".hidden", "v1 2", string(make([]byte, 65))} {
+ if validSystemVersion(bad) {
+ t.Errorf("validSystemVersion(%q) = true", bad)
+ }
+ }
+ for _, good := range []string{"v0.18.0", "66587fa", "a", "release-2026.09.06_rc1"} {
+ if !validSystemVersion(good) {
+ t.Errorf("validSystemVersion(%q) = false", good)
+ }
+ }
+}
+
+func TestWriteFileDurableLeavesWholeFileOrNothing(t *testing.T) {
+ dir := t.TempDir()
+ p := filepath.Join(dir, "journal.json")
+ if err := writeFileDurable(p, []byte("one"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := writeFileDurable(p, []byte("two"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ b, _ := os.ReadFile(p)
+ if string(b) != "two" {
+ t.Fatalf("content = %q", b)
+ }
+ if _, err := os.Stat(p + ".tmp"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatal("temp left behind")
+ }
+ st, _ := os.Stat(p)
+ if st.Mode().Perm() != 0o644 {
+ t.Fatalf("mode = %v", st.Mode().Perm())
+ }
+ // A directory that does not exist is an error, not a silent no-op.
+ if err := writeFileDurable(filepath.Join(dir, "no", "such", "f"), []byte("x"), 0o644); err == nil {
+ t.Fatal("wrote into a missing directory")
+ }
+}
+
+func TestSystemStateReadersRoundTrip(t *testing.T) {
+ opt := t.TempDir()
+ root := systemRootDir(opt)
+ if err := os.MkdirAll(root, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if r, err := readSystemResult(opt); err != nil || r != nil {
+ t.Fatalf("missing result: %v %v", r, err)
+ }
+ seen, err := readSystemSeen(opt)
+ if err != nil || len(seen) != 0 {
+ t.Fatalf("missing seen: %v %v", seen, err)
+ }
+ at := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
+ res := systemResult{Channel: "stable", Version: "v1", PromotedAt: at, Outcome: systemOutcomeRolledBack, Reason: "gate", At: at}
+ if err := writeJSONDurable(filepath.Join(root, "result.json"), res, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ got, err := readSystemResult(opt)
+ if err != nil || !got.matches("stable", "v1", at) || !got.terminal() {
+ t.Fatalf("result = %+v err=%v", got, err)
+ }
+ if got.matches("canary", "v1", at) || got.matches("stable", "v2", at) || got.matches("stable", "v1", at.Add(time.Second)) {
+ t.Fatal("matches ignores part of the tuple")
+ }
+ got.Outcome = systemOutcomeDeferred
+ if got.terminal() {
+ t.Fatal("deferred is not terminal")
+ }
+ got.Outcome = systemOutcomeNoted
+ if got.terminal() {
+ t.Fatal("noted is not terminal for the leg: the version may still differ next time")
+ }
+ seen["stable"] = at
+ if err := writeJSONDurable(filepath.Join(root, "seen.json"), seen, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ seen2, _ := readSystemSeen(opt)
+ if !seen2["stable"].Equal(at) {
+ t.Fatalf("seen = %v", seen2)
+ }
+ j := systemJournal{Phase: systemPhaseInstalled, Target: "v1", PrevBuild: "v0", UnitsAdded: []string{"a.path"}, StartedAt: at}
+ if err := writeJSONDurable(filepath.Join(root, "journal.json"), j, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ j2, err := readSystemJournal(opt)
+ if err != nil || j2.Phase != systemPhaseInstalled || j2.UnitsAdded[0] != "a.path" {
+ t.Fatalf("journal = %+v err=%v", j2, err)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestSystemPaths|TestWriteFileDurable|TestSystemStateReaders' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: undefined identifiers.
+
+- [ ] **Step 3: Implement `internal/host/systemstate.go`**
+
+```go
+package host
+
+import (
+ "encoding/json"
+ "errors"
+ "os"
+ "path/filepath"
+ "regexp"
+ "time"
+)
+
+// The on-box state of the platform self-update (spec §4, §6). Two homes:
+//
+// <data>/system/router/ — written by the sandboxed agent: the
+// request and the artifact cache. Root
+// reads exactly one file here and trusts
+// nothing in it beyond a version string.
+// <opt>/system/router/ — written by root: journal, seen (replay
+// watermarks), applied (the .prev
+// authority), result (what the agent
+// publishes). Root-owned, world-readable.
+const (
+ systemComponent = "router"
+ systemRequestMarker = "system-update-requested"
+ systemRequestEvery = time.Minute
+ systemArtifactLimit = int64(96 << 20)
+
+ systemOutcomeApplied = "applied"
+ systemOutcomeRolledBack = "rolled-back"
+ systemOutcomeRollbackFailed = "rollback-failed"
+ systemOutcomeRefused = "refused"
+ systemOutcomeDeferred = "deferred"
+ systemOutcomeNoted = "noted"
+
+ systemPhaseBackingUp = "backing-up"
+ systemPhaseInstalling = "installing"
+ systemPhaseInstalled = "installed"
+ systemPhaseUnits = "units"
+ systemPhaseRestarted = "restarted"
+ systemPhaseGated = "gated"
+)
+
+// systemRequest is the agent's hint to root: which promote it saw. No
+// channel (root's own host.env says), no key, no sha, no path (root derives
+// the cache path from the version and takes key and sha from the pointer).
+type systemRequest struct {
+ Version string `json:"version"`
+ PromotedAt time.Time `json:"promoted_at"`
+ NoOp bool `json:"noop"`
+ SeenAt time.Time `json:"seen_at"`
+}
+
+// systemResult is root's answer to the last request, keyed by the REQUEST's
+// tuple so a refusal of one promote never suppresses the next.
+type systemResult struct {
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ PromotedAt time.Time `json:"promoted_at"`
+ Outcome string `json:"outcome"`
+ Reason string `json:"reason,omitempty"`
+ At time.Time `json:"at"`
+}
+
+func (r *systemResult) matches(channel, version string, promotedAt time.Time) bool {
+ return r != nil && r.Channel == channel && r.Version == version && r.PromotedAt.Equal(promotedAt)
+}
+
+// terminal: outcomes the leg must not retry for the same pointer.
+func (r *systemResult) terminal() bool {
+ switch r.Outcome {
+ case systemOutcomeApplied, systemOutcomeRolledBack, systemOutcomeRollbackFailed, systemOutcomeRefused:
+ return true
+ }
+ return false
+}
+
+// systemApplied is the last build that PASSED A GATE on this box, forward or
+// rollback. It is the only thing that may authorise refreshing carlos.prev.
+type systemApplied struct {
+ Version string `json:"version"`
+ Channel string `json:"channel"`
+ At time.Time `json:"at"`
+}
+
+// systemSeen is the replay watermark per channel: the newest PromotedAt root
+// has verified. Advanced on every verified pointer, including no-ops.
+type systemSeen map[string]time.Time
+
+// systemJournal is the open transaction. Written before every box-changing
+// step, removed last; a run that finds one recovers before anything else.
+type systemJournal struct {
+ Phase string `json:"phase"`
+ Target string `json:"target"`
+ PrevBuild string `json:"prev_build"`
+ UnitsAdded []string `json:"units_added,omitempty"`
+ UnitsRemoved []string `json:"units_removed,omitempty"`
+ StartedAt time.Time `json:"started_at"`
+}
+
+var systemVersionRe = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
+
+// validSystemVersion is what makes the cache path derivable: no leading dot,
+// no separator, nothing that could be a traversal.
+func validSystemVersion(v string) bool { return systemVersionRe.MatchString(v) }
+
+func systemDataDir(dd string) string { return filepath.Join(dd, "system", systemComponent) }
+func systemRequestPath(dd string) string {
+ return filepath.Join(systemDataDir(dd), "requested.json")
+}
+func systemCachePath(dd, version string) string {
+ return filepath.Join(systemDataDir(dd), version, "carlos")
+}
+func systemRootDir(optDir string) string { return filepath.Join(optDir, "system", systemComponent) }
+func systemPrevPath(optDir string) string { return filepath.Join(optDir, "carlos.prev") }
+func systemBinaryPath(optDir string) string { return filepath.Join(optDir, "carlos") }
+
+// writeFileDurable: temp, fsync, rename, directory fsync. A crash leaves the
+// old file or the new one, never a torn one, and the rename itself is on
+// disk before the caller's next step depends on it.
+func writeFileDurable(path string, data []byte, mode os.FileMode) (err error) {
+ tmp := path + ".tmp"
+ f, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ f.Close()
+ os.Remove(tmp)
+ }
+ }()
+ if _, err = f.Write(data); err != nil {
+ return err
+ }
+ if err = f.Chmod(mode); err != nil {
+ return err
+ }
+ if err = f.Sync(); err != nil {
+ return err
+ }
+ if err = f.Close(); err != nil {
+ return err
+ }
+ if err = os.Rename(tmp, path); err != nil {
+ return err
+ }
+ d, err := os.Open(filepath.Dir(path))
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ return d.Sync()
+}
+
+func writeJSONDurable(path string, v any, mode os.FileMode) error {
+ b, err := json.MarshalIndent(v, "", " ")
+ if err != nil {
+ return err
+ }
+ return writeFileDurable(path, append(b, '\n'), mode)
+}
+
+// readJSONFile leaves os.ErrNotExist intact so callers can treat "no file"
+// as a state, not a failure.
+func readJSONFile(path string, v any) error {
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ return json.Unmarshal(b, v)
+}
+
+func readSystemResult(optDir string) (*systemResult, error) {
+ var r systemResult
+ err := readJSONFile(filepath.Join(systemRootDir(optDir), "result.json"), &r)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return &r, err
+}
+
+func readSystemSeen(optDir string) (systemSeen, error) {
+ s := systemSeen{}
+ err := readJSONFile(filepath.Join(systemRootDir(optDir), "seen.json"), &s)
+ if errors.Is(err, os.ErrNotExist) {
+ return s, nil
+ }
+ return s, err
+}
+
+func readSystemApplied(optDir string) (*systemApplied, error) {
+ var a systemApplied
+ err := readJSONFile(filepath.Join(systemRootDir(optDir), "applied.json"), &a)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return &a, err
+}
+
+func readSystemJournal(optDir string) (*systemJournal, error) {
+ var j systemJournal
+ err := readJSONFile(filepath.Join(systemRootDir(optDir), "journal.json"), &j)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return &j, err
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestSystemPaths|TestWriteFileDurable|TestSystemStateReaders' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: PASS.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/host/ && git add internal/host/systemstate.go internal/host/systemstate_test.go && git commit -q -F - <<'EOF'
+host: the on-box state of a platform self-update
+
+Request (agent-written hint), result, seen watermarks, applied, and
+the journal, with durable writes: temp, fsync, rename, directory
+fsync. Cache paths are derived from a validated version string.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 5: The heartbeat learns platform, channel and last apply outcome
+
+**Files:**
+- Modify: `internal/status/snapshot.go` — `Probes` (~`:98`, next to `Release`), `BoxMetrics` (~`:158`), `CollectBox` (~`:404`, where `Release: p.Release` is copied), the emptiness guard (~`:543`)
+- Modify: `internal/host/agent.go` `statusLoop` (~`:2318`, the `Release: version.Build,` line)
+- Create: `internal/host/systemprobe.go`
+- Test: `internal/status/snapshot_test.go` (add), `internal/host/systemprobe_test.go`
+
+**Interfaces:**
+- Consumes: `readSystemResult` (Task 4), `release.OptDir()`, `status.Probes`, `status.BoxMetrics`.
+- Produces:
+ ```go
+ // package status
+ type SystemState struct{ Channel, Version string; PromotedAt time.Time; Outcome, Reason string; At time.Time }
+ // Probes gains: Platform string; SystemChannel string; System *SystemState
+ // BoxMetrics gains (json): platform, system_channel, system
+ // package host
+ func systemChannel(getenv func(string) string) string // CARLOS_SYSTEM_CHANNEL, default "stable"
+ func systemStateProbe(optDir string) *status.SystemState // nil when no result.json
+ const systemChannelEnv = "CARLOS_SYSTEM_CHANNEL"
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+`internal/status/snapshot_test.go`, add:
+```go
+func TestBoxMetricsCarryThePlatformSelfUpdateState(t *testing.T) {
+ at := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
+ p := Probes{Release: "v1", Platform: "linux-arm64", SystemChannel: "canary",
+ System: &SystemState{Channel: "canary", Version: "v2", PromotedAt: at, Outcome: "rolled-back", Reason: "gate", At: at}}
+ b := CollectBox(nil, p, at)
+ if b.Metrics == nil || b.Metrics.Platform != "linux-arm64" || b.Metrics.SystemChannel != "canary" || b.Metrics.System == nil || b.Metrics.System.Outcome != "rolled-back" {
+ t.Fatalf("metrics = %+v", b.Metrics)
+ }
+ out, _ := json.Marshal(b)
+ for _, want := range []string{`"platform":"linux-arm64"`, `"system_channel":"canary"`, `"outcome":"rolled-back"`} {
+ if !strings.Contains(string(out), want) {
+ t.Errorf("json lacks %s: %s", want, out)
+ }
+ }
+ // A box with only the new fields is not "empty".
+ b2 := CollectBox(nil, Probes{Platform: "linux-arm64"}, at)
+ if b2.Metrics == nil {
+ t.Fatal("platform alone must produce a metrics block")
+ }
+ // The public fleet feed still never carries the metrics block.
+ f := BuildFleet([]Box{b}, at)
+ fb, _ := json.Marshal(f)
+ if strings.Contains(string(fb), "system_channel") || strings.Contains(string(fb), "rolled-back") {
+ t.Fatalf("fleet.json leaked the private metrics: %s", fb)
+ }
+}
+```
+(Check `BuildFleet`'s exact signature at `fleet.go:157` and adjust the call; it takes the box list and a time.)
+
+`internal/host/systemprobe_test.go`:
+```go
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestSystemChannelDefaultsToStable(t *testing.T) {
+ if got := systemChannel(func(string) string { return "" }); got != "stable" {
+ t.Fatalf("default = %q", got)
+ }
+ if got := systemChannel(func(k string) string {
+ if k == systemChannelEnv {
+ return " canary "
+ }
+ return ""
+ }); got != "canary" {
+ t.Fatalf("env = %q", got)
+ }
+}
+
+func TestSystemStateProbeReadsRootsResult(t *testing.T) {
+ opt := t.TempDir()
+ if got := systemStateProbe(opt); got != nil {
+ t.Fatalf("no result.json → %+v, want nil", got)
+ }
+ root := systemRootDir(opt)
+ os.MkdirAll(root, 0o755)
+ at := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
+ writeJSONDurable(filepath.Join(root, "result.json"), systemResult{Channel: "stable", Version: "v3", PromotedAt: at, Outcome: "applied", At: at}, 0o644)
+ got := systemStateProbe(opt)
+ if got == nil || got.Version != "v3" || got.Outcome != "applied" || !got.PromotedAt.Equal(at) {
+ t.Fatalf("probe = %+v", got)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/status/ ./internal/host/ -run 'TestBoxMetricsCarry|TestSystemChannel|TestSystemStateProbe' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: undefined fields/functions.
+
+- [ ] **Step 3: Implement**
+
+`internal/status/snapshot.go` — in `Probes`, after `Release string` (`:98`):
+```go
+ // Platform is runtime.GOOS-runtime.GOARCH, so the console knows which
+ // artifacts a platform promote must mirror. SystemChannel is the box's
+ // CARLOS_SYSTEM_CHANNEL, so the console knows which boxes a promote
+ // reaches. System is root's last self-update outcome (spec §5, §6).
+ Platform string
+ SystemChannel string
+ System *SystemState
+```
+Add the type near `BoxMetrics`:
+```go
+// SystemState is the last platform self-update outcome root recorded on the
+// box, published so `carlos system status` can show a rolled-back box with
+// its reason within one heartbeat.
+type SystemState struct {
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ PromotedAt time.Time `json:"promoted_at"`
+ Outcome string `json:"outcome"`
+ Reason string `json:"reason,omitempty"`
+ At time.Time `json:"at"`
+}
+```
+In `BoxMetrics`, after `Release` (`:158`):
+```go
+ Platform string `json:"platform,omitempty"` // runtime.GOOS-runtime.GOARCH
+ SystemChannel string `json:"system_channel,omitempty"` // CARLOS_SYSTEM_CHANNEL
+ System *SystemState `json:"system,omitempty"` // last self-update outcome
+```
+In `CollectBox`, where `Release: p.Release` is copied into the metrics block, add `Platform: p.Platform, SystemChannel: p.SystemChannel, System: p.System`. In the emptiness guard (`:543`), add `|| p.Platform != "" || p.SystemChannel != "" || p.System != nil`.
+
+`internal/host/systemprobe.go`:
+```go
+package host
+
+import (
+ "strings"
+
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// systemChannelEnv is the box's platform channel: canary or stable. Set in
+// host.env, read through the getenv seam like every other CARLOS_* value;
+// the ROOT apply reads it from its own unit environment, never from the
+// agent's request (spec §6 step 2).
+const systemChannelEnv = "CARLOS_SYSTEM_CHANNEL"
+
+func systemChannel(getenv func(string) string) string {
+ if c := strings.TrimSpace(getenv(systemChannelEnv)); c != "" {
+ return c
+ }
+ return "stable"
+}
+
+// systemStateProbe is root's result.json as the heartbeat carries it, or nil
+// when this box has never been asked to update.
+func systemStateProbe(optDir string) *status.SystemState {
+ r, err := readSystemResult(optDir)
+ if err != nil || r == nil {
+ return nil
+ }
+ return &status.SystemState{Channel: r.Channel, Version: r.Version, PromotedAt: r.PromotedAt, Outcome: r.Outcome, Reason: r.Reason, At: r.At}
+}
+```
+`internal/host/agent.go` `statusLoop`, next to `Release: version.Build,` (`:2318`):
+```go
+ Platform: runtime.GOOS + "-" + runtime.GOARCH,
+ SystemChannel: systemChannel(os.Getenv),
+ System: systemStateProbe(release.OptDir()),
+```
+(add `"runtime"` to the imports). Add `CARLOS_SYSTEM_CHANNEL` to the env contract in the package doc comment at the top of `agent.go`, one line: `CARLOS_SYSTEM_CHANNEL — platform channel this box follows for self-update, canary|stable (default stable)`.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/status/ ./internal/host/ -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: `ok` for both.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/ && git add internal/status/snapshot.go internal/status/snapshot_test.go internal/host/agent.go internal/host/systemprobe.go internal/host/systemprobe_test.go && git commit -q -F - <<'EOF'
+status: a box reports its platform, its system channel and its last self-update outcome
+
+Three optional fields on the private metrics block; the public fleet
+feed does not carry them, as before.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 6: The three units, `enabledUnits`, `-plan-json`, the path probe
+
+**Files:**
+- Create: `internal/host/units/carlos-system-update.path`, `internal/host/units/carlos-system-update.service`, `internal/host/units/carlos-system-recover.service`
+- Modify: `internal/host/bootstrap.go` (`enabledUnits` `:72`, `RunBootstrap` flags `:115-132`, the staging print block `:327-341`)
+- Modify: `internal/host/probes.go` (add a fourth `*Active()`)
+- Modify: `internal/host/host_test.go` (`expectedUnits` `:29-46`; `TestProbedUnitsAreTheOnesBootstrapArms` `:1129`)
+- Test: `internal/host/host_test.go` (add `TestBootstrapPlanJSON`)
+
+**Interfaces:**
+- Consumes: `unitsFS`, `unitNames`, `systemctlPlan`, `bootstrap(root, consumers)`, `parseIsActive`.
+- Produces:
+ ```go
+ // package host
+ var inertUnits = []string{"carlos-restore-verify.timer", "carlos-hibernate-verify.timer"}
+ type bootstrapPlan struct{ Enabled []string `json:"enabled"`; Inert []string `json:"inert"` }
+ func currentBootstrapPlan() bootstrapPlan
+ // `carlos bootstrap -root <dir> -plan-json` prints ONLY the JSON plan on stdout
+ const systemUpdatePathUnit = "carlos-system-update.path"
+ func systemUpdatePathActive() *bool
+ ```
+
+- [ ] **Step 1: Add the failing tests**
+
+In `host_test.go`, add the three names to `expectedUnits` (keep it alphabetical):
+```go
+ "carlos-system-recover.service",
+ "carlos-system-update.path",
+ "carlos-system-update.service",
+```
+and add:
+```go
+func TestBootstrapPlanJSONNamesWhatALiveRunArms(t *testing.T) {
+ p := currentBootstrapPlan()
+ want := map[string]bool{"carlos-edge.service": true, "carlos-adopt.timer": true, "carlos-adopt.path": true,
+ "carlos-stage.path": true, "carlos-restart.path": true, "carlos-system-update.path": true, "carlos-system-recover.service": true}
+ got := map[string]bool{}
+ for _, u := range p.Enabled {
+ got[u] = true
+ }
+ for u := range want {
+ if !got[u] {
+ t.Errorf("plan lacks %s", u)
+ }
+ }
+ if got["carlos-system-update.service"] {
+ t.Error("the path-triggered oneshot must not be enabled: it is started by its .path")
+ }
+ inert := map[string]bool{}
+ for _, u := range p.Inert {
+ inert[u] = true
+ }
+ if !inert["carlos-restore-verify.timer"] || !inert["carlos-hibernate-verify.timer"] {
+ t.Errorf("inert = %v", p.Inert)
+ }
+ // And systemctlPlan never starts a service, whatever is enabled.
+ for _, args := range systemctlPlan(p.Enabled) {
+ if args[0] == "start" {
+ for _, u := range args[1:] {
+ if strings.HasSuffix(u, ".service") {
+ t.Errorf("plan starts a service: %v", args)
+ }
+ }
+ }
+ }
+}
+
+func TestSystemUnitsAreShapedLikeTheStageRelay(t *testing.T) {
+ read := func(name string) string {
+ b, err := unitsFS.ReadFile("units/" + name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return string(b)
+ }
+ p := read("carlos-system-update.path")
+ for _, want := range []string{"PathChanged=/data/system-update-requested", "Unit=carlos-system-update.service", "WantedBy=multi-user.target"} {
+ if !strings.Contains(p, want) {
+ t.Errorf("path unit lacks %q", want)
+ }
+ }
+ u := read("carlos-system-update.service")
+ for _, want := range []string{"Type=oneshot", "StartLimitIntervalSec=0", "TimeoutStartSec=15min", "OnFailure=carlos-system-recover.service",
+ "EnvironmentFile=-/etc/carlos/host.env", "system apply", "carlos.prev", "MemoryMax=256M"} {
+ if !strings.Contains(u, want) {
+ t.Errorf("update unit lacks %q", want)
+ }
+ }
+ for _, never := range []string{"ProtectSystem", "User="} {
+ if strings.Contains(u, never) {
+ t.Errorf("update unit must run as root with /opt writable; found %q", never)
+ }
+ }
+ r := read("carlos-system-recover.service")
+ for _, want := range []string{"Type=oneshot", "Before=carlos-edge.service", "WantedBy=multi-user.target", "TimeoutStartSec=10min", "system apply --recover", "carlos.prev"} {
+ if !strings.Contains(r, want) {
+ t.Errorf("recover unit lacks %q", want)
+ }
+ }
+}
+```
+Update `TestProbedUnitsAreTheOnesBootstrapArms` so its probed set includes `systemUpdatePathUnit` (read the test; it compares probe constants against `enabledUnits`).
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestBootstrap|TestSystemUnits|TestProbedUnits' -count=1 2>&1); rc=$?; echo "$out" | tail -30; echo rc=$rc`
+Expected: FAIL (missing units, undefined `currentBootstrapPlan`).
+
+- [ ] **Step 3: Write the units**
+
+`internal/host/units/carlos-system-update.path`:
+```ini
+# The relay from the sandboxed agent to a root apply of a new platform
+# binary (spec §4). The agent (User=carlos, ProtectSystem=strict) can fetch
+# and verify the artifact but cannot write /opt or /etc; it touches this
+# marker and root does the rest. Same shape as carlos-stage.path.
+[Unit]
+Description=CARLOS platform self-update on agent request
+
+[Path]
+PathChanged=/data/system-update-requested
+Unit=carlos-system-update.service
+
+[Install]
+WantedBy=multi-user.target
+```
+
+`internal/host/units/carlos-system-update.service`:
+```ini
+# Root apply of a platform self-update (spec §6): re-verify the pointer,
+# copy the candidate into a root-owned dir, journal, swap the binary and
+# units, restart the edge, gate it, roll back to carlos.prev on failure.
+#
+# ExecStart goes through one line of shell so that when a journal is open
+# (a previous apply was cut off) and carlos.prev exists, the RECOVERY runs
+# from carlos.prev rather than from the candidate that did not work.
+#
+# No ProtectSystem, no User=: it must write /opt/carlos and
+# /etc/systemd/system. StartLimitIntervalSec=0 because the path unit can
+# fire in bursts. TimeoutStartSec bounds a hung apply; OnFailure= makes a
+# killed or timed-out apply recover without waiting for a touch.
+[Unit]
+Description=carlos platform self-update (root apply)
+After=network-online.target
+Wants=network-online.target
+StartLimitIntervalSec=0
+OnFailure=carlos-system-recover.service
+
+[Service]
+Type=oneshot
+TimeoutStartSec=15min
+EnvironmentFile=-/etc/carlos/host.env
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ]; then exec /opt/carlos/carlos.prev system apply; fi; exec /opt/carlos/carlos system apply'
+
+# --- hardening (the carlos-stage.service set) ---
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+MemoryMax=256M
+TasksMax=64
+```
+
+`internal/host/units/carlos-system-recover.service`:
+```ini
+# Recovery of an interrupted platform self-update (spec §6, "every run
+# begins with recovery"). Runs once per boot, BEFORE the edge, and on the
+# update oneshot's failure. It restores files only — carlos.prev over
+# carlos, the previous unit set — and never restarts or gates the edge
+# from inside a unit the edge is ordered after: at boot the edge's own
+# start IS the restart. At runtime (edge already started this boot) it
+# issues a non-blocking restart and exits.
+[Unit]
+Description=carlos platform self-update recovery
+Before=carlos-edge.service
+DefaultDependencies=yes
+
+[Service]
+Type=oneshot
+TimeoutStartSec=10min
+EnvironmentFile=-/etc/carlos/host.env
+ExecStart=/bin/sh -c 'if [ -f /opt/carlos/system/router/journal.json ] && [ -x /opt/carlos/carlos.prev ]; then exec /opt/carlos/carlos.prev system apply --recover; fi; exec /opt/carlos/carlos system apply --recover'
+
+ProtectHome=yes
+PrivateTmp=yes
+PrivateDevices=yes
+ProtectClock=yes
+ProtectHostname=yes
+ProtectKernelTunables=yes
+ProtectKernelModules=yes
+ProtectKernelLogs=yes
+RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
+RestrictNamespaces=yes
+RestrictRealtime=yes
+LockPersonality=yes
+MemoryDenyWriteExecute=yes
+MemoryMax=256M
+TasksMax=64
+
+[Install]
+WantedBy=multi-user.target
+```
+
+- [ ] **Step 4: `bootstrap.go` changes**
+
+Replace `enabledUnits` (`:72`):
+```go
+var enabledUnits = []string{"carlos-edge.service", "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path",
+ "carlos-system-update.path", "carlos-system-recover.service"}
+
+// inertUnits are installed everywhere and deliberately never armed by
+// bootstrap or by a self-update apply: restore-verify until an operator
+// stages litestream, hibernate-verify by long-standing choice.
+var inertUnits = []string{"carlos-restore-verify.timer", "carlos-hibernate-verify.timer"}
+
+// bootstrapPlan is what `bootstrap -root <dir> -plan-json` prints: the
+// arming list of THIS binary, so a self-update apply arms what the new
+// binary's bootstrap would arm and never a list an older binary remembers.
+type bootstrapPlan struct {
+ Enabled []string `json:"enabled"`
+ Inert []string `json:"inert"`
+}
+
+func currentBootstrapPlan() bootstrapPlan {
+ return bootstrapPlan{Enabled: append([]string(nil), enabledUnits...), Inert: append([]string(nil), inertUnits...)}
+}
+```
+In `RunBootstrap`, add the flag after `offcloud`:
+```go
+ planJSON := fs.Bool("plan-json", false, "with -root: print the arming plan as JSON on stdout instead of the human summary")
+```
+and pass it through: change `bootstrap(*root, consumers)` to `bootstrapWith(*root, consumers, *planJSON)`, where `bootstrap(root, consumers)` becomes `func bootstrap(root string, consumers []string) error { return bootstrapWith(root, consumers, false) }` and `bootstrapWith` is the old body with the staging print block (`:327-341`) wrapped:
+```go
+ if !live {
+ if planJSON {
+ return json.NewEncoder(os.Stdout).Encode(currentBootstrapPlan())
+ }
+ fmt.Printf("bootstrap: staged under %s — on a real box (--root /) it would also:\n", root)
+ …existing prints…
+ return nil
+ }
+```
+All the `fmt.Printf(" wrote %s\n", path)` / `" created %s\n"` lines in the staging path must go to **stderr** when `planJSON` is set, or the JSON is not alone on stdout: introduce `out := io.Writer(os.Stdout); if planJSON { out = os.Stderr }` at the top of `bootstrapWith` and change those `fmt.Printf` to `fmt.Fprintf(out, …)`. (`-plan-json` with `-root /` is refused: `log.Fatal("bootstrap: -plan-json is for a staging root")`.)
+
+`internal/host/probes.go`, alongside `restartPathActive`:
+```go
+const systemUpdatePathUnit = "carlos-system-update.path"
+
+// systemUpdatePathActive: is the self-update relay armed? nil = unknown.
+func systemUpdatePathActive() *bool {
+ out, _ := exec.Command("systemctl", "is-active", systemUpdatePathUnit).CombinedOutput()
+ return parseIsActive(string(out))
+}
+```
+(copy the exact shape of `restartPathActive` at `probes.go:82`, including how it runs `systemctl`).
+
+- [ ] **Step 5: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -count=1 2>&1); rc=$?; echo "$out" | tail -30; echo rc=$rc`
+Expected: `ok`. Then, as a real check of the flag: `go run . bootstrap -root /tmp/c/stage -plan-json 2>/dev/null | head -3` prints one JSON object with `enabled` and `inert`.
+
+- [ ] **Step 6: Commit**
+
+```bash
+gofmt -l internal/host/ && git add internal/host/units/carlos-system-update.path internal/host/units/carlos-system-update.service internal/host/units/carlos-system-recover.service internal/host/bootstrap.go internal/host/probes.go internal/host/host_test.go && git commit -q -F - <<'EOF'
+host: the self-update relay, apply and recover units, and a JSON arming plan
+
+bootstrap -root <dir> -plan-json prints this binary's enabledUnits and
+inert list, so an apply arms what the NEW binary's bootstrap would arm.
+Both oneshots exec carlos.prev when a journal is open.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+### Task 7: The agent leg (`internal/host/systemleg.go`)
+
+**Files:**
+- Create: `internal/host/systemleg.go`
+- Modify: `internal/host/agent.go` — `fastDeps` (`:1077`), `fastTick` (`:1330`; the serial-move branch `:1352-1367` and the unconditional part before `fastMembers` `:1370`), `fastConvergeLoop` (`:1434-1459`)
+- Test: `internal/host/systemleg_test.go`, `internal/host/agent_test.go` (`fastFixture.deps` at `:1098` may leave the new field nil)
+
+**Interfaces:**
+- Consumes: `release.GetSystemPointer`, `release.FetchToFile`, `release.SystemComponentRouter`, `release.OptDir`, `systemChannel`, `systemRequest`, `readSystemResult`, `readSystemSeen`, `systemCachePath`, `systemRequestPath`, `systemRequestMarker`, `systemRequestEvery`, `systemArtifactLimit`, `validSystemVersion`, `version.Build`.
+- Produces:
+ ```go
+ type systemLeg struct { … }
+ func newSystemLeg(store release.Store, pub ed25519.PublicKey, getenv func(string) string, dataDir, optDir string) *systemLeg
+ func (l *systemLeg) observe(ctx context.Context) // serial move / hourly: read pointer, decide, set pending
+ func (l *systemLeg) tick(ctx context.Context, now time.Time) // every 2 s: fire pending, re-touch, hourly re-observe
+ // fastDeps gains: systemLeg *systemLeg
+ ```
+ Test seams on the struct: `now func() time.Time`, `jitter func(time.Duration) time.Duration`, `build string`, `platform string`.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type legFixture struct {
+ t *testing.T
+ store *release.DirStore
+ priv ed25519.PrivateKey
+ leg *systemLeg
+ dd string
+ opt string
+ now time.Time
+}
+
+func newLegFixture(t *testing.T, channel, build string) *legFixture {
+ t.Helper()
+ priv, pub, _ := ed25519.GenerateKey(nil)
+ f := &legFixture{t: t, store: release.NewDirStore(t.TempDir()), priv: priv, dd: t.TempDir(), opt: t.TempDir(),
+ now: time.Date(2026, 9, 6, 10, 0, 0, 0, time.UTC)}
+ getenv := func(k string) string {
+ if k == systemChannelEnv {
+ return channel
+ }
+ return ""
+ }
+ f.leg = newSystemLeg(f.store, pub, getenv, f.dd, f.opt)
+ f.leg.build = build
+ f.leg.platform = "linux-arm64"
+ f.leg.now = func() time.Time { return f.now }
+ f.leg.jitter = func(d time.Duration) time.Duration { return d / 2 } // deterministic: half the spread
+ return f
+}
+
+func (f *legFixture) promote(version string, body []byte, spread time.Duration) {
+ f.t.Helper()
+ sum := sha256.Sum256(body)
+ rel := release.SystemRelease{Version: version, Artifacts: map[string]release.SystemArtifact{
+ "linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}}
+ if _, err := f.store.Put(context.Background(), rel.Artifacts["linux-arm64"].Key, body, ""); err != nil {
+ f.t.Fatal(err)
+ }
+ if _, err := release.PromoteSystem(context.Background(), f.store, release.RootSigner(f.priv), "router", f.leg.channel, rel,
+ release.SystemPromoteOptions{Spread: spread}); err != nil {
+ f.t.Fatal(err)
+ }
+}
+
+func (f *legFixture) marker() (os.FileInfo, error) { return os.Stat(filepath.Join(f.dd, systemRequestMarker)) }
+
+func (f *legFixture) request() *systemRequest {
+ var r systemRequest
+ if err := readJSONFile(systemRequestPath(f.dd), &r); err != nil {
+ return nil
+ }
+ return &r
+}
+
+func TestSystemLegDoesNothingWithoutAPointer(t *testing.T) {
+ f := newLegFixture(t, "stable", "v1")
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now.Add(time.Hour))
+ if _, err := f.marker(); err == nil {
+ t.Fatal("marker touched with no pointer")
+ }
+}
+
+func TestSystemLegFetchesVerifiesAndRequestsAfterTheSpread(t *testing.T) {
+ f := newLegFixture(t, "stable", "v1")
+ body := []byte("ELF v2")
+ f.promote("v2", body, 10*time.Minute)
+ f.leg.observe(context.Background())
+ if _, err := f.marker(); err == nil {
+ t.Fatal("requested before the spread elapsed")
+ }
+ f.leg.tick(context.Background(), f.now.Add(4*time.Minute)) // jitter = 5m
+ if _, err := f.marker(); err == nil {
+ t.Fatal("requested before the deadline")
+ }
+ f.leg.tick(context.Background(), f.now.Add(5*time.Minute+time.Second))
+ if _, err := f.marker(); err != nil {
+ t.Fatal("marker not touched after the deadline")
+ }
+ got, _ := os.ReadFile(systemCachePath(f.dd, "v2"))
+ if string(got) != "ELF v2" {
+ t.Fatalf("cache = %q", got)
+ }
+ r := f.request()
+ if r == nil || r.Version != "v2" || r.NoOp || r.PromotedAt.IsZero() {
+ t.Fatalf("request = %+v", r)
+ }
+ raw, _ := os.ReadFile(systemRequestPath(f.dd))
+ for _, never := range []string{"channel", "key", "sha", "path"} {
+ if strings.Contains(string(raw), `"`+never+`"`) {
+ t.Errorf("request carries %q — root must not take that from the sandbox", never)
+ }
+ }
+}
+
+func TestSystemLegRetouchesUntilRootAnswers(t *testing.T) {
+ f := newLegFixture(t, "stable", "v1")
+ f.promote("v2", []byte("x"), 0)
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now)
+ first, _ := f.marker()
+ f.leg.tick(context.Background(), f.now.Add(30*time.Second))
+ same, _ := f.marker()
+ if !same.ModTime().Equal(first.ModTime()) {
+ t.Fatal("re-touched inside the minute throttle")
+ }
+ // The touch is a rewrite (PathChanged needs a change), so mtime moves.
+ time.Sleep(20 * time.Millisecond)
+ f.leg.tick(context.Background(), f.now.Add(61*time.Second))
+ later, _ := f.marker()
+ if !later.ModTime().After(first.ModTime()) {
+ t.Fatal("not re-touched after a minute with no result")
+ }
+ // Root answers: a terminal result for this tuple stops the re-touch and
+ // the leg deletes its own cache.
+ r := f.request()
+ os.MkdirAll(systemRootDir(f.opt), 0o755)
+ writeJSONDurable(filepath.Join(systemRootDir(f.opt), "result.json"),
+ systemResult{Channel: "stable", Version: "v2", PromotedAt: r.PromotedAt, Outcome: systemOutcomeRolledBack, Reason: "gate", At: f.now}, 0o644)
+ time.Sleep(20 * time.Millisecond)
+ f.leg.tick(context.Background(), f.now.Add(3*time.Minute))
+ final, _ := f.marker()
+ if !final.ModTime().Equal(later.ModTime()) {
+ t.Fatal("re-touched after a terminal result")
+ }
+ if _, err := os.Stat(systemCachePath(f.dd, "v2")); err == nil {
+ t.Fatal("cache kept after a terminal result")
+ }
+ // And a later observe of the SAME pointer does nothing: refuse to repeat a failure.
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now.Add(10*time.Minute))
+ if f.leg.pending != nil {
+ t.Fatal("re-requested a pointer root already rolled back")
+ }
+}
+
+func TestSystemLegNoOpRequestFetchesNothing(t *testing.T) {
+ f := newLegFixture(t, "stable", "v2")
+ f.promote("v2", []byte("same"), 0)
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now)
+ r := f.request()
+ if r == nil || !r.NoOp {
+ t.Fatalf("request = %+v, want a no-op", r)
+ }
+ if _, err := os.Stat(systemCachePath(f.dd, "v2")); err == nil {
+ t.Fatal("a no-op must not download the artifact")
+ }
+ // Once root has seen it (seen.json advanced to this PromotedAt), nothing more.
+ os.MkdirAll(systemRootDir(f.opt), 0o755)
+ writeJSONDurable(filepath.Join(systemRootDir(f.opt), "seen.json"), systemSeen{"stable": r.PromotedAt}, 0o644)
+ f.leg.pending = nil
+ os.Remove(filepath.Join(f.dd, systemRequestMarker))
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now.Add(2*time.Minute))
+ if _, err := f.marker(); err == nil {
+ t.Fatal("requested again after root recorded the watermark")
+ }
+}
+
+func TestSystemLegIgnoresDevBuildEqualityAndOtherPlatforms(t *testing.T) {
+ f := newLegFixture(t, "stable", "dev")
+ f.promote("v2", []byte("x"), 0)
+ f.leg.platform = "linux-amd64" // no artifact for me
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now)
+ if _, err := f.marker(); err == nil {
+ t.Fatal("requested with no artifact for this platform")
+ }
+}
+
+func TestSystemLegRefusesShaMismatchAndOversize(t *testing.T) {
+ f := newLegFixture(t, "stable", "v1")
+ f.promote("v2", []byte("good"), 0)
+ // Swap the object under the signed sha.
+ f.store.Put(context.Background(), release.SystemArtifactKey("v2", "linux-arm64"), []byte("evil"), "")
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now)
+ if _, err := f.marker(); err == nil {
+ t.Fatal("requested an artifact whose sha does not match")
+ }
+ if _, err := os.Stat(systemCachePath(f.dd, "v2")); err == nil {
+ t.Fatal("mismatched artifact left in the cache")
+ }
+}
+
+func TestSystemLegIgnoresAPointerOlderThanTheWatermark(t *testing.T) {
+ f := newLegFixture(t, "stable", "v1")
+ f.promote("v2", []byte("x"), 0)
+ os.MkdirAll(systemRootDir(f.opt), 0o755)
+ writeJSONDurable(filepath.Join(systemRootDir(f.opt), "seen.json"), systemSeen{"stable": time.Now().Add(time.Hour)}, 0o644)
+ f.leg.observe(context.Background())
+ f.leg.tick(context.Background(), f.now)
+ if _, err := f.marker(); err == nil {
+ t.Fatal("requested a pointer root would refuse as a replay")
+ }
+}
+
+func TestFastTickRunsTheSystemLeg(t *testing.T) {
+ fx := newFastFixture(t)
+ d := fx.deps(nil)
+ observes := 0
+ d.systemLeg = &systemLeg{observeHook: func() { observes++ }}
+ ctx := context.Background()
+ fastTick(ctx, d, time.Now()) // boot seed
+ if observes != 0 {
+ t.Fatalf("boot seed observed %d times", observes)
+ }
+ release.BumpSerial(ctx, fx.store, time.Now())
+ fastTick(ctx, d, time.Now().Add(10*time.Second))
+ if observes != 1 {
+ t.Fatalf("one serial move → %d observes", observes)
+ }
+}
+```
+(`observeHook func()` is a test seam on `systemLeg`: when non-nil, `observe` calls it and returns. Add `"crypto/ed25519"` to the imports.)
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestSystemLeg|TestFastTickRunsTheSystemLeg' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: undefined `systemLeg`, `newSystemLeg`.
+
+- [ ] **Step 3: Implement `internal/host/systemleg.go`**
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "errors"
+ "log"
+ "math/rand/v2"
+ "os"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/version"
+)
+
+// systemLeg is the sandboxed half of the platform self-update (spec §4):
+// notice the channel pointer, fetch and verify the artifact into /data, ask
+// root. Two entry points so the spread has something to run it: observe on
+// a serial move (and hourly), tick every 2 s with no store read.
+type systemLeg struct {
+ store release.Store
+ pub ed25519.PublicKey
+ channel string
+ platform string
+ build string
+ dataDir string
+ optDir string
+
+ now func() time.Time
+ jitter func(time.Duration) time.Duration
+
+ pending *systemPending
+ lastTouch time.Time
+ lastObserve time.Time
+ backoffUntil time.Time
+ backoff time.Duration
+
+ observeHook func() // test seam
+}
+
+type systemPending struct {
+ version string
+ promotedAt time.Time
+ noop bool
+ artifact release.SystemArtifact
+ deadline time.Time
+ requested bool
+}
+
+const systemObserveEvery = time.Hour
+
+func newSystemLeg(store release.Store, pub ed25519.PublicKey, getenv func(string) string, dataDir, optDir string) *systemLeg {
+ return &systemLeg{
+ store: store, pub: pub, channel: systemChannel(getenv),
+ platform: runtime.GOOS + "-" + runtime.GOARCH, build: version.Build,
+ dataDir: dataDir, optDir: optDir,
+ now: time.Now,
+ jitter: func(spread time.Duration) time.Duration { return rand.N(spread + 1) },
+ }
+}
+
+// observe reads the pointer and decides. It never blocks on a download:
+// that happens in tick, after the spread.
+func (l *systemLeg) observe(ctx context.Context) {
+ if l.observeHook != nil {
+ l.observeHook()
+ return
+ }
+ now := l.now()
+ l.lastObserve = now
+ p, _, err := release.GetSystemPointer(ctx, l.store, l.pub, release.SystemComponentRouter, l.channel)
+ if err != nil {
+ if !errors.Is(err, os.ErrNotExist) {
+ log.Printf("agent: system pointer %s: %v", l.channel, err)
+ }
+ return
+ }
+ seen, _ := readSystemSeen(l.optDir)
+ if wm, ok := seen[l.channel]; ok && p.PromotedAt.Before(wm) {
+ return // root would refuse it as a replay
+ }
+ if res, _ := readSystemResult(l.optDir); res.matches(l.channel, p.Current.Version, p.PromotedAt) && res.terminal() {
+ l.forget(p.Current.Version)
+ return // refuse to repeat a failure
+ }
+ noop := p.Current.Version == l.build
+ if noop {
+ if wm, ok := seen[l.channel]; ok && !p.PromotedAt.After(wm) {
+ return // nothing to do, and root has seen this promote
+ }
+ }
+ art, ok := p.Current.Artifacts[l.platform]
+ if !ok && !noop {
+ if l.pending == nil || l.pending.version != p.Current.Version {
+ log.Printf("agent: system pointer %s names %s with no artifact for %s — not for this box", l.channel, p.Current.Version, l.platform)
+ }
+ return
+ }
+ if l.pending != nil && l.pending.version == p.Current.Version && l.pending.promotedAt.Equal(p.PromotedAt) {
+ return // already pending; keep its deadline
+ }
+ l.pending = &systemPending{version: p.Current.Version, promotedAt: p.PromotedAt, noop: noop, artifact: art,
+ deadline: now.Add(l.jitter(p.SpreadDuration()))}
+ if noop {
+ l.pending.deadline = now
+ }
+}
+
+// tick fires a pending request whose deadline has passed and re-touches an
+// unanswered one every minute. It reads no store except for the one fetch.
+func (l *systemLeg) tick(ctx context.Context, now time.Time) {
+ if now.Sub(l.lastObserve) >= systemObserveEvery {
+ l.observe(ctx)
+ }
+ p := l.pending
+ if p == nil {
+ return
+ }
+ if res, _ := readSystemResult(l.optDir); res.matches(l.channel, p.version, p.promotedAt) {
+ switch {
+ case res.terminal():
+ l.forget(p.version)
+ return
+ case res.Outcome == systemOutcomeNoted:
+ l.pending = nil
+ return
+ case res.Outcome == systemOutcomeDeferred && res.At.After(p.deadline):
+ // root could not reach the store; try again with backoff
+ if l.backoff == 0 {
+ l.backoff = fastBackoffBase
+ } else if l.backoff < fastBackoffCap {
+ l.backoff *= 2
+ }
+ p.deadline = now.Add(l.backoff)
+ p.requested = false
+ }
+ }
+ if now.Before(p.deadline) || now.Before(l.backoffUntil) {
+ return
+ }
+ if !p.requested {
+ if !p.noop {
+ if err := l.fetch(ctx, p); err != nil {
+ log.Printf("agent: system update %s: %v", p.version, err)
+ os.Remove(systemCachePath(l.dataDir, p.version))
+ l.backoffUntil = now.Add(fastBackoffBase)
+ return
+ }
+ }
+ if err := l.writeRequest(p, now); err != nil {
+ log.Printf("agent: system update %s: request: %v", p.version, err)
+ return
+ }
+ p.requested = true
+ }
+ if now.Sub(l.lastTouch) >= systemRequestEvery {
+ if err := os.WriteFile(filepath.Join(l.dataDir, systemRequestMarker), []byte(now.UTC().Format(time.RFC3339Nano)+"\n"), 0o644); err != nil {
+ log.Printf("agent: system update %s: cannot ask for a root apply: %v", p.version, err)
+ return
+ }
+ l.lastTouch = now
+ }
+}
+
+func (l *systemLeg) fetch(ctx context.Context, p *systemPending) error {
+ dst := systemCachePath(l.dataDir, p.version)
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+ return release.FetchToFile(ctx, l.store, p.artifact.Key, dst, systemArtifactLimit, p.artifact.SHA256)
+}
+
+func (l *systemLeg) writeRequest(p *systemPending, now time.Time) error {
+ if err := os.MkdirAll(systemDataDir(l.dataDir), 0o755); err != nil {
+ return err
+ }
+ return writeJSONDurable(systemRequestPath(l.dataDir), systemRequest{Version: p.version, PromotedAt: p.promotedAt, NoOp: p.noop, SeenAt: now.UTC()}, 0o644)
+}
+
+// forget: root has answered with a terminal outcome. Drop the pending item
+// and the cache — root never deletes under /data, the agent does.
+func (l *systemLeg) forget(version string) {
+ l.pending = nil
+ l.backoff = 0
+ if validSystemVersion(version) {
+ os.RemoveAll(filepath.Dir(systemCachePath(l.dataDir, version)))
+ }
+}
+```
+
+`agent.go` wiring:
+- `fastDeps` gains `systemLeg *systemLeg // the platform self-update leg (spec §4); nil = not armed`.
+- In `fastTick`, inside the serial-move branch after the `scheduleLeg` call (`:1365`): `if d.systemLeg != nil { d.systemLeg.observe(ctx) }`.
+- Before `members := d.fastMembers(now)` (`:1370`), outside the switch: `if d.systemLeg != nil { d.systemLeg.tick(ctx, now) }`.
+- In `fastConvergeLoop`, after `d.scheduleLeg = scheduleLegFor(sd)` (`:1506`): `d.systemLeg = newSystemLeg(store, pub, os.Getenv, dd, optDir)` and extend the "armed" log line with `+ system pointer`.
+- The `adoptLoop` doc comment's DO-NOT list (`:566-600`) gets one sentence: the system leg lives in the fast loop and re-observes hourly on its own; nothing is added here.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: `ok`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/host/ && git add internal/host/systemleg.go internal/host/systemleg_test.go internal/host/agent.go internal/host/agent_test.go && git commit -q -F - <<'EOF'
+host: the agent notices a platform promote, fetches, verifies, and asks root
+
+observe on a serial move (and hourly), tick every 2 s: spread jitter,
+streamed fetch with the signed sha, a request carrying only version
+and promoted_at, and a marker re-touched every minute until root
+answers. A pointer root already rolled back is never re-requested.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+### Task 8a: `carlos system apply` — the refuse path (steps 1–5, no-op, lock)
+
+**Files:**
+- Create: `internal/host/systemapply.go`
+- Test: `internal/host/systemapply_test.go`
+
+**Interfaces:**
+- Consumes: Task 4 state helpers; `release.GetSystemPointer`; `release.SystemComponentRouter`; `systemChannel`.
+- Produces (package `host`):
+ ```go
+ type sysOps struct {
+ systemctl func(args ...string) (string, error)
+ run func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error)
+ now func() time.Time
+ sleep func(time.Duration)
+ probeEdge func() error
+ edgeStartedThisBoot func() bool
+ logf func(format string, args ...any)
+ }
+ type systemApplier struct {
+ ops sysOps
+ store release.Store
+ pub ed25519.PublicKey
+ channel string
+ optDir, dataDir string
+ etcDir string // "/etc" in production; a temp dir in tests
+ build string // version.Build of THIS process
+ gateWait, soak time.Duration // 20s, 15s in production
+ }
+ func (a *systemApplier) Run(ctx context.Context, recover bool) error
+ const (
+ systemQuiesceUnits = "carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-adopt.service carlos-config-sync.service carlos-stage.service carlos-restart.service"
+ systemEdgeUnit = "carlos-edge"
+ )
+ func (a *systemApplier) rootDir() string; stagingDir() string; lockPath() string
+ func (a *systemApplier) writeResult(req systemRequest, outcome, reason string) error
+ func (a *systemApplier) readRequest() (systemRequest, error) // O_NOFOLLOW walk, 4 KiB cap, validSystemVersion
+ func (a *systemApplier) verifyPointer(ctx) (*release.SystemPointer, error)
+ func (a *systemApplier) stageCandidate(p *release.SystemPointer, req systemRequest) (string, error) // path of staging/carlos
+ func openBeneath(root string, parts ...string) (*os.File, error) // per-component O_DIRECTORY|O_NOFOLLOW, then O_NOFOLLOW
+ var errSystemRefused = errors.New("refused"); var errSystemDeferred = errors.New("deferred")
+ ```
+ In this task `Run` handles: lock → (recovery: Task 8c, stub as "journal present → return error") → read request → verify pointer + replay guard → advance seen → no-op → stage candidate → **stop with `errNotYetImplemented`** (Task 8b continues). `Run` returns nil on `noted`/`refused`/`deferred` after writing `result.json`; exit codes are `RunSystemApply`'s business (8c).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "errors"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// applyFixture is a fake box: temp opt/data/etc roots, a DirStore with a
+// signed pointer, and a sysOps that records every call and answers like a
+// healthy systemd. The candidate "binary" is answered by the fake run.
+type applyFixture struct {
+ t *testing.T
+ a *systemApplier
+ store *release.DirStore
+ priv ed25519.PrivateKey
+ calls []string // "systemctl is-active carlos-edge", "run <base> version", …
+ answer map[string]string // "version:<path>" → stdout; "plan:<path>" → JSON plan
+ now time.Time
+ edgeUp bool
+ nrestarts string
+ activeEnter string
+ probeErr error
+ edgeStartedThisBoot bool
+}
+
+func newApplyFixture(t *testing.T, channel, build string) *applyFixture {
+ t.Helper()
+ priv, pub, _ := ed25519.GenerateKey(nil)
+ f := &applyFixture{t: t, store: release.NewDirStore(t.TempDir()), priv: priv, answer: map[string]string{},
+ now: time.Date(2026, 9, 6, 11, 0, 0, 0, time.UTC), edgeUp: true, nrestarts: "0", activeEnter: "1000", edgeStartedThisBoot: true}
+ f.a = &systemApplier{
+ store: f.store, pub: pub, channel: channel,
+ optDir: t.TempDir(), dataDir: t.TempDir(), etcDir: t.TempDir(), build: build,
+ }
+ f.a.ops = sysOps{
+ systemctl: func(args ...string) (string, error) {
+ f.calls = append(f.calls, "systemctl "+strings.Join(args, " "))
+ switch {
+ case args[0] == "is-active" && args[1] == systemEdgeUnit:
+ if f.edgeUp {
+ return "active\n", nil
+ }
+ return "failed\n", errors.New("inactive")
+ case args[0] == "show":
+ return f.nrestarts + "\n" + f.activeEnter + "\n", nil
+ }
+ return "", nil
+ },
+ run: func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
+ f.calls = append(f.calls, "run "+filepath.Base(name)+" "+strings.Join(args, " "))
+ if len(args) == 1 && args[0] == "version" {
+ if v, ok := f.answer["version:"+name]; ok {
+ return v, nil
+ }
+ return "", errors.New("no such binary")
+ }
+ if len(args) >= 3 && args[0] == "bootstrap" && args[1] == "-root" {
+ plan, ok := f.answer["plan:"+name]
+ if !ok {
+ return "", errors.New("bootstrap failed")
+ }
+ units := strings.Split(f.answer["units:"+name], " ")
+ dir := filepath.Join(args[2], "etc", "systemd", "system")
+ os.MkdirAll(dir, 0o755)
+ for _, u := range units {
+ if u != "" {
+ os.WriteFile(filepath.Join(dir, u), []byte("[Unit]\n# "+filepath.Base(name)+"\n"), 0o644)
+ }
+ }
+ os.MkdirAll(filepath.Join(args[2], "etc", "sysusers.d"), 0o755)
+ os.WriteFile(filepath.Join(args[2], "etc", "sysusers.d", "carlos.conf"), []byte("g carlos-sockets -\n"), 0o644)
+ os.MkdirAll(filepath.Join(args[2], "etc", "tmpfiles.d"), 0o755)
+ os.WriteFile(filepath.Join(args[2], "etc", "tmpfiles.d", "carlos.conf"), []byte("d /run/carlos\n"), 0o644)
+ os.MkdirAll(filepath.Join(args[2], "data"), 0o755)
+ os.WriteFile(filepath.Join(args[2], "data", "registry.db"), nil, 0o640)
+ return plan, nil
+ }
+ return "", errors.New("unexpected command")
+ },
+ now: func() time.Time { return f.now },
+ sleep: func(d time.Duration) { f.now = f.now.Add(d) },
+ probeEdge: func() error { return f.probeErr },
+ edgeStartedThisBoot: func() bool { return f.edgeStartedThisBoot },
+ logf: t.Logf,
+ }
+ os.MkdirAll(systemRootDir(f.a.optDir), 0o755)
+ os.MkdirAll(filepath.Join(f.a.etcDir, "systemd", "system"), 0o755)
+ // The on-disk binary answers `version` with build.
+ os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos "+build+"\n"), 0o755)
+ f.answer["version:"+systemBinaryPath(f.a.optDir)] = "carlos " + build + "\n"
+ f.answer["plan:"+systemBinaryPath(f.a.optDir)] = `{"enabled":["carlos-edge.service","carlos-adopt.timer","carlos-adopt.path","carlos-stage.path","carlos-restart.path","carlos-system-update.path","carlos-system-recover.service"],"inert":["carlos-restore-verify.timer"]}`
+ f.answer["units:"+systemBinaryPath(f.a.optDir)] = "carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-update.service carlos-system-recover.service carlos-old-only.timer"
+ return f
+}
+
+// promote signs a pointer for f.a.channel naming version with body as the
+// linux-arm64 artifact, stages body into the agent's cache, and writes the
+// request the agent would have written. Returns the request.
+func (f *applyFixture) promote(version string, body []byte, noop bool) systemRequest {
+ f.t.Helper()
+ sum := sha256.Sum256(body)
+ rel := release.SystemRelease{Version: version, Artifacts: map[string]release.SystemArtifact{
+ "linux-arm64": {Key: release.SystemArtifactKey(version, "linux-arm64"), SHA256: hex.EncodeToString(sum[:])}}}
+ f.store.Put(context.Background(), rel.Artifacts["linux-arm64"].Key, body, "")
+ p, err := release.PromoteSystem(context.Background(), f.store, release.RootSigner(f.priv), "router", f.a.channel, rel, release.SystemPromoteOptions{})
+ if err != nil {
+ f.t.Fatal(err)
+ }
+ req := systemRequest{Version: version, PromotedAt: p.PromotedAt, NoOp: noop, SeenAt: f.now}
+ os.MkdirAll(systemDataDir(f.a.dataDir), 0o755)
+ writeJSONDurable(systemRequestPath(f.a.dataDir), req, 0o644)
+ if !noop {
+ os.MkdirAll(filepath.Dir(systemCachePath(f.a.dataDir, version)), 0o755)
+ os.WriteFile(systemCachePath(f.a.dataDir, version), body, 0o644)
+ }
+ return req
+}
+
+func (f *applyFixture) result() *systemResult { r, _ := readSystemResult(f.a.optDir); return r }
+
+func (f *applyFixture) called(prefix string) bool {
+ for _, c := range f.calls {
+ if strings.HasPrefix(c, prefix) {
+ return true
+ }
+ }
+ return false
+}
+
+func TestApplyNoRequestIsNothingToDo(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if f.result() != nil || len(f.calls) != 0 {
+ t.Fatalf("result=%+v calls=%v", f.result(), f.calls)
+ }
+}
+
+func TestApplyLockKeepsTwoRunsApart(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ f.promote("v2", []byte("x"), false)
+ // Hold the lock as another process would.
+ held, err := os.OpenFile(f.a.lockPath(), os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer held.Close()
+ if err := flockExclusiveNonblock(held); err != nil {
+ t.Fatal(err)
+ }
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatalf("a locked-out path run must exit 0 quietly: %v", err)
+ }
+ if f.result() != nil {
+ t.Fatal("a locked-out run wrote a result")
+ }
+}
+
+func TestApplyRequestIsAHintNotAnInstruction(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ // A symlinked parent under /data.
+ os.MkdirAll(filepath.Join(f.a.dataDir, "elsewhere"), 0o755)
+ os.Symlink(filepath.Join(f.a.dataDir, "elsewhere"), filepath.Join(f.a.dataDir, "system"))
+ os.MkdirAll(filepath.Join(f.a.dataDir, "elsewhere", "router"), 0o755)
+ os.WriteFile(filepath.Join(f.a.dataDir, "elsewhere", "router", "requested.json"), []byte(`{"version":"v2"}`), 0o644)
+ if _, err := f.a.readRequest(); err == nil {
+ t.Fatal("followed a symlinked parent under /data")
+ }
+ os.Remove(filepath.Join(f.a.dataDir, "system"))
+ // A symlinked request file.
+ os.MkdirAll(systemDataDir(f.a.dataDir), 0o755)
+ os.WriteFile(filepath.Join(f.a.dataDir, "real.json"), []byte(`{"version":"v2"}`), 0o644)
+ os.Symlink(filepath.Join(f.a.dataDir, "real.json"), systemRequestPath(f.a.dataDir))
+ if _, err := f.a.readRequest(); err == nil {
+ t.Fatal("followed a symlinked request")
+ }
+ os.Remove(systemRequestPath(f.a.dataDir))
+ // A traversal version.
+ writeJSONDurable(systemRequestPath(f.a.dataDir), systemRequest{Version: "../../etc/passwd", PromotedAt: f.now}, 0o644)
+ if _, err := f.a.readRequest(); err == nil {
+ t.Fatal("accepted a traversal version")
+ }
+ // Oversized.
+ os.WriteFile(systemRequestPath(f.a.dataDir), []byte(`{"version":"`+strings.Repeat("a", 5000)+`"}`), 0o644)
+ if _, err := f.a.readRequest(); err == nil {
+ t.Fatal("accepted a 5 KiB request")
+ }
+}
+
+func TestApplyChannelComesFromRootNotTheRequest(t *testing.T) {
+ // The box is on stable; a valid CANARY pointer exists and the agent
+ // requests its version. Root reads control/system/router/stable.json
+ // (missing) and refuses — a signed canary promote does not authorise a
+ // stable box.
+ f := newApplyFixture(t, "stable", "v1")
+ f.a.channel = "canary"
+ req := f.promote("v2", []byte("x"), false) // signs the CANARY pointer
+ f.a.channel = "stable"
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeRefused || !r.matches("stable", "v2", req.PromotedAt) {
+ t.Fatalf("result = %+v", r)
+ }
+ if _, err := os.Stat(systemBinaryPath(f.a.optDir) + ".prev"); err == nil {
+ t.Fatal("touched the box")
+ }
+}
+
+func TestApplyStaleTupleIsRefusedAgainstTheRequestsTuple(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ old := f.promote("v2", []byte("x"), false)
+ time.Sleep(1100 * time.Millisecond)
+ f.promote("v3", []byte("y"), false)
+ // Put the OLD request back: the agent's spread started under v2.
+ writeJSONDurable(systemRequestPath(f.a.dataDir), old, 0o644)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeRefused || r.Version != "v2" || !r.PromotedAt.Equal(old.PromotedAt) {
+ t.Fatalf("result = %+v (must name the REQUEST's tuple so v3 is not suppressed)", r)
+ }
+}
+
+func TestApplyReplayGuardIsPerChannelAndEqualIsAllowed(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ req := f.promote("v2", []byte("x"), true)
+ // Older than the watermark → refused, seen unchanged.
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "seen.json"), systemSeen{"stable": req.PromotedAt.Add(time.Hour), "canary": time.Time{}}, 0o644)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeRefused || !strings.Contains(r.Reason, "replay") {
+ t.Fatalf("result = %+v", r)
+ }
+ // Equal → allowed (a retry of the same promote).
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "seen.json"), systemSeen{"stable": req.PromotedAt}, 0o644)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeNoted {
+ t.Fatalf("result = %+v", r)
+ }
+ // Another channel's newer watermark does not block this channel.
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "seen.json"), systemSeen{"canary": req.PromotedAt.Add(time.Hour)}, 0o644)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeNoted {
+ t.Fatalf("result = %+v", r)
+ }
+ seen, _ := readSystemSeen(f.a.optDir)
+ if !seen["stable"].Equal(req.PromotedAt) {
+ t.Fatalf("seen not advanced: %v", seen)
+ }
+}
+
+func TestApplyNoOpNotesAndTouchesNothing(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v2")
+ f.promote("v2", []byte("x"), true)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeNoted {
+ t.Fatalf("result = %+v", r)
+ }
+ if _, err := os.Stat(f.a.stagingDir()); err == nil {
+ t.Fatal("a no-op created staging/")
+ }
+ if len(f.calls) != 0 {
+ t.Fatalf("a no-op ran commands: %v", f.calls)
+ }
+}
+
+func TestApplyStoreErrorIsDeferredNotRefused(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ req := f.promote("v2", []byte("x"), false)
+ f.a.store = failingStore{f.store}
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeDeferred || !r.matches("stable", "v2", req.PromotedAt) {
+ t.Fatalf("result = %+v", r)
+ }
+}
+
+type failingStore struct{ release.Store }
+
+func (failingStore) Get(context.Context, string) ([]byte, string, error) {
+ return nil, "", errors.New("door restarting")
+}
+
+func TestApplyStagesTheCandidateFromARootOwnedCopy(t *testing.T) {
+ f := newApplyFixture(t, "stable", "v1")
+ body := []byte("#!/bin/sh\necho carlos v2\n")
+ f.promote("v2", body, false)
+ f.answer["version:"+filepath.Join(f.a.stagingDir(), "carlos")] = "carlos v2\n"
+ p, err := f.a.verifyPointer(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ req, _ := f.a.readRequest()
+ staged, err := f.a.stageCandidate(p, req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, _ := os.Stat(staged)
+ if st.Mode().Perm() != 0o755 {
+ t.Fatalf("staged mode = %v after verification", st.Mode().Perm())
+ }
+ if dst, _ := os.Stat(f.a.stagingDir()); dst.Mode().Perm() != 0o700 {
+ t.Fatalf("staging dir mode = %v", dst.Mode().Perm())
+ }
+ // Swapping the cache after staging changes nothing about the copy.
+ os.WriteFile(systemCachePath(f.a.dataDir, "v2"), []byte("evil"), 0o644)
+ got, _ := os.ReadFile(staged)
+ if string(got) != string(body) {
+ t.Fatal("staged copy follows the cache")
+ }
+ // A sha mismatch is refused BEFORE the copy is made executable and
+ // staging/ is cleared.
+ f2 := newApplyFixture(t, "stable", "v1")
+ f2.promote("v2", body, false)
+ os.WriteFile(systemCachePath(f2.a.dataDir, "v2"), []byte("evil"), 0o644)
+ p2, _ := f2.a.verifyPointer(context.Background())
+ req2, _ := f2.a.readRequest()
+ if _, err := f2.a.stageCandidate(p2, req2); err == nil || !strings.Contains(err.Error(), "sha256") {
+ t.Fatalf("err = %v", err)
+ }
+ // A candidate that lies about its version is refused.
+ f3 := newApplyFixture(t, "stable", "v1")
+ f3.promote("v2", body, false)
+ f3.answer["version:"+filepath.Join(f3.a.stagingDir(), "carlos")] = "carlos v9\n"
+ p3, _ := f3.a.verifyPointer(context.Background())
+ req3, _ := f3.a.readRequest()
+ if _, err := f3.a.stageCandidate(p3, req3); err == nil || !strings.Contains(err.Error(), "reports") {
+ t.Fatalf("err = %v", err)
+ }
+ // A symlinked cache file is refused.
+ f4 := newApplyFixture(t, "stable", "v1")
+ f4.promote("v2", body, false)
+ os.Remove(systemCachePath(f4.a.dataDir, "v2"))
+ os.WriteFile(filepath.Join(f4.a.dataDir, "real"), body, 0o644)
+ os.Symlink(filepath.Join(f4.a.dataDir, "real"), systemCachePath(f4.a.dataDir, "v2"))
+ p4, _ := f4.a.verifyPointer(context.Background())
+ req4, _ := f4.a.readRequest()
+ if _, err := f4.a.stageCandidate(p4, req4); err == nil {
+ t.Fatal("followed a symlinked cache file")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestApply' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: undefined `systemApplier`, `sysOps`, `flockExclusiveNonblock`.
+
+- [ ] **Step 3: Implement the refuse path in `internal/host/systemapply.go`**
+
+```go
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+ "syscall"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The root half of the platform self-update (spec §6). Runs as root from
+// carlos-system-update.service (or --recover from carlos-system-recover).
+// Every box-changing step is journaled first; every run begins with
+// recovery; root reads ONE file under /data and treats it as a hint.
+
+const (
+ systemEdgeUnit = "carlos-edge"
+ systemLockFile = "lock"
+ systemMaxRequest = 4 << 10
+ systemGateWait = 20 * time.Second
+ systemGateSoak = 15 * time.Second
+ systemVersionWait = 30 * time.Second
+ systemBootstrapWait = 120 * time.Second
+)
+
+// systemQuiesceUnits are the root units that exec /opt/carlos/carlos
+// against the running edge. Stopped (services too — stopping a timer does
+// not stop the pass it already started) before the rename, re-armed after
+// the gate.
+var systemQuiesceUnits = []string{
+ "carlos-adopt.timer", "carlos-adopt.path", "carlos-stage.path", "carlos-restart.path",
+ "carlos-adopt.service", "carlos-config-sync.service", "carlos-stage.service", "carlos-restart.service",
+}
+
+var (
+ errSystemRefused = errors.New("refused")
+ errSystemDeferred = errors.New("deferred")
+ errNotYetImplemented = errors.New("system apply: install path not implemented")
+)
+
+// sysOps is the seam between the apply and the box: systemctl, exec with a
+// deadline, the clock, sleep, the edge probe. Tests record and answer.
+type sysOps struct {
+ systemctl func(args ...string) (string, error)
+ run func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error)
+ now func() time.Time
+ sleep func(time.Duration)
+ probeEdge func() error
+ edgeStartedThisBoot func() bool
+ logf func(format string, args ...any)
+}
+
+type systemApplier struct {
+ ops sysOps
+ store release.Store
+ pub ed25519.PublicKey
+ channel string
+ optDir string
+ dataDir string
+ etcDir string
+ build string
+ gateWait, soak time.Duration
+}
+
+func (a *systemApplier) rootDir() string { return systemRootDir(a.optDir) }
+func (a *systemApplier) stagingDir() string { return filepath.Join(a.rootDir(), "staging") }
+func (a *systemApplier) lockPath() string { return filepath.Join(a.rootDir(), systemLockFile) }
+func (a *systemApplier) file(name string) string { return filepath.Join(a.rootDir(), name) }
+
+// Run is the whole oneshot. It returns an error only for a failure the
+// caller should exit non-zero on (an apply that rolled back, a rollback
+// that failed, a broken box); refusals and no-ops are recorded in
+// result.json and return nil.
+func (a *systemApplier) Run(ctx context.Context, recover bool) error {
+ if err := os.MkdirAll(a.rootDir(), 0o755); err != nil {
+ return err
+ }
+ lock, err := os.OpenFile(a.lockPath(), os.O_CREATE|os.O_RDWR, 0o600)
+ if err != nil {
+ return err
+ }
+ defer lock.Close()
+ if recover {
+ if err := flockExclusive(lock); err != nil {
+ return err
+ }
+ } else if err := flockExclusiveNonblock(lock); err != nil {
+ a.ops.logf("system apply: another apply is running; leaving it to finish")
+ return nil
+ }
+ defer os.RemoveAll(a.stagingDir())
+
+ if err := a.recoverIfNeeded(ctx); err != nil {
+ return err
+ }
+ if recover {
+ return nil
+ }
+ req, err := a.readRequest()
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ a.ops.logf("system apply: request: %v", err)
+ return nil // nothing to record against: the request itself is unreadable
+ }
+ p, err := a.verifyPointer(ctx)
+ if err != nil {
+ return a.record(req, err)
+ }
+ if req.Version != p.Current.Version || !req.PromotedAt.Equal(p.PromotedAt) {
+ return a.record(req, fmt.Errorf("%w: request names %s@%s, pointer is %s@%s", errSystemRefused,
+ req.Version, req.PromotedAt.Format(time.RFC3339), p.Current.Version, p.PromotedAt.Format(time.RFC3339)))
+ }
+ seen, err := readSystemSeen(a.optDir)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: seen.json unreadable: %v", errSystemRefused, err))
+ }
+ if wm, ok := seen[a.channel]; ok && p.PromotedAt.Before(wm) {
+ return a.record(req, fmt.Errorf("%w: replay — pointer promoted %s is older than the %s watermark %s", errSystemRefused,
+ p.PromotedAt.Format(time.RFC3339), a.channel, wm.Format(time.RFC3339)))
+ }
+ seen[a.channel] = p.PromotedAt
+ if err := writeJSONDurable(a.file("seen.json"), seen, 0o644); err != nil {
+ return err
+ }
+ if req.NoOp || p.Current.Version == a.onDiskBuild(ctx) {
+ return a.writeResult(req, systemOutcomeNoted, "")
+ }
+ staged, err := a.stageCandidate(p, req)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err))
+ }
+ return a.install(ctx, req, p, staged) // Task 8b
+}
+
+func (a *systemApplier) install(ctx context.Context, req systemRequest, p *release.SystemPointer, staged string) error {
+ return errNotYetImplemented
+}
+
+func (a *systemApplier) recoverIfNeeded(ctx context.Context) error {
+ j, err := readSystemJournal(a.optDir)
+ if err != nil || j == nil {
+ return err
+ }
+ return fmt.Errorf("system apply: journal open at %s — recovery not implemented", j.Phase) // Task 8c
+}
+
+// record turns a refusal or deferral into result.json and a nil return.
+func (a *systemApplier) record(req systemRequest, err error) error {
+ outcome := systemOutcomeRefused
+ if errors.Is(err, errSystemDeferred) {
+ outcome = systemOutcomeDeferred
+ }
+ a.ops.logf("system apply: %s %s: %s: %v", a.channel, req.Version, outcome, err)
+ return a.writeResult(req, outcome, err.Error())
+}
+
+func (a *systemApplier) writeResult(req systemRequest, outcome, reason string) error {
+ return writeJSONDurable(a.file("result.json"), systemResult{
+ Channel: a.channel, Version: req.Version, PromotedAt: req.PromotedAt,
+ Outcome: outcome, Reason: reason, At: a.ops.now().UTC(),
+ }, 0o644)
+}
+
+// readRequest is the one read under /data: a fixed path reached without
+// following a single symlink, a regular file under 4 KiB, a version that
+// passes validSystemVersion. Anything else is an error, and root acts on
+// nothing.
+func (a *systemApplier) readRequest() (systemRequest, error) {
+ f, err := openBeneath(a.dataDir, "system", systemComponent, "requested.json")
+ if err != nil {
+ return systemRequest{}, err
+ }
+ defer f.Close()
+ st, err := f.Stat()
+ if err != nil {
+ return systemRequest{}, err
+ }
+ if !st.Mode().IsRegular() || st.Size() > systemMaxRequest {
+ return systemRequest{}, fmt.Errorf("request: not a regular file under %d bytes", systemMaxRequest)
+ }
+ var req systemRequest
+ if err := json.NewDecoder(io.LimitReader(f, systemMaxRequest)).Decode(&req); err != nil {
+ return systemRequest{}, fmt.Errorf("request: %w", err)
+ }
+ if !validSystemVersion(req.Version) {
+ return systemRequest{}, fmt.Errorf("request: version %q is not a version", req.Version)
+ }
+ if req.PromotedAt.IsZero() {
+ return systemRequest{}, errors.New("request: promoted_at unset")
+ }
+ return req, nil
+}
+
+// openBeneath opens root/parts... one component at a time with
+// O_DIRECTORY|O_NOFOLLOW for every directory and O_NOFOLLOW for the leaf,
+// so no component under a user-owned tree can be a symlink.
+func openBeneath(root string, parts ...string) (*os.File, error) {
+ fd, err := syscall.Open(root, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_CLOEXEC, 0)
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", root, err)
+ }
+ for i, part := range parts {
+ flags := syscall.O_RDONLY | syscall.O_NOFOLLOW | syscall.O_CLOEXEC
+ if i < len(parts)-1 {
+ flags |= syscall.O_DIRECTORY
+ }
+ next, err := syscall.Openat(fd, part, flags, 0)
+ syscall.Close(fd)
+ if err != nil {
+ return nil, fmt.Errorf("open %s: %w", filepath.Join(append([]string{root}, parts[:i+1]...)...), err)
+ }
+ fd = next
+ }
+ return os.NewFile(uintptr(fd), filepath.Join(append([]string{root}, parts...)...)), nil
+}
+
+// verifyPointer fetches THIS box's channel pointer and verifies it with
+// the release public key. The channel is root's own (host.env), never the
+// request's. A store error is deferred, a bad pointer refused.
+func (a *systemApplier) verifyPointer(ctx context.Context) (*release.SystemPointer, error) {
+ p, _, err := release.GetSystemPointer(ctx, a.store, a.pub, release.SystemComponentRouter, a.channel)
+ switch {
+ case err == nil:
+ return p, nil
+ case errors.Is(err, os.ErrNotExist):
+ return nil, fmt.Errorf("%w: no %s pointer for this box's channel", errSystemRefused, a.channel)
+ case strings.Contains(err.Error(), "system pointer"):
+ return nil, fmt.Errorf("%w: %v", errSystemRefused, err)
+ default:
+ return nil, fmt.Errorf("%w: %v", errSystemDeferred, err)
+ }
+}
+
+// onDiskBuild asks the installed binary what it is, with a deadline. Not
+// version.Build: after an interrupted apply the two can differ.
+func (a *systemApplier) onDiskBuild(ctx context.Context) string {
+ out, err := a.ops.run(ctx, systemVersionWait, systemBinaryPath(a.optDir), "version")
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(out), "carlos "))
+}
+
+// stageCandidate copies the agent's cache into staging/ (0700) as 0600,
+// verifies the copy's sha against the pointer, makes it executable, and
+// runs its `version`. Nothing under /data is read after the copy.
+func (a *systemApplier) stageCandidate(p *release.SystemPointer, req systemRequest) (string, error) {
+ art, ok := p.Current.Artifacts[a.platform()]
+ if !ok {
+ return "", fmt.Errorf("pointer names no artifact for %s", a.platform())
+ }
+ os.RemoveAll(a.stagingDir())
+ if err := os.MkdirAll(a.stagingDir(), 0o700); err != nil {
+ return "", err
+ }
+ if err := os.Chmod(a.stagingDir(), 0o700); err != nil {
+ return "", err
+ }
+ src, err := openBeneath(a.dataDir, "system", systemComponent, req.Version, "carlos")
+ if err != nil {
+ return "", fmt.Errorf("candidate: %w", err)
+ }
+ defer src.Close()
+ if st, err := src.Stat(); err != nil || !st.Mode().IsRegular() {
+ return "", errors.New("candidate: not a regular file")
+ }
+ staged := filepath.Join(a.stagingDir(), "carlos")
+ dst, err := os.OpenFile(staged, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return "", err
+ }
+ h := sha256.New()
+ n, err := io.Copy(io.MultiWriter(dst, h), io.LimitReader(src, systemArtifactLimit+1))
+ dst.Close()
+ if err != nil {
+ return "", err
+ }
+ if n > systemArtifactLimit {
+ return "", fmt.Errorf("candidate: larger than %d bytes", systemArtifactLimit)
+ }
+ if got := hex.EncodeToString(h.Sum(nil)); got != art.SHA256 {
+ return "", fmt.Errorf("candidate: sha256 %s does not match the signed %s", got, art.SHA256)
+ }
+ if err := os.Chmod(staged, 0o755); err != nil {
+ return "", err
+ }
+ out, err := a.ops.run(context.Background(), systemVersionWait, staged, "version")
+ if err != nil || !strings.Contains(out, "carlos "+p.Current.Version) {
+ return "", fmt.Errorf("candidate reports %q, not %s", strings.TrimSpace(out), p.Current.Version)
+ }
+ return staged, nil
+}
+
+func (a *systemApplier) platform() string { return runtime.GOOS + "-" + runtime.GOARCH }
+
+func flockExclusive(f *os.File) error { return syscall.Flock(int(f.Fd()), syscall.LOCK_EX) }
+func flockExclusiveNonblock(f *os.File) error { return syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) }
+```
+Add `"runtime"` to the imports. Tests that stage a candidate need `platform()` to be `linux-arm64`: make `platform` a field set in the constructor (`platform string`, default `runtime.GOOS+"-"+runtime.GOARCH`) rather than a method, and have the fixture set `f.a.platform = "linux-arm64"`. Do that; update the test fixture accordingly.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestApply' -count=1 2>&1); rc=$?; echo "$out" | tail -30; echo rc=$rc`
+Expected: PASS for every test in this task.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/host/ && git add internal/host/systemapply.go internal/host/systemapply_test.go && git commit -q -F - <<'EOF'
+host: carlos system apply — the refuse path
+
+Lock, one O_NOFOLLOW-walked request under /data, the channel from
+root's own env, the pointer re-verified, the per-channel replay
+watermark, the no-op, and the candidate copied into a 0700 staging
+dir and verified there before it is made executable.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 8b: `carlos system apply` — install, gate, arm, record (steps 6–12)
+
+**Files:**
+- Modify: `internal/host/systemapply.go` (replace the `install` stub)
+- Test: `internal/host/systemapply_test.go` (add)
+
+**Interfaces:**
+- Consumes: `systemctlPlan`, `bootstrapPlan`, `systemQuiesceUnits`, the fixture from 8a.
+- Produces:
+ ```go
+ func (a *systemApplier) install(ctx, req, p, staged) error
+ func (a *systemApplier) decidePrev(ctx) (prevExists bool, err error) // journal backing-up, copy when authorised
+ func (a *systemApplier) stagePlan(ctx, binary, dir string) (bootstrapPlan, []string, error) // runs bootstrap -root dir -plan-json; returns plan + unit file names
+ func (a *systemApplier) journal(j systemJournal) error
+ func (a *systemApplier) installUnits(stagedRoot string, remove []string) error // remove, copy units, sysusers, tmpfiles, daemon-reload
+ func (a *systemApplier) restartEdge() error // reset-failed then restart
+ func (a *systemApplier) gate(ctx, target string) error
+ func (a *systemApplier) arm(plan bootstrapPlan) error // systemctlPlan verbatim
+ func (a *systemApplier) rollback(ctx, req, reason string, j *systemJournal) error // Task 8c; stub returns the reason as an error
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+Add to `systemapply_test.go`:
+```go
+// applyHappy sets up a v1 box with a v2 candidate whose fake bootstrap
+// plan adds carlos-new-only.path and drops carlos-old-only.timer.
+func applyHappy(t *testing.T) (*applyFixture, systemRequest) {
+ f := newApplyFixture(t, "stable", "v1")
+ body := []byte("#!/bin/sh\necho carlos v2\n")
+ req := f.promote("v2", body, false)
+ staged := filepath.Join(f.a.stagingDir(), "carlos")
+ f.answer["version:"+staged] = "carlos v2\n"
+ f.answer["plan:"+staged] = `{"enabled":["carlos-edge.service","carlos-adopt.timer","carlos-adopt.path","carlos-stage.path","carlos-restart.path","carlos-system-update.path","carlos-system-recover.service","carlos-new-only.path"],"inert":["carlos-restore-verify.timer"]}`
+ f.answer["units:"+staged] = "carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-update.service carlos-system-recover.service carlos-new-only.path"
+ // .prev, once made, answers as v1 and stages v1's units.
+ prev := systemPrevPath(f.a.optDir)
+ f.answer["version:"+prev] = "carlos v1\n"
+ f.answer["plan:"+prev] = f.answer["plan:"+systemBinaryPath(f.a.optDir)]
+ f.answer["units:"+prev] = f.answer["units:"+systemBinaryPath(f.a.optDir)]
+ // After the install the on-disk binary answers v2.
+ f.answer["version:"+systemBinaryPath(f.a.optDir)] = "carlos v2\n"
+ return f, req
+}
+
+func TestApplyInstallsGatesArmsAndRecords(t *testing.T) {
+ f, req := applyHappy(t)
+ if err := f.a.Run(context.Background(), false); err != nil {
+ t.Fatal(err)
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeApplied || !r.matches("stable", "v2", req.PromotedAt) {
+ t.Fatalf("result = %+v", r)
+ }
+ ap, _ := readSystemApplied(f.a.optDir)
+ if ap == nil || ap.Version != "v2" || ap.Channel != "stable" {
+ t.Fatalf("applied = %+v", ap)
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j != nil {
+ t.Fatalf("journal left open: %+v", j)
+ }
+ if _, err := os.Stat(f.a.stagingDir()); err == nil {
+ t.Fatal("staging/ left behind")
+ }
+ bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ if !strings.Contains(string(bin), "carlos v2") {
+ t.Fatal("binary not swapped")
+ }
+ prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
+ if !strings.Contains(string(prev), "carlos v1") {
+ t.Fatal(".prev is not the old binary")
+ }
+ units := filepath.Join(f.a.etcDir, "systemd", "system")
+ if _, err := os.Stat(filepath.Join(units, "carlos-new-only.path")); err != nil {
+ t.Error("new unit not installed")
+ }
+ if _, err := os.Stat(filepath.Join(units, "carlos-old-only.timer")); err == nil {
+ t.Error("removed unit still installed")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "sysusers.d", "carlos.conf")); err != nil {
+ t.Error("sysusers not installed")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "tmpfiles.d", "carlos.conf")); err != nil {
+ t.Error("tmpfiles not installed")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "registry.db")); err == nil {
+ t.Error("staging data/ copied")
+ }
+ // Order of the systemctl calls is the spec.
+ joined := strings.Join(f.calls, "\n")
+ order := []string{
+ "systemctl stop carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-adopt.service carlos-config-sync.service carlos-stage.service carlos-restart.service",
+ "systemctl disable --now carlos-old-only.timer",
+ "run systemd-sysusers",
+ "run systemd-tmpfiles --create",
+ "systemctl daemon-reload",
+ "systemctl reset-failed carlos-edge",
+ "systemctl restart carlos-edge",
+ "systemctl is-active carlos-edge",
+ "systemctl daemon-reload",
+ "systemctl enable carlos-edge.service carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-system-recover.service carlos-new-only.path",
+ "systemctl start carlos-adopt.timer carlos-adopt.path carlos-stage.path carlos-restart.path carlos-system-update.path carlos-new-only.path",
+ }
+ at := 0
+ for _, want := range order {
+ i := strings.Index(joined[at:], want)
+ if i < 0 {
+ t.Fatalf("call order broken: %q not found after position %d in:\n%s", want, at, joined)
+ }
+ at += i + len(want)
+ }
+ for _, c := range f.calls {
+ if strings.HasPrefix(c, "systemctl start ") && strings.Contains(c, ".service") {
+ t.Fatalf("apply started a service: %s", c)
+ }
+ }
+ if strings.Contains(joined, "systemctl enable --now") {
+ t.Fatal("enable --now is never used")
+ }
+}
+
+func TestApplyGateSoakCatchesAFlap(t *testing.T) {
+ f, req := applyHappy(t)
+ // Edge comes up, then restarts once during the soak.
+ restarts := 0
+ orig := f.a.ops.systemctl
+ f.a.ops.systemctl = func(args ...string) (string, error) {
+ if args[0] == "show" {
+ restarts++
+ if restarts > 1 {
+ return "1\n2000\n", nil
+ }
+ }
+ return orig(args...)
+ }
+ err := f.a.Run(context.Background(), false)
+ if err == nil {
+ t.Fatal("a flapping edge passed the gate")
+ }
+ r := f.result()
+ if r == nil || !r.matches("stable", "v2", req.PromotedAt) || r.Outcome == systemOutcomeApplied {
+ t.Fatalf("result = %+v", r)
+ }
+}
+
+func TestApplyPrevIsRefreshedOnlyWhenTheDiskIsKnownGood(t *testing.T) {
+ // applied.json names v0 but the disk says v1 (a rollback-failed left a
+ // bad v1 in place, .prev is the good v0): .prev must be left alone.
+ f, _ := applyHappy(t)
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
+ f.answer["version:"+systemPrevPath(f.a.optDir)] = "carlos v0\n"
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v0", Channel: "stable", At: f.now}, 0o644)
+ _ = f.a.Run(context.Background(), false)
+ prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
+ if !strings.Contains(string(prev), "carlos v0") {
+ t.Fatal(".prev was overwritten by a binary that never passed a gate")
+ }
+}
+
+func TestApplyPrevUnitsComeFromPrevNotFromDisk(t *testing.T) {
+ f, _ := applyHappy(t)
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v0\n"), 0o755)
+ f.answer["version:"+systemPrevPath(f.a.optDir)] = "carlos v0\n"
+ f.answer["plan:"+systemPrevPath(f.a.optDir)] = `{"enabled":["carlos-edge.service"],"inert":[]}`
+ f.answer["units:"+systemPrevPath(f.a.optDir)] = "carlos-edge.service carlos-v0-only.timer"
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v0", Channel: "stable", At: f.now}, 0o644)
+ // Inspect the journal mid-flight by failing the gate.
+ f.edgeUp = false
+ _ = f.a.Run(context.Background(), false)
+ // units_removed must be computed against .prev's set (v0-only.timer),
+ // not the on-disk v1's (old-only.timer).
+ found := false
+ for _, c := range f.calls {
+ if c == "run carlos.prev bootstrap -root "+filepath.Join(f.a.stagingDir(), "prev")+" -plan-json" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("prev units not staged from carlos.prev:\n%s", strings.Join(f.calls, "\n"))
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestApplyInstalls|TestApplyGate|TestApplyPrev' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: FAIL with `errNotYetImplemented`.
+
+- [ ] **Step 3: Implement the install path**
+
+Replace the `install` stub and add the helpers:
+```go
+func (a *systemApplier) install(ctx context.Context, req systemRequest, p *release.SystemPointer, staged string) error {
+ target := p.Current.Version
+ prevOK, err := a.decidePrev(ctx)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: %v", errSystemRefused, err))
+ }
+ if !prevOK {
+ return a.record(req, fmt.Errorf("%w: no known-good carlos.prev to fall back to", errSystemRefused))
+ }
+ newDir, prevDir := filepath.Join(a.stagingDir(), "new"), filepath.Join(a.stagingDir(), "prev")
+ newPlan, newUnits, err := a.stagePlan(ctx, staged, newDir)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: candidate bootstrap: %v", errSystemRefused, err))
+ }
+ prevPlan, prevUnits, err := a.stagePlan(ctx, systemPrevPath(a.optDir), prevDir)
+ if err != nil {
+ return a.record(req, fmt.Errorf("%w: carlos.prev bootstrap: %v", errSystemRefused, err))
+ }
+ j := systemJournal{Phase: systemPhaseInstalling, Target: target, PrevBuild: a.prevBuild(ctx),
+ UnitsAdded: diff(newUnits, prevUnits), UnitsRemoved: diff(prevUnits, newUnits), StartedAt: a.ops.now().UTC()}
+ if err := a.journal(j); err != nil {
+ return err
+ }
+ fail := func(step string, err error) error {
+ return a.rollback(ctx, req, fmt.Sprintf("%s: %v", step, err), &j, prevPlan)
+ }
+ if _, err := a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...); err != nil {
+ a.ops.logf("system apply: quiesce: %v (continuing)", err)
+ }
+ if err := os.Rename(staged, systemBinaryPath(a.optDir)); err != nil {
+ return fail("install", err)
+ }
+ if err := os.Chown(systemBinaryPath(a.optDir), 0, 0); err != nil && !errors.Is(err, os.ErrPermission) {
+ a.ops.logf("system apply: chown root: %v", err)
+ }
+ j.Phase = systemPhaseInstalled
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ if err := a.installUnits(newDir, j.UnitsRemoved); err != nil {
+ return fail("units", err)
+ }
+ j.Phase = systemPhaseUnits
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ if err := a.restartEdge(); err != nil {
+ return fail("restart", err)
+ }
+ j.Phase = systemPhaseRestarted
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ if err := a.gate(ctx, target); err != nil {
+ return fail("gate", err)
+ }
+ j.Phase = systemPhaseGated
+ if err := a.journal(j); err != nil {
+ return fail("journal", err)
+ }
+ return a.finish(req, target, newPlan)
+}
+
+// finish is steps 11–12, and what recovery runs forward from phase gated.
+func (a *systemApplier) finish(req systemRequest, target string, plan bootstrapPlan) error {
+ if err := a.arm(plan); err != nil {
+ a.ops.logf("system apply: arm: %v", err)
+ }
+ if err := writeJSONDurable(a.file("applied.json"), systemApplied{Version: target, Channel: a.channel, At: a.ops.now().UTC()}, 0o644); err != nil {
+ return err
+ }
+ if err := a.writeResult(req, systemOutcomeApplied, ""); err != nil {
+ return err
+ }
+ os.Remove(a.file("journal.json"))
+ return syncDirBestEffort(a.rootDir())
+}
+
+// decidePrev: refresh carlos.prev from the on-disk binary only when that
+// binary is known good — applied.json names it, or nothing has ever been
+// applied and there is no .prev (a hand-rolled box whose running binary
+// passed the script's own post-condition). Otherwise leave .prev alone.
+func (a *systemApplier) decidePrev(ctx context.Context) (bool, error) {
+ prev := systemPrevPath(a.optDir)
+ _, prevErr := os.Stat(prev)
+ applied, err := readSystemApplied(a.optDir)
+ if err != nil {
+ return false, err
+ }
+ disk := a.onDiskBuild(ctx)
+ known := (applied != nil && applied.Version == disk && disk != "") || (applied == nil && prevErr != nil)
+ if !known {
+ return prevErr == nil, nil
+ }
+ if err := a.journal(systemJournal{Phase: systemPhaseBackingUp, Target: "", PrevBuild: disk, StartedAt: a.ops.now().UTC()}); err != nil {
+ return false, err
+ }
+ if err := copyFileDurable(systemBinaryPath(a.optDir), prev, 0o755); err != nil {
+ return false, err
+ }
+ os.Remove(a.file("journal.json"))
+ return true, nil
+}
+
+func (a *systemApplier) prevBuild(ctx context.Context) string {
+ out, err := a.ops.run(ctx, systemVersionWait, systemPrevPath(a.optDir), "version")
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(out), "carlos "))
+}
+
+// stagePlan runs `<binary> bootstrap -root <dir> -plan-json` and returns
+// that binary's arming plan and the unit files it staged.
+func (a *systemApplier) stagePlan(ctx context.Context, binary, dir string) (bootstrapPlan, []string, error) {
+ os.RemoveAll(dir)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return bootstrapPlan{}, nil, err
+ }
+ out, err := a.ops.run(ctx, systemBootstrapWait, binary, "bootstrap", "-root", dir, "-plan-json")
+ if err != nil {
+ return bootstrapPlan{}, nil, err
+ }
+ var plan bootstrapPlan
+ if err := json.Unmarshal([]byte(out), &plan); err != nil {
+ return bootstrapPlan{}, nil, fmt.Errorf("plan: %w", err)
+ }
+ entries, err := os.ReadDir(filepath.Join(dir, "etc", "systemd", "system"))
+ if err != nil {
+ return bootstrapPlan{}, nil, err
+ }
+ var units []string
+ for _, e := range entries {
+ if !e.IsDir() {
+ units = append(units, e.Name())
+ }
+ }
+ return plan, units, nil
+}
+
+func (a *systemApplier) journal(j systemJournal) error {
+ return writeJSONDurable(a.file("journal.json"), j, 0o600)
+}
+
+// installUnits: remove what the target build dropped, copy its units in,
+// then sysusers BEFORE tmpfiles, then daemon-reload. Never the staging
+// root's data/.
+func (a *systemApplier) installUnits(stagedRoot string, remove []string) error {
+ unitDir := filepath.Join(a.etcDir, "systemd", "system")
+ for _, u := range remove {
+ if _, err := a.ops.systemctl("disable", "--now", u); err != nil {
+ a.ops.logf("system apply: disable %s: %v", u, err)
+ }
+ os.Remove(filepath.Join(unitDir, u))
+ }
+ entries, err := os.ReadDir(filepath.Join(stagedRoot, "etc", "systemd", "system"))
+ if err != nil {
+ return err
+ }
+ for _, e := range entries {
+ if e.IsDir() {
+ continue
+ }
+ if err := copyFileDurable(filepath.Join(stagedRoot, "etc", "systemd", "system", e.Name()), filepath.Join(unitDir, e.Name()), 0o644); err != nil {
+ return err
+ }
+ }
+ sys := filepath.Join(stagedRoot, "etc", "sysusers.d", "carlos.conf")
+ if _, err := os.Stat(sys); err == nil {
+ if err := os.MkdirAll(filepath.Join(a.etcDir, "sysusers.d"), 0o755); err != nil {
+ return err
+ }
+ dst := filepath.Join(a.etcDir, "sysusers.d", "carlos.conf")
+ if err := copyFileDurable(sys, dst, 0o644); err != nil {
+ return err
+ }
+ if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-sysusers", dst); err != nil {
+ return fmt.Errorf("systemd-sysusers: %w", err)
+ }
+ }
+ tmp := filepath.Join(stagedRoot, "etc", "tmpfiles.d", "carlos.conf")
+ if err := os.MkdirAll(filepath.Join(a.etcDir, "tmpfiles.d"), 0o755); err != nil {
+ return err
+ }
+ dst := filepath.Join(a.etcDir, "tmpfiles.d", "carlos.conf")
+ if err := copyFileDurable(tmp, dst, 0o644); err != nil {
+ return err
+ }
+ if _, err := a.ops.run(context.Background(), systemVersionWait, "systemd-tmpfiles", "--create", dst); err != nil {
+ a.ops.logf("system apply: systemd-tmpfiles: %v (continuing, as the script does)", err)
+ }
+ _, err = a.ops.systemctl("daemon-reload")
+ return err
+}
+
+// restartEdge: reset-failed FIRST — a start-limited unit refuses restart —
+// then restart.
+func (a *systemApplier) restartEdge() error {
+ a.ops.systemctl("reset-failed", systemEdgeUnit)
+ _, err := a.ops.systemctl("restart", systemEdgeUnit)
+ return err
+}
+
+// gate: within gateWait the edge is active, answers on loopback, and the
+// installed binary reports the target; then a soak during which neither
+// NRestarts nor ActiveEnterTimestampMonotonic moves.
+func (a *systemApplier) gate(ctx context.Context, target string) error {
+ wait, soak := a.gateWait, a.soak
+ deadline := a.ops.now().Add(wait)
+ var lastErr error
+ for {
+ lastErr = a.gateOnce(ctx, target)
+ if lastErr == nil {
+ break
+ }
+ if !a.ops.now().Before(deadline) {
+ return lastErr
+ }
+ a.ops.sleep(time.Second)
+ }
+ before, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit)
+ if err != nil {
+ return err
+ }
+ a.ops.sleep(soak)
+ after, err := a.ops.systemctl("show", "-p", "NRestarts", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit)
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(before) != strings.TrimSpace(after) {
+ return fmt.Errorf("edge restarted during the soak (%s → %s)", strings.ReplaceAll(strings.TrimSpace(before), "\n", "/"), strings.ReplaceAll(strings.TrimSpace(after), "\n", "/"))
+ }
+ if err := a.gateOnce(ctx, target); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (a *systemApplier) gateOnce(ctx context.Context, target string) error {
+ if out, err := a.ops.systemctl("is-active", systemEdgeUnit); err != nil || strings.TrimSpace(out) != "active" {
+ return fmt.Errorf("%s is %s", systemEdgeUnit, strings.TrimSpace(out))
+ }
+ if err := a.ops.probeEdge(); err != nil {
+ return fmt.Errorf("edge probe: %w", err)
+ }
+ if got := a.onDiskBuild(ctx); got != target {
+ return fmt.Errorf("installed binary reports %q, not %s", got, target)
+ }
+ return nil
+}
+
+// arm is systemctlPlan verbatim: enable all, start timers and paths, never
+// a service — which is why apply can call it from inside itself.
+func (a *systemApplier) arm(plan bootstrapPlan) error {
+ for _, args := range systemctlPlan(plan.Enabled) {
+ if _, err := a.ops.systemctl(args...); err != nil {
+ return fmt.Errorf("systemctl %s: %w", strings.Join(args, " "), err)
+ }
+ }
+ return nil
+}
+
+func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason string, j *systemJournal, prevPlan bootstrapPlan) error {
+ return errors.New("rollback not implemented: " + reason) // Task 8c
+}
+
+func diff(have, drop []string) []string {
+ skip := map[string]bool{}
+ for _, d := range drop {
+ skip[d] = true
+ }
+ var out []string
+ for _, h := range have {
+ if !skip[h] {
+ out = append(out, h)
+ }
+ }
+ return out
+}
+
+// copyFileDurable is writeFileDurable for a file too large to hold: temp,
+// fsync, rename, directory fsync.
+func copyFileDurable(src, dst string, mode os.FileMode) (err error) {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ tmp := dst + ".tmp"
+ out, err := os.OpenFile(tmp, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, mode)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if err != nil {
+ out.Close()
+ os.Remove(tmp)
+ }
+ }()
+ if _, err = io.Copy(out, in); err != nil {
+ return err
+ }
+ if err = out.Chmod(mode); err != nil {
+ return err
+ }
+ if err = out.Sync(); err != nil {
+ return err
+ }
+ if err = out.Close(); err != nil {
+ return err
+ }
+ if err = os.Rename(tmp, dst); err != nil {
+ return err
+ }
+ return syncDirBestEffort(filepath.Dir(dst))
+}
+
+func syncDirBestEffort(dir string) error {
+ d, err := os.Open(dir)
+ if err != nil {
+ return err
+ }
+ defer d.Close()
+ return d.Sync()
+}
+```
+The test for the flap expects `TestApplyGateSoakCatchesAFlap` to end in a rollback; with the 8c stub it ends in an error from `rollback`, and `result.json` is not yet written by the stub. Make the stub write `result.json` with `outcome: rolled-back` and reason, then return the error — that is the 8c contract in miniature and keeps this task's tests honest:
+```go
+func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason string, j *systemJournal, prevPlan bootstrapPlan) error {
+ _ = a.writeResult(req, systemOutcomeRolledBack, reason)
+ return errors.New("rollback not implemented: " + reason)
+}
+```
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestApply' -count=1 2>&1); rc=$?; echo "$out" | tail -30; echo rc=$rc`
+Expected: PASS. `TestApplyInstallsGatesArmsAndRecords` checks the exact systemctl order; if a call is missing, fix the code, not the expected list, unless the spec says otherwise.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/host/ && git add internal/host/systemapply.go internal/host/systemapply_test.go && git commit -q -F - <<'EOF'
+host: carlos system apply — install, gate, arm, record
+
+.prev refreshed only when the disk is known good; both unit sets
+staged from the binaries that own them; quiesce before the rename;
+sysusers before tmpfiles; reset-failed before restart; a 15 s soak on
+NRestarts; arming after the gate via systemctlPlan.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 8c: `carlos system apply` — rollback, recovery, `--recover`, the real `sysOps`
+
+**Files:**
+- Modify: `internal/host/systemapply.go` (replace `rollback` and `recoverIfNeeded`; add `RunSystemApply` and `productionSysOps`)
+- Test: `internal/host/systemapply_test.go` (add)
+
+**Interfaces:**
+- Produces:
+ ```go
+ func RunSystemApply(args []string) // flags: -recover; exits 1 on a non-nil Run error
+ func productionSysOps() sysOps
+ func (a *systemApplier) rollback(ctx, req, reason, j *systemJournal, prevPlan bootstrapPlan) error
+ func (a *systemApplier) recoverIfNeeded(ctx) error
+ func (a *systemApplier) restoreFiles(ctx, j *systemJournal) (bootstrapPlan, error) // files only: binary, units, sysusers, tmpfiles, daemon-reload, enable
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestApplyRollsBackWhenTheGateFails(t *testing.T) {
+ f, req := applyHappy(t)
+ f.edgeUp = false
+ err := f.a.Run(context.Background(), false)
+ if err == nil {
+ t.Fatal("a failed gate must exit non-zero")
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeRolledBack || !r.matches("stable", "v2", req.PromotedAt) || !strings.Contains(r.Reason, "gate") {
+ t.Fatalf("result = %+v", r)
+ }
+ bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ if !strings.Contains(string(bin), "carlos v1") {
+ t.Fatal("binary not restored")
+ }
+ if _, err := os.Stat(systemPrevPath(f.a.optDir)); err != nil {
+ t.Fatal(".prev consumed by the rollback")
+ }
+ units := filepath.Join(f.a.etcDir, "systemd", "system")
+ if _, err := os.Stat(filepath.Join(units, "carlos-new-only.path")); err == nil {
+ t.Error("added unit survived the rollback")
+ }
+ if _, err := os.Stat(filepath.Join(units, "carlos-old-only.timer")); err != nil {
+ t.Error("removed unit not restored")
+ }
+ if ap, _ := readSystemApplied(f.a.optDir); ap != nil {
+ t.Fatalf("applied.json written by a rollback: %+v", ap)
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j != nil {
+ t.Fatal("journal left open after rollback")
+ }
+ joined := strings.Join(f.calls, "\n")
+ if !strings.Contains(joined, "systemctl disable --now carlos-new-only.path") {
+ t.Error("added unit not disabled")
+ }
+ // reset-failed precedes the rollback's restart too, and the previous
+ // plan is armed afterwards.
+ i := strings.LastIndex(joined, "systemctl reset-failed carlos-edge")
+ k := strings.LastIndex(joined, "systemctl restart carlos-edge")
+ m := strings.LastIndex(joined, "systemctl start carlos-adopt.timer")
+ if !(i < k && k < m) {
+ t.Fatalf("rollback order wrong:\n%s", joined)
+ }
+}
+
+func TestApplyRollbackFailedLeavesPrevAndAppliedAlone(t *testing.T) {
+ f, req := applyHappy(t)
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "applied.json"), systemApplied{Version: "v1", Channel: "stable", At: f.now}, 0o644)
+ f.edgeUp = false // never comes up, on either binary
+ if err := f.a.Run(context.Background(), false); err == nil {
+ t.Fatal("must exit non-zero")
+ }
+ r := f.result()
+ if r == nil || r.Outcome != systemOutcomeRollbackFailed || !r.matches("stable", "v2", req.PromotedAt) {
+ t.Fatalf("result = %+v", r)
+ }
+ ap, _ := readSystemApplied(f.a.optDir)
+ if ap == nil || ap.Version != "v1" {
+ t.Fatalf("applied = %+v, must still name v1", ap)
+ }
+ prev, _ := os.ReadFile(systemPrevPath(f.a.optDir))
+ if !strings.Contains(string(prev), "carlos v1") {
+ t.Fatal(".prev damaged")
+ }
+ // A second apply of a NEW version must not refresh .prev from the
+ // unproven disk binary.
+ f.answer["version:"+systemBinaryPath(f.a.optDir)] = "carlos v1\n" // rollback restored v1 on disk
+ os.Remove(systemRequestPath(f.a.dataDir))
+ f.promote("v3", []byte("#!/bin/sh\necho carlos v3\n"), false)
+ f.answer["version:"+filepath.Join(f.a.stagingDir(), "carlos")] = "carlos v3\n"
+ f.answer["plan:"+filepath.Join(f.a.stagingDir(), "carlos")] = f.answer["plan:"+systemPrevPath(f.a.optDir)]
+ f.answer["units:"+filepath.Join(f.a.stagingDir(), "carlos")] = f.answer["units:"+systemPrevPath(f.a.optDir)]
+ _ = f.a.Run(context.Background(), false)
+ prev2, _ := os.ReadFile(systemPrevPath(f.a.optDir))
+ if string(prev2) != string(prev) {
+ t.Fatal(".prev refreshed after rollback-failed")
+ }
+}
+
+func TestRecoveryRollsBackFilesOnlyAtBootAndRestartsAtRuntime(t *testing.T) {
+ for _, phase := range []string{systemPhaseInstalling, systemPhaseInstalled, systemPhaseUnits, systemPhaseRestarted} {
+ t.Run(phase, func(t *testing.T) {
+ f, _ := applyHappy(t)
+ // Simulate: v2 installed, journal open at phase, box rebooted.
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
+ os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v2\n"), 0o755)
+ os.WriteFile(filepath.Join(f.a.etcDir, "systemd", "system", "carlos-new-only.path"), []byte("x"), 0o644)
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
+ systemJournal{Phase: phase, Target: "v2", PrevBuild: "v1", UnitsAdded: []string{"carlos-new-only.path"}, UnitsRemoved: []string{"carlos-old-only.timer"}, StartedAt: f.now}, 0o600)
+ f.edgeStartedThisBoot = false
+ if err := f.a.Run(context.Background(), true); err != nil {
+ t.Fatal(err)
+ }
+ bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ if !strings.Contains(string(bin), "carlos v1") {
+ t.Fatal("binary not restored")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "systemd", "system", "carlos-new-only.path")); err == nil {
+ t.Error("added unit survived")
+ }
+ if _, err := os.Stat(filepath.Join(f.a.etcDir, "systemd", "system", "carlos-old-only.timer")); err != nil {
+ t.Error("removed unit not restored")
+ }
+ joined := strings.Join(f.calls, "\n")
+ if strings.Contains(joined, "systemctl restart carlos-edge") || strings.Contains(joined, "systemctl start ") {
+ t.Fatalf("boot recovery restarted or started something:\n%s", joined)
+ }
+ if !strings.Contains(joined, "systemctl enable carlos-edge.service") {
+ t.Fatal("previous plan not enabled for the boot")
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeRolledBack || !strings.Contains(r.Reason, phase) {
+ t.Fatalf("result = %+v", r)
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j != nil {
+ t.Fatal("journal left open")
+ }
+ })
+ }
+ // Runtime (OnFailure=): same, plus a non-blocking restart and the
+ // previous plan's timers/paths started.
+ f, _ := applyHappy(t)
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
+ systemJournal{Phase: systemPhaseUnits, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
+ f.edgeStartedThisBoot = true
+ if err := f.a.Run(context.Background(), true); err != nil {
+ t.Fatal(err)
+ }
+ joined := strings.Join(f.calls, "\n")
+ for _, want := range []string{"systemctl stop carlos-adopt.timer", "systemctl reset-failed carlos-edge", "systemctl restart --no-block carlos-edge", "systemctl start carlos-adopt.timer"} {
+ if !strings.Contains(joined, want) {
+ t.Errorf("runtime recovery lacks %q:\n%s", want, joined)
+ }
+ }
+}
+
+func TestRecoveryFinishesForwardFromGated(t *testing.T) {
+ f, req := applyHappy(t)
+ os.WriteFile(systemBinaryPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v2\n"), 0o755)
+ writeJSONDurable(systemRequestPath(f.a.dataDir), req, 0o644)
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
+ systemJournal{Phase: systemPhaseGated, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
+ if err := f.a.Run(context.Background(), true); err != nil {
+ t.Fatal(err)
+ }
+ bin, _ := os.ReadFile(systemBinaryPath(f.a.optDir))
+ if !strings.Contains(string(bin), "carlos v2") {
+ t.Fatal("a gated apply was undone")
+ }
+ ap, _ := readSystemApplied(f.a.optDir)
+ if ap == nil || ap.Version != "v2" {
+ t.Fatalf("applied = %+v", ap)
+ }
+ if r := f.result(); r == nil || r.Outcome != systemOutcomeApplied {
+ t.Fatalf("result = %+v", r)
+ }
+}
+
+func TestRecoveryRemovesTheJournalLast(t *testing.T) {
+ // A restart that fails mid-recovery leaves the journal for the next run.
+ f, _ := applyHappy(t)
+ os.WriteFile(systemPrevPath(f.a.optDir), []byte("#!/bin/sh\necho carlos v1\n"), 0o755)
+ writeJSONDurable(filepath.Join(systemRootDir(f.a.optDir), "journal.json"),
+ systemJournal{Phase: systemPhaseInstalled, Target: "v2", PrevBuild: "v1", StartedAt: f.now}, 0o600)
+ orig := f.a.ops.systemctl
+ f.a.ops.systemctl = func(args ...string) (string, error) {
+ if args[0] == "daemon-reload" {
+ return "", errors.New("systemd busy")
+ }
+ return orig(args...)
+ }
+ if err := f.a.Run(context.Background(), true); err == nil {
+ t.Fatal("a failed recovery must exit non-zero")
+ }
+ if j, _ := readSystemJournal(f.a.optDir); j == nil {
+ t.Fatal("journal removed although recovery did not complete")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run 'TestApplyRolls|TestApplyRollback|TestRecovery' -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: FAIL ("rollback not implemented", "recovery not implemented").
+
+- [ ] **Step 3: Implement**
+
+Replace `rollback` and `recoverIfNeeded`; add `restoreFiles`, `RunSystemApply`, `productionSysOps`:
+```go
+// rollback is step 13: back to carlos.prev and its units, restart, gate,
+// arm the previous plan. applied.json is untouched — the .prev build is
+// what passed a gate, and it is what applied.json already names.
+func (a *systemApplier) rollback(ctx context.Context, req systemRequest, reason string, j *systemJournal, prevPlan bootstrapPlan) error {
+ a.ops.logf("system apply: %s %s: rolling back: %s", a.channel, j.Target, reason)
+ plan, err := a.restoreFiles(ctx, j)
+ if err != nil {
+ _ = a.writeResult(req, systemOutcomeRollbackFailed, reason+"; restore: "+err.Error())
+ os.Remove(a.file("journal.json"))
+ return fmt.Errorf("system apply: rollback of %s failed: %v", j.Target, err)
+ }
+ if err := a.restartEdge(); err != nil {
+ _ = a.writeResult(req, systemOutcomeRollbackFailed, reason+"; restart: "+err.Error())
+ os.Remove(a.file("journal.json"))
+ return fmt.Errorf("system apply: rollback restart failed: %v", err)
+ }
+ if err := a.gate(ctx, j.PrevBuild); err != nil {
+ _ = a.writeResult(req, systemOutcomeRollbackFailed, reason+"; previous build failed its gate too: "+err.Error())
+ os.Remove(a.file("journal.json"))
+ return fmt.Errorf("system apply: rollback gate failed: %v", err)
+ }
+ if err := a.arm(plan); err != nil {
+ a.ops.logf("system apply: arm previous plan: %v", err)
+ }
+ if err := a.writeResult(req, systemOutcomeRolledBack, reason); err != nil {
+ return err
+ }
+ os.Remove(a.file("journal.json"))
+ return fmt.Errorf("system apply: %s rolled back: %s", j.Target, reason)
+}
+
+// restoreFiles is the files-only half shared by rollback and recovery:
+// carlos.prev over carlos (keeping .prev), units_added disabled and
+// removed, the previous unit set copied back (bringing units_removed
+// back), sysusers, tmpfiles, daemon-reload, and `enable` of the previous
+// plan so it comes up at boot. No restart, no start.
+func (a *systemApplier) restoreFiles(ctx context.Context, j *systemJournal) (bootstrapPlan, error) {
+ prev := systemPrevPath(a.optDir)
+ if _, err := os.Stat(prev); err != nil {
+ return bootstrapPlan{}, fmt.Errorf("no carlos.prev: %w", err)
+ }
+ a.ops.systemctl(append([]string{"stop"}, systemQuiesceUnits...)...)
+ if err := copyFileDurable(prev, systemBinaryPath(a.optDir), 0o755); err != nil {
+ return bootstrapPlan{}, err
+ }
+ prevDir := filepath.Join(a.stagingDir(), "prev")
+ plan, _, err := a.stagePlan(ctx, prev, prevDir)
+ if err != nil {
+ return bootstrapPlan{}, fmt.Errorf("carlos.prev bootstrap: %w", err)
+ }
+ if err := a.installUnits(prevDir, j.UnitsAdded); err != nil {
+ return bootstrapPlan{}, err
+ }
+ if _, err := a.ops.systemctl(append([]string{"enable"}, plan.Enabled...)...); err != nil {
+ return bootstrapPlan{}, err
+ }
+ return plan, nil
+}
+
+// recoverIfNeeded: an open journal means a previous run was cut off.
+// Before gated → back, files only, restart only if the edge has started
+// this boot; gated → forward. The journal is removed LAST.
+func (a *systemApplier) recoverIfNeeded(ctx context.Context) error {
+ j, err := readSystemJournal(a.optDir)
+ if err != nil || j == nil {
+ return err
+ }
+ req, _ := a.readRequest()
+ if req.Version == "" {
+ req = systemRequest{Version: j.Target, PromotedAt: j.StartedAt}
+ }
+ a.ops.logf("system apply: recovering an apply of %s interrupted at %s", j.Target, j.Phase)
+ switch j.Phase {
+ case systemPhaseGated:
+ plan, _, err := a.stagePlan(ctx, systemBinaryPath(a.optDir), filepath.Join(a.stagingDir(), "new"))
+ if err != nil {
+ return fmt.Errorf("system apply: recovery: %w", err)
+ }
+ return a.finish(req, j.Target, plan)
+ case systemPhaseBackingUp:
+ os.Remove(systemPrevPath(a.optDir) + ".tmp")
+ os.Remove(a.file("journal.json"))
+ return nil
+ }
+ plan, err := a.restoreFiles(ctx, j)
+ if err != nil {
+ return fmt.Errorf("system apply: recovery: %w", err) // journal kept: the next run recovers again
+ }
+ if a.ops.edgeStartedThisBoot() {
+ a.ops.systemctl("reset-failed", systemEdgeUnit)
+ if _, err := a.ops.systemctl("restart", "--no-block", systemEdgeUnit); err != nil {
+ return fmt.Errorf("system apply: recovery: restart: %w", err)
+ }
+ var armed []string
+ for _, u := range plan.Enabled {
+ if strings.HasSuffix(u, ".timer") || strings.HasSuffix(u, ".path") {
+ armed = append(armed, u)
+ }
+ }
+ if len(armed) > 0 {
+ a.ops.systemctl(append([]string{"start"}, armed...)...)
+ }
+ }
+ if err := a.writeResult(req, systemOutcomeRolledBack, "interrupted at "+j.Phase); err != nil {
+ return err
+ }
+ os.Remove(a.file("journal.json"))
+ return syncDirBestEffort(a.rootDir())
+}
+
+// RunSystemApply is `carlos system apply [--recover]`: the root oneshot.
+func RunSystemApply(args []string) {
+ fs := flag.NewFlagSet("system apply", flag.ExitOnError)
+ recover := fs.Bool("recover", false, "recover an interrupted apply (files only) and exit: the boot and OnFailure unit")
+ fs.Usage = cliflag.Usage(fs, "")
+ _ = fs.Parse(args)
+ if os.Geteuid() != 0 {
+ log.Fatal("carlos system apply is the root oneshot behind carlos-system-update.service; it is not for operators")
+ }
+ ctx := context.Background()
+ store, err := openDeployStore(ctx)
+ if err != nil {
+ log.Fatalf("system apply: %v", err)
+ }
+ pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
+ if err != nil {
+ log.Fatalf("system apply: %v", err)
+ }
+ a := &systemApplier{
+ ops: productionSysOps(), store: store, pub: pub, channel: systemChannel(os.Getenv),
+ optDir: release.OptDir(), dataDir: dataDir(), etcDir: "/etc", build: version.Build,
+ platform: runtime.GOOS + "-" + runtime.GOARCH, gateWait: systemGateWait, soak: systemGateSoak,
+ }
+ if err := a.Run(ctx, *recover); err != nil {
+ log.Fatalf("%v", err)
+ }
+}
+
+func productionSysOps() sysOps {
+ return sysOps{
+ systemctl: func(args ...string) (string, error) {
+ out, err := exec.Command("systemctl", args...).CombinedOutput()
+ if err != nil {
+ return string(out), fmt.Errorf("systemctl %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
+ }
+ return string(out), nil
+ },
+ run: func(ctx context.Context, timeout time.Duration, name string, args ...string) (string, error) {
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ out, err := exec.CommandContext(ctx, name, args...).Output()
+ if err != nil {
+ return string(out), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
+ }
+ return string(out), nil
+ },
+ now: time.Now,
+ sleep: time.Sleep,
+ probeEdge: func() error {
+ // Any HTTP answer on loopback proves the process serves; a box
+ // with no route yet must still pass (spec §6 step 10).
+ c := &http.Client{Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ resp, err := c.Get("http://127.0.0.1/")
+ if err != nil {
+ return err
+ }
+ resp.Body.Close()
+ return nil
+ },
+ edgeStartedThisBoot: func() bool {
+ out, err := exec.Command("systemctl", "show", "-p", "ActiveEnterTimestampMonotonic", "--value", systemEdgeUnit).Output()
+ return err == nil && strings.TrimSpace(string(out)) != "" && strings.TrimSpace(string(out)) != "0"
+ },
+ logf: log.Printf,
+ }
+}
+```
+Imports to add: `"flag"`, `"log"`, `"net/http"`, `"os/exec"`, `"github.com/carlosframework/platform/internal/cliflag"`, `"github.com/carlosframework/platform/internal/version"`. In the fixture, set `f.a.platform = "linux-arm64"` (from 8a). The edge's plain-HTTP listener: confirm the port with `grep -n 'ListenAndServe\|":80"\|Listen(' internal/edge/edge.go | head`; if the edge only listens on 443, probe `https://127.0.0.1/` with `InsecureSkipVerify` and a `ServerName` of `localhost` instead, and say which in the commit.
+
+- [ ] **Step 4: Run the whole host package**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -count=1 2>&1); rc=$?; echo "$out" | tail -30; echo rc=$rc`
+Expected: `ok`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/host/ && git add internal/host/systemapply.go internal/host/systemapply_test.go && git commit -q -F - <<'EOF'
+host: carlos system apply — rollback, recovery, and the real box
+
+Rollback restores carlos.prev and its unit set, gates it, arms the
+previous plan, and leaves applied.json alone. Recovery is files-only
+and goes forward from phase gated; it restarts the edge only when the
+edge has started this boot, and removes the journal last.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+### Task 9: `carlos system` dispatch and the `apply` verb
+
+**Files:**
+- Create: `system.go` (package main), `system_test.go`
+- Modify: `main.go` (the `switch cmd` at `:30-134`, `usage()` at `:139-233`), `update.go` (`noHintVerbs` at `:76-85`)
+
+**Interfaces:**
+- Consumes: `host.RunSystemApply` (Task 8c), `main.go` dispatch pattern (`case "ops": ops.Run(args)`), `usage()` section "On the host".
+- Produces:
+ ```go
+ func runSystem(args []string) // carlos system <verb> | carlos system router <verb>
+ func systemVerb(args []string) (verb string, rest []string, component string) // strips an optional leading "router"
+ ```
+ Verbs in this task: `apply` → `host.RunSystemApply(rest)`; `update`/`status`/`rollback` → `log.Fatal("carlos system " + verb + " arrives in a later task")` placeholders that Task 12 replaces; `help`/none → usage, exit 2.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package main
+
+import "testing"
+
+func TestSystemVerbAcceptsTheComponentForm(t *testing.T) {
+ cases := []struct {
+ in []string
+ verb, comp string
+ rest int
+ }{
+ {[]string{"update", "--channel", "canary"}, "update", "router", 2},
+ {[]string{"router", "update"}, "update", "router", 0},
+ {[]string{"router", "status"}, "status", "router", 0},
+ {[]string{"apply", "--recover"}, "apply", "router", 1},
+ {[]string{}, "", "router", 0},
+ {[]string{"router"}, "", "router", 0},
+ }
+ for _, c := range cases {
+ verb, rest, comp := systemVerb(c.in)
+ if verb != c.verb || comp != c.comp || len(rest) != c.rest {
+ t.Errorf("systemVerb(%v) = %q %v %q", c.in, verb, rest, comp)
+ }
+ }
+ if verb, _, _ := systemVerb([]string{"console", "update"}); verb != "" {
+ t.Errorf("an unknown component must not be a verb: got %q", verb)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test . -run TestSystemVerb -count=1 2>&1); rc=$?; echo "$out" | tail -5; echo rc=$rc`
+Expected: undefined `systemVerb`.
+
+- [ ] **Step 3: Implement `system.go`**
+
+```go
+package main
+
+import (
+ "fmt"
+ "log"
+ "os"
+
+ "github.com/carlosframework/platform/internal/host"
+)
+
+// carlos system: what is not an app (spec §0, the console-as-an-app
+// dividing line). One component today, the router — the substrate binary.
+// `carlos system router update` is the same verb scoped to it, so the
+// command Paul named works and a future split needs no new verb.
+
+const systemComponentRouter = "router"
+
+func systemVerb(args []string) (verb string, rest []string, component string) {
+ component = systemComponentRouter
+ if len(args) > 0 && args[0] == systemComponentRouter {
+ args = args[1:]
+ }
+ if len(args) == 0 {
+ return "", nil, component
+ }
+ switch args[0] {
+ case "update", "status", "rollback", "apply", "help", "-h", "--help":
+ return args[0], args[1:], component
+ }
+ return "", args, component
+}
+
+func runSystem(args []string) {
+ verb, rest, _ := systemVerb(args)
+ switch verb {
+ case "apply":
+ host.RunSystemApply(rest)
+ case "update":
+ runSystemUpdate(rest)
+ case "status":
+ runSystemStatus(rest)
+ case "rollback":
+ runSystemRollback(rest)
+ case "help", "-h", "--help":
+ systemUsage()
+ default:
+ if len(args) > 0 && verb == "" {
+ fmt.Fprintf(os.Stderr, "system: unknown verb %q\n\n", args[0])
+ }
+ systemUsage()
+ os.Exit(2)
+ }
+}
+
+func systemUsage() {
+ fmt.Fprint(os.Stderr, `usage: carlos system <verb> [flags]
+ carlos system router <verb> [flags]
+
+ update promote a platform release to a channel; every box on it rolls itself
+ status every box: platform, channel, running build, channel target, last apply outcome
+ rollback point a channel back at the release before this one
+ apply (root oneshot behind carlos-system-update.service; not for operators)
+
+The platform is not an app: the console is updated with carlos deploy --app console.
+`)
+}
+
+// Placeholders until Task 12.
+func runSystemUpdate(args []string) { log.Fatal("carlos system update: not yet wired to the console") }
+func runSystemStatus(args []string) { log.Fatal("carlos system status: not yet wired to the console") }
+func runSystemRollback(args []string) { log.Fatal("carlos system rollback: not yet wired to the console") }
+```
+`main.go`: add `case "system": runSystem(args)` after the `ops` case; in `usage()` under "On the host" add ` system platform self-update: update | status | rollback (apply is the root oneshot)`; in `update.go` `noHintVerbs` add `"system": true`.
+
+- [ ] **Step 4: Run and check the binary**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test . -run 'TestSystemVerb' -count=1 2>&1); rc=$?; echo "$out" | tail -5; echo rc=$rc; go build -o /tmp/c/carlos . && /tmp/c/carlos system help 2>&1 | head -3; /tmp/c/carlos system apply 2>&1 | head -1`
+Expected: PASS; usage printed; `apply` as non-root prints "not for operators".
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l . && git add system.go system_test.go main.go update.go && git commit -q -F - <<'EOF'
+cli: carlos system, with apply wired to the root oneshot
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 10: The public release feed (`internal/publicrelease`)
+
+**Files:**
+- Create: `internal/publicrelease/feed.go`, `internal/publicrelease/feed_test.go`
+- Modify: `update.go` (`fetchLatest` `:100-119`, `selfReplace` `:295-327`, `fetchUpdateFile` `:358-369`)
+
+**Interfaces:**
+- Consumes: the package vars `updateLatestURL`, `updateDownloadBase`, `cliUpdatePubKey` in `update.go` (kept, since `update_test.go` repoints them).
+- Produces:
+ ```go
+ package publicrelease
+ type Feed struct {
+ LatestURL string // https://apt.carlosframework.com/latest.txt
+ DownloadBase string // https://github.com/carlosframework/releases/releases/download (tag-pinned; never latest/download)
+ PubKey string // base64 ed25519 public key that signs SHA256SUMS.txt
+ Client *http.Client // nil = a 5-minute client
+ }
+ func Default() Feed
+ var TagRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
+ func (f Feed) Latest(ctx context.Context) (string, error)
+ func (f Feed) Sums(ctx context.Context, tag string) (map[string]string, error) // asset → sha, signature verified
+ func (f Feed) Asset(ctx context.Context, tag, asset string, limit int64) (data []byte, sha256hex string, err error) // verified against Sums
+ func AssetName(platform string) string // "carlos-" + platform
+ ```
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package publicrelease
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func testFeed(t *testing.T, sign bool, assets map[string][]byte) (Feed, string) {
+ t.Helper()
+ pub, priv, _ := ed25519.GenerateKey(nil)
+ var sums strings.Builder
+ for name, body := range assets {
+ s := sha256.Sum256(body)
+ fmt.Fprintf(&sums, "%s %s\n", hex.EncodeToString(s[:]), name)
+ }
+ sig := ed25519.Sign(priv, []byte(sums.String()))
+ mux := http.NewServeMux()
+ mux.HandleFunc("/latest.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "v0.18.0") })
+ mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums.String()) })
+ mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt.sig", func(w http.ResponseWriter, r *http.Request) {
+ if sign {
+ fmt.Fprintln(w, base64.StdEncoding.EncodeToString(sig))
+ } else {
+ fmt.Fprintln(w, base64.StdEncoding.EncodeToString(make([]byte, ed25519.SignatureSize)))
+ }
+ })
+ for name, body := range assets {
+ body := body
+ mux.HandleFunc("/dl/v0.18.0/"+name, func(w http.ResponseWriter, r *http.Request) { w.Write(body) })
+ }
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+ return Feed{LatestURL: ts.URL + "/latest.txt", DownloadBase: ts.URL + "/dl", PubKey: base64.StdEncoding.EncodeToString(pub)}, ts.URL
+}
+
+func TestFeedLatestAndVerifiedAsset(t *testing.T) {
+ body := []byte("ELF arm64")
+ f, _ := testFeed(t, true, map[string][]byte{"carlos-linux-arm64": body, "carlos-linux-amd64": []byte("ELF amd64")})
+ ctx := context.Background()
+ tag, err := f.Latest(ctx)
+ if err != nil || tag != "v0.18.0" {
+ t.Fatalf("latest = %q %v", tag, err)
+ }
+ data, sha, err := f.Asset(ctx, tag, AssetName("linux-arm64"), 1<<20)
+ if err != nil || string(data) != string(body) {
+ t.Fatalf("asset: %v", err)
+ }
+ s := sha256.Sum256(body)
+ if sha != hex.EncodeToString(s[:]) {
+ t.Fatalf("sha = %s", sha)
+ }
+ if _, _, err := f.Asset(ctx, tag, "carlos-windows-amd64.zip", 1<<20); err == nil {
+ t.Fatal("an asset absent from the sums was accepted")
+ }
+}
+
+func TestFeedRefusesABadSignatureAndATamperedAsset(t *testing.T) {
+ body := []byte("ELF")
+ f, _ := testFeed(t, false, map[string][]byte{"carlos-linux-arm64": body})
+ if _, err := f.Sums(context.Background(), "v0.18.0"); err == nil || !strings.Contains(err.Error(), "signature") {
+ t.Fatalf("bad signature: %v", err)
+ }
+ // Signed sums, but the served asset differs from the sum.
+ f2, _ := testFeed(t, true, map[string][]byte{"carlos-linux-arm64": body})
+ f2.DownloadBase = f2.DownloadBase // same server
+ pub, _ := base64.StdEncoding.DecodeString(f2.PubKey)
+ _ = pub
+ // Simulate tampering by asking for a limit smaller than the asset: the
+ // truncated download cannot match its sum.
+ if _, _, err := f2.Asset(context.Background(), "v0.18.0", "carlos-linux-arm64", 1); err == nil {
+ t.Fatal("a truncated asset matched its sum")
+ }
+}
+
+func TestFeedLatestRequiresATag(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "main") }))
+ defer ts.Close()
+ f := Feed{LatestURL: ts.URL, DownloadBase: ts.URL, PubKey: ""}
+ if _, err := f.Latest(context.Background()); err == nil {
+ t.Fatal("accepted a non-tag latest")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/publicrelease/ -count=1 2>&1); rc=$?; echo "$out" | tail -5; echo rc=$rc`
+Expected: package does not exist.
+
+- [ ] **Step 3: Implement `feed.go`**
+
+```go
+// Package publicrelease reads the vendor's public signed release feed: the
+// latest tag, the signed checksum list, and a verified asset. Two callers
+// share it — `carlos update` on a workstation and the console's `carlos
+// system update` promote — so the verification chain is written once.
+package publicrelease
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+)
+
+type Feed struct {
+ LatestURL string
+ DownloadBase string // tag-pinned: <DownloadBase>/<tag>/<file>; never latest/download
+ PubKey string // base64 ed25519 public key over the raw SHA256SUMS.txt bytes
+ Client *http.Client
+}
+
+// Default is the vendor feed. The strings are duplicated in update.go's
+// package vars so update_test.go can repoint them; keep the two in step.
+func Default() Feed {
+ return Feed{
+ LatestURL: "https://apt.carlosframework.com/latest.txt",
+ DownloadBase: "https://github.com/carlosframework/releases/releases/download",
+ PubKey: "R+EO3qQ47VsuKl9Qq/pkDXBucPYvzp5lU2ut4zpnJPc=",
+ }
+}
+
+var TagRe = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+$`)
+
+func AssetName(platform string) string { return "carlos-" + platform }
+
+func (f Feed) client() *http.Client {
+ if f.Client != nil {
+ return f.Client
+ }
+ return &http.Client{Timeout: 5 * time.Minute}
+}
+
+func (f Feed) get(ctx context.Context, url string, limit int64) ([]byte, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
+ if err != nil {
+ return nil, err
+ }
+ res, err := f.client().Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer res.Body.Close()
+ if res.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("%s: status %d", url, res.StatusCode)
+ }
+ return io.ReadAll(io.LimitReader(res.Body, limit))
+}
+
+// Latest is the newest release tag, and only a tag.
+func (f Feed) Latest(ctx context.Context) (string, error) {
+ b, err := f.get(ctx, f.LatestURL, 128)
+ if err != nil {
+ return "", err
+ }
+ tag := strings.TrimSpace(string(b))
+ if !TagRe.MatchString(tag) {
+ return "", fmt.Errorf("%s: %q is not a release tag", f.LatestURL, tag)
+ }
+ return tag, nil
+}
+
+// Sums fetches SHA256SUMS.txt and its .sig for tag, verifies the signature
+// with PubKey, and returns asset → sha256 hex.
+func (f Feed) Sums(ctx context.Context, tag string) (map[string]string, error) {
+ base := f.DownloadBase + "/" + tag + "/"
+ sums, err := f.get(ctx, base+"SHA256SUMS.txt", 1<<20)
+ if err != nil {
+ return nil, err
+ }
+ sig, err := f.get(ctx, base+"SHA256SUMS.txt.sig", 4096)
+ if err != nil {
+ return nil, fmt.Errorf("%v (a release without a signed checksum list predates self-update)", err)
+ }
+ pub, err := base64.StdEncoding.DecodeString(f.PubKey)
+ if err != nil || len(pub) != ed25519.PublicKeySize {
+ return nil, errors.New("release feed public key is invalid")
+ }
+ rawSig, err := base64.StdEncoding.DecodeString(strings.TrimSpace(string(sig)))
+ if err != nil || !ed25519.Verify(ed25519.PublicKey(pub), sums, rawSig) {
+ return nil, errors.New("SHA256SUMS.txt signature does not verify — refusing the release")
+ }
+ out := map[string]string{}
+ for _, line := range strings.Split(string(sums), "\n") {
+ fields := strings.Fields(line)
+ if len(fields) == 2 {
+ out[fields[1]] = fields[0]
+ }
+ }
+ return out, nil
+}
+
+// Asset fetches one named asset for tag and refuses it unless its sha256
+// matches the signed checksum list.
+func (f Feed) Asset(ctx context.Context, tag, asset string, limit int64) ([]byte, string, error) {
+ sums, err := f.Sums(ctx, tag)
+ if err != nil {
+ return nil, "", err
+ }
+ want, ok := sums[asset]
+ if !ok {
+ return nil, "", fmt.Errorf("no %s in the release's checksum list", asset)
+ }
+ data, err := f.get(ctx, f.DownloadBase+"/"+tag+"/"+asset, limit)
+ if err != nil {
+ return nil, "", err
+ }
+ s := sha256.Sum256(data)
+ got := hex.EncodeToString(s[:])
+ if got != want {
+ return nil, "", fmt.Errorf("%s does not match its signed checksum — refusing it", asset)
+ }
+ return data, got, nil
+}
+```
+`update.go`: replace the bodies of `fetchLatest` and the fetch-and-verify half of `selfReplace` (`:296-327`) with calls through a feed built from the package vars, so tests that repoint the vars still work:
+```go
+func updateFeed() publicrelease.Feed {
+ return publicrelease.Feed{LatestURL: updateLatestURL, DownloadBase: updateDownloadBase, PubKey: cliUpdatePubKey}
+}
+```
+`fetchLatest(timeout)` → `updateFeed()` with `Client: &http.Client{Timeout: timeout}` and `.Latest(ctx)`; in `selfReplace`, `binData, _, err := updateFeed().Asset(ctx, latest, asset, 256<<20)`, keeping the workstation-only half (temp file, lie detector, rename) as is. Delete `fetchUpdateFile` if nothing else calls it. Run `go test . -run 'Update' -count=1` to prove `update_test.go` still passes.
+
+- [ ] **Step 4: Run the tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/publicrelease/ . -run 'Feed|Update' -count=1 2>&1); rc=$?; echo "$out" | tail -10; echo rc=$rc`
+Expected: `ok` for both packages.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l . internal/publicrelease && git add internal/publicrelease update.go && git commit -q -F - <<'EOF'
+publicrelease: the vendor feed's verification chain, written once
+
+carlos update and the console's carlos system update share it: latest
+tag, signed SHA256SUMS, per-asset sha.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 11: The console doors (`internal/console/apisystem.go`)
+
+**Files:**
+- Create: `internal/console/apisystem.go`, `internal/console/apisystem_test.go`
+- Modify: `internal/console/authcli.go` `registerCLIRoutes()` (add five `cliRoute` lines), `internal/console/server.go` `Config` (add `SystemFeed publicrelease.Feed`)
+
+**Interfaces:**
+- Consumes: `s.cliRoute(pattern, svcrole.Set{}, handler)`; `principalFrom(ctx)`, `PrincipalUser`; `s.store.Role(OpsAccountID, addr)`, `RoleOwner`; `s.cfg.Releases`, `s.cfg.Key`, `s.cfg.ReleasePub`; `s.bumpSerial(ctx)`; `writeJSON`, `jsonError`, `readJSONStrictLimit`; `status.LoadBoxes`, `release.NewPrefixStore`, `s.openPassRegistry().ListFleets()` (the `fleetMeters` loop, `economicspass.go:648-670`); `release.PromoteSystem`, `release.RollbackSystem`, `release.GetSystemPointer`, `release.RootSigner`; `publicrelease.Feed`; `apiShipMaxBytes`-style limits.
+- Produces (routes, all person-only, ops-owner-only, bare 404 on refusal like `IsOperator` doors):
+ ```
+ GET /api/cli/system/status → {"pointers": {"canary": {...}|null, "stable": ...}, "boxes": [{label, platform, channel, release, target, system:{...}|null, stale}]}
+ POST /api/cli/system/update → {"job": "<id>"}; body {"channel","version"?,"upload"?,"spread"?,"component"?}
+ GET /api/cli/system/jobs/{id} → {"steps": ["resolved v0.18.0", ...], "done": bool, "error": ""}
+ PUT /api/cli/system/upload → {"upload": "<handle>", "size": n}; multipart: meta {"version","platform"}, file
+ POST /api/cli/system/rollback → {"channel","version"}; body {"channel","component"?}
+ ```
+ ```go
+ func (s *Server) apiSystemOwner(w, r) (addr string, ok bool)
+ type systemJob struct{ mu sync.Mutex; Steps []string; Done bool; Err string }
+ func (s *Server) runSystemUpdate(ctx, job *systemJob, actor string, in systemUpdateRequest)
+ ```
+ Job and upload registries are fields on `Server`: `systemJobs map[string]*systemJob`, `systemUploads map[string]systemUpload`, guarded by `systemMu sync.Mutex`; one upload slot (128 MiB cap) held in memory, consumed by the job that names it.
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+package console
+
+import (
+ "bytes"
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "fmt"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/publicrelease"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// systemFeed serves a signed fake release from an httptest server and
+// points the fixture's console at it.
+func systemFeed(t *testing.T, fx *consoleFixture, assets map[string][]byte) {
+ t.Helper()
+ pub, priv, _ := ed25519.GenerateKey(nil)
+ var sums strings.Builder
+ for name, body := range assets {
+ s := sha256.Sum256(body)
+ fmt.Fprintf(&sums, "%s %s\n", hex.EncodeToString(s[:]), name)
+ }
+ sig := base64.StdEncoding.EncodeToString(ed25519.Sign(priv, []byte(sums.String())))
+ mux := http.NewServeMux()
+ mux.HandleFunc("/latest.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, "v0.18.0") })
+ mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, sums.String()) })
+ mux.HandleFunc("/dl/v0.18.0/SHA256SUMS.txt.sig", func(w http.ResponseWriter, r *http.Request) { fmt.Fprintln(w, sig) })
+ for name, body := range assets {
+ body := body
+ mux.HandleFunc("/dl/v0.18.0/"+name, func(w http.ResponseWriter, r *http.Request) { w.Write(body) })
+ }
+ ts := httptest.NewServer(mux)
+ t.Cleanup(ts.Close)
+ fx.srv.cfg.SystemFeed = publicrelease.Feed{LatestURL: ts.URL + "/latest.txt", DownloadBase: ts.URL + "/dl", PubKey: base64.StdEncoding.EncodeToString(pub)}
+}
+
+func waitJob(t *testing.T, fx *consoleFixture, token, id string) map[string]any {
+ t.Helper()
+ for i := 0; i < 100; i++ {
+ st, out := getJSON(t, fx.ts.URL+"/api/cli/system/jobs/"+id, token)
+ if st != http.StatusOK {
+ t.Fatalf("job = %d %v", st, out)
+ }
+ if out["done"] == true {
+ return out
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatal("job never finished")
+ return nil
+}
+
+func seedBox(t *testing.T, fx *consoleFixture, label, platform, channel, rel string) {
+ t.Helper()
+ b := status.CollectBox(nil, status.Probes{Release: rel, Platform: platform, SystemChannel: channel}, time.Now())
+ b.Label = label
+ if err := status.WriteBox(context.Background(), fx.rel, b); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func TestSystemDoorsAreForOpsOwnersOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "member@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ member := cliToken(t, fx, "member@keymail.dev", "code-sys-member")
+ for _, u := range []string{"/api/cli/system/status"} {
+ if st, _ := getJSON(t, fx.ts.URL+u, member); st != http.StatusNotFound {
+ t.Errorf("GET %s as a member = %d, want 404", u, st)
+ }
+ }
+ if st, _ := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]string{"channel": "canary"}, member); st != http.StatusNotFound {
+ t.Errorf("update as a member = %d, want 404", st)
+ }
+ if st, _ := postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, member); st != http.StatusNotFound {
+ t.Errorf("rollback as a member = %d, want 404", st)
+ }
+ if st, _ := getJSON(t, fx.ts.URL+"/api/cli/system/status", "not-a-token"); st == http.StatusOK {
+ t.Error("unauthenticated status answered 200")
+ }
+}
+
+func TestSystemUpdateMirrorsSignsAndBumps(t *testing.T) {
+ fx := newConsoleFixture(t)
+ arm := []byte("ELF arm64")
+ systemFeed(t, fx, map[string][]byte{"carlos-linux-arm64": arm, "carlos-linux-amd64": []byte("ELF amd64")})
+ seedBox(t, fx, "ie", "linux-arm64", "canary", "v0.17.0")
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-owner")
+ before, _, _ := release.ReadSerial(context.Background(), fx.rel)
+ st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "spread": "2m"}, owner)
+ if st != http.StatusOK {
+ t.Fatalf("update = %d %v", st, out)
+ }
+ job := waitJob(t, fx, owner, str(t, out, "job"))
+ if job["error"] != "" {
+ t.Fatalf("job failed: %v", job)
+ }
+ p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if p.Current.Version != "v0.18.0" || p.Spread != "2m0s" || p.Current.Source != "release:v0.18.0" {
+ t.Fatalf("pointer = %+v", p)
+ }
+ a, ok := p.Current.Artifacts["linux-arm64"]
+ if !ok {
+ t.Fatalf("no arm64 artifact: %+v", p.Current.Artifacts)
+ }
+ if _, ok := p.Current.Artifacts["linux-amd64"]; ok {
+ t.Fatal("mirrored a platform no box reports")
+ }
+ body, _, err := fx.rel.Get(context.Background(), a.Key)
+ if err != nil || !bytes.Equal(body, arm) {
+ t.Fatalf("artifact not mirrored: %v", err)
+ }
+ s := sha256.Sum256(arm)
+ if a.SHA256 != hex.EncodeToString(s[:]) {
+ t.Fatalf("sha = %s", a.SHA256)
+ }
+ after, _, _ := release.ReadSerial(context.Background(), fx.rel)
+ if after.Serial == before.Serial {
+ t.Fatal("serial not bumped")
+ }
+ // A second promote of the same version to stable copies nothing and
+ // refuses different bytes under the same version.
+ st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "stable", "version": "v0.18.0"}, owner)
+ if st != http.StatusOK {
+ t.Fatalf("second update = %d %v", st, out)
+ }
+ if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
+ t.Fatalf("second job: %v", job)
+ }
+ if _, err := fx.rel.Put(context.Background(), a.Key, []byte("different"), ""); err == nil {
+ // Overwrote the object out from under the pointer; a re-promote must refuse.
+ st, out = postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "version": "v0.18.0"}, owner)
+ job := waitJob(t, fx, owner, str(t, out, "job"))
+ if job["error"] == "" || !strings.Contains(job["error"].(string), "immutable") {
+ t.Fatalf("re-promote over different bytes: %v", job)
+ }
+ }
+}
+
+func TestSystemUpdateFromAnUpload(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-upload")
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ meta, _ := mw.CreateFormField("meta")
+ meta.Write([]byte(`{"version":"66587fa","platform":"linux-arm64"}`))
+ file, _ := mw.CreateFormFile("file", "carlos-linux-arm64")
+ file.Write([]byte("ELF local"))
+ mw.Close()
+ req, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("Authorization", "Bearer "+owner)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var up map[string]any
+ if res.StatusCode != http.StatusOK {
+ t.Fatalf("upload = %d", res.StatusCode)
+ }
+ if err := json.NewDecoder(res.Body).Decode(&up); err != nil {
+ t.Fatal(err)
+ }
+ st, out := postJSON(t, fx.ts.URL+"/api/cli/system/update", map[string]any{"channel": "canary", "upload": up["upload"]}, owner)
+ if st != http.StatusOK {
+ t.Fatalf("update = %d %v", st, out)
+ }
+ if job := waitJob(t, fx, owner, str(t, out, "job")); job["error"] != "" {
+ t.Fatalf("job: %v", job)
+ }
+ p, _, err := release.GetSystemPointer(context.Background(), fx.rel, fx.pub, "router", "canary")
+ if err != nil || p.Current.Version != "66587fa" || p.Current.Source != "file" {
+ t.Fatalf("pointer = %+v %v", p, err)
+ }
+ // Upload without a platform is refused at the door.
+ var buf2 bytes.Buffer
+ mw2 := multipart.NewWriter(&buf2)
+ m2, _ := mw2.CreateFormField("meta")
+ m2.Write([]byte(`{"version":"abc"}`))
+ f2, _ := mw2.CreateFormFile("file", "x")
+ f2.Write([]byte("x"))
+ mw2.Close()
+ req2, _ := http.NewRequest(http.MethodPut, fx.ts.URL+"/api/cli/system/upload", &buf2)
+ req2.Header.Set("Content-Type", mw2.FormDataContentType())
+ req2.Header.Set("Authorization", "Bearer "+owner)
+ res2, _ := http.DefaultClient.Do(req2)
+ if res2.StatusCode != http.StatusBadRequest {
+ t.Fatalf("upload without platform = %d", res2.StatusCode)
+ }
+}
+
+func TestSystemStatusAndRollback(t *testing.T) {
+ fx := newConsoleFixture(t)
+ owner := cliToken(t, fx, "paul@keymail.dev", "code-sys-status")
+ seedBox(t, fx, "ie", "linux-arm64", "canary", "v1")
+ sg := release.RootSigner(fx.priv)
+ rel := func(v string) release.SystemRelease {
+ return release.SystemRelease{Version: v, Artifacts: map[string]release.SystemArtifact{"linux-arm64": {Key: release.SystemArtifactKey(v, "linux-arm64"), SHA256: strings.Repeat("a", 64)}}}
+ }
+ release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v1"), release.SystemPromoteOptions{})
+ time.Sleep(1100 * time.Millisecond)
+ release.PromoteSystem(context.Background(), fx.rel, sg, "router", "canary", rel("v2"), release.SystemPromoteOptions{})
+ st, out := getJSON(t, fx.ts.URL+"/api/cli/system/status", owner)
+ if st != http.StatusOK {
+ t.Fatalf("status = %d %v", st, out)
+ }
+ boxes := out["boxes"].([]any)
+ if len(boxes) != 1 {
+ t.Fatalf("boxes = %v", boxes)
+ }
+ b := boxes[0].(map[string]any)
+ if b["label"] != "ie" || b["release"] != "v1" || b["target"] != "v2" || b["channel"] != "canary" {
+ t.Fatalf("box row = %v", b)
+ }
+ time.Sleep(1100 * time.Millisecond)
+ st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner)
+ if st != http.StatusOK || str(t, out, "version") != "v1" {
+ t.Fatalf("rollback = %d %v", st, out)
+ }
+ if st, out = postJSON(t, fx.ts.URL+"/api/cli/system/rollback", map[string]string{"channel": "canary"}, owner); st != http.StatusConflict {
+ t.Fatalf("second rollback = %d %v (nothing left in history)", st, out)
+ }
+}
+```
+(add `"encoding/json"` to the imports.)
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/console/ -run 'TestSystem' -count=1 2>&1); rc=$?; echo "$out" | tail -10; echo rc=$rc`
+Expected: build failure (`SystemFeed` undefined) or 404s.
+
+- [ ] **Step 3: Implement**
+
+`server.go` `Config` gains, next to `Releases`:
+```go
+ // SystemFeed is where `carlos system update` fetches a public release
+ // from. Zero value = the vendor feed; tests point it at a stub.
+ SystemFeed publicrelease.Feed
+```
+and `Server` gains `systemMu sync.Mutex; systemJobs map[string]*systemJob; systemUploads map[string]systemUpload`.
+
+`authcli.go` `registerCLIRoutes()`:
+```go
+ // carlos system (spec §5): ops-account owners, person sessions only.
+ s.cliRoute("GET /api/cli/system/status", svcrole.Set{}, s.handleAPISystemStatus)
+ s.cliRoute("POST /api/cli/system/update", svcrole.Set{}, s.handleAPISystemUpdate)
+ s.cliRoute("GET /api/cli/system/jobs/{id}", svcrole.Set{}, s.handleAPISystemJob)
+ s.cliRoute("PUT /api/cli/system/upload", svcrole.Set{}, s.handleAPISystemUpload)
+ s.cliRoute("POST /api/cli/system/rollback", svcrole.Set{}, s.handleAPISystemRollback)
+```
+
+`apisystem.go`:
+```go
+package console
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "sort"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/carlosframework/platform/internal/publicrelease"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// The console half of `carlos system` (spec §5). Every door here is for an
+// OWNER of the ops account on a PERSON session, checked on every call;
+// refusal is a bare 404 like the operator doors, so a non-owner learns
+// nothing. The console holds the bucket, the signing key and the egress;
+// the operator's laptop holds none of them.
+
+const (
+ systemUploadMaxBytes = int64(128 << 20)
+ systemAssetMaxBytes = int64(256 << 20)
+ systemJobTTL = time.Hour
+)
+
+var systemPlatformRe = targetRe // apicli.go: ^[a-z0-9]+-[a-z0-9]+$
+
+type systemJob struct {
+ mu sync.Mutex
+ Steps []string
+ Done bool
+ Err string
+ at time.Time
+}
+
+func (j *systemJob) step(format string, args ...any) {
+ j.mu.Lock()
+ j.Steps = append(j.Steps, fmt.Sprintf(format, args...))
+ j.mu.Unlock()
+}
+
+func (j *systemJob) finish(err error) {
+ j.mu.Lock()
+ j.Done = true
+ if err != nil {
+ j.Err = err.Error()
+ }
+ j.mu.Unlock()
+}
+
+type systemUpload struct {
+ version, platform string
+ data []byte
+ at time.Time
+}
+
+func (s *Server) apiSystemOwner(w http.ResponseWriter, r *http.Request) (string, bool) {
+ p, ok := principalFrom(r.Context())
+ if !ok || p.Kind != PrincipalUser {
+ http.NotFound(w, r)
+ return "", false
+ }
+ role, err := s.store.Role(OpsAccountID, p.Address)
+ if err != nil {
+ log.Printf("console: api system: role: %v", err)
+ http.NotFound(w, r)
+ return "", false
+ }
+ if role != RoleOwner {
+ http.NotFound(w, r)
+ return "", false
+ }
+ return p.Address, true
+}
+
+func (s *Server) systemFeed() publicrelease.Feed {
+ if s.cfg.SystemFeed.DownloadBase == "" {
+ return publicrelease.Default()
+ }
+ return s.cfg.SystemFeed
+}
+
+// systemBoxes reads every heartbeat: the platform prefix and each fleet's.
+func (s *Server) systemBoxes(ctx context.Context) ([]status.Box, error) {
+ boxes, err := status.LoadBoxes(ctx, s.cfg.Releases)
+ if err != nil {
+ return nil, err
+ }
+ reg, err := s.openPassRegistry()
+ if err != nil {
+ return boxes, nil // no registry: platform boxes only
+ }
+ fleets, err := reg.ListFleets()
+ if err != nil {
+ return boxes, nil
+ }
+ for _, f := range fleets {
+ fb, err := status.LoadBoxes(ctx, release.NewPrefixStore(s.cfg.Releases, "fleets/"+f.Name+"/"))
+ if err != nil {
+ log.Printf("console: api system: fleet %s boxes: %v", f.Name, err)
+ continue
+ }
+ boxes = append(boxes, fb...)
+ }
+ return boxes, nil
+}
+
+type systemUpdateRequest struct {
+ Component string `json:"component"`
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ Upload string `json:"upload"`
+ Spread string `json:"spread"`
+}
+
+func (s *Server) handleAPISystemUpdate(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.apiSystemOwner(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Key == nil {
+ jsonError(w, http.StatusServiceUnavailable, "no signing key on this host — the console is running read-only")
+ return
+ }
+ var in systemUpdateRequest
+ if !readJSONStrictLimit(w, r, &in, 4<<10) {
+ return
+ }
+ if in.Component == "" {
+ in.Component = release.SystemComponentRouter
+ }
+ if in.Component != release.SystemComponentRouter {
+ jsonError(w, http.StatusBadRequest, "unknown component "+in.Component)
+ return
+ }
+ if in.Channel != release.RungCanary && in.Channel != release.RungStable {
+ jsonError(w, http.StatusBadRequest, "channel must be canary or stable")
+ return
+ }
+ spread := 5 * time.Minute
+ if in.Spread != "" {
+ d, err := time.ParseDuration(in.Spread)
+ if err != nil || d < 0 || d > 24*time.Hour {
+ jsonError(w, http.StatusBadRequest, "spread must be a duration between 0 and 24h")
+ return
+ }
+ spread = d
+ }
+ id := s.newSystemJob()
+ job := s.systemJobs[id]
+ go s.runSystemUpdate(context.WithoutCancel(r.Context()), job, addr, in, spread)
+ writeJSON(w, http.StatusOK, map[string]string{"job": id})
+}
+
+func (s *Server) newSystemJob() string {
+ b := make([]byte, 8)
+ rand.Read(b)
+ id := hex.EncodeToString(b)
+ s.systemMu.Lock()
+ defer s.systemMu.Unlock()
+ if s.systemJobs == nil {
+ s.systemJobs = map[string]*systemJob{}
+ }
+ for k, j := range s.systemJobs {
+ if time.Since(j.at) > systemJobTTL {
+ delete(s.systemJobs, k)
+ }
+ }
+ s.systemJobs[id] = &systemJob{at: time.Now()}
+ return id
+}
+
+// runSystemUpdate is the promote: resolve, ingest (or take the upload),
+// mirror, sign, bump. Every step is idempotent, so a lost job is re-run.
+func (s *Server) runSystemUpdate(ctx context.Context, job *systemJob, actor string, in systemUpdateRequest, spread time.Duration) {
+ err := func() error {
+ var rel release.SystemRelease
+ if in.Upload != "" {
+ s.systemMu.Lock()
+ up, ok := s.systemUploads[in.Upload]
+ delete(s.systemUploads, in.Upload)
+ s.systemMu.Unlock()
+ if !ok {
+ return errors.New("upload handle unknown or already consumed")
+ }
+ if in.Version != "" && in.Version != up.version {
+ return fmt.Errorf("upload is %s, request says %s", up.version, in.Version)
+ }
+ job.step("using uploaded %s for %s", up.version, up.platform)
+ sum := sha256.Sum256(up.data)
+ rel = release.SystemRelease{Version: up.version, Source: "file", Artifacts: map[string]release.SystemArtifact{}}
+ if err := s.mirrorSystemArtifact(ctx, job, up.version, up.platform, up.data, hex.EncodeToString(sum[:])); err != nil {
+ return err
+ }
+ rel.Artifacts[up.platform] = release.SystemArtifact{Key: release.SystemArtifactKey(up.version, up.platform), SHA256: hex.EncodeToString(sum[:])}
+ } else {
+ feed := s.systemFeed()
+ tag := in.Version
+ if tag == "" {
+ var err error
+ if tag, err = feed.Latest(ctx); err != nil {
+ return err
+ }
+ }
+ if !publicrelease.TagRe.MatchString(tag) {
+ return fmt.Errorf("%q is not a release tag; use --file for a local build", tag)
+ }
+ job.step("resolved %s", tag)
+ platforms, err := s.systemPlatforms(ctx)
+ if err != nil {
+ return err
+ }
+ rel = release.SystemRelease{Version: tag, Source: "release:" + tag, Artifacts: map[string]release.SystemArtifact{}}
+ for _, plat := range platforms {
+ data, sha, err := feed.Asset(ctx, tag, publicrelease.AssetName(plat), systemAssetMaxBytes)
+ if err != nil {
+ return fmt.Errorf("%s: %w", plat, err)
+ }
+ job.step("verified %s (%d bytes)", publicrelease.AssetName(plat), len(data))
+ if err := s.mirrorSystemArtifact(ctx, job, tag, plat, data, sha); err != nil {
+ return err
+ }
+ rel.Artifacts[plat] = release.SystemArtifact{Key: release.SystemArtifactKey(tag, plat), SHA256: sha}
+ }
+ }
+ p, err := release.PromoteSystem(ctx, s.cfg.Releases, release.RootSigner(s.cfg.Key), in.Component, in.Channel, rel,
+ release.SystemPromoteOptions{Actor: actor, Spread: spread})
+ if err != nil {
+ return err
+ }
+ job.step("pointed %s/%s at %s (spread %s)", in.Component, in.Channel, p.Current.Version, p.Spread)
+ s.audit(nil, "platform", actor, "system-update", "pointed "+in.Component+"/"+in.Channel+" at "+p.Current.Version)
+ s.bumpSerial(ctx)
+ job.step("serial bumped: boxes on %s start their spread", in.Channel)
+ return nil
+ }()
+ job.finish(err)
+}
+
+// systemPlatforms: the distinct platforms the fleet reports; linux-arm64
+// when nothing has reported the field yet.
+func (s *Server) systemPlatforms(ctx context.Context) ([]string, error) {
+ boxes, err := s.systemBoxes(ctx)
+ if err != nil {
+ return nil, err
+ }
+ set := map[string]bool{}
+ for _, b := range boxes {
+ if b.Metrics != nil && b.Metrics.Platform != "" {
+ set[b.Metrics.Platform] = true
+ }
+ }
+ if len(set) == 0 {
+ set["linux-arm64"] = true
+ }
+ var out []string
+ for p := range set {
+ out = append(out, p)
+ }
+ sort.Strings(out)
+ return out, nil
+}
+
+// mirrorSystemArtifact: present with the same sha → skip; present with a
+// different sha → refuse (versions are immutable, spec §3).
+func (s *Server) mirrorSystemArtifact(ctx context.Context, job *systemJob, version, platform string, data []byte, sha string) error {
+ key := release.SystemArtifactKey(version, platform)
+ existing, etag, err := s.cfg.Releases.Get(ctx, key)
+ switch {
+ case err == nil:
+ sum := sha256.Sum256(existing)
+ if hex.EncodeToString(sum[:]) == sha {
+ job.step("%s already mirrored", key)
+ return nil
+ }
+ return fmt.Errorf("%s exists with different bytes — versions are immutable; pick a new version", key)
+ case errors.Is(err, os.ErrNotExist):
+ etag = ""
+ default:
+ return err
+ }
+ if _, err := s.cfg.Releases.Put(ctx, key, data, etag); err != nil {
+ return err
+ }
+ job.step("mirrored %s", key)
+ return nil
+}
+
+func (s *Server) handleAPISystemJob(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.apiSystemOwner(w, r); !ok {
+ return
+ }
+ s.systemMu.Lock()
+ job, ok := s.systemJobs[r.PathValue("id")]
+ s.systemMu.Unlock()
+ if !ok {
+ jsonError(w, http.StatusNotFound, "no such job — the console may have restarted; re-run, every step is idempotent")
+ return
+ }
+ job.mu.Lock()
+ defer job.mu.Unlock()
+ writeJSON(w, http.StatusOK, map[string]any{"steps": append([]string(nil), job.Steps...), "done": job.Done, "error": job.Err})
+}
+
+func (s *Server) handleAPISystemUpload(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.apiSystemOwner(w, r); !ok {
+ return
+ }
+ r.Body = http.MaxBytesReader(w, r.Body, systemUploadMaxBytes+64<<10)
+ mr, err := r.MultipartReader()
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, "multipart body required")
+ return
+ }
+ var meta struct {
+ Version string `json:"version"`
+ Platform string `json:"platform"`
+ }
+ first, err := mr.NextPart()
+ if err != nil || first.FormName() != "meta" {
+ jsonError(w, http.StatusBadRequest, "first part must be meta")
+ return
+ }
+ if err := json.NewDecoder(io.LimitReader(first, 4<<10)).Decode(&meta); err != nil {
+ jsonError(w, http.StatusBadRequest, "meta: "+err.Error())
+ return
+ }
+ if !release.ValidVersion(meta.Version) || meta.Version == "dev" {
+ jsonError(w, http.StatusBadRequest, "meta.version is required and must be a version")
+ return
+ }
+ if !systemPlatformRe.MatchString(meta.Platform) {
+ jsonError(w, http.StatusBadRequest, "meta.platform is required (e.g. linux-arm64): the console cannot run a foreign binary to ask it")
+ return
+ }
+ part, err := mr.NextPart()
+ if err != nil || part.FormName() != "file" {
+ jsonError(w, http.StatusBadRequest, "second part must be file")
+ return
+ }
+ data, err := io.ReadAll(io.LimitReader(part, systemUploadMaxBytes+1))
+ if err != nil {
+ jsonError(w, http.StatusBadRequest, "file: "+err.Error())
+ return
+ }
+ if int64(len(data)) > systemUploadMaxBytes {
+ jsonError(w, http.StatusRequestEntityTooLarge, "file exceeds 128 MiB")
+ return
+ }
+ b := make([]byte, 8)
+ rand.Read(b)
+ handle := hex.EncodeToString(b)
+ s.systemMu.Lock()
+ if s.systemUploads == nil {
+ s.systemUploads = map[string]systemUpload{}
+ }
+ for k := range s.systemUploads { // one slot: a forgotten upload is not a memory leak
+ delete(s.systemUploads, k)
+ }
+ s.systemUploads[handle] = systemUpload{version: meta.Version, platform: meta.Platform, data: data, at: time.Now()}
+ s.systemMu.Unlock()
+ writeJSON(w, http.StatusOK, map[string]any{"upload": handle, "size": len(data)})
+}
+
+func (s *Server) handleAPISystemRollback(w http.ResponseWriter, r *http.Request) {
+ addr, ok := s.apiSystemOwner(w, r)
+ if !ok {
+ return
+ }
+ if s.cfg.Key == nil {
+ jsonError(w, http.StatusServiceUnavailable, "no signing key on this host — the console is running read-only")
+ return
+ }
+ var in struct {
+ Component string `json:"component"`
+ Channel string `json:"channel"`
+ }
+ if !readJSONStrictLimit(w, r, &in, 1<<10) {
+ return
+ }
+ if in.Component == "" {
+ in.Component = release.SystemComponentRouter
+ }
+ p, err := release.RollbackSystem(r.Context(), s.cfg.Releases, release.RootSigner(s.cfg.Key), in.Component, in.Channel, addr)
+ switch {
+ case errors.Is(err, release.ErrNoSystemHistory):
+ jsonError(w, http.StatusConflict, err.Error())
+ return
+ case errors.Is(err, os.ErrNotExist):
+ jsonError(w, http.StatusNotFound, "no "+in.Channel+" pointer to roll back")
+ return
+ case err != nil:
+ jsonError(w, http.StatusInternalServerError, err.Error())
+ return
+ }
+ s.audit(nil, "platform", addr, "system-rollback", "pointed "+in.Component+"/"+in.Channel+" back at "+p.Current.Version)
+ s.bumpSerial(r.Context())
+ writeJSON(w, http.StatusOK, map[string]string{"channel": in.Channel, "version": p.Current.Version})
+}
+
+func (s *Server) handleAPISystemStatus(w http.ResponseWriter, r *http.Request) {
+ if _, ok := s.apiSystemOwner(w, r); !ok {
+ return
+ }
+ ctx := r.Context()
+ pointers := map[string]any{}
+ targets := map[string]string{}
+ for _, ch := range []string{release.RungCanary, release.RungStable} {
+ p, _, err := release.GetSystemPointer(ctx, s.cfg.Releases, s.cfg.ReleasePub, release.SystemComponentRouter, ch)
+ if err != nil {
+ pointers[ch] = nil
+ continue
+ }
+ pointers[ch] = map[string]any{"version": p.Current.Version, "promoted_at": p.PromotedAt, "spread": p.Spread, "source": p.Current.Source,
+ "history": len(p.History)}
+ targets[ch] = p.Current.Version
+ }
+ boxes, err := s.systemBoxes(ctx)
+ if err != nil {
+ jsonError(w, http.StatusBadGateway, "box heartbeats unreadable: "+err.Error())
+ return
+ }
+ now := time.Now()
+ rows := make([]map[string]any, 0, len(boxes))
+ for _, b := range boxes {
+ row := map[string]any{"label": b.Label, "region": b.Region, "stale": status.BoxStale(b, now, status.SnapshotPeriod),
+ "platform": "", "channel": "", "release": "", "target": "", "system": nil}
+ if b.Metrics != nil {
+ ch := b.Metrics.SystemChannel
+ if ch == "" {
+ ch = "stable"
+ }
+ row["platform"], row["channel"], row["release"] = b.Metrics.Platform, ch, b.Metrics.Release
+ row["target"] = targets[ch]
+ if b.Metrics.System != nil {
+ row["system"] = b.Metrics.System
+ }
+ }
+ rows = append(rows, row)
+ }
+ sort.Slice(rows, func(i, j int) bool { return rows[i]["label"].(string) < rows[j]["label"].(string) })
+ writeJSON(w, http.StatusOK, map[string]any{"pointers": pointers, "boxes": rows})
+}
+```
+Check three things against the code while writing: `s.audit`'s signature (`apicli.go:855` passes `(acct, app, addr, action, detail)`; if `acct` cannot be nil, use the ops account fetched by id, or add a `s.auditSystem(addr, action, detail)` writing an event row against `OpsAccountID`); `status.BoxStale`'s signature (`fleet.go:137`); `os` import. `readJSONStrictLimit` is at `authcli.go:849`.
+
+- [ ] **Step 4: Run the console tests**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/console/ -count=1 2>&1); rc=$?; echo "$out" | tail -20; echo rc=$rc`
+Expected: `ok`. `TestEveryCLIDoorNamesItsRoles` and `TestEveryClosedDoorRefusesAServiceCredential` cover the five new doors automatically; if either fails, the route line is wrong, not the test.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/console/ && git add internal/console/apisystem.go internal/console/apisystem_test.go internal/console/authcli.go internal/console/server.go && git commit -q -F - <<'EOF'
+console: the carlos system doors — status, update job, upload, rollback
+
+Ops-account owners on a person session, bare 404 otherwise. The
+update is a job: resolve, ingest from the signed public feed (or an
+upload), mirror per reported platform, sign the pointer, bump the
+serial. Versions are immutable in the bucket.
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+### Task 12: `carlos system update | status | rollback` (API mode)
+
+**Files:**
+- Modify: `system.go` (replace the three placeholders), `system_test.go`
+
+**Interfaces:**
+- Consumes: `requireDeployAPISession(consoleFlag) *apiSession` (`deploy.go:1151`); `(*apiSession).getJSON/postJSON` (`apimode.go:737,799`); `(*consoleClient).doMultipart` (`auth.go:485`) via `sess.c`; `writeFilePart(mw, name, path)` (`apimode.go:3009`); `interactiveTerminal`, `promptYes` (`account_choose.go`); `stdout`, `colorFor`, `mutedf` (`color.go`); `flagUsage`.
+- Produces:
+ ```go
+ func runSystemUpdate(args []string) // --console --channel --version --file --platform --spread --yes
+ func runSystemStatus(args []string) // --console --channel
+ func runSystemRollback(args []string) // --console --channel --yes
+ func printSystemStatus(w io.Writer, resp systemStatusResponse, channel string)
+ type systemStatusResponse struct{ Pointers map[string]*systemPointerRow; Boxes []systemBoxRow }
+ ```
+
+- [ ] **Step 1: Write the failing test** (the pure renderer; the doors are covered in Task 11)
+
+```go
+func TestPrintSystemStatusSaysWhatEachBoxIsDoing(t *testing.T) {
+ resp := systemStatusResponse{
+ Pointers: map[string]*systemPointerRow{"canary": {Version: "v2", Spread: "5m0s"}, "stable": nil},
+ Boxes: []systemBoxRow{
+ {Label: "ie", Platform: "linux-arm64", Channel: "canary", Release: "v2", Target: "v2"},
+ {Label: "us", Platform: "linux-arm64", Channel: "canary", Release: "v1", Target: "v2",
+ System: &systemStateRow{Version: "v2", Outcome: "rolled-back", Reason: "gate: carlos-edge is failed"}},
+ {Label: "au", Platform: "", Channel: "stable", Release: "v1", Target: "", Stale: true},
+ },
+ }
+ var b strings.Builder
+ printSystemStatus(&b, resp, "")
+ out := b.String()
+ for _, want := range []string{"canary v2", "stable (no pointer)", "ie ", "current", "us ", "rolled-back", "gate: carlos-edge is failed", "au ", "UNREACHABLE", "pre-upgrade"} {
+ if !strings.Contains(out, want) {
+ t.Errorf("status output lacks %q:\n%s", want, out)
+ }
+ }
+ var only strings.Builder
+ printSystemStatus(&only, resp, "stable")
+ if strings.Contains(only.String(), "ie ") {
+ t.Error("--channel stable still listed a canary box")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test . -run TestPrintSystemStatus -count=1 2>&1); rc=$?; echo "$out" | tail -5; echo rc=$rc`
+Expected: undefined types.
+
+- [ ] **Step 3: Implement**
+
+Replace the placeholders in `system.go`:
+```go
+type systemPointerRow struct {
+ Version string `json:"version"`
+ PromotedAt time.Time `json:"promoted_at"`
+ Spread string `json:"spread"`
+ Source string `json:"source"`
+ History int `json:"history"`
+}
+
+type systemStateRow struct {
+ Channel string `json:"channel"`
+ Version string `json:"version"`
+ PromotedAt time.Time `json:"promoted_at"`
+ Outcome string `json:"outcome"`
+ Reason string `json:"reason"`
+ At time.Time `json:"at"`
+}
+
+type systemBoxRow struct {
+ Label string `json:"label"`
+ Region string `json:"region"`
+ Platform string `json:"platform"`
+ Channel string `json:"channel"`
+ Release string `json:"release"`
+ Target string `json:"target"`
+ System *systemStateRow `json:"system"`
+ Stale bool `json:"stale"`
+}
+
+type systemStatusResponse struct {
+ Pointers map[string]*systemPointerRow `json:"pointers"`
+ Boxes []systemBoxRow `json:"boxes"`
+}
+
+func runSystemUpdate(args []string) {
+ fs := flag.NewFlagSet("system update", flag.ExitOnError)
+ consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)")
+ channel := fs.String("channel", "stable", "canary or stable")
+ version := fs.String("version", "", "release tag to promote (default: the latest public release); with --file, the version the binary reports")
+ file := fs.String("file", "", "a locally built binary instead of a public release (dev and pre-release)")
+ platform := fs.String("platform", "linux-arm64", "with --file: the binary's <goos>-<goarch>")
+ spread := fs.String("spread", "5m", "boxes jitter their restarts over this; 0 for a hotfix")
+ yes := fs.Bool("yes", false, "do not ask")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ if *file != "" && *version == "" {
+ log.Fatal("--file needs --version: the console cannot run a foreign-arch binary to ask it")
+ }
+ sess := requireDeployAPISession(*consoleFlag)
+ ctx := context.Background()
+
+ // What this reaches, before it is done.
+ st, body, err := sess.getJSON(ctx, "/api/cli/system/status")
+ if err != nil || st != http.StatusOK {
+ log.Fatalf("carlos system status: %s", apiErrorFromBody(st, body, err))
+ }
+ var cur systemStatusResponse
+ _ = json.Unmarshal(body, &cur)
+ var reach []string
+ for _, b := range cur.Boxes {
+ if b.Channel == *channel {
+ reach = append(reach, fmt.Sprintf("%s (%s, running %s)", b.Label, b.Platform, orDash(b.Release)))
+ }
+ }
+ what := "the latest public release"
+ if *version != "" {
+ what = *version
+ }
+ fmt.Fprintf(stdout, "promote %s to %s/%s — %d box(es) will roll over %s:\n", what, systemComponentRouter, *channel, len(reach), *spread)
+ for _, r := range reach {
+ fmt.Fprintf(stdout, " %s\n", r)
+ }
+ if len(reach) == 0 {
+ mutedf(stdout, " (no box reports channel %s yet; boxes on an older build count as stable)\n", *channel)
+ }
+ if !*yes {
+ if !interactiveTerminal() {
+ log.Fatal("no terminal to confirm on; pass --yes")
+ }
+ if !promptYes(stdout, "go ahead? [y/N] ") {
+ return
+ }
+ }
+
+ in := map[string]any{"channel": *channel, "spread": *spread}
+ if *version != "" {
+ in["version"] = *version
+ }
+ if *file != "" {
+ handle, err := sess.uploadSystemBinary(ctx, *file, *version, *platform)
+ if err != nil {
+ log.Fatalf("upload: %v", err)
+ }
+ in["upload"] = handle
+ }
+ st, body, err = sess.postJSON(ctx, "/api/cli/system/update", in)
+ if err != nil || st != http.StatusOK {
+ log.Fatalf("carlos system update: %s", apiErrorFromBody(st, body, err))
+ }
+ var started struct{ Job string `json:"job"` }
+ _ = json.Unmarshal(body, &started)
+ if err := sess.watchSystemJob(ctx, started.Job, stdout); err != nil {
+ log.Fatal(err)
+ }
+ fmt.Fprintf(stdout, "%s boxes on %s roll themselves; watch with: carlos system status --channel %s\n", colorFor(stdout).ok("promoted"), *channel, *channel)
+}
+
+// watchSystemJob polls the job and prints each step once, as deploy's
+// watchVersion does for a route.
+func (s *apiSession) watchSystemJob(ctx context.Context, id string, w io.Writer) error {
+ printed := 0
+ deadline := time.Now().Add(15 * time.Minute)
+ for {
+ st, body, err := s.getJSON(ctx, "/api/cli/system/jobs/"+id)
+ if err != nil || st != http.StatusOK {
+ return fmt.Errorf("job %s: %s", id, apiErrorFromBody(st, body, err))
+ }
+ var j struct {
+ Steps []string `json:"steps"`
+ Done bool `json:"done"`
+ Error string `json:"error"`
+ }
+ _ = json.Unmarshal(body, &j)
+ for ; printed < len(j.Steps); printed++ {
+ fmt.Fprintf(w, " %s\n", j.Steps[printed])
+ }
+ if j.Done {
+ if j.Error != "" {
+ return fmt.Errorf("carlos system update: %s", j.Error)
+ }
+ return nil
+ }
+ if time.Now().After(deadline) {
+ return errors.New("carlos system update: the console's job did not finish in 15 minutes")
+ }
+ time.Sleep(time.Second)
+ }
+}
+
+func (s *apiSession) uploadSystemBinary(ctx context.Context, path, version, platform string) (string, error) {
+ st, body, err := s.c.doMultipart(ctx, "/api/cli/system/upload", func(mw *multipart.Writer) error {
+ meta, err := mw.CreateFormField("meta")
+ if err != nil {
+ return err
+ }
+ if err := json.NewEncoder(meta).Encode(map[string]string{"version": version, "platform": platform}); err != nil {
+ return err
+ }
+ return writeFilePart(mw, "file", path)
+ })
+ if err != nil || st != http.StatusOK {
+ return "", fmt.Errorf("%s", apiErrorFromBody(st, body, err))
+ }
+ var out struct{ Upload string `json:"upload"` }
+ _ = json.Unmarshal(body, &out)
+ return out.Upload, nil
+}
+
+func runSystemStatus(args []string) {
+ fs := flag.NewFlagSet("system status", flag.ExitOnError)
+ consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)")
+ channel := fs.String("channel", "", "only boxes on this channel")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ sess := requireDeployAPISession(*consoleFlag)
+ st, body, err := sess.getJSON(context.Background(), "/api/cli/system/status")
+ if err != nil || st != http.StatusOK {
+ log.Fatalf("carlos system status: %s", apiErrorFromBody(st, body, err))
+ }
+ var resp systemStatusResponse
+ if err := json.Unmarshal(body, &resp); err != nil {
+ log.Fatalf("carlos system status: %v", err)
+ }
+ printSystemStatus(stdout, resp, *channel)
+}
+
+func printSystemStatus(w io.Writer, resp systemStatusResponse, channel string) {
+ for _, ch := range []string{"canary", "stable"} {
+ if channel != "" && ch != channel {
+ continue
+ }
+ p := resp.Pointers[ch]
+ if p == nil {
+ fmt.Fprintf(w, "%-7s (no pointer)\n", ch)
+ continue
+ }
+ fmt.Fprintf(w, "%-7s %s promoted %s spread %s %s\n", ch, p.Version, p.PromotedAt.Format(time.RFC3339), p.Spread, p.Source)
+ }
+ fmt.Fprintln(w)
+ c := colorFor(w)
+ for _, b := range resp.Boxes {
+ if channel != "" && b.Channel != channel {
+ continue
+ }
+ state := ""
+ switch {
+ case b.Stale:
+ state = c.bad("UNREACHABLE")
+ case b.Platform == "":
+ state = c.warn("pre-upgrade (no platform reported; counts as stable)")
+ case b.Target == "":
+ state = "no target"
+ case b.Release == b.Target:
+ state = c.ok("current")
+ default:
+ state = c.warn("behind → " + b.Target)
+ }
+ fmt.Fprintf(w, "%-20s %-12s %-7s %-10s %s", b.Label, orDash(b.Platform), b.Channel, orDash(b.Release), state)
+ if b.System != nil && b.System.Outcome != "applied" && b.System.Outcome != "noted" {
+ fmt.Fprintf(w, " %s %s: %s", c.bad(b.System.Outcome), b.System.Version, b.System.Reason)
+ }
+ fmt.Fprintln(w)
+ }
+}
+
+func runSystemRollback(args []string) {
+ fs := flag.NewFlagSet("system rollback", flag.ExitOnError)
+ consoleFlag := fs.String("console", "", "console origin (default: the only logged-in one)")
+ channel := fs.String("channel", "", "canary or stable (required)")
+ yes := fs.Bool("yes", false, "do not ask")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ if *channel == "" {
+ log.Fatal("--channel is required")
+ }
+ sess := requireDeployAPISession(*consoleFlag)
+ if !*yes {
+ if !interactiveTerminal() {
+ log.Fatal("no terminal to confirm on; pass --yes")
+ }
+ if !promptYes(stdout, fmt.Sprintf("point %s back at the release before this one? every box on it rolls back [y/N] ", *channel)) {
+ return
+ }
+ }
+ st, body, err := sess.postJSON(context.Background(), "/api/cli/system/rollback", map[string]string{"channel": *channel})
+ if err != nil || st != http.StatusOK {
+ log.Fatalf("carlos system rollback: %s", apiErrorFromBody(st, body, err))
+ }
+ var out struct{ Version string `json:"version"` }
+ _ = json.Unmarshal(body, &out)
+ fmt.Fprintf(stdout, "%s %s → %s\n", colorFor(stdout).ok("rolled back"), *channel, out.Version)
+}
+```
+Imports: `context`, `encoding/json`, `errors`, `flag`, `io`, `mime/multipart`, `net/http`, `time`. `orDash` exists in `cli.go:3327+`; `apiErrorFromBody` exists in `apimode.go` (confirm its signature with `grep -n 'func apiErrorFromBody' *.go`; adapt the three-argument call if it takes fewer). `sess.c` is the `*consoleClient` field on `apiSession` (confirm with `grep -n 'type apiSession struct' -A 6 apimode.go`).
+
+- [ ] **Step 4: Run the tests and try the binary against a fixture console**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test . -run 'TestPrintSystemStatus|TestSystemVerb' -count=1 2>&1); rc=$?; echo "$out" | tail -5; echo rc=$rc; go vet . `
+Expected: PASS, vet clean.
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l . && git add system.go system_test.go && git commit -q -F - <<'EOF'
+cli: carlos system update, status, rollback through the console
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 13: The hourly belt — `carlos adopt` arms the self-update units
+
+**Files:**
+- Modify: `internal/host/agent.go` `RunAdopt` (`:2811`; insert before `openServiceRegistry` at `:2825`)
+- Create: `internal/host/systemunits.go`, `internal/host/systemunits_test.go`
+
+**Interfaces:**
+- Consumes: `unitsFS`, `systemctlPlan`, `copyFileDurable`, a `sysOps`-shaped `systemctl` func.
+- Produces:
+ ```go
+ // ensureSystemUnits installs and arms carlos-system-update.path/.service
+ // and carlos-system-recover.service when any is missing from etcDir or
+ // the path unit is inactive / the recover unit not enabled. Non-fatal.
+ func ensureSystemUnits(etcDir string, systemctl func(args ...string) (string, error), logf func(string, ...any))
+ ```
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestEnsureSystemUnitsInstallsAndArmsWhatIsMissing(t *testing.T) {
+ etc := t.TempDir()
+ os.MkdirAll(filepath.Join(etc, "systemd", "system"), 0o755)
+ var calls []string
+ systemctl := func(args ...string) (string, error) {
+ calls = append(calls, strings.Join(args, " "))
+ switch args[0] {
+ case "is-active":
+ return "inactive\n", nil
+ case "is-enabled":
+ return "disabled\n", nil
+ }
+ return "", nil
+ }
+ ensureSystemUnits(etc, systemctl, t.Logf)
+ for _, u := range []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"} {
+ if _, err := os.Stat(filepath.Join(etc, "systemd", "system", u)); err != nil {
+ t.Errorf("%s not installed", u)
+ }
+ }
+ joined := strings.Join(calls, "\n")
+ for _, want := range []string{"daemon-reload", "enable carlos-system-update.path carlos-system-recover.service", "start carlos-system-update.path"} {
+ if !strings.Contains(joined, want) {
+ t.Errorf("calls lack %q:\n%s", want, joined)
+ }
+ }
+ if strings.Contains(joined, "start carlos-system-recover.service") || strings.Contains(joined, "start carlos-system-update.service") {
+ t.Fatal("a service was started")
+ }
+ // All present and armed: no writes, no systemctl beyond the two checks.
+ calls = nil
+ systemctl2 := func(args ...string) (string, error) {
+ calls = append(calls, strings.Join(args, " "))
+ switch args[0] {
+ case "is-active":
+ return "active\n", nil
+ case "is-enabled":
+ return "enabled\n", nil
+ }
+ return "", nil
+ }
+ ensureSystemUnits(etc, systemctl2, t.Logf)
+ if len(calls) != 2 {
+ t.Fatalf("an armed box got %v", calls)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -run TestEnsureSystemUnits -count=1 2>&1); rc=$?; echo "$out" | tail -5; echo rc=$rc`
+
+- [ ] **Step 3: Implement**
+
+```go
+package host
+
+import (
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+var systemUnitFiles = []string{"carlos-system-update.path", "carlos-system-update.service", "carlos-system-recover.service"}
+
+// ensureSystemUnits is the second belt of spec §7: a box the roll script
+// forgot to arm is armed within the hour by the root adopt pass. It
+// installs the embedded units when missing and arms them through
+// systemctlPlan (never starting a service). Non-fatal: it runs before
+// RunAdopt's registry and pubkey gates, and logs instead of exiting.
+func ensureSystemUnits(etcDir string, systemctl func(args ...string) (string, error), logf func(string, ...any)) {
+ unitDir := filepath.Join(etcDir, "systemd", "system")
+ missing := false
+ for _, u := range systemUnitFiles {
+ if _, err := os.Stat(filepath.Join(unitDir, u)); err != nil {
+ missing = true
+ }
+ }
+ active, _ := systemctl("is-active", systemUpdatePathUnit)
+ enabled, _ := systemctl("is-enabled", "carlos-system-recover.service")
+ if !missing && strings.TrimSpace(active) == "active" && strings.TrimSpace(enabled) == "enabled" {
+ return
+ }
+ for _, u := range systemUnitFiles {
+ b, err := unitsFS.ReadFile("units/" + u)
+ if err != nil {
+ logf("adopt: system units: %v", err)
+ return
+ }
+ if err := writeFileDurable(filepath.Join(unitDir, u), b, 0o644); err != nil {
+ logf("adopt: system units: %v", err)
+ return
+ }
+ }
+ for _, args := range systemctlPlan([]string{systemUpdatePathUnit, "carlos-system-recover.service"}) {
+ if _, err := systemctl(args...); err != nil {
+ logf("adopt: system units: systemctl %s: %v", strings.Join(args, " "), err)
+ return
+ }
+ }
+ logf("adopt: self-update units installed and armed")
+}
+```
+In `RunAdopt`, immediately after `fs.Parse(args)` and before `openServiceRegistry`:
+```go
+ // Spec §7 belt 2: arm the self-update relay if the last hand roll did
+ // not. Before the fatal gates below, and never fatal itself.
+ if !*restartOnly {
+ ensureSystemUnits("/etc", productionSysOps().systemctl, log.Printf)
+ }
+```
+
+- [ ] **Step 4: Run**
+
+Run: `export TMPDIR=/tmp/c; out=$(go test ./internal/host/ -count=1 2>&1); rc=$?; echo "$out" | tail -10; echo rc=$rc`
+
+- [ ] **Step 5: Commit**
+
+```bash
+gofmt -l internal/host/ && git add internal/host/systemunits.go internal/host/systemunits_test.go internal/host/agent.go && git commit -q -F - <<'EOF'
+host: the hourly adopt pass arms the self-update units if the roll script did not
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+```
+
+---
+
+### Task 14: Docs, the env contract, and the last two script lines
+
+**Files:**
+- Modify: `docs/site/cli.md` (after `### carlos status`, ~line 930), `AGENTS.md` (the section that names `carlos-stage.path` / `update-platform-binary.sh`, ~lines 246-277)
+- Out of repo, hand off (do not edit from this checkout): `platform-infrastructure/scripts/update-platform-binary.sh` and the Tito copy in `amadan.net/tito/carlos-infrastructure/scripts/update-platform-binary.sh`
+
+- [ ] **Step 1: `docs/site/cli.md`** — add under `## Your deployment`, after `### carlos status`:
+
+````markdown
+### carlos system
+
+The platform's own release channel. `update` promotes a public release (or,
+with `--file`, a binary you built) to `canary` or `stable`; every box on that
+channel fetches it through its normal store, verifies it against the signed
+pointer, installs it, restarts its edge, and rolls back to the previous
+binary if the new one does not come up and stay up. `status` shows each box's
+platform, channel, running build, the channel's target, and the last apply
+outcome with its reason. `rollback` points a channel back at the release
+before this one, and every box on it rolls back the same way it rolled
+forward. All three talk to your console; you need to be an owner of the ops
+account.
+
+```sh
+carlos system update --channel canary
+carlos system status --channel canary
+carlos system update --channel stable --spread 10m
+carlos system rollback --channel stable
+```
+
+A box is put on canary with `CARLOS_SYSTEM_CHANNEL=canary` in its host.env.
+Boxes that have not reported a platform yet count as stable. `carlos system
+apply` is the root oneshot behind `carlos-system-update.service`; it is not
+for operators. The console is an app — update it with `carlos deploy --app
+console`.
+````
+
+- [ ] **Step 2: `AGENTS.md`** — in the section describing the root relays, add after the `carlos-restart.path` paragraph:
+
+```markdown
+- `carlos-system-update.path` relays the agent's platform self-update request
+ to `carlos system apply` (root; spec 2026-09-06-carlos-system-update-design.md
+ §6). `carlos-system-recover.service` runs once per boot, before the edge,
+ and on the apply's failure. Both must be armed; the last run of
+ `update-platform-binary.sh` does it, and the hourly `carlos adopt` pass
+ installs and arms them if it did not. A box follows `CARLOS_SYSTEM_CHANNEL`
+ (canary|stable, default stable) in host.env.
+```
+
+- [ ] **Step 3: The pinfra hand-off** — write `docs/superpowers/plans/2026-09-06-carlos-system-update-pinfra.md` with exactly this content, for whoever edits the two scripts (they are in other repos; this checkout does not touch them):
+
+```markdown
+# update-platform-binary.sh: the last two lines it needs
+
+After the `carlos-restart.path` block, guarded the same way:
+
+ if [ -f /etc/systemd/system/carlos-system-update.path ]; then
+ systemctl enable --now carlos-system-update.path
+ systemctl enable carlos-system-recover.service
+ fi
+
+And to the post-conditions at the end:
+
+ systemctl is-active carlos-system-update.path
+ systemctl is-enabled carlos-system-recover.service
+
+Same change in amadan.net/tito/carlos-infrastructure/scripts/update-platform-binary.sh.
+After every deployment has been hand-rolled once onto a binary carrying the
+system-update spec, the script is retired (spec §10).
+```
+
+- [ ] **Step 4: Full gate**
+
+Run: `export TMPDIR=/tmp/c; out=$(make test 2>&1); rc=$?; echo "$out" | tail -30; echo rc=$rc`
+Expected: rc=0 (vet, gofmt, every package).
+
+- [ ] **Step 5: Commit, push, describe, land**
+
+```bash
+git add docs/site/cli.md AGENTS.md docs/superpowers/plans/2026-09-06-carlos-system-update-pinfra.md && git commit -q -F - <<'EOF'
+docs: carlos system, the env contract, and the roll script's last two lines
+
+Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
+Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
+EOF
+git push -u origin carlos-system-update
+amadan branch describe carlos/platform carlos-system-update -body - <<'EOF'
+carlos system update: the platform rolls itself. Spec: docs/superpowers/specs/2026-09-06-carlos-system-update-design.md. Plan: docs/superpowers/plans/2026-09-06-carlos-system-update.md.
+EOF
+# then: amadan ci status carlos/platform -branch carlos-system-update (until 0)
+# then: amadan branch merge carlos/platform carlos-system-update -expect <full sha>
+```
+
+---
+
+## Self-review
+
+**Spec coverage.** §3 pointer, keys, immutability, spread, history-of-releases, rollback-is-new-promote, door grants, streaming read → Tasks 1, 2, 3. §4 leg (observe/tick, re-touch, watermark check, suppression, jitter, streamed fetch, request shape, deferred retry), units, exec shim, `enabledUnits` → Tasks 6, 7. §5 authorization, job, upload, update/status/rollback, `router` alias, no console verb → Tasks 9, 11, 12. §6 root state files, durability, lock, recovery-first (forward from gated), request walk, channel from env, replay guard per channel, no-op, staging copy 0600→0755, `.prev` decision, both unit sets from their binaries, quiesce incl. services, removed units, sysusers→tmpfiles→daemon-reload, reset-failed, gate+soak, arm after gate via `systemctlPlan`, record, rollback, rollback-failed → Tasks 4, 8a–8c. §7 first-roll arming (script + hourly belt), env contract → Tasks 13, 14. §9/§10 nothing to build. §11 unit-level proof: every bullet maps to a test in Tasks 1–13; the live checks are the operator's after landing.
+
+**Gaps found and fixed in review:** the spec's `release.Artifact` name collides with an existing type — Task 1 uses `SystemArtifact`; the spec's "interface gains `Open`" would break 48 fakes — Task 2 uses an `Opener` side-interface; the spec's 403 on the system doors is a bare 404 here, matching the console's operator doors; the spec's "ops-account owner" is `Role(OpsAccountID) == RoleOwner`, not `IsOperator` — Task 11 says which.
+
+**Placeholder scan.** Tasks 9 and 8a/8b carry explicit stubs that the next task replaces (`errNotYetImplemented`, the placeholder verbs, the stub `rollback`); each is named and replaced in a listed task. No "TBD", no "similar to".
+
+**Type consistency.** `systemRequest{Version, PromotedAt, NoOp, SeenAt}` is written by Task 7 and read by 8a; `systemResult.matches/terminal` are used identically in 7 and 8a; `bootstrapPlan{Enabled, Inert}` is produced by Task 6 and consumed by 8b/8c/13; `sysOps` fields are the same in 8a's fixture and 8c's `productionSysOps`; `release.SystemPromoteOptions{Actor, Spread}` in 1, 7's fixture and 11; `systemStatusResponse` in 12 matches the JSON Task 11 writes (`pointers`, `boxes` with `label, region, platform, channel, release, target, system, stale`).