فهرست منبع

refactor(services): move shared values behind service APIs

imccyu 1 هفته پیش
والد
کامیت
f4e49ccf8f

+ 0 - 4
packages/api/remotes/src/client/index.ts

@@ -99,10 +99,6 @@ export type {
   DynamicCordisUndefineReceipt,
   RequestRunOutcome,
 } from '@deepseek-ai/dsh-cordis-host-runner/types'
-// The JSON vocabulary those payloads are built from, re-exported for the same
-// reason: a Client contribution names what it sends without importing a Host
-// package, and this assembly is where both planes legitimately meet.
-export type { JsonValue } from '@deepseek-ai/dsh-session/types'
 // Credential state vocabulary for the credentials namespace (values never ride it).
 export type { CredentialInfo } from '@deepseek-ai/dsh-credentials/types'
 // Redacted namespace vocabulary for the settings namespace (secrets never ride

+ 1 - 2
packages/api/remotes/src/index.ts

@@ -10,8 +10,7 @@ import type {
 } from '@deepseek-ai/dsh-api-gateway'
 import { Deque } from '@deepseek-ai/dsh-deque'
 import { carrierKeyOf } from '@deepseek-ai/dsh-scope'
-import { isJsonValue } from '@deepseek-ai/dsh-session'
-import type { JsonValue } from '@deepseek-ai/dsh-session'
+import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-util-values'
 import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
 
 // The owner packages' client-safe `./types` exports carry the cordis `Events`

+ 6 - 2
packages/api/remotes/src/remote-events.ts

@@ -6,7 +6,7 @@
  * type-only.
  */
 
-import { SESSION_CONTROLLER_REMOTE_EVENTS } from '@deepseek-ai/dsh-api-session-controller/remote-events'
+import type {} from '@deepseek-ai/dsh-api-session-controller/remote-events'
 import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol'
 
 /**
@@ -16,7 +16,11 @@ import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protoc
 export const API_REMOTE_FORWARDED_EVENTS = [
   { event: 'agent-preset/selected', mode: 'emit' },
   { event: 'approval/request', mode: 'waterfall' },
-  ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })),
+  { event: 'api-session/activity', mode: 'emit' },
+  { event: 'api-session/added', mode: 'emit' },
+  { event: 'api-session/error', mode: 'emit' },
+  { event: 'api-session/removed', mode: 'emit' },
+  { event: 'api-session/status', mode: 'emit' },
   { event: 'commands/change', mode: 'emit' },
   { event: 'credentials/reference-updated', mode: 'emit' },
   { event: 'cordis/request-run', mode: 'emit' },

+ 3 - 0
packages/api/remotes/tsconfig.host.json

@@ -45,6 +45,9 @@
     {
       "path": "../../util/deque"
     },
+    {
+      "path": "../../util/values"
+    },
     {
       "path": "../../interaction/user-approval"
     },

+ 4 - 4
packages/api/session-controller/src/commands.ts

@@ -2,6 +2,7 @@
 
 import { randomUUID } from 'node:crypto'
 import type { Context } from '@deepseek-ai/cordis'
+import { brandString } from '@deepseek-ai/dsh-brand'
 import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent'
 import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
@@ -9,8 +10,7 @@ import {
   ReasoningEffortId, createUserMessage, freezeMessage,
 } from '@deepseek-ai/dsh-llm'
 import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm'
-import { SessionId } from '@deepseek-ai/dsh-session'
-import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session'
+import type { SessionEvent, SessionHeader, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
 import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query'
 import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title'
 import { canonicalClientTimeZone } from '@deepseek-ai/dsh-util-time'
@@ -73,7 +73,7 @@ export class SessionCommandController {
     if (request.workspaceId !== undefined && request.cwd !== undefined) {
       throw new RemoteError('gateway/bad-request', 'session.create accepts workspaceId or cwd, not both', {})
     }
-    const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`)
+    const sessionId = request.sessionId ?? brandString<SessionId>(`session-${randomUUID()}`)
     let workspace: Workspace | undefined
     if (request.workspaceId !== undefined) {
       workspace = this.ctx.workspaceRegistry.get(request.workspaceId)
@@ -236,7 +236,7 @@ export class SessionCommandController {
         {},
       )
     }
-    const childId = SessionId(`session-${randomUUID()}`)
+    const childId = brandString<SessionId>(`session-${randomUUID()}`)
     const composition = await this.agents.composeAgent(this.agents.presetForObservation(source))
     try {
       const { provider, model } = this.ctx.agentDefaultModel.currentSelection()

+ 2 - 1
packages/api/session-controller/src/control.ts

@@ -5,8 +5,9 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
 import { Deque } from '@deepseek-ai/dsh-deque'
 import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
 import type {
-  JsonValue, Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
+  Session, SessionEvent, SessionEventMap, SessionId, UserMessage,
 } from '@deepseek-ai/dsh-session'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type {
   SessionControlBaseline,
   SessionControlFrame,

+ 10 - 9
packages/api/session-controller/src/remote-events.ts

@@ -1,13 +1,14 @@
-/** Session Controller events forwarded unchanged through the Remote Event carrier. */
-export const SESSION_CONTROLLER_REMOTE_EVENTS = [
-  'api-session/activity',
-  'api-session/added',
-  'api-session/error',
-  'api-session/removed',
-  'api-session/status',
-] as const
+/** Session Controller events available to a Remote Event assembly. */
+type SessionControllerRemoteEvent =
+  | 'api-session/activity'
+  | 'api-session/added'
+  | 'api-session/error'
+  | 'api-session/removed'
+  | 'api-session/status'
 
 declare module '@deepseek-ai/dsh-typert-protocol' {
   interface TypertRemoteEventSelection extends
-    Record<typeof SESSION_CONTROLLER_REMOTE_EVENTS[number], true> {}
+    Record<SessionControllerRemoteEvent, true> {}
 }
+
+export {}

+ 2 - 1
packages/api/session-controller/src/types.ts

@@ -7,9 +7,10 @@ import type { Branded } from '@deepseek-ai/dsh-brand'
 import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
 import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
 import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows'
