Skip to content

"Settings schema" here is not a one-component renderer that turns any object into a <form>. It is the schema/draft model layer that settings editors build on: the Host serializes a Schemastery envelope; the browser rehydrates it into a live validator, reads node relations to decide which fields exist and their roles, validates drafts, and edits drafts immutably by path. The concrete controls are hand-written per screen, but they all share this model seam.

The package that used to own this layer, @deepseek-ai/dsh-client-schema-form (packages/client/schema-form), was deleted upstream. The model survived and moved into the settings domain base plugin: it is now the ctx.settingsSchema service in packages/client/ui-settings, and the reads/writes it used to issue through settings.* RPCs now go over the settings-controller Remote namespace (ctx.remote.settings) with a revision CAS.

The schema library: vendor/schemastery ​

@deepseek-ai/schemastery (vendored) is a type-driven schema validator — a single-file (src/index.ts, ~900 lines) port used across the harness for plugin Config schemas and wire envelopes. Core type constructors:

ConstructorProducesTypical form control
Schema.string()Schema<string>text input
Schema.number()Schema<number>number/step input
Schema.boolean()Schema<boolean>toggle / checkbox
Schema.union([…])choice among literalsselect / segmented
Schema.from(value) / Schema.const(value)fixed value or inferred typestatic / constant
Schema.array(inner)Schema<T[]>list / reorderable rows
Schema.dict(inner, sKey?)Schema<Dict<string,T>>key→value map editor
Schema.object({…})fixed-key plain objectgrouped field card
Schema.any() / Schema.never()anything / nothingpassthrough
Schema.transform(inner, fn)validated→convertedcustom post-processing
Schema.lazy(builder)deferred/recursivedeferred subtree

Each node also carries a meta block (default, required, description, min/max/step, pattern, badges for deprecated/experimental) that forms render and validate against. Schema.prototype understands the structural relations forms probe — node.type, node.inner (for dict/array), node.dict (for object), and node.list for tuples — which is exactly what nodeAtPath reads. Serialization is .toJSON() / new Schema(serialized) and it conforms to @standard-schema/spec; the Host builds plugin/settings Config schemas with z.object({…}) — the same z imported everywhere in host plugins.

The browser model layer: ctx.settingsSchema ​

packages/client/ui-settings/src/client/schema.ts defines SettingsSchemaService, a Cordis Service (super(ctx, 'settingsSchema')) that owns the synchronous schema introspection and immutable draft edits. Dynamic client plugins receive this entity through the service rather than importing executable helpers from one another (the client bundle purity gate).

SymbolJob
rehydrate(serialized)new Schema(serialized) — back into a live validator/tree (SchemaNode)
validate(schema, draft)run the schema; return failure message or undefined
nodeAtPath(root, path)walk object props / dict/array inner to the node at a settings path
getPath(value, path)read a nested value (arrays via string keys)
hasPath(value, path)whether a draft explicitly carries the path (marks a user override)
setPath / deletePathimmutable path-write / unset, materializing containers as needed

setPath follows a clone-the-container-spine discipline: it never mutates the draft, cloning object/array containers down to the leaf and materializing a missing intermediate as the array/object the next key needs (cloneSpine in the same file). This makes minimal, merge-safe edits the primitive for all settings editors. Because validation goes through the same rehydrated node the editor introspects, a field is validated against exactly the constraints its control surfaces — no second, hand-maintained constraint set.

One shared describe mirror ​

Reads are deliberately centralized. packages/client/ui-settings/src/client/settings-mirror.ts defines SettingsDescribeMirror, the one settings.describe reader in the browser: every consumer derives from its snapshot, so startup cost and freshness are properties of this class, not of how many features own a preference.

  • The Host stays the fact source: the mirror refreshes on the invalidations its owning plugin subscribes to — ctx.remote.$on('settings/document-updated', …) and ctx.on('connection/reset', …) — plus a first-use ensure(). Concurrent load() calls fold into the in-flight read plus one rerun, so an invalidation arriving mid-read is never lost and never duplicated.
  • The snapshot is { status: 'idle' | 'loading' | 'ready' | 'unavailable', view, error }; the view is the whole describe() answer — { namespaces: SettingsNamespaceView[], writable, hasDocument }. unavailable is the terminal non-loopback state; ready persists across later failed refreshes (the held view keeps serving).
  • The wire read is one call: ctx.remote.settings.describe() — the Host side answers with settings.describe({ redactSecrets: true }) (see the controller below), so a role('secret') field can never ride a response.
  • Write answers fold back without a second wire read: acceptView(view) replaces one namespace's row in the held view, invalidating any read still in flight (so a stale document read cannot publish a pre-write snapshot).
  • Persistence is client-selected: const persistence = ctx.remote.$host.isLoopback ? 'host' : 'memory' (packages/client/ui-settings/src/client/index.ts), because settings file persistence is loopback-only; a non-loopback page gets mode: 'memory' from the start.

