"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:
| Constructor | Produces | Typical 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 literals | select / segmented |
Schema.from(value) / Schema.const(value) | fixed value or inferred type | static / 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 object | grouped field card |
Schema.any() / Schema.never() | anything / nothing | passthrough |
Schema.transform(inner, fn) | validated→converted | custom post-processing |
Schema.lazy(builder) | deferred/recursive | deferred 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).
| Symbol | Job |
|---|---|
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 / deletePath | immutable 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', …)andctx.on('connection/reset', …)— plus a first-useensure(). Concurrentload()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 wholedescribe()answer —{ namespaces: SettingsNamespaceView[], writable, hasDocument }.unavailableis the terminal non-loopback state;readypersists across later failed refreshes (the held view keeps serving). - The wire read is one call:
ctx.remote.settings.describe()— the Host side answers withsettings.describe({ redactSecrets: true })(see the controller below), so arole('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 getsmode: '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 aSettingsScope<T>that is a selector over the mirror's snapshot —derive()finds the namespace'sSettingsNamespaceView, validates itsvaluebysettingsSchema.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.settingswith a revision CAS.mutate(ops, expectedRevision?)picksexpectedRevision ?? pendingRevision ?? snapshot.revision, callsctx.remote.settings.mutate(ns, ops, revision), and on success folds the answered namespace view back throughmirror.acceptView.set(field, value)/unset(field)are one-opmutateconveniences. - Stale-write recovery: a failed write (conflict or transport) reloads the mirror — unless a newer write already superseded it (the
writeGenerationcounter; 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.
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), pluscanOpenAgentPresetDirectory()/openSettingsDocument()/openAgentPresetDirectory()(native document/preset opening). Every remote read usesredactSecrets: true; the write paths re-read the namespace redacted after committing and classify every seam refusal assettings/conflict(aSETTINGS_CONFLICTwithexpected/actualrevisions) orsettings/rejected.- Credentials ride beside it:
src/credentials.tsmounts thecredentialsRemote 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, andSettingsSecretVieware declared inpackages/settings/settings/src/types.tsand 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 oldsettingsNamespace(...)runtime helper is gone — a literal lowercase-hyphenated string is branded by the seam's types, andinstallSectionvalidates the grammar at runtime (TypeErroron 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 listinject: ['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:
- Settings scope rows (
ui-settings-general,ui-theme,ui-agent-preset,ui-permission-presets, …): each binds its namespace throughctx.settingsScope.bind, reads the derived snapshot through the framework's standard store props, and writes with one-fieldset/unset. The Language and Appearance rows are the canonical examples. - 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 readsnodeAtPathoff the rehydrated namespace schema to decide which curated fields apply, then edits the stored section via minimalremote.settings.mutatepath ops (only the fields it can name, never a rebuilt subtree).DeepSeekModelsEditorsimilarly renders the model catalog (id, name, context window, reasoning levels) as a list with its own validation. - Plugin configuration cards (
ui-settings-plugins): oneSettingsScopeper 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
/apitransport 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/userlayering,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—SettingsScopeControllerand the mutate/CAS cycle.packages/api/settings-controller/src/index.ts+src/credentials.ts— thesettings/credentialsRemote namespaces.