carlos / platform Public

release: bake clock lives in the data dir — the sandboxed edge could never write /opt (#13)

carlos-edge.service runs ProtectSystem=strict with ReadWritePaths=/data
/run/carlos, so the in-process adopt loops could never write
/opt/carlos/channel-first-seen.json (1,244 errors/day on flagship), and
the failed bake.Save() aborted the whole adopt pass on top of it.

Adds LoadFirstSeenMigrating (reads path, falling back to legacyPath's
content when path doesn't exist yet; always saves to path) and a
BakeDir PassOption that points a Pass/PassFilter call at the data dir.
Wires it into all three production callers in host/agent.go. The
legacy /opt file is never written or deleted, so an older binary still
reading it keeps a working clock.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell adaa53016e8fe05b17b22e8e5e73b413bbdc28da parent 7eb397e
5 files changed, +135 −3
  • internal/host/agent.go +3 −3
  • internal/release/adopt.go +18 −0
  • internal/release/adopt_test.go +28 −0
  • internal/release/bake.go +16 −0
  • internal/release/bake_test.go +70 −0
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 7edbdcc9d..12914a8ad 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -213,7 +213,7 @@ func adoptLoop(reg *registry.Registry) {
// hourly callers both nagging would double the log line every hour;
// worse, if this loop's cadence ever changes, a nag wired here changes
// frequency with it silently. One caller, one nag.
- if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute); err != nil {
+ if err := release.Pass(ctx, reg, store, pub, optDir, restartRoute, release.BakeDir(dataDir())); err != nil {
log.Printf("agent: adopt pass: %v", err)
}
}
@@ -297,7 +297,7 @@ func canaryAdoptLoop(reg *registry.Registry) {
// still would nag on every single tick — a journal-flooding poll storm
// out of what is supposed to be an hourly, easy-to-skim log line. The
// nag belongs to RunAdopt (carlos-adopt.service) alone.
- if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute); err != nil {
+ if err := release.PassFilter(ctx, reg, store, pub, optDir, restartRoute, isCanaryRoute, release.BakeDir(dataDir())); err != nil {
log.Printf("agent: canary adopt pass: %v", err)
}
}
@@ -778,7 +778,7 @@ func RunAdopt(args []string) {
// package call release.Pass/PassFilter with no options at all, so an
// expired canary nags once an hour from here, never every 2-3 seconds
// from the in-process fast loop.
- if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout)); err != nil {
+ if err := release.Pass(ctx, reg, store, pub, release.OptDir(), restartRoute, release.NagExpiredCanaries(os.Stdout), release.BakeDir(dataDir())); err != nil {
log.Fatalf("adopt: %v", err)
}
diff --git a/internal/release/adopt.go b/internal/release/adopt.go
index 895ecf69d..037bf260d 100644
--- a/internal/release/adopt.go
+++ b/internal/release/adopt.go
@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"os"
+ "path/filepath"
"runtime"
"time"
@@ -33,6 +34,9 @@ type passConfig struct {
// default — every call site that does not pass NagExpiredCanaries)
// means the nag never fires at all, not even to a discarded writer.
nag io.Writer
+ // bakeDir, when non-empty, is the directory holding the bake clock
+ // (BakeDir option); empty keeps the legacy /opt-dir location.
+ bakeDir string
}
// NagExpiredCanaries opts a Pass/PassFilter call into spec §5's nag: one
@@ -58,6 +62,17 @@ func NagExpiredCanaries(w io.Writer) PassOption {
return func(c *passConfig) { c.nag = w }
}
+// BakeDir moves this pass's bake clock into dir — in production the data
+// dir (CARLOS_DATA_DIR, default /data), the one place both the sandboxed
+// edge unit (ReadWritePaths=/data) and the root adopt one-shot can write.
+// Reads migrate: a box whose clock still lives at the legacy
+// /opt/carlos/channel-first-seen.json keeps every observation (issue #13).
+// Unset, the clock stays at its legacy path — existing tests and callers
+// are unaffected.
+func BakeDir(dir string) PassOption {
+ return func(c *passConfig) { c.bakeDir = dir }
+}
+
// Pass is one full adoption sweep: group instance routes by (App, Channel),
// resolve each channel pointer once, verify signatures and artifact sha256s,
// download/stage missing versions, and repoint live/<host> symlinks.
@@ -185,6 +200,9 @@ func PassFilter(ctx context.Context, reg *registry.Registry, s Store, pub ed2551
// ever ticks for pointers that verified above.
now := time.Now()
bake := LoadFirstSeen(FirstSeenPath(optDir))
+ if cfg.bakeDir != "" {
+ bake = LoadFirstSeenMigrating(filepath.Join(cfg.bakeDir, "channel-first-seen.json"), FirstSeenPath(optDir))
+ }
changed := false
for k, ptr := range pointers {
if ptr != nil && bake.Observe(k.acct, k.app, k.channel, ptr.Version, now) {
diff --git a/internal/release/adopt_test.go b/internal/release/adopt_test.go
index ab541f310..ecc1f7c63 100644
--- a/internal/release/adopt_test.go
+++ b/internal/release/adopt_test.go
@@ -76,6 +76,34 @@ func TestPassRepointsAwakeAndHibernating(t *testing.T) {
}
}
+// TestPassBakeDirMovesTheClock proves the BakeDir option's whole point
+// (issue #13): the bake clock lands under the given dir, not optDir's
+// legacy /opt-dir location.
+func TestPassBakeDirMovesTheClock(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ ctx := context.Background()
+ priv, pub := testKeys(t)
+ opt := t.TempDir()
+ bakeDir := t.TempDir()
+ reg := testRegistry(t)
+
+ shipRelease(t, s, "bab", "myapp", "v1", adoptPlatform, []byte("build one"))
+ if err := Promote(ctx, s, priv, "bab", "myapp", "canary/x", "v1"); err != nil {
+ t.Fatalf("Promote: %v", err)
+ }
+ addRoute(t, reg, "awake.example.com", "myapp", "canary/x", false)
+
+ if err := Pass(ctx, reg, s, pub, opt, func(string) error { return nil }, BakeDir(bakeDir)); err != nil {
+ t.Fatalf("Pass: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(bakeDir, "channel-first-seen.json")); err != nil {
+ t.Fatalf("BakeDir option did not land the clock in bakeDir: %v", err)
+ }
+ if _, err := os.Stat(FirstSeenPath(opt)); !os.IsNotExist(err) {
+ t.Fatalf("bake clock also written to the legacy opt dir despite BakeDir (stat err %v)", err)
+ }
+}
+
func TestPassPoisonedBuildSkipsRemainingHosts(t *testing.T) {
s := NewDirStore(t.TempDir())
ctx := context.Background()
diff --git a/internal/release/bake.go b/internal/release/bake.go
index 9def2aae6..d58cd4585 100644
--- a/internal/release/bake.go
+++ b/internal/release/bake.go
@@ -129,6 +129,22 @@ func LoadFirstSeen(path string) *FirstSeen {
return st
}
+// LoadFirstSeenMigrating reads the bake clock from path, reaching back to
+// legacyPath for its CONTENT when path does not exist yet — the one-time
+// migration off /opt/carlos (issue #13: the sandboxed edge cannot write the
+// version store, and must not be able to; the clock belongs in the data
+// dir). Save always writes path. The legacy file is never written or
+// deleted: an older binary still running against it keeps a working clock,
+// and this binary simply stops advancing it.
+func LoadFirstSeenMigrating(path, legacyPath string) *FirstSeen {
+ if _, err := os.Stat(path); os.IsNotExist(err) {
+ st := LoadFirstSeen(legacyPath)
+ st.path = path
+ return st
+ }
+ return LoadFirstSeen(path)
+}
+
// Observe records a channel head this box hasn't seen at that head before.
// Returns whether anything changed (the caller persists via Save). Never
// call it on a pointer that failed verification — the clock only ever ticks
diff --git a/internal/release/bake_test.go b/internal/release/bake_test.go
index 1217e5734..485fb9a60 100644
--- a/internal/release/bake_test.go
+++ b/internal/release/bake_test.go
@@ -1,6 +1,7 @@
package release
import (
+ "bytes"
"context"
"encoding/json"
"os"
@@ -244,3 +245,72 @@ func TestBakeClockReadsPreAccountRecordsAsOps(t *testing.T) {
t.Fatal("a pre-accounts record satisfied a different account's window")
}
}
+
+func TestLoadFirstSeenMigrating(t *testing.T) {
+ now := time.Date(2026, 8, 3, 12, 0, 0, 0, time.UTC)
+
+ t.Run("prefers the new path when it exists", func(t *testing.T) {
+ dir := t.TempDir()
+ newPath := filepath.Join(dir, "new.json")
+ oldPath := filepath.Join(dir, "old.json")
+ stNew := LoadFirstSeen(newPath)
+ stNew.Observe("acct", "app", "stable", "v-new", now)
+ if err := stNew.Save(); err != nil {
+ t.Fatal(err)
+ }
+ stOld := LoadFirstSeen(oldPath)
+ stOld.Observe("acct", "app", "stable", "v-old", now)
+ if err := stOld.Save(); err != nil {
+ t.Fatal(err)
+ }
+ st := LoadFirstSeenMigrating(newPath, oldPath)
+ if _, ok := st.EligibleAt("acct", "app", "stable", "v-new", nil); !ok {
+ t.Fatal("new path's observation missing")
+ }
+ if _, ok := st.EligibleAt("acct", "app", "stable", "v-old", nil); ok {
+ t.Fatal("legacy content leaked in despite the new path existing")
+ }
+ })
+
+ t.Run("falls back to legacy content, saves to the new path, leaves legacy untouched", func(t *testing.T) {
+ dir := t.TempDir()
+ newPath := filepath.Join(dir, "new.json")
+ oldPath := filepath.Join(dir, "old.json")
+ stOld := LoadFirstSeen(oldPath)
+ stOld.Observe("acct", "app", "stable", "v1", now)
+ if err := stOld.Save(); err != nil {
+ t.Fatal(err)
+ }
+ legacyRaw, err := os.ReadFile(oldPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st := LoadFirstSeenMigrating(newPath, oldPath)
+ if _, ok := st.EligibleAt("acct", "app", "stable", "v1", nil); !ok {
+ t.Fatal("legacy observation not carried over")
+ }
+ if err := st.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(newPath); err != nil {
+ t.Fatalf("Save did not write the new path: %v", err)
+ }
+ afterRaw, err := os.ReadFile(oldPath)
+ if err != nil || !bytes.Equal(legacyRaw, afterRaw) {
+ t.Fatalf("legacy file must be byte-identical after migration (err %v)", err)
+ }
+ })
+
+ t.Run("both missing is a fresh clock at the new path", func(t *testing.T) {
+ dir := t.TempDir()
+ newPath := filepath.Join(dir, "new.json")
+ st := LoadFirstSeenMigrating(newPath, filepath.Join(dir, "absent.json"))
+ st.Observe("acct", "app", "stable", "v1", now)
+ if err := st.Save(); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := os.Stat(newPath); err != nil {
+ t.Fatalf("Save must create the new path: %v", err)
+ }
+ })
+}