The mirror is exposed through ctx.settingsScope.describe() as the SettingsDescribeFace for cross-namespace surfaces, and per-namespace scopes derive from it.

Per-namespace scopes and writes ​

packages/client/ui-settings/src/client/settings-scope.ts keeps the SettingsScope seam (settings-contract.ts), now implemented by SettingsScopeController<T> over the shared mirror plus the serialized write path:

  • Reads never touch the wire here. ctx.settingsScope.bind<T>({ namespace, decode? }) returns a SettingsScope<T> that is a selector over the mirror's snapshot — derive() finds the namespace's SettingsNamespaceView, validates its value by settingsSchema.validate(settingsSchema.rehydrate(view.schema), value), and publishes { status, value, base, user, revision, writable, mode }. A section that is not a plain object, fails validation, or carries a schema the client cannot rehydrate publishes no value — the row renders its own absent state instead of a half-decoded one.
  • Writes go through ctx.remote.settings with a revision CAS. mutate(ops, expectedRevision?) picks expectedRevision ?? pendingRevision ?? snapshot.revision, calls ctx.remote.settings.mutate(ns, ops, revision), and on success folds the answered namespace view back through mirror.acceptView. set(field, value) / unset(field) are one-op mutate conveniences.
  • Stale-write recovery: a failed write (conflict or transport) reloads the mirror — unless a newer write already superseded it (the writeGeneration counter; a superseded writer records its answer's revision as the next fence). Only the latest write's settlement may publish, so two editors racing on the same section never interleave stale state. Writes are queued on one tail per scope, so a throwing subscriber cannot strand later operations.
txt
Host settings-controller Remote (packages/api/settings-controller)
   └─ describe() → { writable, hasDocument, namespaces }        (redactSecrets always on)
        └─ SettingsDescribeMirror (one snapshot store in the browser)
             └─ SettingsScopeController.derive()  → per-namespace SettingsScope
             └─ SettingsScopeController.mutate(ns, ops, expectedRevision)
                  └─ ctx.remote.settings.mutate → { op:'set'|'unset', path, value } + CAS
                       └─ acceptView(namespaceView)  ← folds the write answer in
   └─ update(ns, patch, expectedRevision) / replace(ns, section, expectedRevision)

The host also offers the two other write modes an editor can use instead of path ops: settings.update (deep-merge a patch into the user section) and settings.replace (wholesale section reset) — both take the same optional expectedRevision.

The host controller: settings-controller ​

The Remote namespace is owned by packages/api/settings-controller (@deepseek-ai/dsh-api-settings-controller), the Host service backing the generated ctx.remote.settings:

  • SettingsController extends TypertRemoteService — describe(), update(ns, patch, expectedRevision), replace(ns, section, expectedRevision), mutate(ns, ops, expectedRevision), plus canOpenAgentPresetDirectory() / openSettingsDocument() / openAgentPresetDirectory() (native document/preset opening). Every remote read uses redactSecrets: true; the write paths re-read the namespace redacted after committing and classify every seam refusal as settings/conflict (a SETTINGS_CONFLICT with expected/actual revisions) or settings/rejected.
  • Credentials ride beside it: src/credentials.ts mounts the credentials Remote namespace (ctx.remote.credentials) — describe(refs) (batched, ≤ 64 refs), set(ref, value), unset(ref). Secret values cross in one direction only: no method returns one, so a configuration page can never echo a stored key.
  • Both namespaces stay registered when no provider is mounted, so calls return the configuration API's actionable missing-provider diagnostic rather than a dead endpoint.

Typed namespaces ​

The wire types come from the settings seam and are re-exported by the Client assembly:

  • SettingsNamespaceView ({ ns, schema: JsonValue, value, base?, user?, applies: 'live' | 'restart', secrets, revision }), SettingsPathOpView, SettingsDescribeValue, and SettingsSecretView are declared in packages/settings/settings/src/types.ts and exported from @deepseek-ai/dsh-api-remotes/client — the type door a browser package names.
  • Namespace identity is now a branded string: SettingsNamespace = Branded<'SettingsNamespace'>. The old settingsNamespace(...) runtime helper is gone — a literal lowercase-hyphenated string is branded by the seam's types, and installSection validates the grammar at runtime (TypeError on a non-[a-z0-9-] id).
  • The carrier's own door is @deepseek-ai/dsh-client-connection/client (RpcId, RpcRequest, RpcResponse, RpcResult, ConnectionHandle, …). Client bundles list inject: ['remote', 'remote.settings'] (and the event allowlist types) and never import a Host package root.

How a schema becomes a form ​

There is no opaque schema→DOM renderer at the shipped revision; editors are per-scenario but built on these seams:

  1. Settings scope rows (ui-settings-general, ui-theme, ui-agent-preset, ui-permission-presets, …): each binds its namespace through ctx.settingsScope.bind, reads the derived snapshot through the framework's standard store props, and writes with one-field set/unset. The Language and Appearance rows are the canonical examples.
  2. Provider model editor (packages/client/ui-settings-models/src/client/ProviderEditor.tsx): the DeepSeek / pi-ai card is hand-written but schema-driven for the "自定义设置" extras — it reads nodeAtPath off the rehydrated namespace schema to decide which curated fields apply, then edits the stored section via minimal remote.settings.mutate path ops (only the fields it can name, never a rebuilt subtree). DeepSeekModelsEditor similarly renders the model catalog (id, name, context window, reasoning levels) as a list with its own validation.
  3. Plugin configuration cards (ui-settings-plugins): one SettingsScope per exposed namespace, rendered from the same mirror.

Note the API key is a write-only credential field (credentials.set/unset) — the page never asks for the env-var name, deriving <ROUTE>_API_KEY by default, and the mirror never hands it a stored value to display.

One more consequence of the service-only collaboration model: dsh-client-ui-approval (the browser approval surface) declares zero runtime dependencies — only a cordis peer — because every cross-plugin fact it needs arrives through injected services; nothing about it is baked into the settings model layer.

On-disk layering stays in the seam ​

The base/user layering, the revision counter, and the "an override equal to the default is still an override" rule all live in packages/settings/settings — see Settings system for the domain side; this page covers the browser transport of the same document.

Packages in this section ​

Package
@deepseek-ai/dsh-client-ui-settings (model layer: settingsSchema, mirror, scopes)
@deepseek-ai/dsh-api-settings-controller (Host Remote: settings + credentials)
@deepseek-ai/dsh-client-ui-settings-general
@deepseek-ai/dsh-client-ui-settings-models
@deepseek-ai/dsh-client-ui-settings-plugins
@deepseek-ai/schemastery (vendored)
@deepseek-ai/dsh-client-ui-approval (zero runtime deps)

Further reading ​

  • Frontend: The web frontend — where settings screens are mounted in the boot chain.
  • Frontend: Client runtime and wire — the Connection RPC envelopes and /api transport every scope read/write rides.
  • Frontend: UI modules — the settings slots (settings.section, settings.plugins.tab, settings.general.item).
  • LLM platform: Settings — the domain side: base/user layering, installSection, SettingsConflictError.
  • packages/client/ui-settings/src/client/schema.ts — SettingsSchemaService (rehydrate, validate, nodeAtPath, setPath).
  • packages/client/ui-settings/src/client/settings-mirror.ts — SettingsDescribeMirror, the one describe reader.
  • packages/client/ui-settings/src/client/settings-scope.ts — SettingsScopeController and the mutate/CAS cycle.
  • packages/api/settings-controller/src/index.ts + src/credentials.ts — the settings/credentials Remote namespaces.