Files
silo-server/internal/settingscontract/contract.go
T
QuickandClaude Opus 5 d8faf83c6e fix(settings): make the settings contract enforceable and fix the appearance cache
The contract manifest landed as a document nothing checked. This makes it a
mechanism, and fixes the one defect in the change set that hurt users on merge
rather than at cutover.

Web appearance cache. useTheme cleared the cache for any account whose stamp
did not match and never repopulated it — the only writers were the four
user-action setters — so every upgrading user lost their warm start on every
load, not once, and x-large-text and high-contrast users lost theirs too. The
owner-stamp protocol is replaced with per-account key namespacing
(`silo-theme:7`): a foreign value is absent rather than present-and-distrusted,
so nothing has to be deleted, the first account keeps its warm start, and there
is no shared stamp for a second tab, a stale debounce timer, or an out-of-order
effect to race on. Widening ownership to profile scope, which this manifest
requires, is now a change to appearanceCacheOwner alone. Adds the API-to-cache
mirror useTheme was missing, cancels pending debounced writes across an account
change, and re-seeds provider state during render so no frame paints the
previous account's look.

Canonicalization. writeCanonical used json.Marshal, which HTML-escapes < > and
&, and canonicalNumber used Go's 'g' format — both diverge from RFC 8785, so
the first label containing an ampersand or bound below 1e-4 would have forked
the server's ETag from every conforming client. Output is now byte-identical to
ECMAScript String() across the edge cases, verified against node. The ETag also
covers the value schemas, which decide what the server accepts and previously
could change while the tag stood still. All four derived representations are
memoized; a conditional GET no longer costs a full parse and re-serialize.

Validation. strictUnmarshal's decoder.More() answered false for a stray ] or },
so `true]` validated as a boolean. Enum matching compared fmt.Sprintf tokens, so
the string "3" satisfied an integer member. Declared steps were never enforced.
The language pattern rejected tags both mobile platforms emit unprompted
(en_US, ca-ES-valencia, ar-EG-u-nu-latn) and never normalized case, so en-US and
en-us were two rows for one preference; NormalizeValue now canonicalizes on the
shared path.

Manifest. show_forced_subtitles defaulted false where the server column is NOT
NULL DEFAULT true, which would have turned forced subtitles off for every
profile that never touched it. preferred_quality declared 13 members where the
planner speaks 6 and collapses the rest to auto. metadata_language's allowlist
was bound to the very column it migrates from. subtitle-appearance pinned
fontFamily to three families while Apple stores any installed system font.
Registers five user-facing settings the clients already ship, and corrects three
notes that described Android behaviour that was not true.

Enforcement. The package had no non-test callers, so MustLoad never ran; it now
loads and logs at startup. The inventory test compared the manifest against a
hand-copied map and could not see the drift it named; it now iterates
settingsRegistry and checks defaults too — both verified to fail on injected
drift. Adds .github/workflows/ci.yml, the repo's first CI that runs go test,
go vet, gofmt, and the frontend suite. Known pre-existing failures are named
individually in the Makefile so everything else stays gated and the list can
only shrink.

Part of #135

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-26 15:24:33 +00:00

298 lines
9.9 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"
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: {},
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
}
// 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, and bounds against.
Revision int `json:"revision"`
// 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"`
Deprecated bool `json:"deprecated,omitempty"`
// Notes is maintainer commentary. It is stripped from the public manifest.
Notes string `json:"notes,omitempty"`
}
// 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 *float64 `json:"minimum,omitempty"`
Maximum *float64 `json:"maximum,omitempty"`
Step *float64 `json:"step,omitempty"`
// Revision tags for additively widened bounds.
MinimumIntroducedIn int `json:"minimum_introduced_in,omitempty"`
MaximumIntroducedIn int `json:"maximum_introduced_in,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
}
// 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
}