Files
silo-server/docs/settings-api.md
T
Quick104andClaude Opus 5 b43b7ef06b fix(activity): resolve client identity without polluting client_version
Review follow-up on the client build/channel work. Fourteen findings; the
substantive ones:

The v3 body fallback took client_playback_context.app_version whenever the
header was absent. The web player sends the literal "web" there and sends no
X-Silo-Client, so every browser session would have stamped client_version="web"
— the one field the contract promises is semver and the field a future
minimum-version gate has to key on. client_playback_context carries no app name,
so the body can never identify a nameless client anyway; the fallback now
applies only to a client that sent X-Silo-Client, and a test pins the "web"
case.

An over-long app_build or app_channel in the start body failed the whole request
with 400 while the same value in a header was silently clamped — an opaque
diagnostic label could refuse playback. validateCapabilitiesV3 now clamps both
with the same helper the header path uses, which is what the docs already
claimed.

Route events posted out of band resolved identity from headers only, so a client
reporting its build in the start body attributed plan_selected to a build and
every later event of the same attempt to none. They now fill empty fields from
the session, as the replan path already did.

playbackClientFullDisplayName discarded build and channel whenever the client
reported no name, so the new Client card could never show a build for a
user-agent-labelled session. It now qualifies whatever label the compact
formatter resolved, which also drops its duplicated name+version assembly.

normalizeClientMetadataValue truncated by bytes; a multi-byte header value cut
mid-rune yields invalid UTF-8, which Postgres rejects — and the per-node session
upserts share one transaction, so one malformed client string would fail that
whole node's sync. It now clamps on a rune boundary.

replan-request.schema.json never got app_build/app_channel even though
ReplanRequestV3 reuses ClientPlaybackContextV3 and validates the same bounds. A
new contract test asserts every $def the two request schemas share is identical,
so the copies cannot drift again.

Also: the four client log attrs move to ClientInfo.LogAttrs(), which is now
their single definition and omits fields the client did not report rather than
persisting empty keys into opslog; startPlannedPlaybackV3 takes the resolved
identity instead of re-parsing the headers; client_label_full is omitted when it
would repeat client_label; getSessionClientLabelFull delegates to
getSessionClientLabel instead of re-implementing it; the Activity search matches
the exact label so a build number is findable; and the web ClientPlaybackContextV3
type mirrors the two new optional fields.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 16:23:03 -04:00

12 KiB

Canonical Settings API

The canonical settings API stores typed user preferences from the shared settings contract. Client implementations should discover the server contract before rendering controls or writing a value; do not keep a separate list of keys, scopes, enum members, or defaults.

All paths below are relative to /api/v1.

Contract discovery

Method and path Purpose
GET /settings/manifest Public client projection of the current manifest. Supports If-None-Match.
GET /settings/capability Contract API version, revision, remote scopes, supported client families, and batch/write capabilities.

/settings/contract and /settings/contract/capabilities are equivalent aliases. A client whose vendored contract is newer than the advertised server revision must hide definitions and features introduced after that revision. Navigation shortcut mutation controls additionally require supports_atomic_shortcuts: true. Revision-5 customization also requires supports_batched_effective: true and supports_idempotent_writes: true so batched resolution and replayed writes have the semantics the clients depend on. Any missing flag fails closed.

Request identity headers

Authenticated settings routes use the active account and profile from the normal Silo session. The contextual headers below identify which client is resolving or writing an override.

Header When required Meaning
X-Profile-Id All /settings/values routes Active household profile.
X-Silo-Device-Id A profile_device explicit request, or an effective request containing a key that permits profile_device Stable exact-device identity.
X-Silo-Client-Family Required for a profile_client explicit request; optional on effective reads Closed like-client identity: tv, mobile, tablet, desktop, or web. A valid value includes the like-client layer; absence skips it.
X-Silo-Mutation-Id Optional on PUT Idempotency key for safe retries. Reusing it for a different identity or value returns a conflict.

Client family is intentionally independent of the free-form X-Silo-Device-Platform display metadata. The server never guesses one from the other. Send the exact lower-case family:

Client Family
tvOS or Android TV tv
iPhone or Android phone mobile
iPad or Android tablet tablet
macOS desktop
Browser web

App identity headers

Separately from the family, every first-party client should send its own app identity on playback requests. These are server-wide contextual headers, not settings-specific: the playback session stores them, the admin Activity page renders them ("Silo Android TV 1.0.0 (build 5)"), and playback decision logs carry them so a report can be tied to an exact build.

Header Clamp Meaning
X-Silo-Client 128 Product name, e.g. Silo Android TV, Silo iOS.
X-Silo-Client-Version 64 Marketing version, e.g. 1.0.0. Sent verbatim and displayed verbatim — do not pre-shorten it.
X-Silo-Client-Build 64 Opaque per-platform build identifier (Android versionCode, Apple CFBundleVersion). Never parsed or compared.
X-Silo-Client-Channel 32 Opaque distribution channel: release, beta, sideload, dev. Stored verbatim; release is not displayed.

Values are trimmed and truncated to the clamp above — never rejected, on either route, because an identity label must not be able to fail a playback start. Nothing is validated against an enum either, so a client may introduce a new channel without a server change.

Protocol-v3 POST /playback/start accepts client_playback_context.app_version, .app_build, and .app_channel as a body-level fallback for clients that cannot set the headers on every request. The headers win field by field when both are present, and the fallback applies only to a client that sent X-Silo-Client: client_playback_context carries no app name, so nothing in the body can identify a client that did not name itself — such a session is labelled from its user agent, and its app_version is a free-form platform string rather than the marketing version client_version promises.

