Files
deepseek-harness/docs/subsystems/settings.md
Tianyi Cui ba3125234a docs: rename core-data-structures/ to subsystems/
The folder is becoming the home of one-doc-per-subsystem pages (intro +
data structures + cordis services/events), so the name must describe the
whole contract, not just the type-vocabulary third of it. Mechanical
rename rebuilt on current master: every inbound Markdown link, generator
constant, website route, type-equiv manifest path, and spec expectation
moves together; the zh sides of the notes whose prose names the folder
are aligned (子系统) in the same change; touched bilingual pairs
re-recorded; translation-prompt snapshot re-recorded (its example embeds
development.md). Historical Agent Note slugs keep their dated
filenames.
2026-08-09 01:27:09 +08:00

163 lines
8.2 KiB
Markdown

# User Settings
English | [中文](settings.zh.md)
The user-settings seam of [dsh-settings](../../packages/settings/settings) holds one user-owned document of per-namespace sections and resolves each registered namespace as schema defaults, then the registrant's composition `base`, then the user section. Providers such as [dsh-settings-local](../../packages/settings/settings-local) store the raw document and push external edits; consumer plugins register a schema and read or observe the resolved value. Composition config stays in `cordis.yml` — a namespace carries only the user-editable subset.
Source: [`packages/settings/settings/src/index.ts`](../../packages/settings/settings/src/index.ts)
## Identity
A namespace names one plugin-owned section of the user document. The brand keeps namespaces from mixing with other cross-boundary ids; construction validates the lowercase kebab-case shape.
```ts type-equiv
/** Nominal id of one registered settings namespace. */
type SettingsNamespace = Branded<'SettingsNamespace'>
```
## Registration
Registration binds a schemastery schema to a namespace on the calling plugin's fiber — disposing that fiber removes the namespace and its observers. The options carry the composition layer, the owner's effect timing, and an optional check for what the schema cannot express.
```ts type-equiv
/** Registration options beyond the namespace schema. */
interface SettingsRegisterOptions<T> {
/** Composition-layer values resolved below the user layer (entry-config subset). */
base?: Partial<T>
/** Owner's effect timing, surfaced to configuration UIs; defaults to `live`. */
applies?: SettingsApplies
/**
* Reject a resolved section the owner could not act on, for constraints its
* schema cannot express — a cross-field requirement, or one field's validity
* depending on another's. Throwing here refuses the *write* that produced the
* value, so a caller learns at `update`/`replace`/`mutate` instead of storing
* something that would silently disable the owner.
*
* Kept separate from the schema because the schema is also what a
* configuration surface renders and what an absent section resolves through;
* folding a cross-field check into it would change both.
*
* Once the owner is registered, a stored section that fails this keeps the
* namespace's last good value and warns, exactly as a schema failure does,
* so an externally edited document cannot strand a running owner. At
* registration there is no last good value yet, so a stored section that
* already fails rejects the registration itself — again exactly as a schema
* failure does.
* @param value - the resolved section, schema-valid by construction.
*/
validate?: (value: T) => void
}
```
`validate` runs after the schema admits a value, so it sees defaults and the composition base exactly as the owner will. `dsh-llm-pi-ai` uses it to refuse a provider profile it could not serve at the write that produced it, rather than storing one that would disable every route in its namespace.
`applies` is a UI hint, not a mechanism: a `restart` owner simply never watches, so its value is read once at construction and configuration surfaces can badge the pending change.
```ts type-equiv
/** When a namespace's changes take effect for its owner. */
type SettingsApplies = 'live' | 'restart'
```
## Owner scope
The scope is the owner-facing handle. `update` merges a sparse patch over the user section only (never into `base`); `replace` sets the section wholesale, which is the removal/reset path — keys absent from the replacement re-inherit `base` and schema defaults. Writes to one namespace are serialized in call order, and resolved values are deep-frozen snapshots.
```ts type-equiv
/** Owner-facing handle for one registered namespace. */
interface SettingsScope<T> {
/** Current resolved value: schema defaults, then `base`, then the user layer. */
get(): T
/**
* Observe committed changes to this namespace's resolved value. Invocations
* of one callback run asynchronously, one at a time, in commit order; a
* rejection is contained and logged like a sync throw. After the disposer
* returns, no further invocation starts — one already queued is skipped;
* one already started still settles, and service disposal waits for it.
* @param callback - invoked after each commit with the next and previous values.
* @returns the disposer removing this observer.
*/
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
/**
* Merge a partial patch into this namespace's user layer and persist it.
* @param patch - plain-object patch over the user section; JSON-shaped data
* only (non-JSON values reject with their path before anything persists).
*/
update(patch: object): Promise<void>
/**
* Replace this namespace's user section wholesale; absent keys re-inherit
* the composition `base` and schema defaults (`replace({})` resets all).
* @param section - the complete next user section; JSON-shaped data only,
* as for {@link update}.
*/
replace(section: object): Promise<void>
}
```
## Descriptors
`describe()` serializes every registered namespace for configuration surfaces: the schemastery `toJSON()` envelope drives schema-rendered forms, the resolved value fills them, and the detached `base`/`user` layers let a form mark user-overridden fields by presence. `describe({ redactSecrets: true })` — mandatory on every wire surface — strips `role('secret')` fields from all three layers and enumerates their `{path, set}` slots so a page can render write-only inputs without ever receiving a secret.
```ts type-equiv
/** One registered namespace as surfaced to configuration UIs. */
interface SettingsDescriptor {
/** The registered namespace. */
ns: SettingsNamespace
/** Serialized schemastery schema (`schema.toJSON()`). */
schema: unknown
/** Current resolved value. */
value: unknown
/**
* Monotonic revision of the raw user section this descriptor was read at.
* Send it back as `expectedRevision` on a write to refuse a stale one.
*/
revision: number
/** Registrant's composition `base` layer (detached), when one was declared. */
base?: unknown
/**
* Raw user section from the stored document (detached), when one exists and
* is well-formed; a field's presence here is what marks it user-overridden.
*/
user?: unknown
/** Owner's declared effect timing. */
applies: SettingsApplies
/** Schema-declared secret positions; present only under `redactSecrets`. */
secrets?: RedactedSecret[]
}
```
A caller that holds only the redacted descriptor cannot safely rebuild a section, so removals travel as path ops instead. Each descriptor also carries a `revision` over the raw section; a write may send it back as `expectedRevision`, and one that no longer matches is refused rather than applied over the writer that landed first.
```ts type-equiv
/**
* One path-addressed edit to a namespace's user section. Path mutation exists
* for a caller holding an INCOMPLETE view of the section — a configuration UI
* reads the redacted descriptor, which by construction never received the
* `role('secret')` fields. Such a caller can name the field it means without
* restating the section: a wholesale `replace` rebuilt from a redacted
* document silently deletes every secret the wire never returned.
*/
type SettingsPathOp =
| { op: 'set'; path: readonly string[]; value: unknown }
| { op: 'unset'; path: readonly string[] }
```
```ts type-equiv
/** Options for {@link Settings.describe}. */
interface SettingsDescribeOptions {
/**
* Strip `role('secret')` fields from `value`/`base`/`user` and enumerate
* them in each descriptor's `secrets`. Every wire surface MUST pass this;
* the verbatim default exists for same-process configuration UIs only.
*/
redactSecrets?: boolean
}
```
## Change commits
Every committed change — an in-process write or an externally observed provider edit — emits `settings/updated (ns, next, prev, source)` after the new value is authoritative, and never when the resolved value is deep-equal. The source tag separates the two entry paths.
```ts type-equiv
/** Origin of one committed settings change. */
type SettingsUpdateSource = 'update' | 'provider'
```