Confirmed and fixed, each with a regression test that failed first: - Concurrent writes to different namespaces lost whole sections on disk (each persist rendered the full document from a stale text): the local provider serializes render->write->rename->text-commit on one internal persist chain shared by every namespace queue. - One throwing settings/updated listener starved the rest (cordis emit stops at the first throw): commit fans out per listener via events.dispatch, contains individual failures, and rethrows the first INVARIANT-coded error only after every listener ran. - Write queues ignored fiber/service lifecycle: the base init now registers a teardown that refuses new writes and drains queued chains; queued tasks re-verify service liveness and namespace ownership before running and again before committing, so a registrant disposed mid-flight is never notified and a disposed service never commits. - Async watcher invocations could interleave (a slow stale call applied last): each watcher carries a serialized invocation chain — one call at a time, in commit order; JSDoc/doc pages state the async timing. - update/replace borrowed the caller's object until the queued task ran: inputs are structured-clone snapshotted at call time; non-cloneable plain objects reject with a typed error. - Composition guard now proves the documented fallback: the consumer uses the optional scoped-inject shape and boots both with the settings entry (hot publish) and without it (entry-config resolution, no scope). - core-data-structures index: settings.md row added to the sub-page table in core.md/core.zh.md. Both packages hold per-file 100% coverage across repeated runs.
@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()— one descriptor per namespace (schema.toJSON()envelope, resolved value,applies) for configuration surfaces.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. 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 removal/reset path a merge cannot express (replace({})re-inheritsbaseand schema defaults).- 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. Thesettings/updatedevent fans out one listener at a time, so one throwing listener cannot starve the rest. - Service teardown refuses new writes and drains every queued write 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.
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. - 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 is last-write-wins).
- No secret-field redaction —
describe()returns resolved values verbatim; a wire surface (RPC/UI) must redactrole('secret')fields before exposure.