Remote scopes

Every stored value has exactly one identity. Context fields not named by the selected scope must be absent.

Scope Identity after account
account none
profile profile_id
profile_client profile_id, client_family
profile_device profile_id, device_id
profile_library profile_id, library_id
profile_series profile_id, series_id

The manifest's allowed_scopes decides where each key may be written, and its resolution_order decides precedence. profile_client values roam only among like clients. For example, a TV value applies to tvOS and Android TV but not to a phone or browser.

Explicit values

Use explicit endpoints to edit or clear one scope, without resolving inherited values:

  • GET /settings/values?keys=<csv>&scope=<scope> returns every requested key with is_set; unset rows remain in the response.
  • GET /settings/values/{key}?scope=<scope> returns one stored value or 404.
  • PUT /settings/values/{key}?scope=<scope> accepts {"value": <typed JSON>}.
  • DELETE /settings/values/{key}?scope=<scope> removes the row so inheritance applies again.

library_id and series_id are query parameters for their matching scopes. An explicitly managed device may be named with device_id, subject to the profile/device authorization checks. The self-service profile_client scope takes its family only from X-Silo-Client-Family.

Example: share a TV menu between tvOS and Android TV clients.

PUT /api/v1/settings/values/nav.primary_menu?scope=profile_client
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Client-Family: tv
Content-Type: application/json

{"value":{"items":[{"type":"builtin","destination":"home"},{"type":"library","library_id":7,"label":"Movies"}]}}

Stored-value responses include client_family when the source or explicit row is at profile_client.

Atomic navigation shortcuts

nav.shortcuts is a profile-wide catalog shared by TV, mobile, desktop, and web clients. Self-service clients must mutate one destination at a time instead of replacing that shared document:

PUT /api/v1/settings/values/nav.shortcuts/item
Authorization: Bearer <token>
X-Profile-Id: <profile-id>
X-Silo-Mutation-Id: <stable-uuid-for-this-intent>
Content-Type: application/json

{"item":{"type":"section","library_id":7,"section_id":"recent","label":"Recently Added"},"present":true}

The item is one member of navigation-shortcuts.json: a library, section, or collection (whose collection_id is a string and whose library_id is optional). Identity is exactly the schema's semantic identity:

  • library: type + library_id
  • section: type + library_id + section_id
  • collection: type + optional library_id + collection_id

present: true appends an absent item or refreshes the label of an existing identity in place without reordering it. present: false removes that identity; the supplied label is validated but ignored for matching. The server atomically rebases on concurrent edits, enforces the full schema and 256-item cap, and returns the normal stored-value object containing the complete resulting value, row revision, and updated_at. An already-satisfied operation is a successful no-op and does not increment the revision. Removing from a catalog that has never been stored returns {"items":[]} at revision 0 with no updated_at.

Retry with the same X-Silo-Mutation-Id. A recorded retry returns the original response with X-Silo-Idempotent-Replay: true; reusing the id for a different semantic operation returns 409 mutation_id_conflict. The mutation ID is serialized before the setting write, and the setting plus its replay receipt commit in one database transaction, so concurrent reuse cannot apply twice and a crash cannot persist only one half. A rare exhausted contention loop returns retryable 409 setting_update_conflict. Malformed envelopes or unknown envelope fields return 400 bad_request, while item schema failures (including unknown item fields) and cap failures return 400 invalid_value.

Ordinary session PUT and DELETE at /settings/values/nav.shortcuts?scope=profile are rejected with 400 atomic_update_required so a whole-document mutation cannot erase concurrent item edits or reset the row revision. The admin endpoint retains whole-document PUT for explicit repair work, but rejects physical DELETE for the same revision-history reason. An admin clears the catalog with {"value":{"items":[]}}, which advances the row revision, or replaces it with another validated document.

Effective values

  • GET /settings/values/effective?keys=<csv> resolves several keys for the request profile, device, and client family. Omitting keys resolves all remote definitions.
  • POST /settings/values/effective resolves a bounded list of content contexts in one store read. Use it for grids or lists rather than issuing one request per item.

An effective request must include the device header when any requested definition permits an exact-device override. The family header is optional for backward compatibility: absence skips the profile_client layer, while a non-empty invalid family is rejected. First-party clients should send their family so like-device preferences participate in resolution. Each response includes its source scope and source context; client_family is included for a family-scoped winner.

Admin projection

Admin routes are mounted behind the normal acting-admin authorization:

  • GET /admin/users/{id}/settings/values lists every stored row across all scopes.
  • PUT /admin/users/{id}/settings/values/{key}?scope=<scope> writes through the same contract validation and normalization as the self-service endpoint.
  • DELETE /admin/users/{id}/settings/values/{key}?scope=<scope> clears the selected row.

Admin requests name profile/context identity with query parameters because the target user is not the admin's active session. In particular, scope=profile_client requires both profile_id and client_family query parameters; it does not use X-Silo-Client-Family.

PUT /api/v1/admin/users/42/settings/values/ui.card_presentation?scope=profile_client&profile_id=main&client_family=tv
Authorization: Bearer <admin-token>
Content-Type: application/json

{"value":{"poster_size":"large","caption":"artwork"}}

The five accepted client_family values are also returned by the capability endpoint so admin tooling does not need to invent them.