Files
silo-server/internal/settingscontract/contract.go
QuickandGitHub 3bdfc58512 feat(settings): sync navigation and card customization by client family (#538)
* test(web): use safe auth placeholders

* feat(settings): sync navigation and card customization

* fix(settings): address customization review feedback

* fix(settings): address customization review feedback

* fix(settings): harden customization capability handling
2026-08-04 08:20:41 -04:00

436 lines
15 KiB
Go

// Package settingscontract loads and validates the canonical cross-platform
// user settings manifest.
//
// The manifest in contracts/settings/v1 is the single source of truth for every
// production, user-facing setting: its key, value type, constraints, storage
// scopes, resolution order, default, and UX copy. The server embeds it, clients
// vendor a pinned copy and generate bindings from it, and no production setting
// may exist without an entry here.
//
// See docs/superpowers/specs/2026-07-10-cross-platform-user-settings-contract-design.md.
package settingscontract
import (
"encoding/json"
"fmt"
"regexp"
)
// Scope identifies the storage identity a value is attached to.
type Scope string
const (
ScopeAccount Scope = "account"
ScopeProfile Scope = "profile"
ScopeProfileClient Scope = "profile_client"
ScopeProfileDevice Scope = "profile_device"
ScopeProfileLibrary Scope = "profile_library"
ScopeProfileSeries Scope = "profile_series"
// ScopeClientLocal is not addressable by the server values API. It exists so
// client_local definitions can express a resolution order in the same shape
// as remote ones.
ScopeClientLocal Scope = "client_local"
// ScopeDefault terminates every resolution order.
ScopeDefault Scope = "default"
)
// remoteScopes are the scopes the server stores and authorizes.
var remoteScopes = map[Scope]struct{}{
ScopeAccount: {},
ScopeProfile: {},
ScopeProfileClient: {},
ScopeProfileDevice: {},
ScopeProfileLibrary: {},
ScopeProfileSeries: {},
}
// IsRemote reports whether the scope is stored and authorized by the server.
func (s Scope) IsRemote() bool {
_, ok := remoteScopes[s]
return ok
}
// ClientFamily identifies a class of like clients whose presentation and
// navigation preferences should roam together. It is explicit request
// metadata rather than an inference from the device platform string: platform
// names are free-form registry metadata, while this value participates in a
// canonical storage identity.
type ClientFamily string
const (
ClientFamilyTV ClientFamily = "tv"
ClientFamilyMobile ClientFamily = "mobile"
ClientFamilyTablet ClientFamily = "tablet"
ClientFamilyDesktop ClientFamily = "desktop"
ClientFamilyWeb ClientFamily = "web"
)
// Valid reports whether f is one of the canonical lower-case wire values.
func (f ClientFamily) Valid() bool {
switch f {
case ClientFamilyTV, ClientFamilyMobile, ClientFamilyTablet, ClientFamilyDesktop, ClientFamilyWeb:
return true
default:
return false
}
}
// Persistence declares who stores a setting's value.
type Persistence string
const (
// PersistenceRemote values are stored by the server and roam per their scope.
PersistenceRemote Persistence = "remote"
// PersistenceClientLocal values are defined by the contract but stored only
// by the client. They are never sent to the API.
PersistenceClientLocal Persistence = "client_local"
)
// ValueType tags a value schema.
type ValueType string
const (
TypeBoolean ValueType = "boolean"
TypeInteger ValueType = "integer"
TypeNumber ValueType = "number"
TypeString ValueType = "string"
TypeEnum ValueType = "enum"
TypeLanguageTag ValueType = "language_tag"
TypeObject ValueType = "object"
)
// ConstraintKind describes how a policy input narrows a setting.
type ConstraintKind string
const (
// ConstraintCeiling caps the value at the policy input. Valid only on an
// ordered enum or a numeric type.
ConstraintCeiling ConstraintKind = "ceiling"
// ConstraintFloor raises the value to the policy input. Same restriction.
ConstraintFloor ConstraintKind = "floor"
// ConstraintAllowlist restricts the value to a policy-supplied set.
ConstraintAllowlist ConstraintKind = "allowlist"
// ConstraintLocked prevents the user from authoring the setting at all.
ConstraintLocked ConstraintKind = "locked"
)
// Manifest is the canonical settings contract.
type Manifest struct {
// APIVersion identifies the settings protocol. It changes only for a change
// no revision rule can express.
APIVersion int `json:"api_version"`
// Revision is bumped by every manifest PR and is what clients filter
// definitions, scopes, enum members, option suggestions, and bounds against.
Revision int `json:"revision"`
// OptionSets are advisory presentation vocabularies. They never constrain
// what a value schema accepts: an open language_tag remains open even when
// its definition points at a suggested option set.
OptionSets map[string]OptionSet `json:"option_sets,omitempty"`
// Definitions is ordered as authored; use Lookup for access by key.
Definitions []Definition `json:"definitions"`
byKey map[string]*Definition
}
// Definition is the canonical contract for one setting.
type Definition struct {
Key string `json:"key"`
IntroducedIn int `json:"introduced_in"`
Persistence Persistence `json:"persistence"`
AllowedScopes []ScopeEntry `json:"allowed_scopes"`
ResolutionOrder []Scope `json:"resolution_order"`
ValueSchema ValueSchema `json:"value_schema"`
DefaultValue json.RawMessage `json:"default_value"`
ConstrainedBy *Constraint `json:"constrained_by,omitempty"`
Platforms []string `json:"platforms,omitempty"`
Category string `json:"category"`
Label string `json:"label"`
Description string `json:"description"`
Unit string `json:"unit,omitempty"`
Control string `json:"recommended_control,omitempty"`
SuggestedOptions string `json:"suggested_options,omitempty"`
UnsetLabel string `json:"unset_label,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
// Notes is maintainer commentary. It is stripped from the public manifest.
Notes string `json:"notes,omitempty"`
}
// OptionSet is an ordered, advisory list of values a client may present for
// an open setting. Type must match the referring definition's value schema.
// The options are kept in authored order so every generated client starts
// from the same stable floor before adding deployment-observed values.
type OptionSet struct {
Type ValueType `json:"type"`
Options []SuggestedOption `json:"options"`
}
// SuggestedOption is one presentation value and the earliest contract
// revision against which it is safe to offer. It deliberately carries no
// display label: clients localize language names with their platform locale
// data instead of requiring a server release for every translation.
type SuggestedOption struct {
Value string `json:"value"`
IntroducedIn int `json:"introduced_in"`
}
// OptionsAtRevision returns the authored suggestions visible to a peer at
// revision. The returned slice is a copy and preserves manifest order.
func (s OptionSet) OptionsAtRevision(revision int) []SuggestedOption {
options := make([]SuggestedOption, 0, len(s.Options))
for _, option := range s.Options {
if option.IntroducedIn > revision {
continue
}
options = append(options, option)
}
return options
}
// ScopeEntry is a scope, optionally tagged with the revision that added it to
// its definition. It unmarshals from either a bare string or an object so the
// common case stays readable in the manifest.
type ScopeEntry struct {
Scope Scope `json:"scope"`
IntroducedIn int `json:"introduced_in,omitempty"`
}
// UnmarshalJSON accepts "profile" or {"scope":"profile","introduced_in":14}.
func (s *ScopeEntry) UnmarshalJSON(data []byte) error {
var bare string
if err := json.Unmarshal(data, &bare); err == nil {
s.Scope = Scope(bare)
s.IntroducedIn = 0
return nil
}
var tagged struct {
Scope Scope `json:"scope"`
IntroducedIn int `json:"introduced_in"`
}
if err := json.Unmarshal(data, &tagged); err != nil {
return fmt.Errorf("scope entry must be a string or an object: %w", err)
}
s.Scope = tagged.Scope
s.IntroducedIn = tagged.IntroducedIn
return nil
}
// MarshalJSON emits the bare string form when no revision tag is present, so
// round-tripping the manifest does not rewrite unrelated entries.
func (s ScopeEntry) MarshalJSON() ([]byte, error) {
if s.IntroducedIn == 0 {
return json.Marshal(string(s.Scope))
}
return json.Marshal(struct {
Scope Scope `json:"scope"`
IntroducedIn int `json:"introduced_in"`
}{s.Scope, s.IntroducedIn})
}
// ValueSchema is the tagged type system for setting values. Only the fields
// belonging to Type are meaningful; Validate enforces that.
type ValueSchema struct {
Type ValueType `json:"type"`
Nullable bool `json:"nullable,omitempty"`
// Numeric (integer, number).
Minimum *Bound `json:"minimum,omitempty"`
Maximum *Bound `json:"maximum,omitempty"`
Step *float64 `json:"step,omitempty"`
// String.
MinLength *int `json:"min_length,omitempty"`
MaxLength *int `json:"max_length,omitempty"`
Pattern string `json:"pattern,omitempty"`
// Enum.
Values []EnumMember `json:"values,omitempty"`
Ordered bool `json:"ordered,omitempty"`
// Object.
SchemaRef string `json:"schema_ref,omitempty"`
// compiledPattern is Pattern, compiled once when the manifest loads.
// ValidateValue runs per request, so it must not compile a regex per call.
compiledPattern *regexp.Regexp
}
// Bound is a numeric limit together with every earlier value it has had.
//
// A widened bound cannot be represented as one scalar plus the revision that
// introduced it, because that discards the value it replaced. A client pinned
// to the newer revision talking to a server on the older one would then have no
// correct answer: honoring the new bound offers values the server rejects, and
// filtering the tagged bound out leaves the setting with no bound at all.
// Keeping the history lets AtRevision hand back the limit the peer actually
// enforces.
type Bound struct {
// History is ordered oldest first. The last entry is the bound in force on
// the manifest that declares it.
History []BoundEntry
}
// BoundEntry is one value of a bound and the revision that introduced it. A
// zero IntroducedIn means the bound has held since its definition appeared.
type BoundEntry struct {
Value float64 `json:"value"`
IntroducedIn int `json:"introduced_in,omitempty"`
}
// UnmarshalJSON accepts 240 or
// [{"value":240},{"value":480,"introduced_in":3}]. The bare form is the common
// case — most bounds are never widened — and keeping it readable is worth the
// dual shape, the same trade ScopeEntry makes.
func (b *Bound) UnmarshalJSON(data []byte) error {
var bare float64
if err := json.Unmarshal(data, &bare); err == nil {
b.History = []BoundEntry{{Value: bare}}
return nil
}
var history []BoundEntry
if err := json.Unmarshal(data, &history); err != nil {
return fmt.Errorf("bound must be a number or an array of {value, introduced_in}: %w", err)
}
b.History = history
return nil
}
// MarshalJSON emits the bare number when a bound has never been widened, so
// round-tripping the manifest does not rewrite untouched entries.
func (b Bound) MarshalJSON() ([]byte, error) {
if len(b.History) == 1 && b.History[0].IntroducedIn == 0 {
return json.Marshal(b.History[0].Value)
}
return json.Marshal(b.History)
}
// Current returns the bound in force on this manifest.
func (b *Bound) Current() (float64, bool) {
if b == nil || len(b.History) == 0 {
return 0, false
}
return b.History[len(b.History)-1].Value, true
}
// AtRevision returns the bound a peer at revision enforces: the newest entry
// introduced no later than that revision. Clients call this so they never offer
// a value the connected server will refuse.
func (b *Bound) AtRevision(revision int) (float64, bool) {
if b == nil {
return 0, false
}
var (
value float64
found bool
)
for _, entry := range b.History {
if entry.IntroducedIn > revision {
continue
}
value, found = entry.Value, true
}
return value, found
}
// EnumMember is one allowed enum value. Members are objects rather than bare
// strings so a member added after the definition can carry its own revision.
type EnumMember struct {
Value any `json:"value"`
Label string `json:"label,omitempty"`
IntroducedIn int `json:"introduced_in,omitempty"`
Deprecated bool `json:"deprecated,omitempty"`
}
// Constraint binds a definition to a policy input that narrows it at
// resolution time. A constraint filters what a preference does; it never
// validates what a preference is, so a mutation that exceeds a restriction is
// still stored.
type Constraint struct {
PolicyInput string `json:"policy_input"`
Constraint ConstraintKind `json:"constraint"`
}
// Lookup returns the definition for key.
func (m *Manifest) Lookup(key string) (*Definition, bool) {
if m == nil || m.byKey == nil {
return nil, false
}
def, ok := m.byKey[key]
return def, ok
}
// Keys returns every definition key in manifest order.
func (m *Manifest) Keys() []string {
if m == nil {
return nil
}
keys := make([]string, 0, len(m.Definitions))
for i := range m.Definitions {
keys = append(keys, m.Definitions[i].Key)
}
return keys
}
// index builds the by-key lookup. It reports duplicate keys as an error rather
// than letting the last one silently win.
func (m *Manifest) index() error {
m.byKey = make(map[string]*Definition, len(m.Definitions))
for i := range m.Definitions {
def := &m.Definitions[i]
if _, exists := m.byKey[def.Key]; exists {
return fmt.Errorf("duplicate setting key %q", def.Key)
}
m.byKey[def.Key] = def
}
return nil
}
// AllowsScope reports whether the definition may store a value at scope.
func (d *Definition) AllowsScope(scope Scope) bool {
for _, entry := range d.AllowedScopes {
if entry.Scope == scope {
return true
}
}
return false
}
// IsRemote reports whether the server stores this setting's values.
func (d *Definition) IsRemote() bool {
return d.Persistence == PersistenceRemote
}
// VisibleAtRevision reports whether a client pinned to revision may surface this
// definition. A client hides definitions the connected server does not know.
func (d *Definition) VisibleAtRevision(revision int) bool {
return d.IntroducedIn <= revision
}
// ScopesAtRevision returns the scopes valid against a peer at revision.
func (d *Definition) ScopesAtRevision(revision int) []Scope {
scopes := make([]Scope, 0, len(d.AllowedScopes))
for _, entry := range d.AllowedScopes {
if entry.IntroducedIn > revision {
continue
}
scopes = append(scopes, entry.Scope)
}
return scopes
}
// EnumValuesAtRevision returns the enum members valid against a peer at
// revision. Clients render from this so a newer client never offers a choice an
// older server will reject.
func (d *Definition) EnumValuesAtRevision(revision int) []EnumMember {
if d.ValueSchema.Type != TypeEnum {
return nil
}
members := make([]EnumMember, 0, len(d.ValueSchema.Values))
for _, member := range d.ValueSchema.Values {
if member.IntroducedIn > revision {
continue
}
members = append(members, member)
}
return members
}