-import type { JsonValue, SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
+import type { SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types'
 import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types'
 import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types'
 
 declare module '@deepseek-ai/dsh-session-projection/types' {

+ 26 - 17
packages/api/settings-controller/src/index.ts

@@ -17,13 +17,12 @@ import {
   openNativePath,
   openNativeTextFile,
 } from '@deepseek-ai/dsh-native-command'
-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, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 import { z } from 'zod'
 import { CredentialsController } from './credentials.ts'
 import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts'
@@ -269,22 +268,15 @@ export class SettingsController extends TypertRemoteService {
       throw new RemoteError('gateway/bad-request', `invalid payload for settings.${mode}`, { issues: parsed.error.issues })
     }
     const settings = this.provider()
-    let branded
+    const namespace = parsed.data.ns
     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 new RemoteError('settings/rejected', messageOf(error), { ns }, { cause: 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)
+      if (mode === 'update') await settings.update(namespace, input, expectedRevision)
+      else if (mode === 'replace') await settings.replace(namespace, input, expectedRevision)
+      else await settings.mutate(namespace, input as SettingsPathOp[], expectedRevision)
     } catch (error: unknown) {
       throw rejected(ns, error)
     }
-    const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
+    const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === namespace)
     if (descriptor === undefined) {
       // The write committed but the namespace vanished before this read: only a
       // concurrent registrant disposal can produce it.
@@ -311,6 +303,22 @@ function messageOf(error: unknown): string {
   return error instanceof Error ? error.message : String(error)
 }
 
+interface SettingsConflict {
+  readonly code: 'SETTINGS_CONFLICT'
+  readonly message: string
+  readonly expected: number
+  readonly actual: number
+}
+
+function settingsConflictOf(error: unknown): SettingsConflict | undefined {
+  if (typeof error !== 'object' || error === null) return undefined
+  if (Reflect.get(error, 'code') !== 'SETTINGS_CONFLICT'
+    || typeof Reflect.get(error, 'message') !== 'string'
+    || typeof Reflect.get(error, 'expected') !== 'number'
+    || typeof Reflect.get(error, 'actual') !== 'number') return undefined
+  return error as SettingsConflict
+}
+
 /**
  * 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
@@ -320,11 +328,12 @@ function messageOf(error: unknown): string {
  * @returns the failure to raise for that refusal.
  */
 function rejected(ns: string, error: unknown): RemoteError {
-  if (error instanceof SettingsConflictError) {
+  const conflict = settingsConflictOf(error)
+  if (conflict !== undefined) {
     return new RemoteError(
       'settings/conflict',
-      error.message,
-      { ns, expected: error.expected, actual: error.actual },
+      conflict.message,
+      { ns, expected: conflict.expected, actual: conflict.actual },
       { cause: error },
     )
   }

+ 4 - 5
packages/api/settings-controller/tests/settings-controller.host.spec.ts

@@ -1,13 +1,12 @@
 import { describe, expect, it, vi } 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 type { SettingsDescriptor } from '@deepseek-ai/dsh-settings'
 import { RemoteError, remoteErrorOf, 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 NS = 'ui-test'
 
 const Profile = z.object({
   preference: z.union(['light', 'dark']).default('light'),
@@ -47,8 +46,8 @@ class SlotlessSettings extends MemorySettings {
 
 /** 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`))
+  override mutate(): Promise<void> {
+    return Promise.reject(new Error('settings are read-only in this deployment'))
   }
 }
 

+ 35 - 17
packages/core/system-prompt/src/index.ts

@@ -55,8 +55,7 @@ export interface PromptSection {
   readonly name: string
   /**
    * Sections are concatenated in ascending order. Equal orders use code-unit
-   * name order. Repository-owned placements use
-   * {@link FIRST_PARTY_SECTION_ORDER}.
+   * name order.
    */
   readonly order: number
   /**
@@ -119,15 +118,7 @@ export interface PromptAssembly {
   variables: Record<string, string | undefined>
 }
 
-/**
- * Sparse integer placements for repository-owned prompt sections.
- *
- * Adjacent values differ by at least ten to keep the first-party groups sparse
- * and make accidental collisions mechanically detectable.
- * External plugins may use any finite order; equal orders are deterministic by
- * section name.
- */
-export const FIRST_PARTY_SECTION_ORDER = {
+const SECTION_ORDERS = {
   HARNESS_IDENTITY: -1000,
   HARNESS_SOURCE: -900,
   WEB_SURFACE: -800,
@@ -160,17 +151,26 @@ export const FIRST_PARTY_SECTION_ORDER = {
   STRUCTURED_OUTPUT: 9900,
 } as const
 
+/** Name of a centrally allocated prompt-section position. */
+export type PromptSectionOrderName = keyof typeof SECTION_ORDERS
+
+const CONTEXT_ORDERS = {
+  SANDBOX_POLICY: 110,
+  APPROVAL_POLICY: 115,
+  SUBAGENT_DELEGATION: 120,
+} as const
+
+/** Name of a centrally allocated runtime-context position. */
+export type PromptContextOrderName = keyof typeof CONTEXT_ORDERS
+
 /**
- * The deployment persona's section name and order. Exported because a
+ * The deployment persona's section name. Exported because a
  * composition can replace this slot — an agent preset shadows the
  * deployment's persona with its own — and both sides naming the same section
  * is what makes the replacement work rather than duplicate.
  */
 export const PERSONA_SECTION = 'deployment:persona'
 
-/** Prompt order of the persona slot. */
-export const PERSONA_ORDER = FIRST_PARTY_SECTION_ORDER.DEPLOYMENT_PERSONA
-
 /** Valid variable names: how they are written between the braces. */
 const VARIABLE_NAME = /^[a-z][a-z0-9_]*$/
 
@@ -408,13 +408,13 @@ export class SystemPrompt extends Service {
     if (config.includeHarnessIdentity ?? true) {
       this.section({
         name: 'harness:identity',
-        order: FIRST_PARTY_SECTION_ORDER.HARNESS_IDENTITY,
+        order: this.getSectionOrder('HARNESS_IDENTITY'),
         text: 'You are an AI agent powered by DeepSeek Harness.',
       })
     }
     this.section({
       name: PERSONA_SECTION,
-      order: PERSONA_ORDER,
+      order: this.getSectionOrder('DEPLOYMENT_PERSONA'),
       // The fallback narrows the optional input type; the schema already defaults it.
       text: config.persona ?? '',
     })
@@ -440,6 +440,24 @@ export class SystemPrompt extends Service {
     )
   }
 
+  /**
+   * Resolve the centrally owned placement of a repository prompt section.
+   * @param name - stable section placement name.
+   * @returns the section's numeric sort order.
+   */
+  getSectionOrder(name: PromptSectionOrderName): number {
+    return SECTION_ORDERS[name]
+  }
+
+  /**
+   * Resolve the centrally owned placement of a repository runtime context.
+   * @param name - stable context placement name.
+   * @returns the context's numeric sort order.
+   */
+  getContextOrder(name: PromptContextOrderName): number {
+    return CONTEXT_ORDERS[name]
+  }
+
   /**
    * Register ordered dynamic context in the calling context's scope. Scoped
    * entries shadow global entries with the same name.

+ 26 - 3
packages/core/system-prompt/tests/system-prompt.spec.ts

@@ -1,8 +1,9 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import SystemPrompt, {
-  AssembleContext, FIRST_PARTY_SECTION_ORDER, PromptAssembly, renderContextSnapshot, renderPrompt,
+  AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt,
 } from '@deepseek-ai/dsh-system-prompt'
+import type { PromptContextOrderName, PromptSectionOrderName } from '@deepseek-ai/dsh-system-prompt'
 
 /**
  * Every assembly carries the plugin's own built-ins — `harness:identity`
@@ -12,19 +13,41 @@ import SystemPrompt, {
  */
 const BUILT_IN = ['harness:identity', 'deployment:persona']
 const IDENTITY = 'You are an AI agent powered by DeepSeek Harness.'
+const SECTION_ORDER_NAMES = [
+  'HARNESS_IDENTITY', 'HARNESS_SOURCE', 'WEB_SURFACE', 'DEPLOYMENT_PERSONA',
+  'PLAN_POLICY', 'TEAM_POLICY', 'PTC_ONLY', 'FILE_REFERENCE', 'TOOL_BASH',
+  'TOOL_PWSH', 'TOOL_READ', 'TOOL_WRITE', 'TOOL_EDIT', 'TOOL_GLOB',
+  'TOOL_GREP', 'TOOL_JOBS', 'TOOL_PTY', 'TOOL_WEB_SEARCH', 'TOOL_WEB_FETCH',
+  'TOOL_LSP', 'TOOL_SESSION_QUERY', 'TOOL_GOAL', 'TOOL_CORDIS', 'TOOL_WORKFLOW',
+  'TOOL_RALPH', 'TOOL_SUBAGENT', 'TOOL_REPORT', 'TOOLS_SDK',
+  'DELIVERABLE_FILE_REFERENCES', 'STRUCTURED_OUTPUT',
+] as const satisfies readonly PromptSectionOrderName[]
+const CONTEXT_ORDER_NAMES = [
+  'SANDBOX_POLICY', 'APPROVAL_POLICY', 'SUBAGENT_DELEGATION',
+] as const satisfies readonly PromptContextOrderName[]
 function contributed(assembly: PromptAssembly): PromptAssembly['sections'] {
   return assembly.sections.filter(section => !BUILT_IN.includes(section.name))
 }
 
 describe('SystemPrompt', () => {
-  it('keeps first-party section placements unique, integral, and at least ten apart', () => {
-    const orders = Object.values(FIRST_PARTY_SECTION_ORDER)
+  it('keeps repository section placements unique, integral, and at least ten apart', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt, {})
+    const orders = SECTION_ORDER_NAMES.map(name => ctx.systemPrompt.getSectionOrder(name))
     expect(orders.every(Number.isInteger)).toBe(true)
     expect(new Set(orders).size).toBe(orders.length)
     const sorted = [...orders].sort((a, b) => a - b)
     expect(sorted.slice(1).every((order, index) => order - sorted[index]! >= 10)).toBe(true)
   })
 
+  it('keeps repository context placements unique and integral', async () => {
+    const ctx = new Context()
+    await ctx.plugin(SystemPrompt, {})
+    const orders = CONTEXT_ORDER_NAMES.map(name => ctx.systemPrompt.getContextOrder(name))
+    expect(orders.every(Number.isInteger)).toBe(true)
+    expect(new Set(orders).size).toBe(orders.length)
+  })
+
   describe('built-in sections', () => {
     it('registers the harness identity and the configured deployment persona', async () => {
       const ctx = new Context()

+ 101 - 107
packages/settings/settings/src/index.ts

@@ -8,6 +8,7 @@
 
 import { Context, Service } from '@deepseek-ai/cordis'
 import type z from '@deepseek-ai/schemastery'
+import { deepEqualJson, deepFreeze } from '@deepseek-ai/dsh-util-values'
 import { redactSecrets } from './redact.ts'
 import type { RedactedSecret } from './redact.ts'
 import type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
@@ -17,13 +18,24 @@ export type { RedactedSecret, RedactedValue } from './redact.ts'
 export type { SettingsNamespace, SettingsUpdateSource } from './types.ts'
 
 const NAMESPACE_PATTERN = /^[a-z][a-z0-9-]*$/
-
-/**
- * Brand a raw string as a {@link SettingsNamespace}.
- * @param value - candidate namespace; lowercase kebab-case, as in plugin short names.
- * @returns the branded namespace.
- */
-export function settingsNamespace(value: string): SettingsNamespace {
+type LowercaseLetter = 'a' | 'b' | 'c' | 'd' | 'e' | 'f' | 'g' | 'h' | 'i' | 'j' | 'k' | 'l' | 'm'
+  | 'n' | 'o' | 'p' | 'q' | 'r' | 's' | 't' | 'u' | 'v' | 'w' | 'x' | 'y' | 'z'
+type DecimalDigit = '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9'
+type NamespaceCharacter = LowercaseLetter | DecimalDigit | '-'
+type ValidNamespaceTail<Value extends string> = Value extends ''
+  ? true
+  : Value extends `${NamespaceCharacter}${infer Rest}`
+    ? ValidNamespaceTail<Rest>
+    : false
+type SettingsNamespaceInput<Value extends string> = Value extends SettingsNamespace
+  ? Value
+  : string extends Value
+    ? string
+    : Value extends `${LowercaseLetter}${infer Rest}`
+      ? ValidNamespaceTail<Rest> extends true ? Value : never
+      : never
+
+function parseSettingsNamespace(value: string): SettingsNamespace {
   if (!NAMESPACE_PATTERN.test(value)) {
     throw new TypeError(`settings namespace "${value}" must match ${String(NAMESPACE_PATTERN)}`)
   }
@@ -134,28 +146,6 @@ declare module '@deepseek-ai/cordis' {
   }
 }
 
-/**
- * Deep equality over JSON-compatible data (objects, arrays, primitives) — the
- * Service Definition's single change-detection predicate, exported so the invariant
- * companion checks exactly the implementation's relation.
- * @param a - one JSON-compatible value.
- * @param b - the other JSON-compatible value.
- * @returns whether the two values are structurally equal.
- */
-export function deepEqualJson(a: unknown, b: unknown): boolean {
-  if (a === b) return true
-  if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false
-  if (Array.isArray(a) || Array.isArray(b)) {
-    if (!Array.isArray(a) || !Array.isArray(b) || a.length !== b.length) return false
-    return a.every((entry, index) => deepEqualJson(entry, b[index]))
-  }
-  const left = a as Record<string, unknown>
-  const right = b as Record<string, unknown>
-  const keys = Object.keys(left)
-  if (keys.length !== Object.keys(right).length) return false
-  return keys.every(key => key in right && deepEqualJson(left[key], right[key]))
-}
-
 /**
  * A write refused because the namespace moved since the caller read it. The
  * Service Definition's serialized write queue orders writes; it cannot tell a fresh writer
@@ -304,13 +294,6 @@ function mergeLayers(under: unknown, over: unknown): unknown {
   return merged
 }
 
-/** Recursively freeze one resolved value so handed-out snapshots stay immutable. */
-function deepFreeze<T>(value: T): T {
-  if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return value
-  for (const entry of Object.values(value)) deepFreeze(entry)
-  return Object.freeze(value)
-}
-
 /** One registered watcher and its serialized invocation chain. */
 interface SettingsWatcher {
   callback: (next: never, prev: never) => void | Promise<void>
@@ -431,29 +414,35 @@ export abstract class SettingsProvider extends Service {
    * @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.
+   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
-  register<T>(ns: SettingsNamespace, schema: z<T>, options?: SettingsRegisterOptions<T>): SettingsScope<T> {
-    if (this.registrations.has(ns)) {
-      throw new Error(`settings namespace "${ns}" is already registered`)
+  register<const Namespace extends string, T>(
+    ns: Namespace & SettingsNamespaceInput<Namespace>,
+    schema: z<T>,
+    options?: SettingsRegisterOptions<T>,
+  ): SettingsScope<T> {
+    const parsedNs = parseSettingsNamespace(ns)
+    if (this.registrations.has(parsedNs)) {
+      throw new Error(`settings namespace "${parsedNs}" is already registered`)
     }
     const registration: SettingsRegistration = {
-      ns,
+      ns: parsedNs,
       schema: schema as z<unknown>,
       base: options?.base,
       applies: options?.applies ?? 'live',
       ...options?.validate === undefined
         ? {}
         : { validate: options.validate as (value: unknown) => void },
-      resolved: deepFreeze(this.resolve(schema, options?.base, this.section(ns), options?.validate)),
+      resolved: deepFreeze(this.resolve(schema, options?.base, this.section(parsedNs), options?.validate)),
       revision: 0,
       watchers: new Set(),
     }
     this.ctx.effect(() => {
-      this.registrations.set(ns, registration)
+      this.registrations.set(parsedNs, registration)
       // TODO(settings-registration-quiescence): Deactivate every watcher and await
       // its tail on disposal so callbacks cannot outlive the registrant fiber.
-      return () => this.registrations.delete(ns)
-    }, `settings.register(${JSON.stringify(String(ns))})`)
+      return () => this.registrations.delete(parsedNs)
+    }, `settings.register(${JSON.stringify(String(parsedNs))})`)
     return {
       get: () => registration.resolved as T,
       watch: (callback) => {
@@ -464,11 +453,48 @@ export abstract class SettingsProvider extends Service {
           registration.watchers.delete(watcher)
         }
       },
-      update: patch => this.update(ns, patch),
-      replace: section => this.replace(ns, section),
+      update: patch => this.update(parsedNs, patch),
+      replace: section => this.replace(parsedNs, section),
     }
   }
 
+  /**
+   * Attach one optional-settings consumer to this provider. The consumer
+   * registers its composition entry as the base layer while this provider is
+   * present, then falls back to that entry if the provider detaches.
+   * @param owner - consumer context whose unload suppresses fallback work.
+   * @param ns - consumer-owned settings namespace.
+   * @param schema - schema resolving the namespace.
+   * @param entry - composition entry used as the base and fallback value.
+   * @param hooks - source sink, change notification, and optional validation.
+   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
+   */
+  installSection<const Namespace extends string, T>(
+    owner: Context,
+    ns: Namespace & SettingsNamespaceInput<Namespace>,
+    schema: z<T>,
+    entry: T,
+    hooks: SettingsSectionHooks<T>,
+  ): void {
+    const scope = this.register<Namespace, T>(ns, schema, {
+      base: entry,
+      ...hooks.validate === undefined ? {} : { validate: hooks.validate },
+    })
+    hooks.setSource(() => scope.get())
+    this.ctx.effect(() => () => {
+      // Losing the provider leaves the consumer running; unloading the
+      // consumer does not, so only the former needs fallback work.
+      if (isUnloading(owner)) return
+      hooks.setSource(() => entry)
+      hooks.onChange()
+    })
+    hooks.onChange()
+    scope.watch(() => {
+      if (isUnloading(owner)) return
+      hooks.onChange()
+    })
+  }
+
   /**
    * Describe every registered namespace for configuration surfaces, including
    * the composition `base` and raw user layers so a form can mark which fields
@@ -515,9 +541,10 @@ export abstract class SettingsProvider extends Service {
    * Read one registered namespace's resolved value.
    * @param ns - the namespace to read.
    * @returns the resolved value, or `undefined` while unregistered.
+   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
-  get(ns: SettingsNamespace): unknown {
-    return this.registrations.get(ns)?.resolved
+  get<const Namespace extends string>(ns: Namespace & SettingsNamespaceInput<Namespace>): unknown {
+    return this.registrations.get(parseSettingsNamespace(ns))?.resolved
   }
 
   /**
@@ -530,9 +557,14 @@ export abstract class SettingsProvider extends Service {
    * @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}.
+   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
-  async update(ns: SettingsNamespace, patch: object, expectedRevision?: number): Promise<void> {
-    return this.write(ns, patch, 'merge', expectedRevision)
+  async update<const Namespace extends string>(
+    ns: Namespace & SettingsNamespaceInput<Namespace>,
+    patch: object,
+    expectedRevision?: number,
+  ): Promise<void> {
+    return this.write(parseSettingsNamespace(ns), patch, 'merge', expectedRevision)
   }
 
   /**
@@ -544,9 +576,14 @@ export abstract class SettingsProvider extends Service {
    * @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}.
+   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
-  async replace(ns: SettingsNamespace, section: object, expectedRevision?: number): Promise<void> {
-    return this.write(ns, section, 'replace', expectedRevision)
+  async replace<const Namespace extends string>(
+    ns: Namespace & SettingsNamespaceInput<Namespace>,
+    section: object,
+    expectedRevision?: number,
+  ): Promise<void> {
+    return this.write(parseSettingsNamespace(ns), section, 'replace', expectedRevision)
   }
 
   /**
@@ -560,18 +597,24 @@ export abstract class SettingsProvider extends Service {
    * @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}.
+   * @throws {TypeError} when `ns` is not a lowercase hyphenated identifier.
    */
-  async mutate(ns: SettingsNamespace, ops: readonly SettingsPathOp[], expectedRevision?: number): Promise<void> {
-    if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${ns}" must be an array of path ops`)
+  async mutate<const Namespace extends string>(
+    ns: Namespace & SettingsNamespaceInput<Namespace>,
+    ops: readonly SettingsPathOp[],
+    expectedRevision?: number,
+  ): Promise<void> {
+    const parsedNs = parseSettingsNamespace(ns)
+    if (!Array.isArray(ops)) throw new TypeError(`settings mutate for "${parsedNs}" must be an array of path ops`)
     for (const op of ops) {
       if (!isPlainObject(op) || (op['op'] !== 'set' && op['op'] !== 'unset')) {
-        throw new TypeError(`settings mutate for "${ns}" ops must be {op:'set'|'unset', path}`)
+        throw new TypeError(`settings mutate for "${parsedNs}" ops must be {op:'set'|'unset', path}`)
       }
       if (!Array.isArray(op['path']) || (op['path'] as unknown[]).some(part => typeof part !== 'string')) {
-        throw new TypeError(`settings mutate for "${ns}" op paths must be arrays of strings`)
+        throw new TypeError(`settings mutate for "${parsedNs}" op paths must be arrays of strings`)
       }
     }
-    return this.write(ns, ops, 'mutate', expectedRevision)
+    return this.write(parsedNs, ops, 'mutate', expectedRevision)
   }
 
   /** Validate a write, then queue it on the namespace's serialized write chain. */
@@ -825,7 +868,7 @@ function isUnloading(ctx: Context): boolean {
   return state === FIBER_UNLOADING || state === FIBER_DISPOSED
 }
 
-/** Hooks a consumer hands to {@link installSettingsSection}. */
+/** Hooks a consumer hands to {@link SettingsProvider.installSection}. */
 export interface SettingsSectionHooks<T> {
   /**
    * Receive the active configuration source: the resolved settings scope
@@ -847,53 +890,4 @@ export interface SettingsSectionHooks<T> {
   validate?: (value: T) => void
 }
 
-/**
- * Install the canonical optional-settings consumer wiring: while a settings
- * service exists, register `ns` with the consumer's composition entry as the
- * `base` layer and point the source thunk at the resolved scope; when the
- * service goes away (disposal, provider reload), fall back to the entry so
- * the consumer keeps working exactly as composed. The registration rides the
- * scoped fiber, so no settings service ever mounted means none of this runs.
- * @param ctx - consumer plugin context owning the wiring.
- * @param ns - the consumer-owned settings namespace.
- * @param schema - schema resolving the namespace (typically the plugin Config).
- * @param entry - the consumer's composition entry config, used as `base`.
- * @param hooks - source sink and change notification.
- */
-export function installSettingsSection<T>(
-  ctx: Context,
-  ns: SettingsNamespace,
-  schema: z<T>,
-  entry: T,
-  hooks: SettingsSectionHooks<T>,
-): void {
-  ctx.inject(['settings'], (sctx) => {
-    const scope = sctx.settings.register(ns, schema, {
-      base: entry,
-      ...hooks.validate === undefined ? {} : { validate: hooks.validate },
-    })
-    hooks.setSource(() => scope.get())
-    sctx.effect(() => () => {
-      // This disposer runs for two different reasons. A settings provider
-      // detaching leaves the consumer running, so it must fall back to its
-      // composition entry and re-judge what it derived. The consumer's own
-      // unload runs it too — and there `onChange` would re-register routes
-      // and touch resources the teardown is releasing, so the fallback is
-      // pointless and the notification actively harmful.
-      if (isUnloading(ctx)) return
-      hooks.setSource(() => entry)
-      hooks.onChange()
-    })
-    hooks.onChange()
-    scope.watch(() => {
-      // A stored change landing while the consumer unloads reaches the watcher
-      // before the registration is released, and `onChange` is exactly as
-      // harmful here as in the disposer above: it re-registers routes against
-      // a fiber whose resources are being let go.
-      if (isUnloading(ctx)) return
-      hooks.onChange()
-    })
-  })
-}
-
 export default SettingsProvider

+ 1 - 1
packages/settings/settings/src/invariant.ts

@@ -5,7 +5,7 @@
 
 import type { Context } from '@deepseek-ai/cordis'
 import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
-import { deepEqualJson } from './index.ts'
+import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 
 const PACKAGE_NAME = '@deepseek-ai/dsh-settings'
 

+ 1 - 1
packages/settings/settings/src/types.ts

@@ -9,7 +9,7 @@
  */
 
 import type { Branded } from '@deepseek-ai/dsh-brand'
-import type { JsonValue } from '@deepseek-ai/dsh-session/types'
+import type { JsonValue } from '@deepseek-ai/dsh-util-values'
 
 /** Nominal id of one registered settings namespace. */
 export type SettingsNamespace = Branded<'SettingsNamespace'>

+ 7 - 7
packages/settings/settings/tests/invariant.spec.ts

@@ -1,9 +1,9 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
+import type { SettingsNamespace } from '@deepseek-ai/dsh-settings'
 import z from '@deepseek-ai/schemastery'
 import InvariantRegistry from '@deepseek-ai/dsh-invariants'
 import * as SettingsInvariant from '../src/invariant.ts'
-import { settingsNamespace } from '../src/index.ts'
 import { MemorySettings } from './memory.ts'
 
 async function setup(withProvider: boolean): Promise<Context> {
@@ -18,35 +18,35 @@ describe('settings invariants', () => {
   it('fails a settings/updated emission without a live settings service', async () => {
     const ctx = await setup(false)
     expect(() => {
-      ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
+      ctx.emit('settings/updated', 'ghost' as SettingsNamespace, { a: 1 }, { a: 2 }, 'provider')
     }).toThrow(/without a live settings service/)
   })
 
   it('fails a settings/updated emission for an unregistered namespace', async () => {
     const ctx = await setup(true)
     expect(() => {
-      ctx.emit('settings/updated', settingsNamespace('ghost'), { a: 1 }, { a: 2 }, 'provider')
+      ctx.emit('settings/updated', 'ghost' as SettingsNamespace, { a: 1 }, { a: 2 }, 'provider')
     }).toThrow(/unregistered/)
   })
 
   it('fails a settings/updated emission without a resolved-value change', async () => {
     const ctx = await setup(true)
-    ctx.settings.register(settingsNamespace('ui-theme'), z.object({
+    ctx.settings.register('ui-theme', z.object({
       theme: z.string().default('dark'),
     }))
     expect(() => {
-      ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'dark' }, { theme: 'dark' }, 'update')
+      ctx.emit('settings/updated', 'ui-theme' as SettingsNamespace, { theme: 'dark' }, { theme: 'dark' }, 'update')
     }).toThrow(/without a resolved-value change/)
   })
 
   it('fails a settings/updated emission whose value diverges from the authoritative state', async () => {
     const ctx = await setup(true)
-    ctx.settings.register(settingsNamespace('ui-theme'), z.object({
+    ctx.settings.register('ui-theme', z.object({
       theme: z.string().default('dark'),
     }))
     // Fabricated next ≠ the service's current resolved value ({theme: 'dark'}).
     expect(() => {
-      ctx.emit('settings/updated', settingsNamespace('ui-theme'), { theme: 'forged' }, { theme: 'dark' }, 'update')
+      ctx.emit('settings/updated', 'ui-theme' as SettingsNamespace, { theme: 'forged' }, { theme: 'dark' }, 'update')
     }).toThrow(/authoritative/)
   })
 })

+ 2 - 2
packages/settings/settings/tests/redact.spec.ts

@@ -1,7 +1,7 @@
 import { describe, expect, it } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { redactSecrets, settingsNamespace } from '../src/index.ts'
+import { redactSecrets } from '../src/index.ts'
 import { MemorySettings } from './memory.ts'
 
 const Profile = z.object({
@@ -104,7 +104,7 @@ describe('redactSecrets', () => {
 })
 
 describe('describe() layers and redaction', () => {
-  const NS = settingsNamespace('adapter')
+  const NS = 'adapter'
 
   async function boot(doc?: Record<string, unknown>) {
     const ctx = new Context()

+ 76 - 76
packages/settings/settings/tests/settings.spec.ts

@@ -1,7 +1,8 @@
 import { describe, expect, it, vi } from 'vitest'
 import { Context } from '@deepseek-ai/cordis'
 import z from '@deepseek-ai/schemastery'
-import { SettingsProvider, SettingsConflictError, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
+import { SettingsProvider, SettingsConflictError, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts'
+import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
 import { MemorySettings } from './memory.ts'
 
 /** A provider implementing only the three primitives: the Service Definition owns initialization. */
@@ -75,20 +76,17 @@ function recordUpdates(ctx: Context) {
   return events
 }
 
-describe('settingsNamespace', () => {
-  it('brands lowercase kebab-case names', () => {
-    expect(settingsNamespace('ui-theme')).toBe('ui-theme')
-  })
-
-  it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j', (value) => {
-    expect(() => settingsNamespace(value)).toThrow(TypeError)
+describe('settings namespace validation', () => {
+  it.each(['', 'UI', '9lives', 'a_b', '-lead'])('rejects %j at the service', async (value) => {
+    const { ctx } = await boot()
+    expect(() => ctx.settings.register(value, ThemeSchema)).toThrow(TypeError)
   })
 })
 
 describe('registration', () => {
   it('resolves schema defaults, then composition base, then the user layer', async () => {
     const { ctx } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+    const scope = ctx.settings.register('ui-theme', ThemeSchema, {
       base: { fontSize: 16 },
     })
     // theme: user layer wins; fontSize: base wins over the schema default.
@@ -97,7 +95,7 @@ describe('registration', () => {
 
   it('refuses a write its owner could not act on, and keeps the last good value for a stored one', async () => {
     const { ctx } = await boot()
-    const ns = settingsNamespace('ui-theme')
+    const ns = 'ui-theme'
     // A constraint the schema cannot express: this owner cannot serve a size
     // it considers unreadable, whatever the schema admits.
     const scope = ctx.settings.register(ns, ThemeSchema, {
@@ -126,7 +124,7 @@ describe('registration', () => {
     // owner cannot serve therefore refuses the registration rather than
     // mounting an owner over configuration it rejects.
     const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 4 } } })
-    expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+    expect(() => ctx.settings.register('ui-theme', ThemeSchema, {
       validate: (value) => {
         if (value.fontSize < 10) throw new Error(`font size ${String(value.fontSize)} is unreadable`)
       },
@@ -135,26 +133,26 @@ describe('registration', () => {
 
   it('rejects a duplicate namespace loud', async () => {
     const { ctx } = await boot()
-    ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
-    expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
+    ctx.settings.register('ui-theme', ThemeSchema)
+    expect(() => ctx.settings.register('ui-theme', ThemeSchema))
       .toThrow(/already registered/)
   })
 
   it('fails registration when the stored section is invalid for the schema', async () => {
     const { ctx } = await boot({ doc: { 'ui-theme': { fontSize: 'big' } } })
-    expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)).toThrow()
+    expect(() => ctx.settings.register('ui-theme', ThemeSchema)).toThrow()
   })
 
   it('fails registration when the stored section is not an object', async () => {
     const { ctx } = await boot({ doc: { 'ui-theme': 'dark' } })
-    expect(() => ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema))
+    expect(() => ctx.settings.register('ui-theme', ThemeSchema))
       .toThrow(/must be an object/)
   })
 
   it('describes registered namespaces with schema JSON, value, and applies', async () => {
     const { ctx } = await boot()
-    ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
-    ctx.settings.register(settingsNamespace('workspace'), NestedSchema, { applies: 'restart' })
+    ctx.settings.register('ui-theme', ThemeSchema)
+    ctx.settings.register('workspace', NestedSchema, { applies: 'restart' })
     const descriptors = ctx.settings.describe()
     expect(descriptors.map(entry => [entry.ns, entry.applies])).toEqual([
       ['ui-theme', 'live'],
@@ -169,12 +167,12 @@ describe('registration', () => {
 
   it('reads undefined for an unregistered namespace', async () => {
     const { ctx } = await boot()
-    expect(ctx.settings.get(settingsNamespace('missing'))).toBeUndefined()
+    expect(ctx.settings.get('missing')).toBeUndefined()
   })
 
   it('hands out frozen resolved values', async () => {
     const { ctx } = await boot({ doc: { workspace: { retry: { attempts: 5 } } } })
-    const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
+    const scope = ctx.settings.register('workspace', NestedSchema)
     const value = scope.get()
     expect(Object.isFrozen(value)).toBe(true)
     expect(Object.isFrozen(value.retry)).toBe(true)
@@ -188,22 +186,22 @@ describe('registration', () => {
     const fiber = ctx.plugin({
       inject: ['settings'],
       apply: (child: Context) => {
-        scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+        scope = child.settings.register('ui-theme', ThemeSchema)
         scope.watch((next) => { seen.push(next) })
       },
     })
     await fiber
-    expect(ctx.settings.get(settingsNamespace('ui-theme'))).toEqual({ theme: 'dark', fontSize: 14 })
+    expect(ctx.settings.get('ui-theme')).toEqual({ theme: 'dark', fontSize: 14 })
 
     await fiber.dispose()
-    expect(ctx.settings.get(settingsNamespace('ui-theme'))).toBeUndefined()
+    expect(ctx.settings.get('ui-theme')).toBeUndefined()
     expect(ctx.settings.describe()).toEqual([])
     provider.pushExternal({ 'ui-theme': { theme: 'light' } })
     expect(seen).toEqual([])
 
     // The namespace is free again, and re-registration resolves the user layer
     // that kept living in storage while nobody owned the namespace.
-    const again = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const again = ctx.settings.register('ui-theme', ThemeSchema)
     expect(again.get()).toEqual({ theme: 'light', fontSize: 14 })
   })
 })
@@ -211,7 +209,7 @@ describe('registration', () => {
 describe('update', () => {
   it('persists the merged user section without baking in the base layer', async () => {
     const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+    const scope = ctx.settings.register('ui-theme', ThemeSchema, {
       base: { fontSize: 16 },
     })
     await scope.update({ theme: 'dark' })
@@ -225,7 +223,7 @@ describe('update', () => {
     const { ctx, provider } = await boot({
       doc: { workspace: { retry: { attempts: 5, delayMs: 300 }, tags: ['a', 'b'] } },
     })
-    const scope = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
+    const scope = ctx.settings.register('workspace', NestedSchema)
     await scope.update({ retry: { attempts: 7 }, tags: ['c'] })
     expect(provider.persisted[0]!.section).toEqual({
       retry: { attempts: 7, delayMs: 300 },
@@ -237,7 +235,7 @@ describe('update', () => {
   it('commits, notifies watchers, and emits with source update', async () => {
     const { ctx } = await boot()
     const events = recordUpdates(ctx)
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const watcher = vi.fn()
     scope.watch(watcher)
     await scope.update({ theme: 'light' })
@@ -256,7 +254,7 @@ describe('update', () => {
   it('rejects an invalid patch before persisting anything', async () => {
     const { ctx, provider } = await boot()
     const events = recordUpdates(ctx)
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     await expect(scope.update({ fontSize: 'big' })).rejects.toThrow()
     expect(provider.persisted).toEqual([])
     expect(events).toEqual([])
@@ -268,7 +266,7 @@ describe('update', () => {
 
   it('ignores explicit undefined entries so a sparse patch cannot erase keys', async () => {
     const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     await scope.update({ theme: undefined, fontSize: 18 })
     expect(provider.persisted[0]!.section).toEqual({ theme: 'light', fontSize: 18 })
     expect(scope.get()).toEqual({ theme: 'light', fontSize: 18 })
@@ -276,7 +274,7 @@ describe('update', () => {
 
   it('rejects a non-object patch', async () => {
     const { ctx } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     await expect(scope.update([1])).rejects.toThrow(TypeError)
     await expect(scope.update(new Date() as unknown as object)).rejects.toThrow(TypeError)
     await expect(scope.replace([1])).rejects.toThrow(/replace for "ui-theme"/)
@@ -284,7 +282,7 @@ describe('update', () => {
 
   it('accepts a null-prototype patch object', async () => {
     const { ctx } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const patch: { fontSize?: number } = Object.create(null) as { fontSize?: number }
     patch.fontSize = 18
     await scope.update(patch)
@@ -293,13 +291,13 @@ describe('update', () => {
 
   it('rejects an unregistered namespace', async () => {
     const { ctx } = await boot()
-    await expect(ctx.settings.update(settingsNamespace('missing'), {}))
+    await expect(ctx.settings.update('missing', {}))
       .rejects.toThrow(/not registered/)
   })
 
   it('rejects on a read-only provider before reaching persist', async () => {
     const { ctx, provider } = await boot({ writable: false })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     await expect(scope.update({ theme: 'light' })).rejects.toThrow(/read-only/)
     expect(provider.persisted).toEqual([])
   })
@@ -325,14 +323,14 @@ describe('review regressions', () => {
     ctx.on('settings/updated', () => {
       throw Object.assign(new Error('forged relation'), { code: 'INVARIANT' })
     })
-    ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    ctx.settings.register('ui-theme', ThemeSchema)
     expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) })
       .toThrow(/forged relation/)
   })
 
   it('serializes concurrent updates so neither patch is lost', async () => {
     const { ctx, provider } = await boot({ persistDelayMs: 10 })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     await Promise.all([
       scope.update({ theme: 'light' }),
       scope.update({ fontSize: 20 }),
@@ -346,7 +344,7 @@ describe('review regressions', () => {
     ctx.on('settings/updated', () => {
       throw new Error('listener boom')
     })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     expect(() => { provider.pushExternal({ 'ui-theme': { theme: 'light' } }) }).not.toThrow()
     expect(scope.get().theme).toBe('light')
     provider.pushExternal({ 'ui-theme': { theme: 'dark' } })
@@ -355,7 +353,7 @@ describe('review regressions', () => {
 
   it('contains an async watcher rejection', async () => {
     const { ctx, provider } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     scope.watch(async () => {
       throw new Error('async watcher boom')
     })
@@ -369,13 +367,13 @@ describe('review regressions', () => {
   it('loads the provider document through the base init without provider boilerplate', async () => {
     const ctx = new Context()
     await ctx.plugin(BareProvider, { doc: { 'ui-theme': { fontSize: 7 } } })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     expect(scope.get()).toEqual({ theme: 'dark', fontSize: 7 })
   })
 
   it('replaces the user section wholesale so overrides can be removed', async () => {
     const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light', fontSize: 20 } } })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema, {
+    const scope = ctx.settings.register('ui-theme', ThemeSchema, {
       base: { fontSize: 16 },
     })
     await scope.replace({ theme: 'light' })
@@ -396,7 +394,7 @@ describe('second review regressions', () => {
     })
     const second = vi.fn()
     ctx.on('settings/updated', second)
-    ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    ctx.settings.register('ui-theme', ThemeSchema)
     provider.pushExternal({ 'ui-theme': { theme: 'light' } })
     expect(second).toHaveBeenCalledTimes(1)
   })
@@ -407,7 +405,7 @@ describe('second review regressions', () => {
     const fiber = ctx.plugin({
       inject: ['settings'],
       apply: (child: Context) => {
-        scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+        scope = child.settings.register('ui-theme', ThemeSchema)
       },
     })
     await fiber
@@ -423,7 +421,7 @@ describe('second review regressions', () => {
     const fiber = ctx.plugin({
       inject: ['settings'],
       apply: (child: Context) => {
-        scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+        scope = child.settings.register('ui-theme', ThemeSchema)
         scope.watch(watcher)
       },
     })
@@ -443,7 +441,7 @@ describe('second review regressions', () => {
   it('drains in-flight writes at service dispose and rejects later ones', async () => {
     const { ctx, provider, fiber } = await boot({ persistDelayMs: 20 })
     const service = ctx.settings
-    const scope = service.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = service.register('ui-theme', ThemeSchema)
     const pending = scope.update({ theme: 'light' })
     await new Promise(resolve => setTimeout(resolve, 5))
     await fiber.dispose()
@@ -452,7 +450,7 @@ describe('second review regressions', () => {
     const persistedAtDispose = provider.persisted.length
     expect(persistedAtDispose).toBe(1)
     // …and afterwards nothing writes and new writes reject.
-    await expect(service.update(settingsNamespace('ui-theme'), { theme: 'dark' }))
+    await expect(service.update('ui-theme', { theme: 'dark' }))
       .rejects.toThrow(/disposed|not registered/)
     await new Promise(resolve => setTimeout(resolve, 40))
     expect(provider.persisted.length).toBe(persistedAtDispose)
@@ -460,7 +458,7 @@ describe('second review regressions', () => {
 
   it('serializes invocations of one async watcher in commit order', async () => {
     const { ctx, provider } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const applied: number[] = []
     let firstCall = true
     scope.watch(async (next) => {
@@ -481,14 +479,14 @@ describe('second review regressions', () => {
 
   it('rejects a function value as not JSON-compatible', async () => {
     const { ctx } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     await expect(scope.update({ theme: () => 'dark' }))
       .rejects.toThrow(/JSON-compatible.*function at \$\.theme/)
   })
 
   it('rejects a write still queued when the service disposes', async () => {
     const { ctx, fiber } = await boot({ persistDelayMs: 20 })
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const first = scope.update({ theme: 'light' })
     const second = scope.update({ fontSize: 20 })
     await new Promise(resolve => setTimeout(resolve, 5))
@@ -503,7 +501,7 @@ describe('second review regressions', () => {
     const fiber = ctx.plugin({
       inject: ['settings'],
       apply: (child: Context) => {
-        scope = child.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+        scope = child.settings.register('ui-theme', ThemeSchema)
       },
     })
     await fiber
@@ -517,7 +515,7 @@ describe('second review regressions', () => {
 
   it('snapshots the patch at call time so caller mutation cannot leak in', async () => {
     const { ctx } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const patch = { fontSize: 18 }
     const pending = scope.update(patch)
     patch.fontSize = 99
@@ -530,7 +528,7 @@ describe('publish', () => {
   it('notifies watchers of an external change with source provider', async () => {
     const { ctx, provider } = await boot()
     const events = recordUpdates(ctx)
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const watcher = vi.fn()
     scope.watch(watcher)
     provider.pushExternal({ 'ui-theme': { theme: 'light' } })
@@ -546,7 +544,7 @@ describe('publish', () => {
   it('stays silent when the resolved value is deep-equal', async () => {
     const { ctx, provider } = await boot({ doc: { 'ui-theme': { theme: 'light' } } })
     const events = recordUpdates(ctx)
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const watcher = vi.fn()
     scope.watch(watcher)
     provider.pushExternal({ 'ui-theme': { theme: 'light' } })
@@ -557,8 +555,8 @@ describe('publish', () => {
   it('keeps the last good value for an invalid section while other namespaces commit', async () => {
     const { ctx, provider } = await boot()
     const events = recordUpdates(ctx)
-    const theme = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
-    const workspace = ctx.settings.register(settingsNamespace('workspace'), NestedSchema)
+    const theme = ctx.settings.register('ui-theme', ThemeSchema)
+    const workspace = ctx.settings.register('workspace', NestedSchema)
     provider.pushExternal({
       'ui-theme': { fontSize: 'broken' },
       workspace: { retry: { attempts: 9 } },
@@ -570,7 +568,7 @@ describe('publish', () => {
 
   it('recovers from a bad section once storage turns valid again', async () => {
     const { ctx, provider } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     provider.pushExternal({ 'ui-theme': { fontSize: 'broken' } })
     expect(scope.get()).toEqual({ theme: 'dark', fontSize: 14 })
     provider.pushExternal({ 'ui-theme': { fontSize: 18 } })
@@ -581,7 +579,7 @@ describe('publish', () => {
 describe('third review regressions', () => {
   it('skips a queued watch invocation whose disposer ran before it started', async () => {
     const { ctx, provider } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const watcher = vi.fn()
     const dispose = scope.watch(watcher)
     // The commit chains the invocation as a microtask; the disposer runs in
@@ -594,7 +592,7 @@ describe('third review regressions', () => {
 
   it('waits for an in-flight watch invocation at service dispose', async () => {
     const { ctx, provider, fiber } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     let release: (() => void) | undefined
     let finished = false
     scope.watch(async () => {
@@ -614,7 +612,7 @@ describe('third review regressions', () => {
 
   it('rejects a Date at its path before anything persists', async () => {
     const { ctx, provider } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
+    const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
     await expect(scope.update({ value: { at: new Date(0) } }))
       .rejects.toThrow(/JSON-compatible.*Date at \$\.value\.at/)
     expect(provider.persisted).toEqual([])
@@ -629,13 +627,13 @@ describe('third review regressions', () => {
     ['a class instance', { value: Object.create({ marker: true }) as object }, /non-plain object at \$\.value/],
   ])('rejects %s that structuredClone would admit', async (_label, patch, message) => {
     const { ctx } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
+    const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
     await expect(scope.update(patch)).rejects.toThrow(message)
   })
 
   it('rejects a circular patch instead of storing an alias-looped document', async () => {
     const { ctx } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
+    const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
     const cyclic: Record<string, unknown> = {}
     cyclic['self'] = cyclic
     await expect(scope.update({ value: cyclic })).rejects.toThrow(/circular reference at \$\.value\.self/)
@@ -646,7 +644,7 @@ describe('third review regressions', () => {
 
   it('accepts one object referenced twice without a cycle', async () => {
     const { ctx } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), z.object({ value: z.any() }))
+    const scope = ctx.settings.register('ui-theme', z.object({ value: z.any() }))
     const shared = { leaf: 1 }
     await scope.update({ value: { left: shared, right: shared } })
     expect(scope.get()).toEqual({ value: { left: { leaf: 1 }, right: { leaf: 1 } } })
@@ -663,7 +661,7 @@ describe('third review regressions', () => {
     ctx.on('settings/updated', boom)
     const second = vi.fn()
     ctx.on('settings/updated', second)
-    ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    ctx.settings.register('ui-theme', ThemeSchema)
     provider.pushExternal({ 'ui-theme': { theme: 'light' } })
     expect(second).toHaveBeenCalledTimes(1)
     // Containment gives the rejection a handler; vitest observes no unhandled
@@ -675,7 +673,7 @@ describe('third review regressions', () => {
 describe('watch', () => {
   it('stops after its disposer runs', async () => {
     const { ctx, provider } = await boot()
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     const watcher = vi.fn()
     const dispose = scope.watch(watcher)
     dispose()
@@ -686,7 +684,7 @@ describe('watch', () => {
   it('contains a throwing watcher without blocking the commit or other watchers', async () => {
     const { ctx, provider } = await boot()
     const events = recordUpdates(ctx)
-    const scope = ctx.settings.register(settingsNamespace('ui-theme'), ThemeSchema)
+    const scope = ctx.settings.register('ui-theme', ThemeSchema)
     scope.watch(() => { throw new Error('watcher boom') })
     const second = vi.fn()
     scope.watch(second)
@@ -699,7 +697,7 @@ describe('watch', () => {
   })
 })
 
-describe('installSettingsSection', () => {
+describe('SettingsProvider.installSection', () => {
   const HelperSchema: z<{ theme: string }> = z.object({
     theme: z.string().default('default'),
   })
@@ -709,13 +707,15 @@ describe('installSettingsSection', () => {
     const entry = { theme: 'entry' }
     let current: () => { theme: string } = () => entry
     let changes = 0
-    installSettingsSection(ctx, settingsNamespace('helper-ns'), HelperSchema, entry, {
-      setSource: (source) => {
-        current = source
-      },
-      onChange: () => {
-        changes += 1
-      },
+    ctx.inject(['settings'], (settingsCtx) => {
+      settingsCtx.settings.installSection(ctx, 'helper-ns', HelperSchema, entry, {
+        setSource: (source) => {
+          current = source
+        },
+        onChange: () => {
+          changes += 1
+        },
+      })
     })
     // No settings service mounted: nothing ran, the entry stays authoritative.
     expect(current()).toEqual({ theme: 'entry' })
@@ -728,7 +728,7 @@ describe('installSettingsSection', () => {
     })
     expect(changes).toBe(1)
 
-    await ctx.settings.update(settingsNamespace('helper-ns'), { theme: 'live' })
+    await ctx.settings.update('helper-ns', { theme: 'live' })
     await vi.waitFor(() => {
       expect(changes).toBe(2)
     })
@@ -749,7 +749,7 @@ describe('installSettingsSection', () => {
     const consumer = ctx.plugin({
       inject: ['settings'],
       apply: (child: Context) => {
-        installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
+        child.settings.installSection(child, 'helper-ns', HelperSchema, entry, {
           setSource: (source) => {
             current = source
           },
@@ -782,7 +782,7 @@ describe('installSettingsSection', () => {
     const consumer = ctx.plugin({
       inject: ['settings'],
       apply: (child: Context) => {
-        installSettingsSection(child, settingsNamespace('helper-ns'), HelperSchema, entry, {
+        child.settings.installSection(child, 'helper-ns', HelperSchema, entry, {
           setSource: (source) => {
             current = source
           },
@@ -817,8 +817,8 @@ describe('mutate (path-addressed writes)', () => {
     reasoning: z.string(),
   })
 
-  const KEYED = settingsNamespace('keyed')
-  const NESTED = settingsNamespace('workspace')
+  const KEYED = 'keyed'
+  const NESTED = 'workspace'
 
   async function mounted(doc: Record<string, unknown>) {
     const ctx = new Context()
@@ -922,7 +922,7 @@ describe('mutate (path-addressed writes)', () => {
 })
 
 describe('revision and conflict detection', () => {
-  const REV = settingsNamespace('rev')
+  const REV = 'rev'
   const RevSchema: z<{ a: string; b: string }> = z.object({
     a: z.string().default('base-a'),
     b: z.string(),

+ 1 - 1
packages/settings/settings/tsconfig.json

@@ -21,7 +21,7 @@
       "path": "../../util/brand"
     },
     {
-      "path": "../../core/session"
+      "path": "../../util/values"
     },
     {
       "path": "../../runtime-diagnostics/invariants"