// The convergence signal: one tiny object per app that every successful // config write touches, so a watcher can learn "something changed" from a // single cheap etag instead of re-reading every bundle on a timer. See spec // 2026-08-04-config-console-api-design.md §1 (issue #44) — config should // converge the way a canary adopts, in seconds, without restarting the // box's shared edge service to deliver one app's variable. // // Bucket layout addition: // // apps///config-gen.json CAS-guarded, schema-1 generation object package appconfig import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "errors" "fmt" "io" "log" "os" "time" "github.com/carlosframework/platform/internal/release" ) // Generation is the small sibling object every config write touches — // apps///config-gen.json — so the agent's fast loop (host // package) can watch ONE cheap etag per app instead of re-reading every // bundle hourly. One per app across ALL bundles: SyncExec resolves // per-(app, environment) itself, so the signal only needs app granularity. // Change detection is by etag; the fields exist for humans debugging a // convergence question and to make each touch's bytes distinct (a // content-derived etag, which DirStore uses, would not move for a // byte-identical rewrite). Absent is legal forever — an app last written // by an older CLI has no generation object and never will until someone // writes its config again; the hourly configSyncLoop remains the floor. type Generation struct { Schema int `json:"schema"` // 1 TouchedAt string `json:"touched_at"` Nonce string `json:"nonce"` } // GenKey is the bucket key of app's generation object, derived from // release.AppPrefix for the same reason Key is: everything an app owns sits // under one account-scoped prefix, permanently. Deliberately a sibling of // config.json rather than a field inside it — a field would mean every // watcher re-reads (and re-parses) the whole manifest, and would make the // signal race the very write it is signalling. func GenKey(acct, app string) string { return release.AppPrefix(acct, app) + "config-gen.json" } // TouchGeneration bumps app's generation object, the signal that this app's // config changed. A CAS conflict is SUCCESS, not an error: it means another // writer touched the generation between our read and our write, so the // signal this function exists to fire has already fired — retrying would // only produce a second, redundant bump. os.ErrNotExist on the read means // first touch, so the write is create-only (etag ""), which is what makes // the conflict carve-out safe: two first touches race, one wins, both // callers are correct. // // Exported because `carlos env sync` (and the console door behind it) is a // bare touch with no config write attached — the escape hatch for "apply // what's already in the bucket, now". func claimRawTouchGeneration(ctx context.Context, s release.Store, acct, app string) error { if !release.ValidAccount(acct) { return fmt.Errorf("config: invalid account %q", acct) } if !release.ValidApp(app) { return fmt.Errorf("config: invalid app %q", app) } key := GenKey(acct, app) _, etag, err := s.Get(ctx, key) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } if errors.Is(err, os.ErrNotExist) { etag = "" } nonce := make([]byte, 16) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return err } raw, err := json.Marshal(Generation{ Schema: 1, TouchedAt: time.Now().UTC().Format(time.RFC3339), Nonce: hex.EncodeToString(nonce), }) if err != nil { return err } if _, err := s.Put(ctx, key, raw, etag); err != nil { if errors.Is(err, release.ErrCASConflict) { return nil } return err } return nil } // touchLogged is TouchGeneration demoted to a log line, for use AFTER a // successful manifest write: the write landed, so a failed touch must not // fail the call — reporting failure for a change that is live would send an // operator to re-run a write that already happened. The real cost of a // failed touch is that convergence falls back to the hourly pass, which is // exactly where every pre-generation CLI already leaves it. func touchLogged(ctx context.Context, s release.Store, acct, app string) { if err := TouchGeneration(ctx, s, acct, app); err != nil { log.Printf("config: %s/%s: generation touch failed (converges on the hourly pass): %v", acct, app, err) } } // Apply writes sets and removes unsets from the DEFAULT bundle in one CAS // write, returning how many of unsets were actually present — the console // door's shape, since one PUT may carry both halves and two CAS writes would // mean a half-applied request whenever the second one lost a race. // SetEnv/SetSecret/Unset remain the narrow verbs the CLI's direct mode uses; // this is the same underlying path, just not pre-narrowed to one kind. func claimRawApply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) (int, error) { return apply(ctx, s, acct, app, DefaultBundle, sets, unsets) } // ApplyEnvironment is Apply for a named environment bundle — same one-write // mixed set/unset contract, same refusal to create a bundle on an // unset-only call (see applyAt). func claimRawApplyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (int, error) { return applyEnvironment(ctx, s, acct, app, envName, sets, unsets) } func Apply(ctx context.Context, s release.Store, acct, app string, sets map[string]Var, unsets []string) (claimResult0 int, claimResult1 error) { claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.Apply", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawApply(claimContext, s, acct, app, sets, unsets) return claimResult1 }) return } func ApplyEnvironment(ctx context.Context, s release.Store, acct, app, envName string, sets map[string]Var, unsets []string) (claimResult0 int, claimResult1 error) { claimResult1 = release.WithAppMutation(ctx, s, acct, app, "appconfig.ApplyEnvironment", func(claimContext context.Context) error { claimResult0, claimResult1 = claimRawApplyEnvironment(claimContext, s, acct, app, envName, sets, unsets) return claimResult1 }) return } func TouchGeneration(ctx context.Context, s release.Store, acct, app string) (claimResult0 error) { claimResult0 = release.WithAppMutation(ctx, s, acct, app, "appconfig.TouchGeneration", func(claimContext context.Context) error { claimResult0 = claimRawTouchGeneration(claimContext, s, acct, app) return claimResult0 }) return }