First implementation step for the cross-platform settings contract (#376).
Adds the artifact everything else depends on: the manifest, its JSON Schema,
the object value schemas, and a Go loader that validates the whole thing at
load time. No routes, no storage, no behavior change — nothing reads this yet.
contracts/settings/v1/ holds the artifact at a stable path because clients
vendor it and generate bindings from it. The embed directive has to sit beside
it (go:embed cannot reach outside its own directory), so that directory is a
tiny Go package containing nothing else; loading and validation live in
internal/settingscontract.
38 definitions: 35 remote, 3 contract-known client_local. That covers every key
the legacy registry accepts, every unregistered key the extension bag was
silently accepting from the web client, every unregistered device key Android
writes, and the profile preference columns that become settings.
Registering the previously-unregistered keys is where the drift shows up, and
the manifest records each case in a notes field:
- ui_theme, ui_text_scale, ui_text_weight, ui_high_contrast,
ui_custom_theme_vars, and ui_custom_css reached the server only because
keyUsesUserScope returns true for any unregistered key. They are now typed,
renamed to the dotted convention every other key uses, and moved to profile
scope per the design.
- player.match_frame_rate and player.sleep_timer_default_minutes are written by
Android against a server that does not register them, so every write and reset
is currently rejected. Registered.
- player.next_up_prompt_seconds is Android's alias for
playback.next_up_prompt_seconds and does not become a definition; the test
matrix pins it as a migration alias.
- player.playback_speed is capped at 3.0, matching the server rather than
Android's 4.0.
- subtitle_appearance becomes playback.subtitle_appearance. Every other
canonical key carries a domain prefix, and preserving accidental key names is
an explicit non-goal of the design.
Validation is deliberately stricter than the schema can express. Beyond shape,
it enforces that a resolution order ends in "default", that it only resolves
scopes the definition allows, and — the one most likely to bite — that every
writable scope is actually read, so a setting cannot accept writes at a scope it
will never honor. Defaults are validated against their own value schema, so a
default that violates its own range or enum fails at load. Revision tags are
checked to never run ahead of the manifest revision, which is what makes
revision-aware client filtering trustworthy. Ceiling and floor policy
constraints are rejected on unordered types, where capping would silently do
nothing; playback.preferred_quality's enum is therefore ordered ascending.
ValidateValue is the single validation path, so the mutation endpoint, the
migration, and the manifest's own default checks cannot diverge later. Numbers
decode through json.Number so an integer setting rejects 30.5 rather than
truncating, and object values validate against their referenced JSON Schema
instead of accepting arbitrary JSON the way validateJSONSetting does today.
Canonicalization implements RFC 8785 over the value domain the contract uses:
sorted keys, no insignificant whitespace, ECMAScript number formatting. The
digest is the ETag, and PublicBytes strips maintainer notes so the served
manifest never carries internal commentary.
Promotes santhosh-tekuri/jsonschema/v6 from indirect to direct.
Verification: 124 tests pass across 16 cases; golangci-lint clean;
make verify-local-paths passes. Two failures in internal/api/handlers
(TestRemoveJellyfinCompatWebDisablesWebSetting, the playback v3 seek recovery
test) reproduce unchanged on main and are unrelated.
Part of #376.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>