Selaa lähdekoodia

feat(api): serve settings through Remote controllers

imccyu 1 kuukausi sitten
vanhempi
sitoutus
dd70c0c88d

+ 70 - 0
packages/api/settings-controller/package.json

@@ -0,0 +1,70 @@
+{
+  "name": "@deepseek-ai/dsh-api-settings-controller",
+  "description": "Remote owner for the configuration surfaces over the settings-domain seams",
+  "version": "0.1.1-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/api/settings-controller"
+  },
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./types": {
+      "types": "./lib/types/types.d.ts",
+      "default": "./lib/types/types.js"
+    },
+    "./typert": {
+      "types": "./lib/typert.host.d.ts",
+      "default": "./lib/typert.host.js"
+    },
+    "./remote": {
+      "types": "./lib/typert.remote-client.d.ts",
+      "default": "./lib/typert.remote-client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.js",
+    "lib/types/**/*.d.ts",
+    "lib/typert.host.js",
+    "lib/typert.host.d.ts",
+    "lib/typert.remote-client.js",
+    "lib/typert.remote-client.d.ts"
+  ],
+  "license": "MIT",
+  "dependencies": {
+    "zod": "^4.4.3"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-credentials": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-credentials": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
+    "@deepseek-ai/dsh-settings": "workspace:^",
+    "@deepseek-ai/dsh-typert-protocol": "workspace:^"
+  }
+}

+ 157 - 0
packages/api/settings-controller/src/credentials.ts

@@ -0,0 +1,157 @@
+/**
+ * Host owner of the `credentials` Remote namespace: the reference half of
+ * `ctx.credentials` as a browser configuration page reads and writes it.
+ *
+ * @module @deepseek-ai/dsh-api-settings-controller/src/credentials.ts
+ */
+
+import { Context } from '@deepseek-ai/cordis'
+import { credentialRef } from '@deepseek-ai/dsh-credentials'
+import type { CredentialProvider } from '@deepseek-ai/dsh-credentials'
+import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
+import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { z } from 'zod'
+
+/**
+ * Fan-out bound on one remote `describe` batch. A settings page asks about the
+ * references its own rows name, so this is far above any real page and still
+ * keeps one authenticated request from starting unbounded provider work.
+ */
+const MAX_DESCRIBE_REFS = 64
+
+const credentialRefSchema = z.string().regex(/^[A-Za-z_][A-Za-z0-9_]*$/)
+const describeRequestSchema = z.object({
+  refs: z.array(credentialRefSchema).max(MAX_DESCRIBE_REFS),
+})
+const setRequestSchema = z.object({ ref: credentialRefSchema, value: z.string().min(1) })
+const unsetRequestSchema = z.object({ ref: credentialRefSchema })
+
+/** Parse the domain constraints that are more specific than generated TypeScript codecs. */
+function parseRequest<T>(method: string, schema: z.ZodType<T>, value: unknown): T {
+  const parsed = schema.safeParse(value)
+  if (!parsed.success) {
+    throw new TypertRemoteFailure({
+      code: 'bad-request',
+      message: `invalid payload for ${method}`,
+      details: { issues: parsed.error.issues },
+    })
+  }
+  return parsed.data
+}
+
+/**
+ * Copy exactly the fields {@link CredentialInfo} declares. The Gateway returns
+ * a business result without decoding it, so a provider whose `describe` carried
+ * extra enumerable properties would otherwise serialize them to the caller.
+ * @param info - the provider's answer for one reference.
+ * @returns the same facts with nothing else attached.
+ */
+function projectCredentialInfo(info: CredentialInfo): CredentialInfo {
+  return {
+    configured: info.configured,
+    ...info.source === undefined ? {} : { source: info.source },
+    writable: info.writable,
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Host owner of the `credentials` Remote namespace. */
+    credentialsController: CredentialsController
+  }
+}
+
+/**
+ * Host service backing the generated `ctx.remote.credentials` namespace. It
+ * carries every wire obligation the credential seam itself does not: the batch
+ * fan-out bound, the field-by-field view projection, the reference-grammar
+ * guard, and the refusal mapping. Secret values cross in one direction only —
+ * no method here returns one.
+ */
+export class CredentialsController extends TypertRemoteService {
+  /** @param ctx - Host context where a credential provider may be mounted. */
+  constructor(ctx: Context) {
+    super(ctx, 'credentialsController', { namespace: 'credentials' })
+  }
+
+  /**
+   * Describe several references for one configuration surface. Batched because
+   * a settings page describes every reference its rows name at once, and one
+   * round trip keeps those rows from settling separately.
+   * @param refs - reference names, at most {@link MAX_DESCRIBE_REFS}; a name outside the grammar rejects the whole call as `bad-request`.
+   * @returns one view per requested name, keyed by that name.
+   * @throws TypertRemoteFailure when the request is invalid or no credential provider is mounted.
+   */
+  @Remote
+  async describe(refs: string[]): Promise<Record<string, CredentialInfo>> {
+    const request = parseRequest('credentials.describe', describeRequestSchema, { refs })
+    const branded = request.refs.map(ref => [ref, credentialRef(ref)] as const)
+    const credentials = this.provider()
+    const entries = await Promise.all(branded.map(async ([ref, key]) =>
+      [ref, projectCredentialInfo(await credentials.describe(key))] as const))
+    return Object.fromEntries(entries)
+  }
+
+  /**
+   * Store one value from a configuration surface. The value crosses the wire in
+   * this direction only: no read path returns it.
+   * @param ref - reference name to store under.
+   * @param value - the non-empty secret value.
+   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   */
+  @Remote
+  async set(ref: string, value: string): Promise<void> {
+    const request = parseRequest('credentials.set', setRequestSchema, { ref, value })
+    const branded = credentialRef(request.ref)
+    const credentials = this.provider()
+    await this.write(request.ref, () => credentials.set(branded, request.value))
+  }
+
+  /**
+   * Remove one reference from a configuration surface.
+   * @param ref - reference name to remove.
+   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   */
+  @Remote
+  async unset(ref: string): Promise<void> {
+    const request = parseRequest('credentials.unset', unsetRequestSchema, { ref })
+    const branded = credentialRef(request.ref)
+    const credentials = this.provider()
+    await this.write(request.ref, () => credentials.unset(branded))
+  }
+
+  /** Resolve the optional provider or report how to supply it. */
+  private provider(): CredentialProvider {
+    const credentials = this.ctx.get('credentials')
+    if (credentials === undefined) {
+      throw new TypertRemoteFailure({
+        code: 'internal',
+        message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
+        details: {},
+      })
+    }
+    return credentials
+  }
+
+  /**
+   * Run one remote write and report every refusal as `credential-rejected`
+   * carrying the seam's own message: a read-only source shadowing the reference
+   * is what a configuration surface must show verbatim. Callers brand the
+   * reference before entering, so a name outside the grammar never reaches this
+   * path and fails the same way it does on the read side. The details name only
+   * the reference, so no failure path can carry the value back out.
+   */
+  private async write(ref: string, write: () => Promise<void>): Promise<void> {
+    try {
+      await write()
+    } catch (error: unknown) {
+      throw new TypertRemoteFailure({
+        code: 'credential-rejected',
+        message: error instanceof Error ? error.message : String(error),
+        details: { ref },
+      })
+    }
+  }
+}
+
+export default CredentialsController

