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>
2 files changed,
+372
−0
internal/release/features.go+213 −0internal/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) |
| + } |
| +} |