carlos / platform Public

host: the config relay runs config alone, and the box says whether it is armed

Review of the issue #44 slice found the relay aimed at the wrong unit.
carlos-adopt.path started carlos-adopt.service, whose RunAdopt also runs
release.Pass and NagExpiredCanaries — so one app's `carlos env set` could
stage and cut over a DIFFERENT app's pending release within seconds, and an
expired canary would nag per config write instead of hourly, against the
"the nag belongs to the hourly root pass alone" split adopt.go depends on.

RunAdopt gains -config-only: appconfig.Sync and its restarts, then return.
The release pubkey is parsed only for a full pass, so a config trigger can
never die on a release-key problem. New oneshot carlos-config-sync.service
execs `carlos adopt -config-only` with carlos-adopt.service's root+hardening
shape, StartLimitIntervalSec=0 (a path-triggered unit must not latch into
start-limit-hit after a burst of writes) and no [Install] — the path unit is
its only starter, and now its target.

The path unit was also load-bearing but unobservable: enabled-but-unstarted
watches nothing, and the agent would happily write a marker no one reads.
configPathActive() asks systemctl is-active carlos-adopt.path each status
tick, published as config_path_running on the box snapshot, per-box in the
fleet feed, and as its own column in the boxes table — a separate unit from
adoption, so never folded into that word. Wire-additive: a snapshot written
before this parses as null, which is the honest reading.

Also: the exact+environment query combination on config reads is now pinned
(raw query and both parsed values, client seam and end-to-end from env list
-account-id); `env sync` copy no longer promises unit-backed seconds
unconditionally; and the spec records the deploy prerequisite — the unit
half is inert until a box gets the unit refresh and `enable --now
carlos-adopt.path`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 8db59f20504db12fbdd68682ccdee72dbd7bf279 parent 7828950
14 files changed, +456 −64
  • apimode_test.go +81 −0
  • appconfig_cli.go +14 −8
  • docs/superpowers/specs/2026-08-04-config-console-api-design.md +54 −14
  • internal/host/agent.go +46 −12
  • internal/host/host_test.go +67 −0
  • internal/host/probes.go +18 −0
  • internal/host/units/carlos-adopt.path +9 −5
  • internal/host/units/carlos-config-sync.service +46 −0
  • internal/status/assets/index.html +6 −0
  • internal/status/contract_test.go +8 −2
  • internal/status/dryrun.go +9 −7
  • internal/status/fleet.go +16 −7
  • internal/status/snapshot.go +29 −9
  • internal/status/status_test.go +53 −0