+ 222 - 0
packages/api/settings-controller/src/index.ts

@@ -0,0 +1,222 @@
+/**
+ * Host Remote owner for the configuration surfaces over the settings-domain
+ * seams. Two namespaces: `settings`, the redacted reads and writes of
+ * `ctx.settings`, owned by the class below; and `credentials`, mounted from
+ * here as its own plugin.
+ *
+ * @module @deepseek-ai/dsh-api-settings-controller
+ */
+
+import { Context } from '@deepseek-ai/cordis'
+import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
+import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
+import type {
+  SettingsDescribeValue, SettingsNamespaceView, SettingsPathOpView,
+} from '@deepseek-ai/dsh-settings/types'
+import type { JsonValue } from '@deepseek-ai/dsh-session/types'
+import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { z } from 'zod'
+import { CredentialsController } from './credentials.ts'
+
+export { CredentialsController } from './credentials.ts'
+export type * from './types.ts'
+
+const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
+
+/**
+ * Project one redacted descriptor onto its wire view, field by field. The
+ * Gateway returns a business result without decoding it, so a provider whose
+ * descriptor carried extra enumerable properties would otherwise serialize them
+ * to the caller.
+ * @param descriptor - one descriptor read under `redactSecrets`.
+ * @returns the same facts with nothing else attached.
+ */
+function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
+  return {
+    ns: String(descriptor.ns),
+    schema: descriptor.schema as JsonValue,
+    value: descriptor.value as JsonValue,
+    ...descriptor.base === undefined ? {} : { base: descriptor.base as JsonValue },
+    ...descriptor.user === undefined ? {} : { user: descriptor.user as JsonValue },
+    applies: descriptor.applies,
+    secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
+    revision: descriptor.revision,
+  }
+}
+
+declare module '@deepseek-ai/cordis' {
+  interface Context {
+    /** Host owner of the `settings` Remote namespace. */
+    settingsController: SettingsController
+  }
+}
+
+/**
+ * Host service backing the generated `ctx.remote.settings` namespace. Every
+ * remote read uses `redactSecrets: true`, so a `role('secret')` field cannot
+ * ride a response. Writes expose the settings service's merge, replacement,
+ * and path-addressed operations, and classify every provider refusal as
+ * `settings-conflict` or `settings-rejected` with the service's message.
+ */
+export class SettingsController extends TypertRemoteService {
+  /**
+   * Register the settings namespace and mount the credentials namespace beside
+   * it. Both namespaces stay registered when a provider is absent so calls can
+   * return the configuration API's actionable missing-provider diagnostic.
+   * @param ctx - Host context where settings and credential providers may be mounted.
+   */
+  constructor(ctx: Context) {
+    super(ctx, 'settingsController', { namespace: 'settings' })
+    ctx.plugin(CredentialsController)
+  }
+
+  /**
+   * Describe every registered namespace for a configuration page: redacted
+   * layered values plus the serialized schema the page renders its form from.
+   * @returns provider writability, local-document presence, and one view per namespace.
+   * @throws TypertRemoteFailure when no settings provider is mounted.
+   */
+  @Remote
+  describe(): SettingsDescribeValue {
+    const settings = this.provider()
+    return {
+      writable: settings.writable,
+      hasDocument: settings.documentPath !== undefined,
+      namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
+    }
+  }
+
+  /**
+   * Merge a patch into one namespace's stored user section.
+   * @param ns - namespace key to write.
+   * @param patch - fields to merge into the user section.
+   * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
+   * @returns the namespace's redacted view after the write.
+   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   */
+  @Remote
+  update(
+    ns: string,
+    patch: Record<string, JsonValue>,
+    expectedRevision: number | undefined,
+  ): Promise<SettingsNamespaceView> {
+    return this.write(ns, 'update', patch, expectedRevision)
+  }
+
+  /**
+   * Replace one namespace's stored user section wholesale.
+   * @param ns - namespace key to write.
+   * @param section - complete replacement user section.
+   * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
+   * @returns the namespace's redacted view after the write.
+   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   */
+  @Remote
+  replace(
+    ns: string,
+    section: Record<string, JsonValue>,
+    expectedRevision: number | undefined,
+  ): Promise<SettingsNamespaceView> {
+    return this.write(ns, 'replace', section, expectedRevision)
+  }
+
+  /**
+   * Apply path-addressed edits to one namespace's user section, resolved against
+   * the section as stored rather than against whatever the caller last read,
+   * then answer with that namespace's new redacted view.
+   * @param ns - namespace key to write.
+   * @param ops - the edits to apply, in order.
+   * @param expectedRevision - revision the caller read; `undefined` writes unconditionally.
+   * @returns the namespace's redacted view after the write.
+   * @throws TypertRemoteFailure when the request is invalid, no provider is mounted, or the provider refuses the write.
+   */
+  @Remote
+  async mutate(
+    ns: string,
+    ops: SettingsPathOpView[],
+    expectedRevision: number | undefined,
+  ): Promise<SettingsNamespaceView> {
+    return this.write(ns, 'mutate', ops, expectedRevision)
+  }
+
+  private async write(
+    ns: string,
+    mode: 'update' | 'replace' | 'mutate',
+    input: Record<string, JsonValue> | SettingsPathOpView[],
+    expectedRevision: number | undefined,
+  ): Promise<SettingsNamespaceView> {
+    const parsed = settingsNamespaceRequestSchema.safeParse({ ns })
+    if (!parsed.success) {
+      throw new TypertRemoteFailure({
+        code: 'bad-request',
+        message: `invalid payload for settings.${mode}`,
+        details: { issues: parsed.error.issues },
+      })
+    }
+    const settings = this.provider()
+    let branded
+    try {
+      // A malformed name can address no registration, so it fails exactly as an
+      // unregistered one does.
+      branded = settingsNamespace(parsed.data.ns)
+    } catch (error: unknown) {
+      throw rejected(ns, error)
+    }
+    try {
+      if (mode === 'update') await settings.update(branded, input, expectedRevision)
+      else if (mode === 'replace') await settings.replace(branded, input, expectedRevision)
+      else await settings.mutate(branded, input as SettingsPathOp[], expectedRevision)
+    } catch (error: unknown) {
+      throw rejected(ns, error)
+    }
+    const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
+    if (descriptor === undefined) {
+      // The write committed but the namespace vanished before this read: only a
+      // concurrent registrant disposal can produce it.
+      throw new TypertRemoteFailure({
+        code: 'internal',
+        message: `settings namespace "${ns}" was disposed after the ${mode}`,
+        details: {},
+      })
+    }
+    return namespaceView(descriptor)
+  }
+
+  /** Resolve the optional provider or report how to supply it. */
+  private provider(): SettingsProvider {
+    const settings = this.ctx.get('settings')
+    if (settings === undefined) {
+      throw new TypertRemoteFailure({
+        code: 'internal',
+        message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
+        details: {},
+      })
+    }
+    return settings
+  }
+}
+
+/**
+ * Classify one seam refusal. A stale writer is its own outcome, not a malformed
+ * request: the client must re-read and re-apply rather than treat the write as
+ * invalid.
+ * @param ns - the namespace the write addressed.
+ * @param error - whatever the seam threw.
+ * @returns the failure to raise for that refusal.
+ */
+function rejected(ns: string, error: unknown): TypertRemoteFailure {
+  if (error instanceof SettingsConflictError) {
+    return new TypertRemoteFailure({
+      code: 'settings-conflict',
+      message: error.message,
+      details: { ns, expected: error.expected, actual: error.actual },
+    })
+  }
+  return new TypertRemoteFailure({
+    code: 'settings-rejected',
+    message: error instanceof Error ? error.message : String(error),
+    details: { ns },
+  })
+}
+
+export default SettingsController

