carlos / platform Public

release: the app feature-flags record — apps/<acct>/<app>/features.json (#347)

One small unsigned object per app holding the switches the APP defines and
the platform merely stores: schema-1, CAS-guarded, absent-is-legal like
retention.json. Keys are 1-64 of [a-z0-9_-], values 1-256 printable bytes,
at most 64 keys — bounded so it stays a handful of switches rather than a
config store by another name. UpdateFeatures merges set/unset onto the
current map and reports whether anything changed, so a no-op set never
rewrites set_by/set_at and a door can skip the audit row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 145041b2ca30b30616e8a2dd3035c113f0dcbf77 parent d27e2f0
2 files changed, +372 −0
  • internal/release/features.go +213 −0
  • internal/release/features_test.go +159 −0
diff --git a/internal/release/features.go b/internal/release/features.go
new file mode 100644
index 000000000..0f517de36
--- /dev/null
+++ b/internal/release/features.go
@@ -0,0 +1,213 @@
+// The app feature-flags record (issue #347): one small unsigned object per
+// app holding the switches the APP defines and the platform merely stores
+// and serves — `{"sandbox": "on"}` — read back by the app off the deployment
+// block it already fetches. The platform never interprets a key or a value;
+// what it owns is the write path (a console door, audited) so that enabling
+// an app capability is never an app editing root-owned env on an edge box.
+//
+// Bucket layout addition:
+//
+// apps/<acct>/<app>/features.json CAS-guarded, schema-1 record
+//
+// ABSENT IS LEGAL FOREVER: no record means no features set — the
+// retention.json convention. Nothing on a box reads this key; the only
+// consumer is the console, so the shape is purely additive.
+//
+// Not secrets, not config: values are short strings. Anything an app wants
+// delivered into a process environment is `carlos env`'s job, and anything
+// it wants sealed is `carlos secrets`'.
+package release
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "sort"
+ "time"
+ "unicode"
+)
+
+// FeaturesMax caps how many keys one app's record may hold. The record is
+// read on every domains-door answer, so it stays a handful of switches
+// rather than growing into a config store by another name.
+const FeaturesMax = 64
+
+const (
+ featureKeyMax = 64
+ featureValueMax = 256
+)
+
+// Features is the wire shape at apps/<acct>/<app>/features.json.
+type Features struct {
+ Schema int `json:"schema"` // 1
+ App string `json:"app"` // must agree with the key path
+ Features map[string]string `json:"features"`
+ SetBy string `json:"set_by,omitempty"`
+ SetAt time.Time `json:"set_at"`
+}
+
+// FeaturesKey is the bucket key of app's feature-flags record, derived from
+// AppPrefix like every other per-app key.
+func FeaturesKey(acct, app string) string { return AppPrefix(acct, app) + "features.json" }
+
+// ValidFeatureKey reports whether k is a well-formed feature key: 1-64
+// bytes of lowercase ASCII letters, digits, '_' and '-'. Lowercase only so
+// that "Sandbox" and "sandbox" can never be two switches.
+func ValidFeatureKey(k string) bool {
+ if k == "" || len(k) > featureKeyMax {
+ return false
+ }
+ for _, r := range k {
+ switch {
+ case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-':
+ default:
+ return false
+ }
+ }
+ return true
+}
+
+// ValidFeatureValue reports whether v is a well-formed feature value: 1-256
+// bytes of printable characters. Empty is not a value — the CLI spells
+// "clear this key" as `key=`, and that must arrive as an unset, never as a
+// stored empty string an app then has to treat as a third state.
+func ValidFeatureValue(v string) bool {
+ if v == "" || len(v) > featureValueMax {
+ return false
+ }
+ for _, r := range v {
+ if !unicode.IsPrint(r) {
+ return false
+ }
+ }
+ return true
+}
+
+// GetFeatures reads the app's record. (nil, nil) when absent — no features
+// is a legal permanent state. A record that lies about identity or carries
+// an unknown schema is refused, never repaired. A present record always
+// carries a non-nil map, so consumers range over it without a nil check.
+func GetFeatures(ctx context.Context, s Store, acct, app string) (*Features, error) {
+ rec, _, err := getFeatures(ctx, s, acct, app)
+ return rec, err
+}
+
+func getFeatures(ctx context.Context, s Store, acct, app string) (*Features, string, error) {
+ key := FeaturesKey(acct, app)
+ raw, etag, err := s.Get(ctx, key)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", nil
+ }
+ if err != nil {
+ return nil, "", err
+ }
+ var rec Features
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return nil, "", fmt.Errorf("features at %s: %w", key, err)
+ }
+ if rec.Schema != 1 {
+ return nil, "", fmt.Errorf("features at %s: unknown schema %d", key, rec.Schema)
+ }
+ if rec.App != app {
+ return nil, "", fmt.Errorf("features at %s names app %q", key, rec.App)
+ }
+ if rec.Features == nil {
+ rec.Features = map[string]string{}
+ }
+ return &rec, etag, nil
+}
+
+// UpdateFeatures applies set (key -> value) and unset (keys) onto the app's
+// current map and writes the result. It returns the record as it now
+// stands and whether this call changed anything: a set to the value already
+// held, or an unset of an absent key, is a no-op that rewrites nothing —
+// so set_by/set_at keep naming the last REAL change and a door can skip the
+// audit row.
+//
+// Every key and value is validated before anything is read or written, so a
+// call with one bad entry lands nothing. CAS with a short retry, the
+// retention pattern: two writers racing is last-writer-wins on the MERGED
+// map, which is the right answer for a handful of switches — but a
+// conflict must re-read and re-merge, never blind-write.
+func UpdateFeatures(ctx context.Context, s Store, acct, app string, set map[string]string, unset []string, setBy string, now time.Time) (*Features, bool, error) {
+ if !ValidAccount(acct) {
+ return nil, false, fmt.Errorf("features: invalid account %q", acct)
+ }
+ if !ValidApp(app) {
+ return nil, false, fmt.Errorf("features: invalid app %q", app)
+ }
+ for k, v := range set {
+ if !ValidFeatureKey(k) {
+ return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax)
+ }
+ if !ValidFeatureValue(v) {
+ return nil, false, fmt.Errorf("features: invalid value for %s — 1-%d printable characters", k, featureValueMax)
+ }
+ }
+ for _, k := range unset {
+ if !ValidFeatureKey(k) {
+ return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax)
+ }
+ }
+ key := FeaturesKey(acct, app)
+ var lastErr error
+ for attempt := 0; attempt < 3; attempt++ {
+ cur, etag, err := getFeatures(ctx, s, acct, app)
+ if err != nil {
+ return nil, false, err
+ }
+ next := &Features{Schema: 1, App: app, Features: map[string]string{}}
+ if cur != nil {
+ next.SetBy, next.SetAt = cur.SetBy, cur.SetAt
+ for k, v := range cur.Features {
+ next.Features[k] = v
+ }
+ }
+ changed := false
+ for _, k := range unset {
+ if _, ok := next.Features[k]; ok {
+ delete(next.Features, k)
+ changed = true
+ }
+ }
+ // Sorted so a cap refusal is deterministic across calls with the
+ // same input, whatever order the map ranges in.
+ keys := make([]string, 0, len(set))
+ for k := range set {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ if next.Features[k] == set[k] {
+ continue
+ }
+ if _, exists := next.Features[k]; !exists && len(next.Features) >= FeaturesMax {
+ return nil, false, fmt.Errorf("features: at most %d keys per app", FeaturesMax)
+ }
+ next.Features[k] = set[k]
+ changed = true
+ }
+ if !changed {
+ if cur != nil {
+ return cur, false, nil
+ }
+ return next, false, nil
+ }
+ next.SetBy, next.SetAt = setBy, now.UTC()
+ raw, err := json.Marshal(next)
+ if err != nil {
+ return nil, false, err
+ }
+ if _, err := s.Put(ctx, key, raw, etag); err != nil {
+ if errors.Is(err, ErrCASConflict) {
+ lastErr = err
+ continue
+ }
+ return nil, false, err
+ }
+ return next, true, nil
+ }
+ return nil, false, fmt.Errorf("features: could not write %s: %w", key, lastErr)
+}
diff --git a/internal/release/features_test.go b/internal/release/features_test.go
new file mode 100644
index 000000000..c3636388b
--- /dev/null
+++ b/internal/release/features_test.go
@@ -0,0 +1,159 @@
+package release
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestFeaturesRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC)
+
+ // Absent is legal forever: no features, not an error.
+ got, err := GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil || got != nil {
+ t.Fatalf("absent features = %v, %v; want nil, nil", got, err)
+ }
+
+ rec, changed, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"sandbox": "on"}, nil, "alice@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed || rec.Features["sandbox"] != "on" || rec.SetBy != "alice@keymail.dev" || !rec.SetAt.Equal(now) {
+ t.Fatalf("first set = %+v changed=%v, want sandbox=on by alice, changed", rec, changed)
+ }
+ got, err = GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil || got.Features["sandbox"] != "on" || got.Schema != 1 || got.App != "titogo" {
+ t.Fatalf("features = %+v, want schema 1 titogo sandbox=on", got)
+ }
+
+ // A second set MERGES: the existing key survives, the new one lands.
+ rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "bob@keymail.dev", now.Add(time.Hour))
+ if err != nil || !changed {
+ t.Fatalf("merge set: changed=%v err=%v", changed, err)
+ }
+ if rec.Features["sandbox"] != "on" || rec.Features["beta"] != "1" || rec.SetBy != "bob@keymail.dev" {
+ t.Fatalf("after merge = %+v, want both keys, set by bob", rec)
+ }
+
+ // Setting the same value again changes nothing — and says so, so the
+ // door can skip the audit row.
+ _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "carol", now.Add(2*time.Hour))
+ if err != nil || changed {
+ t.Fatalf("no-op set: changed=%v err=%v, want false, nil", changed, err)
+ }
+ got, _ = GetFeatures(ctx, s, "bab", "titogo")
+ if got.SetBy != "bob@keymail.dev" {
+ t.Fatalf("a no-op set rewrote the record: set_by %q", got.SetBy)
+ }
+
+ // Unset removes a key; unsetting an absent key is a no-op.
+ rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"beta", "never"}, "dave", now.Add(3*time.Hour))
+ if err != nil || !changed {
+ t.Fatalf("unset: changed=%v err=%v", changed, err)
+ }
+ if _, ok := rec.Features["beta"]; ok || rec.Features["sandbox"] != "on" {
+ t.Fatalf("after unset = %+v, want only sandbox", rec.Features)
+ }
+ _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"never"}, "dave", now.Add(4*time.Hour))
+ if err != nil || changed {
+ t.Fatalf("unset absent: changed=%v err=%v, want false, nil", changed, err)
+ }
+
+ // Unsetting the last key leaves an EMPTY record, not an absent one: the
+ // record's set_at still tells a reader when the last change happened.
+ rec, _, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"sandbox"}, "erin", now.Add(5*time.Hour))
+ if err != nil || len(rec.Features) != 0 {
+ t.Fatalf("after last unset = %+v err=%v", rec, err)
+ }
+ got, err = GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil || got == nil || len(got.Features) != 0 || got.SetBy != "erin" {
+ t.Fatalf("empty record = %+v, %v; want present, empty, by erin", got, err)
+ }
+}
+
+// Keys and values are the app's own vocabulary — but they are also bucket
+// bytes every consumer parses, so the shape is bounded: short lowercase
+// keys, short printable values, and a cap on how many.
+func TestUpdateFeaturesRefusesBadShapes(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ now := time.Now()
+ cases := []struct {
+ name string
+ set map[string]string
+ want string
+ }{
+ {"uppercase key", map[string]string{"Sandbox": "on"}, "key"},
+ {"empty key", map[string]string{"": "on"}, "key"},
+ {"key with space", map[string]string{"sand box": "on"}, "key"},
+ {"key too long", map[string]string{strings.Repeat("k", 65): "on"}, "key"},
+ {"empty value", map[string]string{"sandbox": ""}, "value"},
+ {"value too long", map[string]string{"sandbox": strings.Repeat("v", 257)}, "value"},
+ {"control char in value", map[string]string{"sandbox": "on\n"}, "value"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", c.set, nil, "x", now)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Fatalf("err = %v, want one naming the %s", err, c.want)
+ }
+ })
+ }
+ if got, err := GetFeatures(ctx, s, "bab", "titogo"); err != nil || got != nil {
+ t.Fatalf("a refused set wrote something: %+v, %v", got, err)
+ }
+
+ // The count cap: FeaturesMax keys is fine, one more is refused —
+ // whether they arrive at once or on top of an existing record.
+ set := map[string]string{}
+ for i := 0; i < FeaturesMax; i++ {
+ set["k"+strings.Repeat("x", i%3)+string(rune('a'+i%26))+string(rune('a'+i/26))] = "v"
+ }
+ if len(set) != FeaturesMax {
+ t.Fatalf("test bug: %d keys", len(set))
+ }
+ if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", set, nil, "x", now); err != nil {
+ t.Fatalf("%d keys refused: %v", FeaturesMax, err)
+ }
+ _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"one-more": "v"}, nil, "x", now)
+ if err == nil || !strings.Contains(err.Error(), "at most") {
+ t.Fatalf("key %d = %v, want a cap error", FeaturesMax+1, err)
+ }
+ // Replacing an existing key at the cap is not an addition.
+ if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"kaa": "changed"}, nil, "x", now); err != nil {
+ t.Fatalf("replacing at the cap refused: %v", err)
+ }
+}
+
+func TestGetFeaturesRefusesLiesAndUnknownSchema(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ put := func(body string) {
+ s.Delete(ctx, FeaturesKey("bab", "titogo"))
+ if _, err := s.Put(ctx, FeaturesKey("bab", "titogo"), []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ put(`{"schema":1,"app":"other","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`)
+ if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil {
+ t.Fatal("a body naming a different app was accepted")
+ }
+ put(`{"schema":2,"app":"titogo","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`)
+ if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil {
+ t.Fatal("an unknown schema was accepted")
+ }
+ // A record with no features map at all reads as empty, never nil —
+ // consumers range over it without a nil check.
+ put(`{"schema":1,"app":"titogo","set_at":"2026-08-28T12:00:00Z"}`)
+ got, err := GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil || got == nil || got.Features == nil {
+ t.Fatalf("missing map = %+v, %v; want a non-nil empty map", got, err)
+ }
+}