diff --git a/apimode_test.go b/apimode_test.go
index cc5b0653e..043f72e89 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -1995,6 +1995,87 @@ func TestAPISessionConfigGetEnvironmentQueryParam(t *testing.T) {
}
}
+// TestAPISessionConfigGetExactAndEnvironment pins the COMBINATION, which
+// neither single-parameter test covers: apiPath already appended
+// ?exact=sqid for an -account-id call, so configGet's own append has to
+// pick "&" instead of assuming "?" — get that wrong and the environment
+// name is swallowed into the exact value, silently reading the DEFAULT
+// bundle while the console name-matches a segment it was told not to.
+func TestAPISessionConfigGetExactAndEnvironment(t *testing.T) {
+ var gotRawQuery, gotExact, gotEnvironment, gotPath string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotRawQuery, gotPath = r.URL.RawQuery, r.URL.Path
+ gotExact = r.URL.Query().Get("exact")
+ gotEnvironment = r.URL.Query().Get("environment")
+ json.NewEncoder(w).Encode(map[string]any{"bundle": "staging", "environments": []string{}, "vars": []any{}})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ if _, err := s.configGet(context.Background(), apiAcct{Seg: "bah", Exact: true}, "app", "staging"); err != nil {
+ t.Fatalf("configGet: %v", err)
+ }
+ if want := "exact=sqid&environment=staging"; gotRawQuery != want {
+ t.Fatalf("query = %q, want %q", gotRawQuery, want)
+ }
+ // The server's own view: both parameters parse as their own values,
+ // which is the property the separator choice actually protects.
+ if gotExact != "sqid" || gotEnvironment != "staging" {
+ t.Fatalf("server saw exact=%q environment=%q, want exact=sqid environment=staging", gotExact, gotEnvironment)
+ }
+ if want := "/api/cli/apps/bah/app/config"; gotPath != want {
+ t.Fatalf("path = %q, want %q", gotPath, want)
+ }
+}
+
+// TestEnvListVerbAPIModeAccountIDAndEnvironment is
+// TestPromoteVerbAPIModeAccountID's shape for the config surface: -account-id
+// must reach the wire from an env/secrets verb too — sqid in the segment,
+// ?exact=sqid alongside, and the colliding -account name never sent — with a
+// -environment in play at the same time, which is the config verbs' own
+// wrinkle.
+func TestEnvListVerbAPIModeAccountIDAndEnvironment(t *testing.T) {
+ useTempCredentials(t)
+ clearDeploymentEnv(t)
+ checkNoRegistry := requireNoRegistryTouch(t)
+
+ var reqCount int32
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ atomic.AddInt32(&reqCount, 1)
+ if want := "/api/cli/apps/bah/titogo/config"; r.URL.Path != want {
+ t.Errorf("path = %s, want %s (the -account-id sqid as the segment)", r.URL.Path, want)
+ }
+ if got := r.URL.Query().Get("exact"); got != "sqid" {
+ t.Errorf("exact = %q, want %q — -account-id must never be name-matched", got, "sqid")
+ }
+ if got := r.URL.Query().Get("environment"); got != "staging" {
+ t.Errorf("environment = %q, want %q", got, "staging")
+ }
+ if strings.Contains(r.URL.String(), apiTestAccount) {
+ t.Errorf("url %s carries the superseded -account name", r.URL.String())
+ }
+ json.NewEncoder(w).Encode(map[string]any{
+ "bundle": "staging",
+ "environments": []string{"staging"},
+ "vars": []map[string]string{{"key": "PORT", "kind": "env", "value": "8080"}},
+ })
+ }))
+ defer ts.Close()
+
+ loginTo(t, ts.URL, "vicky@keymail.dev")
+
+ // No captureStdout here: runEnvList's printing goes to os.Stdout
+ // directly (printEnvList's tabwriter), not the swappable stdout var the
+ // promote-family tests capture. The assertions that matter are the wire
+ // ones above, inside the handler.
+ runEnvList([]string{"-app", "titogo", "-environment", "staging", "-account", apiTestAccount, "-account-id", "bah"})
+
+ if got := atomic.LoadInt32(&reqCount); got != 1 {
+ t.Fatalf("server got %d requests, want 1", got)
+ }
+ checkNoRegistry()
+}
+
// TestAPISessionConfigGetNotFound pins the gate's byte-identical 404 —
// http.NotFound's text/plain body, no JSON "error" field — reaching the
// same generic membership message every other client method here falls
diff --git a/appconfig_cli.go b/appconfig_cli.go
index de2bcb704..977c5513f 100644
--- a/appconfig_cli.go
+++ b/appconfig_cli.go
@@ -44,9 +44,11 @@ when set.
sync touches apps/<acct>/<app>/config-gen.json — the agent's fast poll loop
(host package) notices within ~3s and re-renders exec-backed instances;
-unit-backed routes converge in seconds too, via a root adopt pass the same
-loop requests (the agent cannot write /etc or restart units itself). It
-never restarts a box's shared edge service, only the one app's own delivery.
+unit-backed routes converge in seconds on a box whose trigger unit
+(carlos-adopt.path) is armed, and on the next hourly root pass otherwise —
+the agent can only request that pass, not write /etc or restart units
+itself. It never restarts a box's shared edge service, only the one app's
+own delivery.
Every command above also works through a logged-in console
(carlos auth login) when CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR are
@@ -450,10 +452,13 @@ func runEnvEnvironments(args []string) {
// configSyncLoop's hourly floor: it touches apps/<acct>/<app>/config-gen.json
// (appconfig.GenKey), which the agent's configGenLoop polls every ~3s and
// treats a moved etag as "run SyncExec now" for the app's exec-backed
-// instances and "ask the root adopt pass to run now" (carlos-adopt.path) for
-// its unit-backed ones, so both shapes converge in seconds rather than one
-// waiting out the hour. It never restarts a box's shared edge service and
-// never targets a bundle directly — the generation
+// instances and "ask the root config pass to run now" (the marker file
+// carlos-adopt.path watches) for its unit-backed ones, so both shapes
+// converge in seconds on a box whose path unit is armed — and the unit half
+// falls back to the hourly pass on a box where it isn't, which is why the
+// printed line says so rather than promising seconds outright. It never
+// restarts a box's shared edge service and never targets a bundle directly
+// — the generation
// object covers every bundle an app has (default plus every environment) in
// one touch, matching TouchGeneration's own "app granularity, not
// per-bundle" design (generation.go).
@@ -480,7 +485,8 @@ func runEnvSync(args []string) {
log.Fatal(err)
}
fmt.Printf("config sync requested for %s — exec-backed instances converge within seconds; "+
- "unit-backed routes within seconds too, on the root adopt pass the agent requests\n", *app)
+ "unit-backed routes within seconds where the box's carlos-adopt.path trigger is armed, "+
+ "else on the next hourly root pass\n", *app)
}
const secretsUsage = `usage: carlos secrets <command>
diff --git a/docs/superpowers/specs/2026-08-04-config-console-api-design.md b/docs/superpowers/specs/2026-08-04-config-console-api-design.md
index 90dc09cfd..39bc8865a 100644
--- a/docs/superpowers/specs/2026-08-04-config-console-api-design.md
+++ b/docs/superpowers/specs/2026-08-04-config-console-api-design.md
@@ -169,22 +169,52 @@ The poll set is the UNION of `appconfig.ExecConfigApps` and the new
app however its routes are backed), and `genPollPass` reports two bools:
`execChanged` runs `SyncExec` as before; `unitChanged` touches
`<dataDir>/config-sync-requested`, which a new `carlos-adopt.path`
-(`PathChanged=/data/config-sync-requested` → `Unit=carlos-adopt.service`)
-relays into the ROOT pass that already owns the unit half — `appconfig.Sync`
-into `/etc/carlos/apps` plus try-restart. No new privileged code: the agent
-still writes nothing under `/etc` and restarts no unit; it only asks. An app
-holding both route shapes sets both halves off the one read.
-
-Two consequences, both accepted:
-
-- A `PathChanged` event landing while `carlos-adopt.service` is mid-run can
- be lost (systemd does not queue it). The next config write re-touches the
- marker, the exec half converged in-process regardless, and
+(`PathChanged=/data/config-sync-requested`) relays into a root pass that
+does the unit half — `appconfig.Sync` into `/etc/carlos/apps` plus
+try-restart. No new privileged code: the agent still writes nothing under
+`/etc` and restarts no unit; it only asks. An app holding both route shapes
+sets both halves off the one read.
+
+The relay's target is `carlos-config-sync.service`, a new oneshot running
+`carlos adopt -config-only` — deliberately NOT `carlos-adopt.service`
+(2026-08-04 review, finding 1). That unit's `RunAdopt` also runs
+`release.Pass` and `NagExpiredCanaries`, so pointing a per-config-write
+trigger at it would let one app's `carlos env set` stage and cut over a
+DIFFERENT app's pending release seconds later, and would fire the canary-TTL
+nag per config write instead of hourly — breaking the "the nag belongs to
+the hourly root pass alone" split `release/adopt.go` and `RunAdopt` both
+depend on. `-config-only` returns immediately after the config half; release
+adoption stays entirely on `carlos-adopt.timer`. The new service carries
+`StartLimitIntervalSec=0` (a path-triggered unit must not latch into
+start-limit-hit after a burst of writes and then ignore every later trigger)
+and no `[Install]` section (the path unit is its only starter).
+
+Liveness is probed like the timer's: `configPathActive()` asks
+`systemctl is-active carlos-adopt.path` each status tick, published as
+`config_path_running` on the box snapshot and per-box in the fleet feed and
+boxes table. Same failure mode as the timer's (enabled ≠ armed) and the same
+argument for publishing it — an unarmed path unit degrades silently, which
+is exactly what the status page exists to catch. Not rolled up into a
+fleet-wide field: worst-of aggregates exist only where the page shows a
+tile.
+
+Three consequences, all accepted:
+
+- A `PathChanged` event landing while `carlos-config-sync.service` is
+ mid-run can be lost (systemd does not queue it). The next config write
+ re-touches the marker, the exec half converged in-process regardless, and
`carlos-adopt.timer` delivers within the hour.
- The loop stays gated on `CARLOS_SECRETS_KEY`, so a box whose AGENT lacks
the key gets no fast path for its unit apps either, even though the root
pass that would run holds its own key. In practice both units read the
same `/etc/carlos/host.env`; the timer floor stands regardless.
+- DEPLOY PREREQUISITE: the unit half is inert until a box gets a unit
+ refresh installing `carlos-adopt.path` + `carlos-config-sync.service` and
+ then `systemctl enable --now carlos-adopt.path` (the .service needs
+ install only — no enable, no start). Shipping the binary alone changes
+ nothing: the agent writes the marker, nothing watches it, and unit-backed
+ config keeps the hourly floor. `config_path_running` is how an operator
+ sees which boxes still need that step.
Cardinality note: unlike canary channels this polls every exec-config app,
but one small GET per app per 3s against the store the canary loop already
@@ -194,8 +224,9 @@ the read is the cheapest object in the bucket.
## §5 Out of scope (recorded, deliberate)
- ~~Unit-backed route fast convergence (root adopt path owns it).~~ Closed
- by the §4 amendment above: the root adopt path still owns the delivery,
- the agent just asks it to run now.
+ by the §4 amendment above: a root pass still owns the delivery, the agent
+ just asks it to run now — and it is the config-only pass, never release
+ adoption.
- `#27` (configSyncLoop boot retry) — narrowed by this spec (the gen loop
re-arms convergence on the next write) but not fixed by it.
- Browser console config UI — CLI-only surface today, same as ledgers.
@@ -221,6 +252,15 @@ the read is the cheapest object in the bucket.
ONE read, cache pruning covers the union); the loop itself stays untested
by convention like adoptLoop/statusLoop. `UnitConfigApps` mirrors
`ExecConfigApps`' own test; the bootstrap unit-list and arm-the-timers
- tests cover `carlos-adopt.path` being installed, enabled AND started.
+ tests cover `carlos-adopt.path` being installed, enabled AND started, and
+ the staging-dir test pins the relay's target (`carlos-config-sync.service`,
+ never `carlos-adopt.service`), its `-config-only` ExecStart, its
+ `StartLimitIntervalSec=0` and its lack of `[Install]`. A host test pins
+ that every probed unit name is one bootstrap installs and arms; a status
+ test pins `config_path_running` travelling Probes → Box → FleetBox, and
+ that a pre-#44 snapshot without the key still parses as null.
+- CLI wire: `?exact=sqid` and `?environment=` TOGETHER on a config read —
+ both as the raw query string and as the two values the server parses —
+ pinned at the client seam and end-to-end from `env list -account-id`.
- e2e (auth_e2e_test.go pattern): login → env set → list → secrets set
(console-served pubkey) → list names → unset → sync → gen object moved.
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 41133a5ac..c8ffe6f57 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -336,7 +336,7 @@ const genAbsent = "\x00absent"
// it would be a lie about the only thing this function reports.
// - ONE bool PER HALF for the whole pass, however many apps moved: the
// caller runs one SyncExec (which resolves every route itself) and asks
-// for at most one root adopt pass (which sweeps every unit route
+// for at most one root config pass (which sweeps every unit route
// itself). Per-app syncs would multiply bucket reads to deliver exactly
// the same files, and per-app root requests would ask the same pass to
// run several times over.
@@ -405,10 +405,11 @@ func genPollPass(ctx context.Context, routes []registry.Route, store release.Sto
return execChanged, unitChanged, readErrs
}
-// configSyncMarker is the file configGenLoop touches to ask the ROOT adopt
+// configSyncMarker is the file configGenLoop touches to ask a root config
// pass to run now: carlos-adopt.path watches it (PathChanged) and starts
-// carlos-adopt.service, which owns the unit half of config delivery
-// (/etc/carlos/apps + try-restart) that this unprivileged process cannot.
+// carlos-config-sync.service (`carlos adopt -config-only`), which owns the
+// unit half of config delivery (/etc/carlos/apps + try-restart) that this
+// unprivileged process cannot — and only that half.
// It lives in the data dir, deliberately outside the *.config.env namespace
// reapConfigEnv sweeps — the reaper matches that suffix alone and must go
// on ignoring this.
@@ -463,10 +464,13 @@ func requestRootSync(dd string) error {
// - No unit-backed rendering or restarting. This process still writes
// nothing under /etc and restarts no unit (see RunAdopt): a unit app's
// movement only touches <dataDir>/config-sync-requested, which
-// carlos-adopt.path relays into a root carlos-adopt.service run that
-// does both. The relay can drop an event that lands while that service
-// is mid-run — accepted: the next config write re-touches the marker,
-// and the timer floor delivers within the hour regardless.
+// carlos-adopt.path relays into a root carlos-config-sync.service run
+// (`carlos adopt -config-only`) that does both. The relay reaches ONLY
+// the config half — never release adoption, never the canary-TTL nag,
+// which would otherwise fire off an unrelated app's config write
+// instead of hourly. The relay can drop an event that lands while that
+// service is mid-run — accepted: the next config write re-touches the
+// marker, and the timer floor delivers within the hour regardless.
// - No hourly floor replaced. configSyncLoop stays exactly as it was: it
// catches apps written by older CLIs (no generation object at all),
// ticks this loop missed, and the boot-retry gap of issue #27.
@@ -522,7 +526,7 @@ func configGenLoop(reg *registry.Registry, svc *activator.Service, dd string) {
if err := requestRootSync(dd); err != nil {
log.Printf("agent: request root config sync: %v", err)
} else {
- log.Printf("agent: config changed for unit-backed app(s), requested root adopt pass")
+ log.Printf("agent: config changed for unit-backed app(s), requested root config sync pass")
}
}
if !execChanged {
@@ -897,6 +901,11 @@ func statusLoop(reg *registry.Registry) {
// flagship's silent no-adopt state, which nothing on any page
// could see before this.
AdoptRunning: adoptTimerActive(),
+ // A second systemctl call per tick, asking the same question of
+ // the config-sync trigger: an enabled-but-unstarted
+ // carlos-adopt.path is a box whose unit-backed config silently
+ // waits for the hourly timer (issue #44).
+ ConfigPathRunning: configPathActive(),
// ExpiredCanaries answers spec §5 for this box's own routes; see
// countExpiredCanaries and the canaryStore/canaryPub comment above.
ExpiredCanaries: countExpiredCanaries(ctx, routes, canaryStore, canaryPub, time.Now()),
@@ -1009,9 +1018,20 @@ func openDeployStore(ctx context.Context) (release.Store, error) {
// (appconfig.SyncExec) runs elsewhere, in the agent's in-process
// configSyncLoop: it needs neither root nor /etc access, because it only
// ever writes under /data and bounces its own children.
+//
+// -config-only runs the UNIT-BACKED CONFIG half ALONE: appconfig.Sync plus
+// its restarts, no release.Pass, no canary nag, no backing advice. That is
+// what carlos-config-sync.service execs, and it exists because the trigger
+// behind it is a per-config-write path unit, not the hourly timer. Letting
+// that trigger run a full adoption pass would mean one tenant's `carlos env
+// set` could stage and cut over a DIFFERENT app's pending release within
+// seconds of an unrelated write, and would fire the canary-TTL nag per
+// config write instead of hourly — the "hourly root pass alone" split
+// release/adopt.go and the nag comment below both depend on.
func RunAdopt(args []string) {
fs := flag.NewFlagSet("adopt", flag.ExitOnError)
regPath := fs.String("registry", filepath.Join(dataDir(), "registry.db"), "registry database")
+ configOnly := fs.Bool("config-only", false, "sync unit-backed config and restart changed routes only: no release adoption, no canary nag")
_ = fs.Parse(args)
reg, err := registry.Open(*regPath)
if err != nil {
@@ -1022,9 +1042,17 @@ func RunAdopt(args []string) {
if err != nil {
log.Fatalf("adopt: %v", err)
}
- pub, err := release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY")
- if err != nil {
- log.Fatalf("adopt: %v", err)
+ // Parsed here, before config sync, exactly as the full pass always has
+ // (a box with an unusable release key must die before it converges
+ // anything), but SKIPPED entirely for -config-only: that mode never
+ // verifies a signature, and making a config-sync trigger fatal on a
+ // release-key problem would fail every config write on a box whose
+ // releases were already going nowhere.
+ var pub ed25519.PublicKey
+ if !*configOnly {
+ if pub, err = release.ParsePublicKey(os.Getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY"); err != nil {
+ log.Fatalf("adopt: %v", err)
+ }
}
// Config sync is optional — inert until CARLOS_SECRETS_KEY is set, the
// same convention every other optional loop in this package follows. It
@@ -1039,6 +1067,12 @@ func RunAdopt(args []string) {
log.Printf("adopt: config sync: %v", err)
}
}
+ if *configOnly {
+ // Everything below is release adoption and its reporting — the half
+ // carlos-config-sync.service must never reach, however often a
+ // config write triggers it.
+ return
+ }
// NagExpiredCanaries(os.Stdout) is set HERE ONLY (2026-08-03 review) — the
// canary-TTL nag (spec §5) belongs to the hourly root pass
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index c15f0cb29..3ef098d9b 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"reflect"
+ "slices"
"strings"
"testing"
"time"
@@ -29,6 +30,7 @@ var expectedUnits = []string{
"carlos-adopt.timer",
"carlos-app@.service",
"carlos-app@.socket",
+ "carlos-config-sync.service",
"carlos-edge.service",
"carlos-litestream.service",
"carlos-restore-verify.service",
@@ -133,6 +135,54 @@ func TestBootstrapWritesUnitsToStagingDir(t *testing.T) {
}
}
+ // The config fast-convergence relay (issue #44). The invariant worth a
+ // test is WHICH unit the path starts: carlos-adopt.service also runs
+ // release.Pass and the canary-TTL nag, so aiming a per-config-write
+ // trigger at it would let one app's `carlos env set` cut over another
+ // app's pending release seconds later and nag on every write instead of
+ // hourly. The relay must reach the config half alone.
+ pathUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-adopt.path"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "PathChanged=/data/config-sync-requested",
+ "Unit=carlos-config-sync.service",
+ "WantedBy=multi-user.target",
+ } {
+ if !strings.Contains(string(pathUnit), want) {
+ t.Errorf("carlos-adopt.path: missing %q", want)
+ }
+ }
+ if strings.Contains(string(pathUnit), "Unit=carlos-adopt.service") {
+ t.Error("carlos-adopt.path must not start carlos-adopt.service — that unit adopts releases and nags on expired canaries")
+ }
+
+ syncUnit, err := os.ReadFile(filepath.Join(unitDir, "carlos-config-sync.service"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, want := range []string{
+ "ExecStart=/opt/carlos/carlos adopt -config-only",
+ "Type=oneshot",
+ "EnvironmentFile=-/etc/carlos/host.env",
+ // A path-triggered unit must never latch into start-limit-hit after a
+ // burst of config writes and then ignore every later trigger.
+ "StartLimitIntervalSec=0",
+ } {
+ if !strings.Contains(string(syncUnit), want) {
+ t.Errorf("carlos-config-sync.service: missing %q", want)
+ }
+ }
+ // Started only by the path unit: an [Install] section would invite an
+ // operator to enable a oneshot that has nothing to do at boot. Matched
+ // as a whole line, since the unit's own comment names the section.
+ for _, line := range strings.Split(string(syncUnit), "\n") {
+ if strings.TrimSpace(line) == "[Install]" {
+ t.Error("carlos-config-sync.service must have no [Install] section — carlos-adopt.path is its only starter")
+ }
+ }
+
// The standard dirs.
for _, d := range []string{
filepath.Join(root, "data"),
@@ -496,6 +546,23 @@ func TestAdoptTimerStateReading(t *testing.T) {
func boolPtr(v bool) *bool { return &v }
+// TestProbedUnitsAreTheOnesBootstrapArms pins the pairing that makes both
+// systemd probes meaningful: each one must name a unit bootstrap actually
+// installs AND arms. A probe pointed at a unit no box has answers "unknown"
+// (systemd's own word for "no such unit"), which this package maps to a
+// confident false — so a typo here would report every box in the fleet as
+// broken, and a renamed unit would report a live box as dead.
+func TestProbedUnitsAreTheOnesBootstrapArms(t *testing.T) {
+ for _, unit := range []string{adoptTimerUnit, configPathUnit} {
+ if !slices.Contains(enabledUnits, unit) {
+ t.Errorf("%s: probed but not in enabledUnits — bootstrap never arms it", unit)
+ }
+ if !slices.Contains(expectedUnits, unit) {
+ t.Errorf("%s: probed but not an installed unit", unit)
+ }
+ }
+}
+
// TestStatusPublishNeverReadsMetricsPrefixes is the privacy tripwire the
// monitoring spec commits to: operator-grade needles live under metrics/ and
// alerts/, and nothing the public status publisher writes may ever be
diff --git a/internal/host/probes.go b/internal/host/probes.go
index 4fe69a30c..ba0966d8e 100644
--- a/internal/host/probes.go
+++ b/internal/host/probes.go
@@ -43,6 +43,24 @@ func adoptTimerActive() *bool {
return parseIsActive(string(out))
}
+// configPathUnit is the unit whose liveness IS "config reaches this box's
+// unit-backed routes in seconds" (issue #44): the path unit watching
+// /data/config-sync-requested, which the agent touches and which starts
+// carlos-config-sync.service. Installed and armed by bootstrap alongside the
+// timer (see enabledUnits).
+const configPathUnit = "carlos-adopt.path"
+
+// configPathActive asks the same question adoptTimerActive asks, about the
+// unit with the same failure mode: `systemctl enable` on a path unit writes a
+// .wants symlink and watches nothing until the box next reboots. An unarmed
+// path unit is invisible from inside the agent — it writes its marker file
+// happily, and nothing ever reads it — so this probe is the only thing that
+// can tell an operator the box quietly fell back to the hourly config floor.
+func configPathActive() *bool {
+ out, _ := exec.Command("systemctl", "is-active", configPathUnit).Output()
+ return parseIsActive(string(out))
+}
+
// parseIsActive maps systemd's is-active vocabulary onto the tri-state the
// status page publishes. "unknown" — systemd's answer when it has no such unit
// loaded — is a genuine false: a box with no adoption timer adopts nothing.
diff --git a/internal/host/units/carlos-adopt.path b/internal/host/units/carlos-adopt.path
index 823eabfbd..b0dc7f79c 100644
--- a/internal/host/units/carlos-adopt.path
+++ b/internal/host/units/carlos-adopt.path
@@ -2,15 +2,19 @@
# configGenLoop touches this file when a config write moves the generation
# object of an app with carlos-app@ routes on this box: the agent renders
# exec-backed instances itself, but it cannot write /etc/carlos/apps or
-# restart units, so it asks the root pass to do both. /data is hardcoded
-# like every path in these unit files — a box with a nonstandard data dir
-# simply never fast-triggers and keeps carlos-adopt.timer's hourly floor.
+# restart units, so it asks a root pass to do both. It starts
+# carlos-config-sync.service — the CONFIG half alone, deliberately NOT
+# carlos-adopt.service: a per-config-write trigger must never run release
+# adoption or the canary-TTL nag, which stay on carlos-adopt.timer's hourly
+# cadence. /data is hardcoded like every path in these unit files — a box
+# with a nonstandard data dir simply never fast-triggers and keeps that
+# hourly floor.
[Unit]
-Description=CARLOS root adopt pass on agent request (config fast convergence)
+Description=CARLOS config sync on agent request (config fast convergence)
[Path]
PathChanged=/data/config-sync-requested
-Unit=carlos-adopt.service
+Unit=carlos-config-sync.service
[Install]
WantedBy=multi-user.target
diff --git a/internal/host/units/carlos-config-sync.service b/internal/host/units/carlos-config-sync.service
new file mode 100644
index 000000000..ce38c0513
--- /dev/null
+++ b/internal/host/units/carlos-config-sync.service
@@ -0,0 +1,46 @@
+# One unit-backed CONFIG sync pass (appconfig.Sync + restart the routes whose
+# env file actually changed) — and nothing else. Started only by
+# carlos-adopt.path, which fires when the agent touches
+# /data/config-sync-requested; it has no [Install] section because nothing
+# else should ever want it. Deliberately NOT carlos-adopt.service: that unit
+# also runs release.Pass and the canary-TTL nag, so pointing a per-config-write
+# trigger at it would let one app's `carlos env set` cut over another app's
+# pending release within seconds, and would nag on every write instead of
+# hourly. Same root + hardening shape as carlos-adopt.service, which is what
+# writing /etc/carlos/apps and restarting carlos-app@ units requires.
+[Unit]
+Description=carlos config sync (unit-backed routes, on agent request)
+After=network-online.target
+Wants=network-online.target
+# A path unit re-triggers as often as config is written; the default start
+# rate limit would latch this unit into start-limit-hit after a burst and then
+# ignore every later trigger until an operator reset it. ([Unit], not
+# [Service] — systemd moved these two keys in v230.)
+StartLimitIntervalSec=0
+
+[Service]
+Type=oneshot
+# CARLOS_DEPLOYMENT_BUCKET/_DIR + CARLOS_SECRETS_KEY live here.
+EnvironmentFile=-/etc/carlos/host.env
+ExecStart=/opt/carlos/carlos adopt -config-only
+
+# --- hardening ---
+# Runs as ROOT deliberately, exactly like carlos-adopt.service: this pass
+# writes /etc/carlos/apps and restarts instance units via systemctl.
+# NoNewPrivileges and the syscall filter are omitted for the systemctl child;
+# the rest applies.
+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
diff --git a/internal/status/assets/index.html b/internal/status/assets/index.html
index dab99dfe0..318e792f6 100644
--- a/internal/status/assets/index.html
+++ b/internal/status/assets/index.html
@@ -45,6 +45,7 @@
<th>Label</th><th>Region</th><th>Load</th>
<th>Static</th><th>Resident</th><th>Hibernating</th><th>Awake</th><th>Asleep</th>
<th>Adoption</th>
+ <th>Config sync</th>
<th></th>
</tr>
</thead>
@@ -177,6 +178,11 @@
tr.appendChild(td(asText(routes[k])));
});
tr.appendChild(td(asBool(b.adoption_running, "running", "not running")));
+ // Config sync is a SEPARATE unit from adoption (carlos-adopt.path vs
+ // carlos-adopt.timer) and fails separately: a box can adopt releases
+ // fine while its unit-backed config quietly waits out the hour. Its
+ // own column, never folded into Adoption's word.
+ tr.appendChild(td(asBool(b.config_path_running, "armed", "not armed")));
var staleTd = document.createElement("td");
if (b.stale) {
// Bordered, not filled — a "trust this less" flag, not an outage
diff --git a/internal/status/contract_test.go b/internal/status/contract_test.go
index 02cb6b135..43cff20a6 100644
--- a/internal/status/contract_test.go
+++ b/internal/status/contract_test.go
@@ -32,7 +32,13 @@ var allowedBoxKeys = map[string][]string{
// "expired_canaries" is likewise deliberate (pipelines spec §5 "Canaries
// expire"): a COUNT of this box's own canary/* routes past their TTL,
// never which ones — no hostname, no channel name, no tenant identity.
- "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "expired_canaries"},
+ //
+ // "config_path_running" is the issue #44 sibling of adoption_running: it
+ // says whether this box's config-sync trigger unit (carlos-adopt.path) is
+ // armed, i.e. whether config reaches unit-backed routes in seconds or only
+ // on the hourly floor. Same publication argument — a platform-own unit's
+ // liveness on an opaquely-labelled box, no tenant in it anywhere.
+ "": {"generated_at", "label", "region", "routes", "load", "capacity", "replication", "adoption_running", "config_path_running", "expired_canaries"},
"routes": {"static", "resident", "hibernating", "awake", "asleep"},
"replication": {"healthy", "lag_seconds"},
}
@@ -41,7 +47,7 @@ var allowedBoxKeys = map[string][]string{
// allowedBoxKeys: every new field is a deliberate publication decision.
var allowedFleetKeys = map[string][]string{
"": {"generated_at", "boxes", "instances", "capacity", "apps", "apps_unlisted", "services", "replication", "adoption_running", "expired_canaries"},
- "boxes[]": {"label", "region", "load", "stale", "routes", "adoption_running", "expired_canaries"},
+ "boxes[]": {"label", "region", "load", "stale", "routes", "adoption_running", "config_path_running", "expired_canaries"},
"boxes[].routes": {"static", "resident", "hibernating", "awake", "asleep"},
"instances": {"total", "static", "resident", "hibernating", "awake", "asleep"},
"capacity": {"level"},
diff --git a/internal/status/dryrun.go b/internal/status/dryrun.go
index 288254cab..83b840a92 100644
--- a/internal/status/dryrun.go
+++ b/internal/status/dryrun.go
@@ -88,15 +88,17 @@ func dryRunFixtures() ([]registry.Route, Probes) {
memFrac, diskFrac := 0.73, 0.95
lag := int64(120)
adopting := true // a known reading, so adoption_running publishes non-null and gets scanned
+ configPath := true // ditto for config_path_running (issue #44)
expiredCanaries := 2 // ditto for expired_canaries — a known count, not the unexercised nil path
probes := Probes{
- MachineID: "ip-10-0-0-7",
- Region: "us-east-1",
- MemFrac: &memFrac,
- DiskFrac: &diskFrac,
- LagSecs: &lag,
- AdoptRunning: &adopting,
- ExpiredCanaries: &expiredCanaries,
+ MachineID: "ip-10-0-0-7",
+ Region: "us-east-1",
+ MemFrac: &memFrac,
+ DiskFrac: &diskFrac,
+ LagSecs: &lag,
+ AdoptRunning: &adopting,
+ ConfigPathRunning: &configPath,
+ ExpiredCanaries: &expiredCanaries,
// Every hibernating route reports "listening" so Awake/Asleep publish
// non-null — an unexercised null field is a field DryRun can't scan.
Listening: func(string) *bool { v := true; return &v },
diff --git a/internal/status/fleet.go b/internal/status/fleet.go
index 7f69d8e92..8810c8c0b 100644
--- a/internal/status/fleet.go
+++ b/internal/status/fleet.go
@@ -42,6 +42,14 @@ type FleetBox struct {
// only whether the platform's own timer is armed on an opaquely-labelled
// machine.
AdoptionRunning *bool `json:"adoption_running"`
+ // ConfigPathRunning appears per-box for exactly the reason
+ // AdoptionRunning does: the value of the reading is knowing WHICH box
+ // lost its config-sync trigger, since "somewhere in the fleet config is
+ // an hour late" is a search, not an answer. Deliberately NOT rolled up
+ // into a fleet-wide field: worst-of aggregates exist here only where the
+ // page shows a tile for them, and this one is a per-box operational
+ // fact, not a headline about the fleet.
+ ConfigPathRunning *bool `json:"config_path_running"`
// ExpiredCanaries appears per-box for the same reason AdoptionRunning
// does: the fleet-wide sum says something is expired somewhere, this
// says on which box, without naming which route. Published in the FEED
@@ -114,13 +122,14 @@ func BuildFleet(ctx context.Context, s release.Store, routes []registry.Route, n
for i, b := range boxes {
stale[i] = now.Sub(b.GeneratedAt) > 3*period
f.Boxes = append(f.Boxes, FleetBox{
- Label: b.Label,
- Region: b.Region,
- Load: b.Load,
- Stale: stale[i],
- Routes: b.Routes,
- AdoptionRunning: b.AdoptionRunning,
- ExpiredCanaries: b.ExpiredCanaries,
+ Label: b.Label,
+ Region: b.Region,
+ Load: b.Load,
+ Stale: stale[i],
+ Routes: b.Routes,
+ AdoptionRunning: b.AdoptionRunning,
+ ConfigPathRunning: b.ConfigPathRunning,
+ ExpiredCanaries: b.ExpiredCanaries,
})
}
diff --git a/internal/status/snapshot.go b/internal/status/snapshot.go
index 378e55f4d..11cf48ab9 100644
--- a/internal/status/snapshot.go
+++ b/internal/status/snapshot.go
@@ -33,6 +33,15 @@ type Probes struct {
// "never asked / couldn't tell", and like every other nil here it must
// publish as null: a box that can't answer is not a box that is fine.
AdoptRunning *bool
+ // ConfigPathRunning is whether this box's config-sync trigger unit
+ // (carlos-adopt.path) is ACTIVE — the issue #44 sibling of AdoptRunning,
+ // with the identical failure mode behind it: enabled writes a symlink,
+ // only started arms the watch. An unarmed path unit degrades silently
+ // (the agent writes its marker, nothing reads it, unit-backed config
+ // waits out the hourly floor), which is precisely the class of quiet
+ // half-working this field exists to end. nil is "never asked / couldn't
+ // tell" and publishes as null, same as every other probe here.
+ ConfigPathRunning *bool
// ExpiredCanaries is how many of this box's own canary/* routes have
// passed release.CanaryTTL (pipelines spec §5 "Canaries expire"),
// pre-computed by the caller against the release store — CollectBox
@@ -86,6 +95,16 @@ type Box struct {
// flagship spent weeks in the false state with no way to see it; this
// field is the way to see it.
AdoptionRunning *bool `json:"adoption_running"`
+ // ConfigPathRunning is whether config reaches this box's UNIT-BACKED
+ // routes in seconds (carlos-adopt.path armed) or only on the hourly
+ // carlos-adopt.timer floor: true = armed, false = the trigger is
+ // installed but not watching, null = unknown. Published for the same
+ // reason as AdoptionRunning above and with the same content — the
+ // liveness of one of the platform's OWN units on an opaquely-labelled
+ // box, which says nothing about any tenant. Snapshots written before
+ // issue #44 carry no such key at all and decode to null, which is the
+ // honest reading of a box that never reported it.
+ ConfigPathRunning *bool `json:"config_path_running"`
// ExpiredCanaries publishes Probes.ExpiredCanaries straight through — a
// count, never hostnames (pipelines spec §5): which canary route expired
// is exactly the kind of identifying detail this package's whole design
@@ -247,15 +266,16 @@ func CollectBox(routes []registry.Route, p Probes, now time.Time) Box {
}
return Box{
- GeneratedAt: now,
- Label: Label(p.MachineID),
- Region: p.Region,
- Routes: counts,
- Load: bandWord(p.MemFrac, loadWords),
- Capacity: bandWord(worst, capacityWords),
- Replication: Replication{Healthy: healthy, LagSecs: p.LagSecs},
- AdoptionRunning: p.AdoptRunning,
- ExpiredCanaries: p.ExpiredCanaries,
+ GeneratedAt: now,
+ Label: Label(p.MachineID),
+ Region: p.Region,
+ Routes: counts,
+ Load: bandWord(p.MemFrac, loadWords),
+ Capacity: bandWord(worst, capacityWords),
+ Replication: Replication{Healthy: healthy, LagSecs: p.LagSecs},
+ AdoptionRunning: p.AdoptRunning,
+ ConfigPathRunning: p.ConfigPathRunning,
+ ExpiredCanaries: p.ExpiredCanaries,
}
}
diff --git a/internal/status/status_test.go b/internal/status/status_test.go
index f38b6d0ab..aa8a5adbc 100644
--- a/internal/status/status_test.go
+++ b/internal/status/status_test.go
@@ -581,6 +581,59 @@ func TestAdoptionUnknownIsANull(t *testing.T) {
}
}
+// TestConfigPathRunningTravelsPerBox pins the issue #44 probe's whole chain:
+// whether this box's config-sync trigger unit (carlos-adopt.path) is ACTIVE
+// travels from Probes onto the box snapshot and through BuildFleet onto the
+// box's public row — the same journey adoption_running makes, and for the
+// same reason. An inactive path unit is a box that silently fell back to the
+// hourly config floor, which is exactly the class of quiet degradation this
+// package exists to make visible. Unknown stays null: an unasked question is
+// not a reassuring answer here either.
+func TestConfigPathRunningTravelsPerBox(t *testing.T) {
+ if b := status.CollectBox(nil, status.Probes{MachineID: "m"}, time.Now()); b.ConfigPathRunning != nil {
+ t.Errorf("config_path_running = %v, want null when the probe never answered", *b.ConfigPathRunning)
+ }
+ for _, want := range []bool{true, false} {
+ got := status.CollectBox(nil, status.Probes{MachineID: "m", ConfigPathRunning: &want}, time.Now()).ConfigPathRunning
+ if got == nil || *got != want {
+ t.Fatalf("ConfigPathRunning=%v: box field = %v, want %v", want, got, want)
+ }
+ }
+
+ s := teststore(t)
+ now := time.Now()
+ no := false
+ b := status.CollectBox(nil, status.Probes{MachineID: "m", ConfigPathRunning: &no}, now)
+ if err := status.WriteBox(context.Background(), s, b); err != nil {
+ t.Fatal(err)
+ }
+ f, err := status.BuildFleet(context.Background(), s, nil, now, 5*time.Minute)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(f.Boxes) != 1 {
+ t.Fatalf("fleet boxes = %d, want 1", len(f.Boxes))
+ }
+ if got := f.Boxes[0].ConfigPathRunning; got == nil || *got {
+ t.Fatalf("fleet box config_path_running = %v, want false — knowing WHICH box lost its trigger is the whole value", got)
+ }
+}
+
+// TestConfigPathRunningIsWireAdditive pins the other half: a snapshot written
+// before this field existed (no "config_path_running" key at all) must still
+// parse, and must read as unknown rather than as a healthy false. Live
+// buckets hold exactly those objects today.
+func TestConfigPathRunningIsWireAdditive(t *testing.T) {
+ var b status.Box
+ old := `{"generated_at":"2026-08-04T00:00:00Z","label":"box-old","routes":{"static":0,"resident":0,"hibernating":0,"awake":null,"asleep":null},"load":null,"capacity":null,"replication":{"healthy":null,"lag_seconds":null},"adoption_running":true,"expired_canaries":null}`
+ if err := json.Unmarshal([]byte(old), &b); err != nil {
+ t.Fatalf("a pre-#44 snapshot must still parse: %v", err)
+ }
+ if b.ConfigPathRunning != nil {
+ t.Fatalf("config_path_running = %v, want null for a snapshot written before the field existed", *b.ConfigPathRunning)
+ }
+}
+
// TestFleetAdoptionWorstOf covers the aggregate: a box that is KNOWN not to be
// adopting is a defect the fleet number must never hide, so one false outranks
// any number of trues; an unknown box can't be summed into a confident true,