+ 23 - 0
packages/api/settings-controller/src/invariant.ts

@@ -0,0 +1,23 @@
+/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-settings-controller/invariant */
+
+/* jscpd:ignore-start */
+import type { Context } from '@deepseek-ai/cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-api-settings-controller'
+
+/** Cordis companion plugin name. */
+export const name = 'api-settings-controller-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the settings and credential seams own storage and
+ * update events, while this package only projects their methods onto the wire.
+ */
+const install: InvariantInstaller = () => {}
+
+/** Register this package's invariant companion. */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 49 - 0
packages/api/settings-controller/src/types.ts

@@ -0,0 +1,49 @@
+/**
+ * Browser-safe failure vocabulary of the configuration surfaces this package
+ * serves. The redacted views themselves live with their seam in
+ * `@deepseek-ai/dsh-settings/types`, whose Cordis event declarations already
+ * register that file for the Client compilation face.
+ *
+ * @module @deepseek-ai/dsh-api-settings-controller/types
+ */
+
+/** Stable settings failure details returned by the `settings` namespace. */
+export interface SettingsErrorDetailsMap {
+  /**
+   * Every seam refusal that is not a stale write: an unregistered or malformed
+   * namespace, a read-only provider, schema validation, storage.
+   */
+  'settings-rejected': { readonly ns: string }
+  /**
+   * The stored revision moved after the caller read it. Its own outcome rather
+   * than an invalid request: the caller must re-read and re-apply.
+   */
+  'settings-conflict': { readonly ns: string; readonly expected: number; readonly actual: number }
+}
+
+/** Settings business failure carried by a rejected Remote call. */
+export type SettingsError = {
+  [Code in keyof SettingsErrorDetailsMap]: {
+    readonly code: Code
+    readonly message: string
+    readonly details: SettingsErrorDetailsMap[Code]
+  }
+}[keyof SettingsErrorDetailsMap]
+
+/** Stable credential failure details returned by the `credentials` namespace. */
+export interface CredentialErrorDetailsMap {
+  /**
+   * The provider refused a valid write, for example because a read-only source
+   * shadows the reference. The details name only the reference, never the value.
+   */
+  'credential-rejected': { readonly ref: string }
+}
+
+/** Credential business failure carried by a rejected Remote call. */
+export type CredentialError = {
+  [Code in keyof CredentialErrorDetailsMap]: {
+    readonly code: Code
+    readonly message: string
+    readonly details: CredentialErrorDetailsMap[Code]
+  }
+}[keyof CredentialErrorDetailsMap]

+ 141 - 0
packages/api/settings-controller/tests/credentials-controller.host.spec.ts

