Every plugin in dsh is configurable, and configuration is a first-class, schema-driven system. The settings service (ctx.settings, packages/settings/settings) is the user-facing half: plugins register a namespace schema, a provider holds one raw document of per-namespace sections, and consumers read resolved values layered as schema defaults → the registrant's composition base → the user document section. The upstream docs/config-catalog.md (generated by scripts/gen-config-catalog.ts) is the machine-readable catalog of every config field in the composition.
| Package | Role |
|---|---|
@deepseek-ai/dsh-settings | Service Definition: ctx.settings, SettingsScope, layered resolution |
@deepseek-ai/dsh-settings-file | File-backed provider (settings.yaml in the harness home) |
@deepseek-ai/schemastery (vendored) | Schema validation library the namespaces are built with |
Layering: schema defaults → base → user
A namespace resolves its value by merging three layers in order:
- Schema defaults — declared by the plugin's schema;
- Composition
base— the registrant's cordis.yml entry-config subset (what the bundle ships); - User document — the provider's stored section (what the user wrote).
Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings. This is a deliberate property — settings are an addition, never a requirement.
The service API
From packages/settings/settings/README.md, the core surface:
documentPath— absolute path of the provider's user-editable file, orundefinedfor non-file providers. Host configuration adapters derive availability from it; browser protocols expose only a boolean capability and never a filesystem target.prepareDocument()— return that path after making the document ready for a native editor.register(ns, schema, { base?, applies? })— returns the ownerSettingsScope(get/watch/update). Registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; duplicate namespaces fail loud.describe(options?)— one descriptor per namespace:schema.toJSON()envelope, resolved value, detachedbase/userlayers,applies. A field's presence inuseris what marks it user-overridden.describe({ redactSecrets: true })stripsrole('secret')fields from every layer and adds thesecretsslot list ({ path, set }); every wire surface must pass it.get(ns)— resolved value,undefinedwhile unregistered.update(ns, patch)— deep-merges the plain-object patch into the user section only (neverbase), validates the resolved candidate, persists through the provider, then commits. Patches may contain only JSON-compatible data — aDate,Map,BigInt, non-finite number, or circular reference rejects with its$-rooted path before anything persists.replace(ns, section)— sets the user section wholesale: the deliberate reset (replace({})re-inheritsbaseand schema defaults).mutate(ns, ops)— applies ordered{ op: 'set' | 'unset', path }edits. This is the removal path for any caller holding an INCOMPLETE view: a configuration UI reads the redacted descriptor, so rebuilding a section from it and replacing wholesale would delete every secret the wire never returned; an op names the one field it means instead.- Every write takes an optional
expectedRevision; a stale write rejects withSettingsConflictError(code: 'SETTINGS_CONFLICT') instead of silently overwriting the writer that landed first.
Revisioning and concurrency
Each descriptor carries the namespace's revision, a monotonic counter over its RAW section. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot — that is exactly what expectedRevision is for. Writes to one namespace are serialized in call order; a read-only provider (writable: false) rejects every write.
Observability: two events
settings/updated (ns, next, prev, source)— fires after each commit;sourceisupdate(in-process write) orprovider(external change). Never fires for a deep-equal resolved value.settings/document-updated (ns, revision)— fires whenever the RAW user section changes, whether or not the resolved value did. Configuration surfaces need this one: storing an override equal to the composition base leaves the resolved value alone but changes what the document says and moves the revision every open editor is holding.
Watchers receive (next, prev) after each commit, one at a time in commit order, with containment: a slow stale invocation can never apply after a newer one, and a throwing listener never starves the rest. Resolved values are deep-frozen snapshots.
The provider contract
Subclasses implement writable, load(), and persist(ns, section), optionally override documentPath / prepareDocument() for one local user-editable file, and push externally observed documents through the protected publish(doc). At publish, each registered namespace re-resolves independently: an invalid section keeps that namespace's last good value and warns — a live reload never takes the process down, while boot-time and registration-time validation fail loud.
The shipped file provider (packages/settings/settings-file) persists settings.yaml in the harness home (see packages/util/home-paths).
The config catalog
docs/config-catalog.md is the generated, complete catalog of config fields — one entry per plugin row in the shipped compositions, with schema, defaults, and nesting. It is produced by scripts/gen-config-catalog.ts (TypeScript AST + Schemastery), not by Typert (that distinction is documented on Typert: The Type Generator). When a plugin adds a config field, the catalog is the first place to check what the field means and where it defaults.
Where settings meet the UI
The browser renders namespace schemas through the schema-form system (see Schema Form): describe() output drives the settings screens, update()/mutate() persist user changes, and document-updated keeps open editors in sync. The UI never sees secrets — the redacted descriptor guarantees it.
Key source files
| Repo-relative path | What it provides |
|---|---|
packages/settings/settings/src/ | Service definition, SettingsScope, resolution engine |
packages/settings/settings-file/src/ | File-backed provider (settings.yaml) |
scripts/gen-config-catalog.ts | Config catalog generator |
docs/config-catalog.md | Generated catalog (bilingual) |
vendor/schemastery/ | Schema library |
Further reading
- Schema Form — how schemas become forms in the UI
- Host Platform — where settings files live in the harness home
- Storage & Persistence — the storage domains settings write to
- Typert: The Type Generator — why the config catalog is not typert-generated
- Repo:
packages/settings/settings/README.md,docs/config-catalog.md