Skip to content

User Settings ​

The user-settings seam of @hydraharness/harness-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 @hydraharness/harness-settings-file 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

Identity ​

A namespace names one plugin-owned section of the user document. The brand prevents callers from mixing settings namespaces with other ids passed between packages or processes; construction validates lowercase kebab-case syntax.

ts
/** 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
/** 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. @hydraharness/harness-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
/** 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
/** Owner-facing handle for one registered namespace. */
interface SettingsScope<T> {
  /** Current user-section revision, sent as `expectedRevision` when writing a value derived from {@link get}. */
  readonly revision: number
  /** 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-compatible 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-compatible 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
/** 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
  /** Lower resolved parent value or registrant's composition `base` (detached), when 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
/**
 * 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
/** Options for {@link SettingsProvider.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
}

Inherited providers ​

inherit(parent, namespaces) mirrors an explicit subset of a lower provider's registrations, resolved values, schemas, validators, and effect timing. A child owner can claim each inherited registration once; its raw section overrides the parent's resolved value without changing the parent document. Parent writes and registrations propagate to descendants, and inherited descriptors expose the lower resolved value as base.

setLayerEnabled(false) suppresses raw overrides in effective reads while retaining them in descriptors and on disk. Writes still validate the proposed section. Workspace configuration uses this to keep untrusted project files editable without executing their settings. observe() lets configuration transports refresh on raw, resolved, registry, and inherited descriptor changes; consumer watch() callbacks retain their ordered asynchronous semantics.

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
/** Origin of one committed settings change. */
type SettingsUpdateSource = 'update' | 'provider'

Cordis API ​

Generated from source by scripts/gen-cordis-catalog.ts (verified fresh by pnpm run verify-cordis-catalog in doc-sync; regenerate with pnpm run gen-cordis-catalog) — the language sides differ only in locale-specific paired document paths. Signature blocks use a ts cordis-catalog fence and keep the original source JSDoc; dispatch modes are defined in the primer, and the framework-inherited ctx API lives in cordis-api/inherited.md.

ctx.settings — SettingsProvider (abstract seam) ​

Abstract settings service. Providers implement raw-document storage (load/persist) and push external changes through Settings.publish; the base class owns namespace registration, resolution, validation, change detection, and the settings/updated commit event.

ts
/**
 * Prepare the provider's user-editable document for a native editor. File
 * providers may materialize an absent document before returning its path;
 * non-file providers return undefined.
 * @returns the absolute local document path, or undefined for non-file storage.
 */
prepareDocument(): Promise<string | undefined>

/**
 * Mirror selected parent namespaces, using their resolved values beneath
 * this provider's raw sections. Schemas, validators, timing, registrations,
 * and parent commits stay synchronized without exposing owner callbacks in
 * descriptors. One consumer may claim each inherited namespace with register().
 * @param parent - provider of the lower settings layer; cycles are refused.
 * @param namespaces - namespaces allowed to inherit into this provider.
 * @returns awaitable disposer removing the inherited registrations and subscription.
 */
inherit(parent: SettingsProvider, namespaces: readonly SettingsNamespace[]): () => void | Promise<void>

/**
 * Enable or suppress this provider's raw layer without changing stored data.
 * Descriptors and writes retain the raw section while resolved reads inherit.
 * @param enabled - whether raw sections participate in effective resolution.
 */
setLayerEnabled(enabled: boolean): void

/**
 * Observe this provider's committed raw, resolved, registry, or inherited
 * descriptor changes. Callbacks run synchronously after the relevant state
 * changes; failures are contained. The calling fiber owns the subscription.
 * @param callback - re-read descriptors after a provider change.
 * @returns awaitable disposer preventing further callback invocations.
 */
observe(callback: () => void): () => void | Promise<void>

/**
 * Register a namespace schema and receive its owner scope. Disposing the
 * calling plugin removes its observers and its non-inherited registration.
 * An inherited namespace accepts one owner while keeping its parent's
 * schema, validation, timing, and lower value. An invalid effective section
 * fails initial registration; suppressed raw sections are validated on writes.
 * @param ns - unique namespace; duplicate registration fails loud.
 * @param schema - schemastery schema resolving this namespace's value.
 * @param options - composition `base` layer and effect timing.
 * @returns the owner scope for reads, observation, and updates.
 */
register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T>

/**
 * Describe every registered namespace for configuration surfaces, including
 * the composition `base` and raw user layers so a form can mark which fields
 * the user overrode (presence in `user`) and what a reset returns to.
 * @param options - redaction switch; wire surfaces must redact.
 * @returns one descriptor per registered namespace, in registration order.
 */
describe(options?: SettingsDescribeOptions): SettingsDescriptor[]

/**
 * Read one registered namespace's resolved value.
 * @param ns - the namespace to read.
 * @returns the resolved value, or `undefined` while unregistered.
 */
get(ns: SettingsNamespace): unknown

/**
 * Merge a patch into one registered namespace's user layer, validate the
 * resolved candidate, persist through the provider, then commit and emit.
 * A validation failure rejects before anything is persisted. Writes to one
 * namespace are serialized: concurrent updates apply in call order, each
 * merging over the previous write's committed section.
 * @param ns - the registered namespace to update.
 * @param patch - plain-object patch over the user section.
 * @param expectedRevision - the descriptor `revision` the caller read; a
 *   namespace that moved past it rejects with {@link SettingsConflictError}.
 */
async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise<void>

/**
 * Replace one registered namespace's user section wholesale, validate,
 * persist, then commit and emit. Keys absent from `section` fall back to the
 * composition `base` and schema defaults — this is the removal/reset path a
 * merge-only patch cannot express (`replace({})` re-inherits everything).
 * @param ns - the registered namespace to replace.
 * @param section - the complete next user section.
 * @param expectedRevision - the descriptor `revision` the caller read; a
 *   namespace that moved past it rejects with {@link SettingsConflictError}.
 */
async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise<void>

/**
 * Apply path-addressed edits to one registered namespace's user section,
 * validate, persist, then commit and emit. The ops are applied to the
 * section as it stands when the write reaches the front of the queue, so a
 * caller never has to restate fields it did not touch — and, crucially,
 * cannot delete fields it never saw. This is the write path for any caller
 * holding a redacted view; `replace` remains the wholesale reset.
 * @param ns - the registered namespace to edit.
 * @param ops - ordered path edits; later ops observe earlier ones.
 * @param expectedRevision - the descriptor `revision` the caller read; a
 *   namespace that moved past it rejects with {@link SettingsConflictError}.
 */
async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void>

Source: packages/settings/settings/src/index.ts

settings/* events ​

settings/document-updated — emit ​

One registered namespace's RAW user section changed, whether or not the resolved value did. settings/updated is the consumer-facing event and stays deep-equal-gated; this one exists for configuration surfaces, which must learn that a field went from inherited to overridden (same resolved value, different meaning) and that their held revision is stale. Listener containment matches settings/updated. The emitting provider is the Cordis receiver for service-realm routing.

ts
/**
 * One registered namespace's RAW user section changed, whether or not the
 * resolved value did. `settings/updated` is the consumer-facing event and
 * stays deep-equal-gated; this one exists for configuration surfaces,
 * which must learn that a field went from inherited to overridden (same
 * resolved value, different meaning) and that their held revision is
 * stale. Listener containment matches `settings/updated`.
 * The emitting provider is the Cordis receiver for service-realm routing.
 * @param ns - the namespace whose stored section changed.
 * @param revision - the namespace's new revision.
 * @mode emit
 */
'settings/document-updated'(ns: SettingsNamespace, revision: number): void

Source: packages/settings/settings/src/types.ts

settings/registry-updated — emit ​

A settings namespace was registered or removed with its owning plugin. Configuration surfaces use this to refresh their served-namespace list after plugin lifecycle changes that do not modify the settings file. The emitting provider is the Cordis receiver for service-realm routing.

ts
/**
 * A settings namespace was registered or removed with its owning plugin.
 * Configuration surfaces use this to refresh their served-namespace list
 * after plugin lifecycle changes that do not modify the settings file.
 * The emitting provider is the Cordis receiver for service-realm routing.
 * @param ns - the namespace whose registration changed.
 * @mode emit
 */
'settings/registry-updated'(ns: SettingsNamespace): void

Source: packages/settings/settings/src/types.ts

settings/updated — emit ​

Committed change to one registered namespace's resolved value, after the provider persisted (for update) or published (provider) the change; never emitted when the resolved value is deep-equal. The committing provider is the Cordis receiver, so ordinary listeners receive only their settings service's isolation realm. Listener failures are contained and logged — a sync throw and an async rejection alike — except INVARIANT-coded failures, which rethrow after every listener ran; that rethrow reaches the emitter only from synchronous listeners, so invariant checks on this event must not be async functions.

ts
/**
 * Committed change to one registered namespace's resolved value, after
 * the provider persisted (for `update`) or published (`provider`) the
 * change; never emitted when the resolved value is deep-equal. The
 * committing provider is the Cordis receiver, so ordinary listeners
 * receive only their settings service's isolation realm.
 * Listener failures are contained and logged — a sync throw and an async
 * rejection alike — except `INVARIANT`-coded failures, which rethrow
 * after every listener ran; that rethrow reaches the emitter only from
 * synchronous listeners, so invariant checks on this event must not be
 * async functions.
 * @param ns - the namespace whose resolved value changed.
 * @param next - the new resolved value.
 * @param prev - the previous resolved value.
 * @param source - whether the change entered through `update()` or the provider.
 * @mode emit
 */
'settings/updated'(ns: SettingsNamespace, next: unknown, prev: unknown, source: SettingsUpdateSource): void

Source: packages/settings/settings/src/types.ts

Client section navigation ​

The client Settings plugin declares section owner props. The shell handles navigation; registrants own their data and mutations.

ts
/**
 * Owner share of a settings section entry. The shell owns modal visibility
 * and navigation; a section's data arrives through its own inject faces and
 * stores. Sections can close the panel or navigate to another registered page.
 */
interface SettingsSectionOwnerProps {
  /** Settings navigation search; General filters its registered row labels. */
  query?: string
  /** Close the settings panel (the shell owns the open state). */
  close: () => void
  /** Open a registered section, clear navigation search, and focus its heading. */
  openSection: (id: string) => void
}