@@ -0,0 +1,141 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
+import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
+import CredentialsController from '../src/credentials.ts'
+import { MemoryCredentials } from '../../../credentials/credentials/tests/memory.ts'
+
+/** A store whose `describe` carries more than the view declares, as a foreign provider might. */
+class LeakyCredentials extends MemoryCredentials {
+  override describe(): Promise<CredentialInfo> {
+    return Promise.resolve(
+      { configured: true, source: 'memory', writable: true, value: 'sk-leaked' } as CredentialInfo,
+    )
+  }
+}
+
+/** A store whose write rejects with a bare string, the way some client libraries do. */
+class LiteralRejectingCredentials extends MemoryCredentials {
+  override async set(): Promise<void> {
+    throw 'the store refused'
+  }
+}
+
+/** A store whose provider-owned policy rejects an otherwise valid write. */
+class RejectingCredentials extends MemoryCredentials {
+  override set(): Promise<void> {
+    return Promise.reject(new Error('a read-only source shadows this reference'))
+  }
+}
+
+async function boot(
+  seed: Record<string, string> = {},
+  provider: typeof MemoryCredentials = MemoryCredentials,
+): Promise<CredentialsController> {
+  const ctx = new Context()
+  await ctx.plugin(provider, seed)
+  await ctx.plugin(CredentialsController)
+  return ctx.credentialsController
+}
+
+describe('the credentials Remote namespace a configuration surface calls', () => {
+  it('publishes the credentials namespace from its own service key', async () => {
+    const controller = await boot()
+    const binding = controller.typertRemote
+    expect(binding.serviceKey).toBe('credentialsController')
+    expect(binding.namespace).toBe('credentials')
+    expect(remoteMethods(controller)).toEqual([
+      { method: 'describe', invocation: { kind: 'direct' } },
+      { method: 'set', invocation: { kind: 'direct' } },
+      { method: 'unset', invocation: { kind: 'direct' } },
+    ])
+  })
+
+  it('reports the actionable configuration error while no credential provider is mounted', async () => {
+    const ctx = new Context()
+    await ctx.plugin(CredentialsController)
+    for (const call of [
+      () => ctx.credentialsController.describe(['DEEPSEEK_API_KEY']),
+      () => ctx.credentialsController.set('DEEPSEEK_API_KEY', 'sk-live'),
+      () => ctx.credentialsController.unset('DEEPSEEK_API_KEY'),
+    ]) {
+      const failure = await call().catch((error: unknown) => error)
+      expect(failure).toBeInstanceOf(TypertRemoteFailure)
+      expect((failure as TypertRemoteFailure).failure).toEqual({
+        code: 'internal',
+        message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition',
+        details: {},
+      })
+    }
+  })
+
+  it('describes a batch of references as one map, values excluded', async () => {
+    const controller = await boot({ DEEPSEEK_API_KEY: 'sk-seeded' })
+    const described = await controller.describe(['DEEPSEEK_API_KEY', 'OPENAI_API_KEY'])
+    expect(described).toEqual({
+      DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true },
+      OPENAI_API_KEY: { configured: false, writable: true },
+    })
+    expect(JSON.stringify(described)).not.toContain('sk-seeded')
+  })
+
+  it('reports an invalid reference as bad-request', async () => {
+    const controller = await boot()
+    for (const call of [
+      () => controller.describe(['DEEPSEEK_API_KEY', 'not a var']),
+      () => controller.set('not a var', 'sk-live'),
+      () => controller.unset('not a var'),
+    ]) {
+      const failure = await call().catch((error: unknown) => error)
+      expect(failure).toBeInstanceOf(TypertRemoteFailure)
+      expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+    }
+  })
+
+  it('answers the largest batch it accepts and reports one reference more as bad-request', async () => {
+    const controller = await boot()
+    const accepted = Array.from({ length: 64 }, (_unused, index) => `REF_${String(index)}`)
+    expect(Object.keys(await controller.describe(accepted))).toHaveLength(64)
+    const failure = await controller.describe([...accepted, 'REF_64']).catch((error: unknown) => error)
+    expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+  })
+
+  it('answers only the fields the view declares, whatever a provider returns', async () => {
+    const controller = await boot({}, LeakyCredentials)
+    const described = await controller.describe(['DEEPSEEK_API_KEY'])
+    expect(described.DEEPSEEK_API_KEY).toEqual({ configured: true, source: 'memory', writable: true })
+    expect(JSON.stringify(described)).not.toContain('sk-leaked')
+  })
+
+  it('stores and removes through the same references the batch describes', async () => {
+    const controller = await boot()
+    await controller.set('DEEPSEEK_API_KEY', 'sk-live')
+    expect(await controller.describe(['DEEPSEEK_API_KEY']))
+      .toEqual({ DEEPSEEK_API_KEY: { configured: true, source: 'memory', writable: true } })
+    await controller.unset('DEEPSEEK_API_KEY')
+    expect(await controller.describe(['DEEPSEEK_API_KEY']))
+      .toEqual({ DEEPSEEK_API_KEY: { configured: false, writable: true } })
+  })
+
+  it('reports a refused write as credential-rejected naming only the reference', async () => {
+    const controller = await boot({}, RejectingCredentials)
+    const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
+    expect(failure).toBeInstanceOf(TypertRemoteFailure)
+    const { code, message, details } = (failure as TypertRemoteFailure).failure
+    expect(code).toBe('credential-rejected')
+    expect(message).toContain('read-only source')
+    expect(details).toEqual({ ref: 'DEEPSEEK_API_KEY' })
+  })
+
+  it('reports an empty value as bad-request', async () => {
+    const controller = await boot()
+    const failure = await controller.set('DEEPSEEK_API_KEY', '').catch((error: unknown) => error)
+    expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+  })
+
+  it('stringifies a refusal that is not an Error', async () => {
+    const controller = await boot({}, LiteralRejectingCredentials)
+    const failure = await controller.set('DEEPSEEK_API_KEY', 'sk-live').catch((error: unknown) => error)
+    expect((failure as TypertRemoteFailure).failure.message).toBe('the store refused')
+  })
+})

+ 16 - 0
packages/api/settings-controller/tests/invariant.spec.ts

@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import * as SettingsControllerInvariant from '../src/invariant.ts'
+
+describe('api-settings-controller invariant companion', () => {
+  it('reserves the package name against duplicate registration', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry)
+    await ctx.plugin(SettingsControllerInvariant)
+
+    expect(() => {
+      ctx.invariants.register('@deepseek-ai/dsh-api-settings-controller', () => {})
+    }).toThrow(/already registered/)
+  })
+})

+ 241 - 0
packages/api/settings-controller/tests/settings-controller.host.spec.ts

