carlos / platform Public

appconfig: the config ack carries its schema and the instance overlay it rendered (plan 4, task 9)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 871c473753d3f0c3972498586f5ba4f06be5f3d4 parent ceef41b
6 files changed, +133 −12
  • internal/appconfig/configack.go +25 −0
  • internal/appconfig/configack_test.go +87 −0
  • internal/appconfig/sync.go +8 −4
  • internal/appconfig/sync_test.go +3 −3
  • internal/appconfig/syncexec.go +9 −4
  • internal/appconfig/syncexec_test.go +1 −1
diff --git a/internal/appconfig/configack.go b/internal/appconfig/configack.go
index eb094d0af..b4f43d488 100644
--- a/internal/appconfig/configack.go
+++ b/internal/appconfig/configack.go
@@ -31,11 +31,27 @@ import (
"github.com/carlosframework/platform/internal/release"
)
+// ConfigAckSchema is the ack schema THIS binary writes. It exists so a
+// reader can tell an agent that renders the instance config layer (senders
+// design §6.2) from one that does not, and the only honest way to do that
+// is a positive statement: an ack written by an older agent decodes with
+// Schema 0, because the field was not there to write.
+//
+// It is not the manifest schema and is not tied to it. Bump it when an
+// agent starts stating something a reader may need to depend on.
+const ConfigAckSchema = 1
+
// ConfigAck is one route's config-adoption acknowledgement, written after
// config sync converges its env/config file — see ConfigAckKey for where.
type ConfigAck struct {
Box string `json:"box"`
Host string `json:"host"`
+ // Schema is ConfigAckSchema as written by the agent that produced this
+ // ack, and 0 for every ack written before this field existed. A reader
+ // asking "does this box render the instance config layer" reads this and
+ // nothing else: absence of an ack, or an ack at schema 0, both mean the
+ // question cannot be answered from the box's own statements yet.
+ Schema int `json:"schema"`
// Shape is one of "unit", "socket-exec" or "tcp-exec-file" — spec §5's
// three delivery shapes.
Shape string `json:"shape"`
@@ -61,6 +77,15 @@ type ConfigAck struct {
// this pass rendered into CARLOS_RELAY, per region — nil if the route's
// manifest carries no CARLOS_RELAY entry at all.
RelaySuffixes map[string]string `json:"relay_suffixes,omitempty"`
+ // InstanceConfigETag is the etag of the instance overlay
+ // (apps/<acct>/<app>/instances-config/<host>.json) this pass rendered
+ // into the file, empty when the route has no overlay at all. The writer
+ // keeps the etag its own Put returned and compares the two
+ // (InstanceConfigDelivery): equal is delivered, different is stale. A
+ // map of delivered var names could not make that distinction — an agent
+ // too old to read the layer and a new agent rendering an overlay whose
+ // vars the reader does not recognise produce the same map.
+ InstanceConfigETag string `json:"instance_config_etag,omitempty"`
}
// ConfigAckKey is where a route's config-adoption ack lives: additive, one
diff --git a/internal/appconfig/configack_test.go b/internal/appconfig/configack_test.go
index 9a8a419b5..00f64f60c 100644
--- a/internal/appconfig/configack_test.go
+++ b/internal/appconfig/configack_test.go
@@ -227,3 +227,90 @@ func TestSyncAckHibernatingRouteReportsNoRestart(t *testing.T) {
t.Fatalf("ack.RestartError = %q, want empty", got.RestartError)
}
}
+
+// The ack says which version of the instance layer the agent rendered
+// (design §6.5). The console holds the etag its own write returned and
+// compares the two, which is decidable in a way a map of delivered key
+// names is not: an old agent and a new agent rendering an overlay that sets
+// nothing look identical by key, and not at all by etag.
+func TestConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ reg := testRegistry(t)
+ envDir := t.TempDir()
+ priv, _ := syncRelayTestKeys(t)
+
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil)
+ if err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ for _, host := range []string{"book.example.com", "cart.example.com"} {
+ if err := reg.Upsert(registry.Route{
+ Host: host, Kind: "instance", App: "titogo", Account: "bab", Status: "ready",
+ Backing: registry.BackingUnit,
+ }); err != nil {
+ t.Fatalf("Upsert %s: %v", host, err)
+ }
+ }
+ acks := map[string]ConfigAck{}
+ if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil },
+ func(a ConfigAck) { acks[a.Host] = a }); err != nil {
+ t.Fatalf("Sync: %v", err)
+ }
+ book, ok := acks["book.example.com"]
+ if !ok {
+ t.Fatal("no ack for the overlaid route")
+ }
+ if book.Schema != ConfigAckSchema {
+ t.Fatalf("Schema = %d, want %d — absence is how an older agent is recognised", book.Schema, ConfigAckSchema)
+ }
+ if book.InstanceConfigETag != want {
+ t.Fatalf("InstanceConfigETag = %q, want the etag the write returned %q", book.InstanceConfigETag, want)
+ }
+ cart, ok := acks["cart.example.com"]
+ if !ok {
+ t.Fatal("no ack for the sibling")
+ }
+ if cart.Schema != ConfigAckSchema || cart.InstanceConfigETag != "" {
+ t.Fatalf("sibling ack = %+v, want the schema and an empty etag: it has no overlay to render", cart)
+ }
+}
+
+// The same statement from the exec half, which is the half an instance
+// sender's box actually runs (design §6.4's delivery check).
+func TestSyncExecConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) {
+ ctx, s, reg, dd, privB64, _ := execKit(t)
+ priv, err := ParsePrivateKey(privB64, "test")
+ if err != nil {
+ t.Fatalf("ParsePrivateKey: %v", err)
+ }
+ if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
+ t.Fatalf("SetEnv: %v", err)
+ }
+ _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
+ map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil)
+ if err != nil {
+ t.Fatalf("ApplyInstance: %v", err)
+ }
+ if err := reg.Upsert(registry.Route{
+ Host: "book.example.com", Kind: "instance", App: "titogo", Account: "bab",
+ Status: "ready", Socket: "/run/carlos/book.sock",
+ }); err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ var got []ConfigAck
+ if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "",
+ func(a ConfigAck) { got = append(got, a) }); err != nil {
+ t.Fatalf("SyncExec: %v", err)
+ }
+ if len(got) != 1 {
+ t.Fatalf("acks = %+v, want exactly one", got)
+ }
+ if got[0].Schema != ConfigAckSchema || got[0].InstanceConfigETag != want {
+ t.Fatalf("ack = %+v, want schema %d and etag %q", got[0], ConfigAckSchema, want)
+ }
+}
diff --git a/internal/appconfig/sync.go b/internal/appconfig/sync.go
index e473a89fb..ca6a5a727 100644
--- a/internal/appconfig/sync.go
+++ b/internal/appconfig/sync.go
@@ -181,7 +181,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
// with no default bundle used to resolve to "never configured" and
// stop here, but with an overlay it now resolves to something (senders
// design §6.2), so the skip has to see the overlay first.
- inst, _, err := overlayFor(k.acct, k.app, rt.Host)
+ inst, instETag, err := overlayFor(k.acct, k.app, rt.Host)
if err != nil {
var listErr *instanceOverlayListErr
if errors.As(err, &listErr) {
@@ -238,7 +238,7 @@ func Sync(ctx context.Context, reg *registry.Registry, s release.Store, priv *ec
fmt.Printf(" %s: exec-backed — config delivered by the agent's in-process loop to /data/<host>.config.env, not this pass\n", rt.Host)
continue
}
- syncHost(rt, m, defaultMissing, priv, envDir, restart, ack)
+ syncHost(rt, m, defaultMissing, priv, envDir, restart, instETag, ack)
}
// An empty route list is never authority to delete anything:
// registry.Open silently creates an empty database when handed a wrong
@@ -405,11 +405,15 @@ func UnitConfigApps(routes []registry.Route) [][2]string {
// defaultMissing says the merged view is a bound environment's bundle ALONE
// because the app has no config.json — see the guard below.
//
+// instETag is the etag of the instance overlay this route's merge already
+// folded in (empty when the route has no overlay), carried through to the
+// ack unchanged — see ConfigAck.InstanceConfigETag.
+//
// ack, if non-nil, is called once this route's file has actually changed
// and a restart was attempted — never on the "up to date" early return,
// since nothing was delivered on that pass and the ack already on file (if
// any) from a previous pass still describes what the route is running.
-func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, ack func(ConfigAck)) {
+func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.PrivateKey, envDir string, restart func(host string) error, instETag string, ack func(ConfigAck)) {
// The host is joined into a root-written path; a registry row carrying
// a separator must never place a file outside envDir. Same trust level
// as release's live/<host> symlinks, but this one is one line to pin.
@@ -525,7 +529,7 @@ func syncHost(rt registry.Route, m *Manifest, defaultMissing bool, priv *ecdh.Pr
// route's shape, the relay identities (if any) it just wrote, and
// whether the restart below woke a real process or found a hibernating
// one with nothing to wake.
- a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()}
+ a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag}
if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil {
// A CARLOS_RELAY entry that fails to decode is loud on its own —
// RenderEnv already refused this render if the failure were a
diff --git a/internal/appconfig/sync_test.go b/internal/appconfig/sync_test.go
index 2f4c10734..5a0d4263e 100644
--- a/internal/appconfig/sync_test.go
+++ b/internal/appconfig/sync_test.go
@@ -400,7 +400,7 @@ func TestSyncHostRefusesNonBareHostname(t *testing.T) {
}
m := &Manifest{Schema: 1, App: "evil", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
for _, host := range []string{"../escape", "a/b", "", ".", ".."} {
- syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil }, nil)
+ syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil }, "", nil)
}
if _, err := os.Stat(filepath.Join(envDir, "..", "escape.env")); !os.IsNotExist(err) {
t.Fatalf("a path-escaping host wrote outside envDir, stat err = %v", err)
@@ -1054,7 +1054,7 @@ func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) {
var restarted int
restart := func(string) error { restarted++; return nil }
- syncHost(rt, m, false, nil, envDir, restart, nil)
+ syncHost(rt, m, false, nil, envDir, restart, "", nil)
path := filepath.Join(envDir, "steady.example.com.env")
got, err := os.ReadFile(path)
if err != nil {
@@ -1072,7 +1072,7 @@ func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) {
}
// Second, identical pass.
- syncHost(rt, m, false, nil, envDir, restart, nil)
+ syncHost(rt, m, false, nil, envDir, restart, "", nil)
if restarted != 1 {
t.Fatalf("restarted = %d after second identical sync, want still 1 (no re-restart)", restarted)
}
diff --git a/internal/appconfig/syncexec.go b/internal/appconfig/syncexec.go
index 7618d719a..c3a2a2dd4 100644
--- a/internal/appconfig/syncexec.go
+++ b/internal/appconfig/syncexec.go
@@ -433,7 +433,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
// stop a REMOVAL too: deciding a file is orphaned while unable to see
// whether this instance has an overlay would delete a live tenant's
// delivered secrets on a listing blip.
- inst, _, instErr := overlayFor(k.acct, k.app, rt.Host)
+ inst, instETag, instErr := overlayFor(k.acct, k.app, rt.Host)
if instErr != nil {
var listErr *instanceOverlayListErr
if errors.As(instErr, &listErr) {
@@ -551,7 +551,7 @@ func SyncExec(ctx context.Context, reg *registry.Registry, s release.Store, priv
if m != nil || inst != nil {
m = applyInstanceOverlay(k.app, m, inst)
}
- syncExecHost(rt, m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, ack)
+ syncExecHost(rt, m, Key(k.acct, k.app), priv, dataDir, hostBounce, header, instETag, ack)
}
// Stale-secrets notice (issue #18 design ruling, ratified 2026-08-03
// review): an addr-shaped route whose app is DEFINITIVELY not opted in
@@ -1056,6 +1056,11 @@ const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos
// unified) — folded into the SAME diff/write/rename path as the vars
// themselves, so a header-only change (e.g. its text edited in a future
// release) converges exactly like a var change would, once.
+//
+// instETag is the etag of the instance overlay this route's merge already
+// folded in (empty when the route has no overlay), carried through to the
+// ack unchanged — see ConfigAck.InstanceConfigETag.
+//
// ack, if non-nil, is called once this route's file has actually changed
// (written, never on removal — see SyncExec's own doc comment) with its
// config-adoption acknowledgement (spec §5). Restarted/RestartError come
@@ -1065,7 +1070,7 @@ const execAddrConfigHeader = `# Written by carlos (SyncExec, issue #18). Carlos
// (the tcp-exec-file shape: its process belongs to an external supervisor
// this platform never restarts at all, so that shape's cutover is a
// member acknowledgement, `carlos relay adopted`, instead).
-func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header string, ack func(ConfigAck)) {
+func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.PrivateKey, dataDir string, bounce func(host string) error, header, instETag string, ack func(ConfigAck)) {
if rt.Host == "" || rt.Host == "." || rt.Host == ".." || rt.Host != filepath.Base(rt.Host) {
fmt.Printf(" %q: not a bare hostname, refusing to derive a config file path from it\n", rt.Host)
return
@@ -1163,7 +1168,7 @@ func syncExecHost(rt registry.Route, m *Manifest, key string, priv *ecdh.Private
fmt.Printf(" %s: config updated\n", rt.Host)
bounceErr := bounceLogged(rt.Host, bounce)
if ack != nil {
- a := ConfigAck{Host: rt.Host, Shape: shapeOf(rt), At: time.Now()}
+ a := ConfigAck{Schema: ConfigAckSchema, Host: rt.Host, Shape: shapeOf(rt), At: time.Now(), InstanceConfigETag: instETag}
if suffixes, err := relaySuffixesFromRelayVar(m, priv); err != nil {
fmt.Printf(" %s: config updated, but CARLOS_RELAY could not be read for the config-ack: %v\n", rt.Host, err)
} else {
diff --git a/internal/appconfig/syncexec_test.go b/internal/appconfig/syncexec_test.go
index 1ee0bfd15..6769eceee 100644
--- a/internal/appconfig/syncexec_test.go
+++ b/internal/appconfig/syncexec_test.go
@@ -773,7 +773,7 @@ func TestSyncExecRefusesNonBareHostname(t *testing.T) {
pk, _ := ParsePrivateKey(priv, "test")
m := &Manifest{Schema: 1, App: "eleven", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
for _, host := range []string{"../escape", "a/b", "", ".", ".."} {
- syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }, "", nil)
+ syncExecHost(registry.Route{Host: host}, m, "apps/bab/eleven/config.json", pk, dd, func(string) error { return nil }, "", "", nil)
}
ents, err := os.ReadDir(dd)
if err != nil {