The remaining P1 from the #939 review, plus the P2 it shares a mechanism with. Nothing carried a version, so two tabs editing one namespace silently overwrote each other — reproduced as tab B's `reasoning` lost to tab A's older draft. The seam's per-namespace write queue orders writes; it cannot tell a fresh writer from one replaying a snapshot a predecessor superseded. Each namespace now carries a monotonic `revision` over its RAW section. A write may send `expectedRevision`, checked at the FRONT of the queue (not at call time, which would race the very predecessor it guards against); a mismatch rejects with `SettingsConflictError` → `settings-conflict` on the wire, carrying both revisions. The editor captures the revision it opened at and, on conflict, asks the user to reopen rather than replaying its snapshot. The same counter fixes the missing broadcast. `settings/updated` is gated on the resolved value — correct for consumers, wrong for configuration surfaces: storing an override equal to the composition base leaves the resolved value alone while changing what the document says (the field is now overridden, not inherited) and moving every open editor's revision. `settings/document-updated (ns, revision)` fires on any raw-section change, in-process or external, and `host/settings-changed` now rides it. That event also closes the stale model picker: editing a provider's `models` changes no route, so `llm/adapters-updated` never fired and an open picker kept serving the old catalog. A change to an exposed provider namespace now emits `host/models-changed` too — that namespace holds the catalog. Docs: both sides of the five touched README pairs, a type-equiv block for `SettingsPathOp`, and an Agent Note recording what the plane exposes and who may overwrite what. The deferred wire-redaction gaps (secrets behind union/intersection/transform, `.default(...)` in the served envelope, schema text in rejection messages, `new Function` rehydration, pi-ai's `headers`) are recorded as TODO(settings-wire-redaction) and in Known Limitations rather than half-fixed.
6.6 KiB
@deepseek-ai/dsh-settings
English | 中文
Abstract user-settings seam (ctx.settings). One provider holds a raw document of per-namespace sections; plugins register a namespace schema and read a resolved value layered as schema defaults, then the registrant's composition base (its cordis.yml entry-config subset), then the user document section. Without a mounted provider nothing changes for consumers: they keep resolving entry config alone, so every composition works with or without settings.
Service API
register(ns, schema, { base?, applies? })— returns the ownerSettingsScope(get/watch/update). The 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; a duplicate namespace fails loud.describe(options?)— one descriptor per namespace (schema.toJSON()envelope, resolved value, detachedbase/userlayers,applies) for configuration surfaces; 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, and the pureredactSecrets(schema, value)walker is exported for other wires.get(ns)— resolved value,undefinedwhile unregistered.update(ns, patch)— deep-merges the plain-object patch into the user section only (never thebase), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its$-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (writable: false) rejects every write. Writes to one namespace are serialized in call order.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 to the section as it stands when the write reaches the front of the queue. 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 deletes every secret the wire never returned, while an op names the one field it means.- Every write takes an optional
expectedRevision. Each descriptor carries the namespace'srevision, a monotonic counter over its RAW section; a write whose expectation no longer matches rejects withSettingsConflictError(code: 'SETTINGS_CONFLICT', both revisions attached) instead of overwriting the writer that landed first. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot. - Resolved values are deep-frozen snapshots. Watchers receive
(next, prev)after each commit: invocations of one callback run asynchronously, one at a time, in commit order (a slow stale invocation can never apply after a newer one), and failures — sync throws and async rejections alike — are contained. After a watch disposer returns, no further invocation starts (one already queued is skipped); an invocation already started still settles. Thesettings/updatedevent fans out one listener at a time, so one throwing listener cannot starve the rest; an async listener's rejection is contained and logged, which is whyINVARIANT-coded failures rethrow only from synchronous listeners. - Service teardown refuses new writes and watcher starts, then drains every queued write and every started watcher invocation before disposal completes; a write whose registrant fiber was disposed mid-flight still reaches storage but commits and notifies nobody.
Provider contract
Subclasses implement writable, load(), and persist(ns, section), and push externally observed documents through the protected publish(doc). The base service init loads and publishes the document once before the service becomes injectable; a provider with its own init (watcher, connection) delegates first via yield* super[Service.init](). 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.
Events
settings/updated (ns, next, prev, source) fires after each commit; source is update (in-process write) or provider (external change). It never fires for a deep-equal resolved value — it is the consumer-facing event, and a consumer only cares that its value moved.
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 (the field is now overridden, not inherited) and moves the revision every open editor is holding. Listener containment matches settings/updated.
Model Experience
Indirectly, through consumer plugins that resolve model-affecting values (for example a default model route) from their namespaces; each consumer's own surface documents the effect.
KV Cache effect
No direct invalidation; a consumer that folds a settings value into the request prefix owns that change.
Known Limitations and Deferred Work
- Single user layer — resolution knows schema defaults, one composition
base, and one user document; there is no project/managed layering or per-value provenance yet. redactSecretsis not a proven wire boundary — the walker followsobject/dict/array, so arole('secret')reached only through a union, intersection, or transform is returned VERBATIM with an emptysecretslist, andschema.toJSON()carries a secret field's.default(...)to every client. Neither case is rejected; a schema whose secrets are not reachable through the walked containers must not be registered on a wire-exposed namespace. A fail-closeddescribeForWire()— one that refuses a schema it cannot prove safe, and sanitizes the serialized envelope and error text — is the real answer and is deferred.- Cross-process concurrency is provider-defined — the seam serializes writes per namespace in-process only; concurrent processes converge by provider behavior (the local file provider read-modify-writes under a writer lock, so namespaces survive concurrent writers and same-namespace conflicts resolve last-write-wins).