@@ -0,0 +1,241 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import { settingsNamespace } from '@deepseek-ai/dsh-settings'
+import type { SettingsDescriptor, SettingsNamespace } from '@deepseek-ai/dsh-settings'
+import { TypertRemoteFailure, remoteMethods } from '@deepseek-ai/dsh-typert-protocol'
+import SettingsController from '../src/index.ts'
+import { MemorySettings } from '../../../settings/settings/tests/memory.ts'
+
+const NS = settingsNamespace('ui-test')
+
+const Profile = z.object({
+  preference: z.union(['light', 'dark']).default('light'),
+  apiKey: z.string().role('secret'),
+})
+
+/** A provider that reports a local document, for the `hasDocument` fact. */
+class DocumentSettings extends MemorySettings {
+  override get documentPath(): string | undefined {
+    return '/deployment/settings.yaml'
+  }
+}
+
+/** A provider whose read forgets the namespace its write just committed. */
+class VanishingSettings extends MemorySettings {
+  override describe(): SettingsDescriptor[] {
+    return []
+  }
+}
+
+/**
+ * A provider whose descriptor omits the secret-slot list. `secrets` is optional
+ * on the descriptor, so a foreign provider may leave it out even under
+ * `redactSecrets`, and the view still has to declare an empty list.
+ */
+class SlotlessSettings extends MemorySettings {
+  override describe(): SettingsDescriptor[] {
+    return [{
+      ns: NS,
+      schema: Profile.toJSON(),
+      value: { preference: 'light' },
+      applies: 'live',
+      revision: 0,
+    } as unknown as SettingsDescriptor]
+  }
+}
+
+/** A provider that refuses every write the way a read-only backing store would. */
+class RefusingSettings extends MemorySettings {
+  override mutate(ns: SettingsNamespace): Promise<void> {
+    return Promise.reject(new Error(`settings "${ns}" is read-only in this deployment`))
+  }
+}
+
+/** A provider that refuses with a bare string, the way some storage clients do. */
+class LiteralRefusingSettings extends MemorySettings {
+  override async mutate(): Promise<void> {
+    throw 'the document is locked'
+  }
+}
+
+async function boot(
+  provider: typeof MemorySettings = MemorySettings,
+  options: { doc?: Record<string, unknown>; base?: { preference: 'light' | 'dark' } } = {},
+): Promise<{ controller: SettingsController; ctx: Context }> {
+  const ctx = new Context()
+  await ctx.plugin(provider, options.doc === undefined ? {} : { doc: options.doc })
+  ctx.settings.register(NS, Profile, options.base === undefined ? {} : { base: options.base })
+  await ctx.plugin(SettingsController)
+  return { controller: ctx.settingsController, ctx }
+}
+
+describe('the settings Remote namespace a configuration page calls', () => {
+  it('publishes the settings namespace from its own service key', async () => {
+    const { controller } = await boot()
+    expect(controller.typertRemote.serviceKey).toBe('settingsController')
+    expect(controller.typertRemote.namespace).toBe('settings')
+    expect(remoteMethods(controller)).toEqual([
+      { method: 'describe', invocation: { kind: 'direct' } },
+      { method: 'update', invocation: { kind: 'direct' } },
+      { method: 'replace', invocation: { kind: 'direct' } },
+      { method: 'mutate', invocation: { kind: 'direct' } },
+    ])
+  })
+
+  it('reports the actionable configuration error while no settings provider is mounted', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SettingsController)
+    for (const call of [
+      () => ctx.settingsController.describe(),
+      () => ctx.settingsController.update('ui-test', {}, undefined),
+      () => ctx.settingsController.replace('ui-test', {}, undefined),
+      () => ctx.settingsController.mutate('ui-test', [], undefined),
+    ]) {
+      const failure = await Promise.resolve().then(call).catch((error: unknown) => error)
+      expect(failure).toBeInstanceOf(TypertRemoteFailure)
+      expect((failure as TypertRemoteFailure).failure).toEqual({
+        code: 'internal',
+        message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition',
+        details: {},
+      })
+    }
+  })
+
+  it('mounts the credentials namespace beside its own', async () => {
+    const ctx = new Context()
+    await ctx.plugin(MemorySettings)
+    ctx.settings.register(NS, Profile)
+    const fiber = ctx.plugin(SettingsController)
+    await fiber.await()
+    expect(ctx.get('credentialsController')).toBeDefined()
+    await fiber.dispose()
+    expect(ctx.get('settingsController')).toBeUndefined()
+    expect(ctx.get('credentialsController')).toBeUndefined()
+  })
+
+  it('describes every namespace redacted, with the deployment facts around them', async () => {
+    const { controller } = await boot(DocumentSettings, { doc: { 'ui-test': { apiKey: 'sk-stored' } } })
+    const value = controller.describe()
+    expect(value).toMatchObject({ writable: true, hasDocument: true })
+    const [view] = value.namespaces
+    expect(view?.ns).toBe('ui-test')
+    // The secret never rides; its slot reports only that one is stored.
+    expect(JSON.stringify(value)).not.toContain('sk-stored')
+    expect(view?.secrets).toEqual([{ path: ['apiKey'], set: true }])
+    // Redaction removes the field rather than replacing it, so the layer that
+    // stored a secret comes back empty instead of carrying a placeholder.
+    expect(view?.user).toEqual({})
+  })
+
+  it('reports a read-only provider and omits the layers it has none of', async () => {
+    const { controller } = await boot(class extends MemorySettings {
+      override get writable(): boolean {
+        return false
+      }
+    })
+    const value = controller.describe()
+    expect(value).toMatchObject({ writable: false, hasDocument: false })
+    const [view] = value.namespaces
+    // No composition base was declared and no user section is stored, so
+    // neither optional layer appears at all.
+    expect(view && 'base' in view).toBe(false)
+    expect(view && 'user' in view).toBe(false)
+  })
+
+  it('declares an empty slot list when the provider names no secrets', async () => {
+    const { controller } = await boot(SlotlessSettings)
+    const [view] = controller.describe().namespaces
+    expect(view?.secrets).toEqual([])
+  })
+
+  it('carries the composition base layer when the registrant declared one', async () => {
+    const { controller } = await boot(MemorySettings, { base: { preference: 'dark' } })
+    const [view] = controller.describe().namespaces
+    expect(view?.base).toEqual({ preference: 'dark' })
+  })
+
+  it('applies path-addressed edits and answers with the namespace it just wrote', async () => {
+    const { controller } = await boot()
+    const view = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
+    expect(view).toMatchObject({ ns: 'ui-test', user: { preference: 'dark' } })
+    expect(view.revision).toBeGreaterThan(0)
+  })
+
+  it('supports merge updates and wholesale replacement on the Remote namespace', async () => {
+    const { controller } = await boot(MemorySettings, {
+      doc: { 'ui-test': { preference: 'dark', apiKey: 'sk-stored' } },
+    })
+    const updated = await controller.update('ui-test', { preference: 'light' }, undefined)
+    expect(updated.user).toEqual({ preference: 'light' })
+    expect(updated.secrets).toEqual([{ path: ['apiKey'], set: true }])
+
+    const replaced = await controller.replace('ui-test', {}, updated.revision)
+    expect(replaced.value).toEqual({ preference: 'light' })
+    expect(replaced.user).toEqual({})
+    expect(replaced.secrets).toEqual([{ path: ['apiKey'], set: false }])
+  })
+
+  it('refuses a stale write as settings-conflict carrying both revisions', async () => {
+    const { controller } = await boot()
+    const held = controller.describe().namespaces[0]!.revision
+    await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], held)
+    const failure = await controller
+      .mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'light' }], held)
+      .catch((error: unknown) => error)
+    expect(failure).toBeInstanceOf(TypertRemoteFailure)
+    const { code, details } = (failure as TypertRemoteFailure).failure
+    expect(code).toBe('settings-conflict')
+    expect(details).toMatchObject({ ns: 'ui-test', expected: held })
+  })
+
+  it('answers a malformed namespace exactly as an unregistered one', async () => {
+    const { controller } = await boot()
+    for (const ns of ['Not A Namespace', 'unregistered']) {
+      const failure = await controller.mutate(ns, [{ op: 'unset', path: ['preference'] }], undefined)
+        .catch((error: unknown) => error)
+      expect((failure as TypertRemoteFailure).failure).toMatchObject({
+        code: 'settings-rejected',
+        details: { ns },
+      })
+    }
+  })
+
+  it('reports an empty namespace as bad-request', async () => {
+    const { controller } = await boot()
+    for (const call of [
+      () => controller.update('', {}, undefined),
+      () => controller.replace('', {}, undefined),
+      () => controller.mutate('', [], undefined),
+    ]) {
+      const failure = await call().catch((error: unknown) => error)
+      expect(failure).toBeInstanceOf(TypertRemoteFailure)
+      expect((failure as TypertRemoteFailure).failure).toMatchObject({ code: 'bad-request' })
+    }
+  })
+
+  it('reports a refused write as settings-rejected carrying the seam message', async () => {
+    const { controller } = await boot(RefusingSettings)
+    const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
+      .catch((error: unknown) => error)
+    const { code, message } = (failure as TypertRemoteFailure).failure
+    expect(code).toBe('settings-rejected')
+    expect(message).toContain('read-only in this deployment')
+  })
+
+  it('stringifies a refusal that is not an Error', async () => {
+    const { controller } = await boot(LiteralRefusingSettings)
+    const failure = await controller.mutate('ui-test', [{ op: 'unset', path: ['preference'] }], undefined)
+      .catch((error: unknown) => error)
+    expect((failure as TypertRemoteFailure).failure.message).toBe('the document is locked')
+  })
+
+  it('reports a namespace disposed between the write and its read-back', async () => {
+    const { controller } = await boot(VanishingSettings)
+    const failure = await controller.mutate('ui-test', [{ op: 'set', path: ['preference'], value: 'dark' }], undefined)
+      .catch((error: unknown) => error)
+    const { code, message } = (failure as TypertRemoteFailure).failure
+    expect(code).toBe('internal')
+    expect(message).toContain('was disposed after the mutate')
+  })
+})

+ 30 - 0
packages/api/settings-controller/tsconfig.json

@@ -0,0 +1,30 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../credentials/credentials"
+    },
+    {
+      "path": "../../core/session"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    },
+    {
+      "path": "../../settings/settings"
+    },
+    {
+      "path": "../../typert/protocol"
+    }
+  ]
+}

+ 5 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -86,6 +86,11 @@
     - id: session-controller
       name: '@deepseek-ai/dsh-api-session-controller'
 
+    # Configuration-surface reads and writes over Typert Remote. Each method
+    # reports an actionable error when its settings-domain provider is absent.
+    - id: settings-controller
+      name: '@deepseek-ai/dsh-api-settings-controller'
+
     # Workspace commands and reconnect-safe projection over Typert Remote.
     - id: workspace-controller
       name: '@deepseek-ai/dsh-api-workspace-controller'

+ 1 - 0
packages/bundle/web-app/package.json

@@ -108,6 +108,7 @@
     "@deepseek-ai/dsh-session-log-export": "workspace:^",
     "@deepseek-ai/dsh-session-stats": "workspace:^",
     "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+    "@deepseek-ai/dsh-api-settings-controller": "workspace:^",
     "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
     "@deepseek-ai/dsh-subprocess": "workspace:^",
     "@deepseek-ai/dsh-workspace": "workspace:^",

+ 4 - 12
packages/credentials/credentials/src/index.ts

@@ -9,9 +9,11 @@
  */
 
 import { Context, Service } from '@deepseek-ai/cordis'
-import type { CredentialKey, CredentialRecord, CredentialRef } from './types.ts'
+import type { CredentialInfo, CredentialKey, CredentialRecord, CredentialRef } from './types.ts'
 
-export type { ApiKeyRecord, CredentialKey, CredentialRecord, CredentialRef, GrantRecord } from './types.ts'
+export type {
+  ApiKeyRecord, CredentialInfo, CredentialKey, CredentialRecord, CredentialRef, GrantRecord,
+} from './types.ts'
 
 const REF_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/
 
@@ -119,16 +121,6 @@ export interface ResolvedCredential {
   source: string
 }
 
-/** Source and writability facts for one reference, safe for configuration UIs — never the value. */
-export interface CredentialInfo {
-  /** Whether {@link CredentialProvider.resolve} would currently return a value. */
-  configured: boolean
-  /** Source layer currently supplying the value; absent while unconfigured. */
-  source?: string
-  /** Whether {@link CredentialProvider.set} would currently succeed for this reference. */
-  writable: boolean
-}
-
 /** Presence and writability facts for one record, safe for configuration UIs — never the value. */
 export interface CredentialRecordInfo {
   /**

+ 18 - 3
packages/credentials/credentials/src/types.ts

@@ -1,8 +1,9 @@
 /**
  * Client-safe type surface of the credential seam: the two key brands, the
- * stored-record union, and the seam's Cordis event declarations. Types only —
- * no runtime code, and nothing here reaches a Host-only symbol, so a Client
- * compilation face reads exactly the signature the Host emits.
+ * stored-record union, the reference view crossing the Remote wire, and the
+ * seam's Cordis event declarations. Types only — no runtime code, and nothing
+ * here reaches a Host-only symbol, so a Client compilation face reads exactly
+ * the signature the Host emits.
  *
  * @module @deepseek-ai/dsh-credentials/types
  */
@@ -58,6 +59,20 @@ export interface GrantRecord {
 /** One durable credential record, tagged by what the seam may do with it. */
 export type CredentialRecord = ApiKeyRecord | GrantRecord
 
+/**
+ * Source and writability facts for one reference, safe for configuration UIs —
+ * never the value. The view has no slot a value could ride in, which is what
+ * lets the whole read half cross the Remote wire.
+ */
+export interface CredentialInfo {
+  /** Whether resolving the reference would currently return a value. */
+  configured: boolean
+  /** Source layer currently supplying the value; absent while unconfigured. */
+  source?: string
+  /** Whether the active provider can write this reference. */
+  writable: boolean
+}
+
 declare module '@deepseek-ai/cordis' {
   interface Events {
     /**

+ 4 - 2
packages/settings/settings/package.json

@@ -37,15 +37,17 @@
   ],
   "license": "MIT",
   "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^"
   },
   "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
     "@deepseek-ai/dsh-brand": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
-    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-session": "workspace:^",
     "@deepseek-ai/schemastery": "workspace:^"
   }
 }

+ 59 - 2
packages/settings/settings/src/types.ts

@@ -1,13 +1,15 @@
 /**
  * Client-safe type surface of the user-settings seam: the namespace brand, the
- * commit-origin union, and the seam's Cordis event declarations. Types only —
- * no runtime code, and nothing here reaches a Host-only symbol, so a Client
+ * commit-origin union, the redacted views a configuration surface reads over
+ * the Remote wire, and the seam's Cordis event declarations. Types only — no
+ * runtime code, and nothing here reaches a Host-only symbol, so a Client
  * compilation face reads exactly the signatures the Host emits.
  *
  * @module @deepseek-ai/dsh-settings/types
  */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
+import type { JsonValue } from '@deepseek-ai/dsh-session/types'
 
 /** Nominal id of one registered settings namespace. */
 export type SettingsNamespace = Branded<'SettingsNamespace'>
@@ -15,6 +17,61 @@ export type SettingsNamespace = Branded<'SettingsNamespace'>
 /** Origin of one committed settings change. */
 export type SettingsUpdateSource = 'update' | 'provider'
 
+/** One schema-declared secret slot inside a redacted namespace value. */
+export interface SettingsSecretView {
+  /** Path from the section root to the removed field. */
+  path: string[]
+  /** Whether the slot currently holds a value; the value itself never rides. */
+  set: boolean
+}
+
+/**
+ * Wire view of one registered namespace, always read under `redactSecrets`. The
+ * JSON-valued fields are `JsonValue` rather than the descriptor's `unknown`
+ * because the Remote boundary admits no unconstrained data.
+ */
+export interface SettingsNamespaceView {
+  /** Namespace key (`llm-deepseek`, `llm-pi-ai`, …). */
+  ns: string
+  /** Serialized schemastery schema envelope (`schema.toJSON()`); rehydrate with `new Schema(json)`. */
+  schema: JsonValue
+  /** Redacted resolved value (schema defaults → composition base → user layer). */
+  value: JsonValue
+  /** Redacted composition base layer, when the registrant declared one. */
+  base?: JsonValue
+  /** Redacted raw user section, when one exists; a field's presence here marks it user-overridden. */
+  user?: JsonValue
+  /** When the owner applies changes. */
+  applies: 'live' | 'restart'
+  /** Every schema-declared secret slot with its configured state. */
+  secrets: SettingsSecretView[]
+  /**
+   * Monotonic revision of the raw user section this view was read at. Send it
+   * back as `expectedRevision` on a write so a stale editor is refused rather
+   * than silently overwriting a concurrent change.
+   */
+  revision: number
+}
+
+/**
+ * One path-addressed edit carried by a remote settings write. `set` writes the
+ * value at the path, creating intermediate objects; `unset` removes it. The
+ * empty path addresses the section root.
+ */
+export type SettingsPathOpView =
+  | { op: 'set'; path: string[]; value: JsonValue }
+  | { op: 'unset'; path: string[] }
+
+/** Every registered namespace with the deployment facts a configuration page renders around them. */
+export interface SettingsDescribeValue {
+  /** Whether the provider accepts writes; `false` disables every write control. */
+  writable: boolean
+  /** Whether a file-backed provider owns a local document, without exposing its Host path. */
+  hasDocument: boolean
+  /** One view per registered namespace. */
+  namespaces: SettingsNamespaceView[]
+}
+
 declare module '@deepseek-ai/cordis' {
   interface Events {
     /**

+ 3 - 0
packages/settings/settings/tsconfig.json

@@ -20,6 +20,9 @@
     {
       "path": "../../util/brand"
     },
+    {
+      "path": "../../core/session"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }

+ 40 - 9
pnpm-lock.yaml

@@ -683,6 +683,9 @@ importers:
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../session-controller
+      '@deepseek-ai/dsh-api-settings-controller':
+        specifier: workspace:^
+        version: link:../settings-controller
       '@deepseek-ai/dsh-api-workspace-controller':
         specifier: workspace:^
         version: link:../workspace-controller
@@ -820,6 +823,31 @@ importers:
         specifier: workspace:^
         version: link:../../workspace/workspace
 
+  packages/api/settings-controller:
+    dependencies:
+      zod:
+        specifier: ^4.4.3
+        version: 4.4.3
+    devDependencies:
+      '@deepseek-ai/cordis':
+        specifier: workspace:^
+        version: link:../../../vendor/cordis
+      '@deepseek-ai/dsh-credentials':
+        specifier: workspace:^
+        version: link:../../credentials/credentials
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
+      '@deepseek-ai/dsh-typert-protocol':
+        specifier: workspace:^
+        version: link:../../typert/protocol
+
   packages/api/workspace-controller:
     dependencies:
       zod:
@@ -1376,6 +1404,9 @@ importers:
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
+      '@deepseek-ai/dsh-api-settings-controller':
+        specifier: workspace:^
+        version: link:../../api/settings-controller
       '@deepseek-ai/dsh-api-workspace-controller':
         specifier: workspace:^
         version: link:../../api/workspace-controller
@@ -1630,6 +1661,9 @@ importers:
       '@deepseek-ai/dsh-session':
         specifier: workspace:^
         version: link:../../core/session
+      '@deepseek-ai/dsh-settings':
+        specifier: workspace:^
+        version: link:../../settings/settings
       '@deepseek-ai/dsh-tool-todo':
         specifier: workspace:^
         version: link:../../todo/tool-todo
@@ -2644,9 +2678,6 @@ importers:
       '@deepseek-ai/dsh-api-session-controller':
         specifier: workspace:^
         version: link:../../api/session-controller
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
@@ -3096,9 +3127,6 @@ importers:
       '@deepseek-ai/dsh-api-remotes':
         specifier: workspace:^
         version: link:../../api/remotes
-      '@deepseek-ai/dsh-client-connection':
-        specifier: workspace:^
-        version: link:../connection
       '@deepseek-ai/dsh-client-locale':
         specifier: workspace:^
         version: link:../locale
@@ -5703,9 +5731,6 @@ importers:
       '@deepseek-ai/dsh-commands':
         specifier: workspace:^
         version: link:../../interaction/commands
-      '@deepseek-ai/dsh-credentials':
-        specifier: workspace:^
-        version: link:../../credentials/credentials
       '@deepseek-ai/dsh-host-directory-picker':
         specifier: workspace:^
         version: link:../directory-picker
@@ -5752,6 +5777,9 @@ importers:
       '@deepseek-ai/dsh-agent-presets':
         specifier: workspace:^
         version: link:../../preset/agent-presets
+      '@deepseek-ai/dsh-credentials':
+        specifier: workspace:^
+        version: link:../../credentials/credentials
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
@@ -7531,6 +7559,9 @@ importers:
       '@deepseek-ai/dsh-invariants':
         specifier: workspace:^
         version: link:../../runtime-diagnostics/invariants
+      '@deepseek-ai/dsh-session':
+        specifier: workspace:^
+        version: link:../../core/session
 
   packages/settings/settings-file:
     dependencies:

+ 6 - 0
scripts/gen-cordis-catalog.ts

@@ -70,6 +70,8 @@ export const SERVICE_PAGE: Record<string, string> = {
   cordisInspect: 'extensions.md',
   authorization: 'credentials.md',
   credentials: 'credentials.md',
+  credentialsController: 'credentials.md',
+  settingsController: 'settings.md',
   directoryPicker: 'workspace.md',
   deepseekLlmApiExtensions: 'llm-streaming.md',
   dynamicCordisRunner: 'extensions.md',
@@ -526,6 +528,10 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SettingsRegisterOptions: 'settings.md',
   SettingsScope: 'settings.md',
   SettingsDescriptor: 'settings.md',
+  SettingsDescribeValue: 'settings.md',
+  SettingsNamespaceView: 'settings.md',
+  SettingsPathOpView: 'settings.md',
+  SettingsSecretView: 'settings.md',
   SettingsPathOp: 'settings.md',
   SettingsDescribeOptions: 'settings.md',
   SettingsUpdateSource: 'settings.md',

+ 14 - 0
scripts/gen-doc-graphs.ts

@@ -157,6 +157,20 @@ const SERVICE_ROLES: ServiceRole[] = [
     consumers: ['host-apiproxy'],
     note: 'Owns Session commands, cold reads, durable-event following, live control state, and Agent activation policy; apiProxy reuses its inspection and Agent-resolution operations for Session-aware domains.',
   },
+  {
+    key: 'credentialsController',
+    pkg: 'api-settings-controller',
+    title: 'Host credential-surface Remote controller',
+    mode: 'core',
+    note: 'Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition.',
+  },
+  {
+    key: 'settingsController',
+    pkg: 'api-settings-controller',
+    title: 'Host settings-surface Remote controller',
+    mode: 'core',
+    note: 'Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition.',
+  },
   {
     key: 'workspaceController',
     pkg: 'api-workspace-controller',

+ 1 - 1
scripts/type-equiv.manifest.json

@@ -1664,7 +1664,7 @@
     {
       "doc": "docs/subsystems/credentials.md",
       "symbol": "CredentialInfo",
-      "source": "packages/credentials/credentials/src/index.ts"
+      "source": "packages/credentials/credentials/src/types.ts"
     },
     {
       "doc": "docs/subsystems/settings.md",

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -162,6 +162,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
   'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
   'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' },
+  'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' },
   'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' },
   'packages/typert/protocol': { kind: 'none', reason: 'Compiler-independent Remote protocol declarations; registers nothing model-facing.' },
   'packages/typert/generator': { kind: 'none', reason: 'The build-time generator runs outside any agent runtime and touches no model request.' },

+ 3 - 0
tsconfig.base.json

@@ -52,6 +52,9 @@
       "@deepseek-ai/dsh-api-session-controller/invariant": ["./packages/api/session-controller/src/invariant.ts"],
       "@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"],
       "@deepseek-ai/dsh-api-session-controller/remote-events": ["./packages/api/session-controller/src/remote-events.ts"],
+      "@deepseek-ai/dsh-api-settings-controller": ["./packages/api/settings-controller/src/index.ts"],
+      "@deepseek-ai/dsh-api-settings-controller/invariant": ["./packages/api/settings-controller/src/invariant.ts"],
+      "@deepseek-ai/dsh-api-settings-controller/types": ["./packages/api/settings-controller/src/types.ts"],
       "@deepseek-ai/dsh-api-workspace-controller": ["./packages/api/workspace-controller/src/index.ts"],
       "@deepseek-ai/dsh-api-workspace-controller/client": ["./packages/api/workspace-controller/src/client/index.ts"],
       "@deepseek-ai/dsh-api-workspace-controller/invariant": ["./packages/api/workspace-controller/src/invariant.ts"],

+ 1 - 0
tsconfig.host.json

@@ -146,6 +146,7 @@
     { "path": "./packages/api/gateway/tsconfig.host.json" },
     { "path": "./packages/api/remotes/tsconfig.host.json" },
     { "path": "./packages/api/session-controller/tsconfig.host.json" },
+    { "path": "./packages/api/settings-controller" },
     { "path": "./packages/api/workspace-controller/tsconfig.host.json" },
     { "path": "./packages/typert/loader" },
     { "path": "./packages/session/session-persistence" },