Pārlūkot izejas kodu

refactor(client): extract Store and renderer Slot infrastructure

imccyu 1 mēnesi atpakaļ
vecāks
revīzija
1b535f611c
36 mainītis faili ar 1462 papildinājumiem un 856 dzēšanām
  1. 46 0
      packages/client/store/package.json
  2. 144 0
      packages/client/store/src/contract.ts
  3. 35 25
      packages/client/store/src/index.ts
  4. 30 0
      packages/client/store/src/invariant.ts
  5. 13 0
      packages/client/store/tests/invariant.client.spec.ts
  6. 70 2
      packages/client/store/tests/store.client.spec.ts
  7. 18 0
      packages/client/store/tsconfig.json
  8. 6 0
      packages/client/store/tsdown.config.ts
  9. 0 5
      packages/client/ui-renderer/package.json
  10. 2 20
      packages/client/ui-renderer/src/client/app.tsx
  11. 143 0
      packages/client/ui-renderer/src/client/bindings.tsx
  12. 19 8
      packages/client/ui-renderer/src/client/index.ts
  13. 169 42
      packages/client/ui-renderer/src/client/registry.ts
  14. 165 127
      packages/client/ui-renderer/src/client/scoped-slots.tsx
  15. 0 160
      packages/client/ui-renderer/src/client/session-provider.tsx
  16. 20 3
      packages/client/ui-renderer/src/invariant.ts
  17. 4 31
      packages/client/ui-renderer/tests/app.client.spec.tsx
  18. 6 6
      packages/client/ui-renderer/tests/invariant.client.spec.ts
  19. 107 49
      packages/client/ui-renderer/tests/registry.client.spec.ts
  20. 18 8
      packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx
  21. 135 52
      packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx
  22. 91 42
      packages/client/ui-renderer/tests/session-provider.client.spec.tsx
  23. 20 9
      packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx
  24. 21 8
      packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx
  25. 55 41
      packages/client/ui-renderer/tests/use-projection.client.spec.tsx
  26. 0 3
      packages/client/ui-renderer/tsconfig.json
  27. 1 0
      packages/client/ui-slots/package.json
  28. 16 20
      packages/client/ui-slots/src/index.ts
  29. 1 1
      packages/client/ui-slots/src/invariant.ts
  30. 74 55
      packages/client/ui-slots/src/renderer.ts
  31. 17 132
      packages/client/ui-slots/src/store.ts
  32. 1 1
      packages/client/ui-slots/tests/core.client.spec.ts
  33. 4 4
      packages/client/ui-slots/tests/type-chain.client.spec.tsx
  34. 3 0
      packages/client/ui-slots/tsconfig.json
  35. 7 1
      packages/test-support/client-runtime/src/index.ts
  36. 1 1
      packages/test-support/client-runtime/tests/runtime.client.spec.tsx

+ 46 - 0
packages/client/store/package.json

@@ -0,0 +1,46 @@
+{
+  "name": "@deepseek-ai/dsh-client-store",
+  "description": "React-free observable and snapshot-store contracts with the shared Zustand/Immer engine",
+  "version": "0.1.1-rc.2",
+  "publishConfig": {
+    "access": "public"
+  },
+  "repository": {
+    "type": "git",
+    "url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
+    "directory": "packages/client/store"
+  },
+  "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"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "license": "MIT",
+  "dependencies": {
+    "immer": "^10.1.1",
+    "zustand": "~4.4.7"
+  },
+  "peerDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^"
+  },
+  "devDependencies": {
+    "@deepseek-ai/cordis": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/types/**/*.d.ts"
+  ]
+}

+ 144 - 0
packages/client/store/src/contract.ts

@@ -0,0 +1,144 @@
+/** Framework-neutral snapshot and store contracts. */
+
+/** Minimal observable snapshot source shared by controllers, stores, and render adapters. */
+export interface ObservableSnapshot<T> {
+  /** Read the cached snapshot reference. */
+  getSnapshot(): T
+  /**
+   * Subscribe to snapshot invalidation.
+   * @param fn - invalidation callback.
+   * @returns unsubscribe function.
+   */
+  subscribe(fn: () => void): () => void
+}
+
+/**
+ * Typed selector hook over a snapshot source. Canonical shape for the whole
+ * slot system (ui-renderer's engine hook is structurally identical; the
+ * framework is the only party that ever constructs one).
+ */
+export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
+
+/**
+ * Selector hook over a source that follows the current session. The hook is
+ * always present, while its selected value is absent whenever no session is
+ * current. This keeps hook call sites stable across no-session/session
+ * transitions without pretending that a session snapshot exists.
+ */
+export type MaybeSnapshotSelectorHook<T> =
+  <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S | undefined
+
+/**
+ * Action declaration table: pure immer-draft transforms over the store state,
+ * declared as the store's complete write set (the audit face — components can
+ * only write through these).
+ */
+/* oxlint-disable-next-line typescript/no-explicit-any --
+ * any[] (not unknown[]): each action carries its own parameter list, and
+ * unknown[] would reject every concrete signature under strict parameter
+ * contravariance. Params are re-inferred per action by BakedActions. */
+export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
+
+/**
+ * Draft-stripped callback form of an actions table: what components
+ * (`props.actions`) and inject factories receive — the framework bakes the
+ * draft parameter away by binding each action to the resolved instance.
+ */
+export type BakedActions<T, A extends ActionsDecl<T>> = {
+  [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
+}
+
+/**
+ * Store declaration spec: initial-state factory (a lambda so every instance
+ * gets a fresh state), optional persistence key (mechanical, framework-run),
+ * and the actions write set.
+ */
+export interface StoreSpec<T, A extends ActionsDecl<T>> {
+  init: () => T
+  persist?: string
+  actions: A
+}
+
+/**
+ * Live engine instance: the create() product consumed by the render machinery
+ * and by tests. A bare snapshot source plus the baked write set — no React
+ * hook rides the engine product (the engine lives in this React-free package);
+ * the render machinery binds the `useStore` hook from this source on its own
+ * side, cached per instance. Production components and render paths never
+ * call create() themselves — instance lifecycle is the framework's.
+ */
+export interface StoreInstance<T, A extends ActionsDecl<T>> {
+  readonly actions: BakedActions<T, A>
+  getSnapshot(): T
+  /**
+   * Subscribe to state changes (uSES subscribe side).
+   * @param fn - change callback.
+   * @returns unsubscribe.
+   */
+  subscribe(fn: () => void): () => void
+  /**
+   * Drop this instance's persisted value (no-op for non-persist specs). The
+   * framework calls it when the owning scope dies for good — a pruned session
+   * must not leave orphaned storage keys behind.
+   */
+  clearPersisted(): void
+}
+
+/**
+ * Store handle: spec + state/actions types + shared identity + instance
+ * factory in one value. Handles are constructed in apply world (shared across
+ * registrations of one plugin) or by the framework from a registrant's
+ * factory (exclusive). Never export a handle at module level — module-cache
+ * identity is a disguised singleton across plugin reloads.
+ */
+export interface StoreHandle<T, A extends ActionsDecl<T>> {
+  readonly spec: StoreSpec<T, A>
+  /**
+   * Create a live engine instance (framework machinery and tests only).
+   * @param scopeKey - session id for session-scope instances; suffixes the
+   * persist key so per-session instances persist independently (root-scope
+   * instances omit it).
+   * @returns a fresh instance seeded from `spec.init()`.
+   */
+  create(scopeKey?: string): StoreInstance<T, A>
+}
+
+/**
+ * Exclusive-store registration form: the registrant passes the factory itself
+ * and the framework calls it per entry x scope (no shared identity exists).
+ */
+/* oxlint-disable-next-line typescript/no-explicit-any --
+ * erased position accepting every StoreHandle instantiation; T/A are
+ * recovered per use site by conditional inference (HandleOf/BoundActions/
+ * PropsStore). */
+export type StoreFactory = () => StoreHandle<any, any>
+
+/** The register `store` option position: a shared handle or an exclusive factory. */
+// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
+export type StoreDecl = StoreHandle<any, any> | StoreFactory
+
+/** Normalize a store declaration to its handle type (factories yield their return). */
+export type HandleOf<H> = H extends () => infer R ? R : H
+
+/**
+ * Handle-keyed baked actions: the `actions` parameter of an inject factory
+ * whose registration declared a store — the same baked callback set the
+ * component receives via {@link PropsStore}.
+ */
+export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
+
+/**
+ * The store props share, derived from the declared handle: a typed selector
+ * hook plus the baked write set. Components never see the instance itself
+ * (no update/set — reads via useStore, writes via the declared actions only).
+ */
+export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
+  ? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
+  : object
+
+/**
+ * The defineStore contract (implementation lives beside this declaration,
+ * bound to the snapshot-store engine): spec in, handle out, with T inferred
+ * from `init` and the actions table constrained by T.
+ */
+export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>

+ 35 - 25
packages/client/runtime/src/client/contract/store.ts → packages/client/store/src/index.ts

@@ -1,30 +1,27 @@
 /**
- * Snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
+ * React-free snapshot store engine (zustand vanilla + immer + subscribeWithSelector +
  * rafFlush middleware + opt-in persist + dev freeze) plus the declarative
  * shell over it: {@link defineStore} bakes an init/persist/actions literal
  * into a {@link StoreHandle}, the registration-side store seat of slot
- * terminals. Lives in the React-free runtime (the data layer owns its
- * engine; ui-renderer is shell-only React
- * glue): engine products are bare observables — subscribe/getSnapshot/
+ * terminals. Engine products are bare observables — subscribe/getSnapshot/
  * update/set, NO selector hook. Hook synthesis is ui-renderer's (the one
  * uSES bridge, cached per source at the binding site).
  */
 import { createStore, type StoreApi } from 'zustand/vanilla'
 import { subscribeWithSelector } from 'zustand/middleware'
 import { shallow } from 'zustand/shallow'
-import { produce } from 'immer'
+import { freeze, produce } from 'immer'
 import type {
-  ActionsDecl, BakedActions, StoreHandle, StoreInstance, StoreSpec,
-} from '@deepseek-ai/dsh-client-ui-slots'
+  ActionsDecl, BakedActions, ObservableSnapshot, StoreHandle, StoreInstance, StoreSpec,
+} from './contract.ts'
 
 // Store contract types are ui-slots authority; re-exported beside the engine
 // so store consumers get one import path.
 export type {
-  ActionsDecl, BakedActions, BoundActions, StoreFactory, StoreHandle, StoreInstance, StoreSpec,
-} from '@deepseek-ai/dsh-client-ui-slots'
-
-/** Minimal observable snapshot source: Session objects and snapshot stores both satisfy it. */
-export interface ObservableSnapshot<T> { getSnapshot(): T; subscribe(fn: () => void): () => void }
+  ActionsDecl, BakedActions, BoundActions, DefineStore, HandleOf, MaybeSnapshotSelectorHook,
+  ObservableSnapshot, PropsStore, SnapshotSelectorHook, StoreDecl, StoreFactory,
+  StoreHandle, StoreInstance, StoreSpec,
+} from './contract.ts'
 
 /** Writable snapshot store (bare data face; React selector hooks are synthesized in ui-renderer). */
 export interface SnapshotStore<T> extends ObservableSnapshot<T> {
@@ -40,6 +37,26 @@ export interface SnapshotStore<T> extends ObservableSnapshot<T> {
   set(next: T): void
 }
 
+/**
+ * Notify an observer set without allowing one callback to starve the rest.
+ * @param listeners - current observer callbacks; copied before dispatch.
+ * @param label - diagnostic owner prefix.
+ * @param args - callback arguments.
+ */
+export function notifySubscribers<Args extends readonly unknown[]>(
+  listeners: Iterable<(...args: Args) => void>,
+  label: string,
+  ...args: Args
+): void {
+  for (const listener of [...listeners]) {
+    try {
+      listener(...args)
+    } catch (error) {
+      console.error(`${label} subscriber failed:`, error)
+    }
+  }
+}
+
 /**
  * Shallow equality for selector slices (zustand/shallow semantics; travels
  * with the engine so hook consumers need no zustand dependency).
@@ -91,10 +108,12 @@ export function createSnapshotStore<T>(
   const api: StoreApi<T> = createStore<T>()(withSelector)
   if (opts?.persist) attachPersistence(api, opts.persist.name)
 
-  let subscribe = (fn: () => void) => api.subscribe(fn)
+  let subscribe = (fn: () => void) => api.subscribe(() => {
+    notifySubscribers([fn], '[client-store]')
+  })
   if (opts?.flush === 'raf') {
     const listeners = new Set<() => void>()
-    const flush = rafBatch(() => { for (const fn of [...listeners]) fn() })
+    const flush = rafBatch(() => { notifySubscribers(listeners, '[client-store]') })
     api.subscribe(flush)
     subscribe = (fn: () => void) => {
       listeners.add(fn)
@@ -146,19 +165,10 @@ function attachPersistence<T>(api: StoreApi<T>, name: string): void {
   })
 }
 
-/** Deep-freeze wholesale-set state outside production: set() bypasses immer's freeze. */
+/** Deep-freeze draftable wholesale-set state outside production: set() bypasses immer's freeze. */
 function devFreeze<T>(value: T): T {
   if (process.env.NODE_ENV === 'production') return value
-  deepFreeze(value)
-  return value
-}
-
-function deepFreeze(value: unknown): void {
-  if (typeof value !== 'object' || value === null || Object.isFrozen(value)) return
-  Object.freeze(value)
-  for (const key of Reflect.ownKeys(value)) {
-    deepFreeze((value as Record<PropertyKey, unknown>)[key])
-  }
+  return freeze(value, true)
 }
 
 // ui-slots owns the contract; this module supplies the engine implementation.

+ 30 - 0
packages/client/store/src/invariant.ts

@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-store`.
+ * @module @deepseek-ai/dsh-client-store/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-client-store'
+
+/** Cordis companion plugin name. */
+export const name = 'client-store-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the package exports a library engine and creates no
+ * process-global state; each store instance is covered by its owning tests.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 13 - 0
packages/client/store/tests/invariant.client.spec.ts

@@ -0,0 +1,13 @@
+import { Context } from '@deepseek-ai/cordis'
+import InvariantRegistry from '@deepseek-ai/dsh-invariants'
+import { describe, expect, it } from 'vitest'
+import * as StoreInvariant from '../src/invariant.ts'
+
+describe('store invariant companion', () => {
+  it('registers the package-owned empty installer', async () => {
+    const ctx = new Context()
+    await ctx.plugin(InvariantRegistry, { enabled: true })
+
+    await expect(ctx.plugin(StoreInvariant).await()).resolves.toBeDefined()
+  })
+})

+ 70 - 2
packages/client/runtime/tests/store.client.spec.ts → packages/client/store/tests/store.client.spec.ts

@@ -1,5 +1,5 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { createSnapshotStore, defineStore, shallowEqual } from '../src/client/contract/store.ts'
+import { createSnapshotStore, defineStore, shallowEqual } from '../src/index.ts'
 
 interface State {
   a: { n: number }
@@ -10,6 +10,8 @@ const init = (): State => ({ a: { n: 1 }, b: { list: ['x'] } })
 
 afterEach(() => {
   vi.unstubAllGlobals()
+  vi.unstubAllEnvs()
+  vi.restoreAllMocks()
 })
 
 describe('createSnapshotStore', () => {
@@ -90,12 +92,37 @@ describe('createSnapshotStore', () => {
     expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
   })
 
+  it('freezes the owned envelope without traversing opaque object handles', () => {
+    class OpaqueHandle {
+      readonly value = { n: 1 }
+    }
+    const handle = new OpaqueHandle()
+    const store = createSnapshotStore({ handle, values: new Set(['x']) })
+
+    store.set({ handle, values: new Set(['y']) })
+
+    const snapshot = store.getSnapshot()
+    expect(Object.isFrozen(snapshot)).toBe(true)
+    expect(Object.isFrozen(snapshot.handle)).toBe(false)
+    expect(() => { snapshot.values.add('z') }).toThrow()
+  })
+
   it('freezes update produce output outside production (immer dev freeze)', () => {
     const store = createSnapshotStore(init())
     store.update((d) => { d.a.n = 2 })
     expect(() => { (store.getSnapshot().a).n = 9 }).toThrow()
   })
 
+  it('does not deep-freeze wholesale state in production', () => {
+    vi.stubEnv('NODE_ENV', 'production')
+    const store = createSnapshotStore(init())
+    const next = init()
+    store.set(next)
+
+    next.a.n = 9
+    expect(store.getSnapshot().a.n).toBe(9)
+  })
+
   it('rehydrates primitive state whole, not spread into index keys', () => {
     const backing = new Map<string, string>()
     vi.stubGlobal('localStorage', {
@@ -122,6 +149,42 @@ describe('createSnapshotStore', () => {
     const revived = createSnapshotStore(init(), { persist: { name: 'spec-store' } })
     expect(revived.getSnapshot().a.n).toBe(42)
   })
+
+  it('reports rehydration failures without preventing store creation', () => {
+    const failure = new Error('storage read failed')
+    vi.stubGlobal('localStorage', {
+      getItem: () => { throw failure },
+      setItem: () => {},
+      removeItem: () => {},
+    })
+    const report = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+    const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-read' } })
+
+    expect(store.getSnapshot()).toEqual(init())
+    expect(report).toHaveBeenCalledWith(
+      "snapshot store 'spec-broken-read' rehydration failed:",
+      failure,
+    )
+  })
+
+  it('reports persistence failures without rejecting the write', () => {
+    const failure = new Error('storage write failed')
+    vi.stubGlobal('localStorage', {
+      getItem: () => null,
+      setItem: () => { throw failure },
+      removeItem: () => {},
+    })
+    const report = vi.spyOn(console, 'error').mockImplementation(() => {})
+    const store = createSnapshotStore(init(), { persist: { name: 'spec-broken-write' } })
+
+    expect(() => { store.update((draft) => { draft.a.n = 7 }) }).not.toThrow()
+    expect(store.getSnapshot().a.n).toBe(7)
+    expect(report).toHaveBeenCalledWith(
+      "snapshot store 'spec-broken-write' persistence failed:",
+      failure,
+    )
+  })
 })
 
 describe('defineStore', () => {
@@ -136,12 +199,17 @@ describe('defineStore', () => {
 
   it('create() yields a live instance: fresh init state, selector-visible action writes', () => {
     const inst = declare().create()
-    expect(inst.store.getSnapshot()).toEqual({ selection: null, draft: '' })
+    expect(inst.getSnapshot()).toEqual({ selection: null, draft: '' })
+    const listener = vi.fn()
+    const unsubscribe = inst.subscribe(listener)
     inst.actions.setDraft('hello')
     inst.actions.select('m1')
     expect(inst.store.getSnapshot()).toEqual({ selection: 'm1', draft: 'hello' })
+    expect(listener).toHaveBeenCalledTimes(2)
+    unsubscribe()
     inst.actions.clearDraft()
     expect(inst.store.getSnapshot().draft).toBe('')
+    expect(listener).toHaveBeenCalledTimes(2)
   })
 
   it('bakes draft-stripped actions that write through update (draft mutation, not replacement)', () => {

+ 18 - 0
packages/client/store/tsconfig.json

@@ -0,0 +1,18 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../../runtime-diagnostics/invariants"
+    }
+  ]
+}

+ 6 - 0
packages/client/store/tsdown.config.ts

@@ -0,0 +1,6 @@
+import { staticLinked } from '../tsdown.client.ts'
+
+export default staticLinked(
+  '@deepseek-ai/dsh-client-store',
+  ['lib/types/index.js', 'lib/types/invariant.js'],
+)

+ 0 - 5
packages/client/ui-renderer/package.json

@@ -31,9 +31,6 @@
   },
   "dsh": {
     "client": {
-      "inject": [
-        "@deepseek-ai/dsh-client-runtime"
-      ],
       "platform": "web",
       "immediately": true
     }
@@ -47,13 +44,11 @@
     "use-sync-external-store": "1.2.0"
   },
   "peerDependencies": {
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@deepseek-ai/cordis": "workspace:^"
   },
   "devDependencies": {
     "@deepseek-ai/cordis": "workspace:^",
-    "@deepseek-ai/dsh-client-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-test-runtime": "workspace:^",
     "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",

+ 2 - 20
packages/client/ui-renderer/src/client/app.tsx

@@ -4,13 +4,10 @@
  */
 import type { ReactNode } from 'react'
 import type { Context } from '@deepseek-ai/cordis'
-import { bindSnapshotSelector } from './bind.ts'
-import { DocumentTitle } from './DocumentTitle.tsx'
-import type {} from '@deepseek-ai/dsh-client-runtime/client'
 
 /** Inputs available after the UI renderer's inject set activates. */
 export interface AssemblyDeps {
-  /** Client context carrying the slots and sessions services. */
+  /** Client context carrying the renderer-owned Slot registry. */
   ctx: Context
 }
 
@@ -21,20 +18,5 @@ export interface AssemblyDeps {
  */
 export function buildRenderApp(deps: AssemblyDeps): () => ReactNode {
   const { ctx } = deps
-  const sessions = ctx.get('sessions')
-  if (sessions === undefined) throw new Error('ui renderer: sessions service unavailable')
-  const useSessions = bindSnapshotSelector(sessions.list)
-  const SessionDocumentTitle = (): ReactNode => {
-    const title = useSessions((state) => {
-      const id = state.current
-      return id === undefined ? undefined : state.byId[id]?.title
-    })
-    return <DocumentTitle {...title === undefined ? {} : { title }} />
-  }
-  return () => (
-    <>
-      <SessionDocumentTitle />
-      {ctx.slots.renderSlot('root', {})}
-    </>
-  )
+  return () => ctx.slots.renderSlot('root', {})
 }

+ 143 - 0
packages/client/ui-renderer/src/client/bindings.tsx

@@ -0,0 +1,143 @@
+/** Internal React bindings for renderer hosts and standard-source scopes. */
+import { createContext, useContext, type ReactNode } from 'react'
+import type {
+  HostObservable,
+  KeyedStandardSource,
+  MaybeSnapshotSelectorHook,
+  SlotRendererHost,
+  SnapshotSelectorHook,
+  StandardSourceBinding,
+} from '@deepseek-ai/dsh-client-ui-slots'
+import { bindSnapshotSelector } from './bind.ts'
+
+/** Missing renderer assembly dependency. */
+export class SlotAssemblyError extends Error {}
+
+/** In-package renderer host context. */
+export const HostContext = createContext<SlotRendererHost | null>(null)
+
+/**
+ * Read the installed renderer host.
+ * @returns the host API.
+ */
+export function useHost(): SlotRendererHost {
+  const host = useContext(HostContext)
+  if (host === null) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
+  return host
+}
+
+const RootBindingContext = createContext<StandardSourceBinding | null>(null)
+const ScopeBindingContext = createContext<StandardSourceBinding | null>(null)
+
+/**
+ * Read the root standard-source binding.
+ * @returns the current root binding.
+ */
+export function useRootBinding(): StandardSourceBinding {
+  const binding = useContext(RootBindingContext)
+  if (binding === null) throw new SlotAssemblyError('slot rendered outside the root standard-source provider')
+  return binding
+}
+
+/**
+ * Read the current-session-optional binding.
+ * @returns a binding whose key is absent when no Session is selected.
+ */
+export function useScopeBinding(): StandardSourceBinding {
+  const binding = useContext(ScopeBindingContext)
+  if (binding === null) throw new SlotAssemblyError('scoped slot rendered outside its scope provider')
+  return binding
+}
+
+/**
+ * Bind one observable source to an identity-stable selector Hook.
+ * @param source - observable source.
+ * @returns cached selector Hook.
+ */
+export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
+  let hook = hookCache.get(source)
+  if (hook === undefined) {
+    hook = bindSnapshotSelector(source)
+    hookCache.set(source, hook)
+  }
+  return hook as SnapshotSelectorHook<T>
+}
+
+const hookCache = new WeakMap<object, unknown>()
+const absentSource: HostObservable<undefined> = {
+  getSnapshot: () => undefined,
+  subscribe: () => () => {},
+}
+
+/**
+ * Bind an optional source without changing Hook call order.
+ * @param source - current source, or absence.
+ * @returns selector Hook returning `undefined` while absent.
+ */
+export function maybeObservableHook<T>(
+  source: HostObservable<T> | undefined,
+): MaybeSnapshotSelectorHook<T> {
+  if (source !== undefined) return observableHook(source)
+  return useAbsentSnapshot
+}
+
+function useAbsentSnapshot<S>(
+  _selector: (snapshot: never) => S,
+  _equal?: (left: S, right: S) => boolean,
+): S | undefined {
+  observableHook(absentSource)(() => undefined)
+  return undefined
+}
+
+/** Erased open-key selector Hook synthesized from one keyed source family. */
+export type KeyedSnapshotHook = (
+  key: string,
+  selector?: (value: unknown) => unknown,
+  equal?: (left: unknown, right: unknown) => boolean,
+) => unknown
+
+/**
+ * Bind an open-key source family.
+ * @param source - keyed resolver, or absence for an optional scope.
+ * @returns cached keyed selector Hook.
+ */
+export function keyedObservableHook(source: KeyedStandardSource | undefined): KeyedSnapshotHook {
+  if (source === undefined) return absentKeyedHook
+  let hook = keyedHookCache.get(source)
+  if (hook === undefined) {
+    hook = (key, selector, equal) => {
+      const useValue = observableHook(source(key) ?? absentSource)
+      return useValue(selector ?? identity, equal)
+    }
+    keyedHookCache.set(source, hook)
+  }
+  return hook
+}
+
+const keyedHookCache = new WeakMap<KeyedStandardSource, KeyedSnapshotHook>()
+const identity = (value: unknown): unknown => value
+const absentKeyedHook: KeyedSnapshotHook = (_key, selector, equal) =>
+  observableHook(absentSource)(selector ?? identity, equal)
+
+/** Subscribe the tree to the atomically assembled root standard-source roster. */
+export function RootStandardProvider({ children }: { children: ReactNode }) {
+  const host = useHost()
+  const binding = observableHook(host.root)(value => value)
+  return <RootBindingContext.Provider value={binding}>{children}</RootBindingContext.Provider>
+}
+
+/** Subscribe to the scope roster before resolving and binding its current adapter. */
+export function ScopeProvider({
+  scope,
+  children,
+}: {
+  scope: 'session' | 'session-maybe'
+  children: ReactNode
+}) {
+  const host = useHost()
+  observableHook(host.scopeRevision)(value => value)
+  const adapter = host.scope(scope)
+  if (adapter === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without an installed adapter`)
+  const binding = observableHook(adapter.current)(value => value)
+  return <ScopeBindingContext.Provider value={binding}>{children}</ScopeBindingContext.Provider>
+}

+ 19 - 8
packages/client/ui-renderer/src/client/index.ts

@@ -7,18 +7,18 @@ import { createElement, useLayoutEffect, useState, type ReactNode } from 'react'
 import { flushSync } from 'react-dom'
 import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
 import type { Context } from '@deepseek-ai/cordis'
-import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSlotRenderer } from './scoped-slots.tsx'
 import { buildRenderApp } from './app.tsx'
+import { SlotRegistry } from './registry.ts'
 
-/** Selector hook over a session's conversation snapshot. */
-export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
+export { SlotRegistry } from './registry.ts'
+export type { RootOwnerProps } from './registry.ts'
 
 export type {
-  ChainRenderOpts, HostObservable, RenderOpts, SessionProvideInfo, SnapshotSelectorHook,
-  SlotRenderer, SlotRendererHost, StoreInstanceLike,
+  ChainRenderOpts, HostObservable, RenderOpts, SnapshotSelectorHook, SlotRenderer,
+  ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter,
+  StandardSourceBinding, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-slots'
-export type { SessionProviderProps } from './session-provider.tsx'
 
 /** Mount operation exposed to the framework-free boot kernel. */
 export interface UiRendererService {
@@ -31,14 +31,24 @@ export interface UiRendererService {
 }
 
 declare module '@deepseek-ai/cordis' {
+  interface Events {
+    /**
+     * A slot declaration or registration set changed.
+     * @mode emit
+     * @param key - mutated SlotMap key.
+     */
+    'slots/changed'(key: string): void
+  }
   interface Context {
+    /** Renderer-owned UI composition registry. */
+    slots: SlotRegistry
     /** Mount face provided after the UI renderer activates. */
     uiRenderer: UiRendererService
   }
 }
 
 /** Services required before application assembly. */
-export const inject = ['slots', 'sessions']
+export const inject: string[] = []
 
 interface BootSnapshot {
   className: string
@@ -76,7 +86,8 @@ function mountApp(container: HTMLElement, app: () => ReactNode): Root {
  * @param ctx - Plugin context.
  */
 export function apply(ctx: Context): void {
-  ctx.slots.install(createSlotRenderer())
+  const slots = new SlotRegistry(ctx)
+  slots.install(createSlotRenderer())
   ctx.reflect.provide('uiRenderer', {
     mount: (container: HTMLElement): (() => void) => {
       const root = mountApp(container, buildRenderApp({ ctx }))

+ 169 - 42
packages/client/runtime/src/client/slots.ts → packages/client/ui-renderer/src/client/registry.ts

@@ -1,8 +1,8 @@
 /**
- * SlotRegistry: the cordis Service layer of the slot system over the pure
+ * SlotRegistry: the renderer-owned Cordis service over the pure
  * SlotCore (ui-slots owns registration semantics, the declaration ledger,
  * the load-time validations, and the unload cascade). This layer owns what
- * needs the runtime: the 'slots/changed' event bridge, register and
+ * needs a live application: the 'slots/changed' event bridge, register and
  * declaration injection through the caller's ctx.effect (fiber unload
  * collects both), the renderer installation contract (install()/renderSlot('root') +
  * the SlotRendererHost face), and the store INSTANCE axis — handle x scope
@@ -16,10 +16,12 @@
  * redundancy. */
 import { Service } from '@deepseek-ai/cordis'
 import type { Context } from '@deepseek-ai/cordis'
-import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotCore, standardHookPropName } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
-  LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
-  SlotScope, SlotSpec, StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
+  HostObservable, LiveSlotNode, LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost,
+  RootStandardSourceContribution, ScopedStandardSourceBinding, SlotScope, SlotScopeAdapter, SlotSpec,
+  StandardSourceBinding,
+  StoreDecl, StoreFactory, StoredEntry, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-slots'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -62,6 +64,8 @@ interface StoreAxisRecord {
   refs: number
   /** Root scope: the single instance under {@link ROOT_INSTANCE_KEY}; session scope: one per session id. */
   instances: Map<string, EngineStoreInstance>
+  /** Scope-lifetime registrations for Session instances. */
+  lifetimes: Map<string, () => void>
 }
 
 /** Type-erased options view the implementation works with (the typed overloads proved the shares). */
@@ -97,6 +101,31 @@ export class SlotRegistry extends Service {
   private _renderer: SlotRenderer | undefined
   private _locale: LocaleFace | undefined
   private _host: SlotRendererHost | undefined
+  private readonly _rootContributions: RootStandardSourceContribution[] = []
+  private readonly _rootListeners = new Set<() => void>()
+  private _rootBinding: StandardSourceBinding = {
+    key: undefined,
+    hooks: {},
+    keyedHooks: {},
+    props: {},
+  }
+  private readonly _rootSource = {
+    getSnapshot: (): StandardSourceBinding => this._rootBinding,
+    subscribe: (listener: () => void): (() => void) => {
+      this._rootListeners.add(listener)
+      return () => { this._rootListeners.delete(listener) }
+    },
+  }
+  private readonly _scopes = new Map<Exclude<SlotScope, 'root' | 'session-maybe'>, SlotScopeAdapter>()
+  private _scopeRevision = 0
+  private readonly _scopeListeners = new Set<() => void>()
+  private readonly _scopeRevisionSource: HostObservable<number> = {
+    getSnapshot: () => this._scopeRevision,
+    subscribe: (listener) => {
+      this._scopeListeners.add(listener)
+      return () => { this._scopeListeners.delete(listener) }
+    },
+  }
 
   /**
    * @param ctx - owning root context.
@@ -237,6 +266,54 @@ export class SlotRegistry extends Service {
     }, 'slots.installLocale()')
   }
 
+  /**
+   * Contribute domain-owned root data. Hook names must be globally unique;
+   * registration and disposal republish one atomic root binding.
+   * @param contribution - bare sources and stable props.
+   * @returns disposer owned by the caller's Cordis fiber.
+   */
+  provideRoot(contribution: RootStandardSourceContribution): () => void {
+    const dispose = this.ctx.effect(() => {
+      this._rootContributions.push(contribution)
+      try {
+        this.rebuildRootBinding()
+      } catch (error) {
+        this._rootContributions.pop()
+        throw error
+      }
+      return () => {
+        const index = this._rootContributions.indexOf(contribution)
+        if (index === -1) return
+        this._rootContributions.splice(index, 1)
+        this.rebuildRootBinding()
+      }
+    }, 'slots.provideRoot()')
+    return () => { void dispose() }
+  }
+
+  /**
+   * Install the owner adapter for one strict scope. Its optional counterpart
+   * resolves through the same adapter.
+   * @param scope - strict scope name.
+   * @param adapter - current/resolved binding source and release notifications.
+   */
+  installScope(
+    scope: Exclude<SlotScope, 'root' | 'session-maybe'>,
+    adapter: SlotScopeAdapter,
+  ): void {
+    if (this._scopes.has(scope)) throw new Error(`slot scope '${scope}' already has an adapter`)
+    this.ctx.effect(() => {
+      this._scopes.set(scope, adapter)
+      this.publishScopeRevision()
+      return () => {
+        if (this._scopes.get(scope) === adapter) {
+          this._scopes.delete(scope)
+          this.publishScopeRevision()
+        }
+      }
+    }, `slots.installScope(${JSON.stringify(scope)})`)
+  }
+
   /**
    * The single ctx-level render entry: the shell renders 'root'; every other
    * key renders inside components through the props renderSlot face. All
@@ -261,23 +338,6 @@ export class SlotRegistry extends Service {
     return this._renderer.renderRoot(this.hostFace(), owner)
   }
 
-  /**
-   * Drop the per-session store instances of a dead session (the sessions
-   * service calls this on scope teardown; root-scoped records are untouched).
-   * Persisted state goes with the session — a never-rendered dead session can
-   * still own keys from an earlier page load, so the instance is materialized
-   * transiently just to clear storage (no-op for unpersisted stores).
-   * @param sessionId - the torn-down session.
-   */
-  pruneStoreScope(sessionId: string): void {
-    for (const [handle, record] of this._stores) {
-      if (record.scope !== 'session') continue
-      const instance = record.instances.get(sessionId) ?? handle.create(sessionId)
-      instance.clearPersisted()
-      record.instances.delete(sessionId)
-    }
-  }
-
   /**
    * Snapshot entries for a key (render-erased view; stable reference between mutations).
    * @param key - SlotMap key.
@@ -381,17 +441,9 @@ export class SlotRegistry extends Service {
     }
   }
 
-  /** Build once after both object-layer services mount; per-session provide bundles still resolve lazily. */
+  /** Build the domain-neutral host face once; installed adapters remain live through getters. */
   private hostFace(): SlotRendererHost {
     if (this._host !== undefined) return this._host
-    const sessions = this.ctx.get('sessions')
-    if (sessions === undefined) {
-      throw new Error("renderSlot('root') before the sessions service mounted — boot order puts runtime apply first")
-    }
-    const workspaces = this.ctx.get('workspaces')
-    if (workspaces === undefined) {
-      throw new Error("renderSlot('root') before the workspaces service mounted — boot order puts runtime apply first")
-    }
     // `locale` is a live getter: the face installs (and, under HMR, swaps)
     // on the locale plugin's own fiber lifetime, while this host object is
     // built once — a captured value would strand renders on a dead face. The
@@ -406,23 +458,59 @@ export class SlotRegistry extends Service {
       reportEntryError: (key, entry, error, info) => { this._core.reportEntryError(key, entry, error, info) },
       specOf: key => this._core.specDynamic(key),
       isLive: entry => this._core.isLive(entry),
-      storeOf: (entry, scopeKey) =>
-        entry.store === undefined ? undefined : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeKey),
-      sessions: {
-        list: sessions.list,
-        provideInfo: sessions.currentProvideInfo,
-      },
-      workspaces: { list: workspaces.list },
+      storeOf: (entry, scopeBinding) =>
+        entry.store === undefined
+          ? undefined
+          : this.resolveStore(entry.store as unknown as EngineStoreHandle, scopeBinding),
+      root: this._rootSource,
+      scopeRevision: this._scopeRevisionSource,
+      scope: scope => service._scopes.get(scope === 'session-maybe' ? 'session' : scope),
       get locale() { return service._locale },
     }
     return this._host
   }
 
+  /** Validate and atomically publish the current root contribution roster. */
+  private rebuildRootBinding(): void {
+    const hooks: Record<string, HostObservable<unknown>> = {}
+    const keyedHooks: Record<string, import('@deepseek-ai/dsh-client-ui-slots').KeyedStandardSource> = {}
+    const props: Record<string, unknown> = {}
+    const finalProps = new Set<string>()
+    for (const contribution of this._rootContributions) {
+      copyUnique('hook', hooks, contribution.hooks, finalProps, standardHookPropName)
+      copyUnique('keyed hook', keyedHooks, contribution.keyedHooks, finalProps, standardHookPropName)
+      copyUnique('prop', props, contribution.props, finalProps, name => name)
+    }
+    this._rootBinding = { key: undefined, hooks, keyedHooks, props }
+    for (const listener of [...this._rootListeners]) {
+      try {
+        listener()
+      } catch (error) {
+        console.error('root standard-source subscriber failed:', error)
+      }
+    }
+  }
+
+  /** Publish one installed-scope roster transition after the map is authoritative. */
+  private publishScopeRevision(): void {
+    this._scopeRevision += 1
+    for (const listener of [...this._scopeListeners]) {
+      try {
+        listener()
+      } catch (error) {
+        console.error('scope-adapter subscriber failed:', error)
+      }
+    }
+  }
+
   /** Resolve (create or reuse) the store instance for a registered handle under a scope key. */
-  private resolveStore(handle: EngineStoreHandle, sessionId: string | undefined): StoreInstanceLike {
+  private resolveStore(
+    handle: EngineStoreHandle,
+    scopeBinding: ScopedStandardSourceBinding | undefined,
+  ): StoreInstanceLike {
     const record = this._stores.get(handle)
     if (record === undefined) throw new Error('store handle is not registered (entry unloaded, or the handle never went through register)')
-    const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : sessionId
+    const key = record.scope === 'root' ? ROOT_INSTANCE_KEY : scopeBinding?.key
     if (key === undefined) throw new Error(`${record.scope} store resolution requires a session id`)
     let instance = record.instances.get(key)
     if (instance === undefined) {
@@ -430,6 +518,25 @@ export class SlotRegistry extends Service {
       // key per session); root instances stay keyless.
       instance = record.scope === 'root' ? handle.create() : handle.create(key)
       record.instances.set(key, instance)
+      if (record.scope !== 'root') {
+        const scopeCtx = scopeBinding?.ctx
+        if (scopeCtx === undefined) {
+          record.instances.delete(key)
+          throw new Error(`${record.scope} store resolution requires a scope lifetime`)
+        }
+        const owned = instance
+        const dispose = scopeCtx.effect(
+          () => () => {
+            if (this._stores.get(handle) !== record || record.instances.get(key) !== owned) return
+            owned.clearPersisted()
+            record.instances.delete(key)
+            record.lifetimes.delete(key)
+          },
+          `slots: store scope ${key}`,
+        )
+        const release = (): void => { void dispose() }
+        record.lifetimes.set(key, release)
+      }
     }
     return instance
   }
@@ -438,7 +545,7 @@ export class SlotRegistry extends Service {
   private _acquire(handle: EngineStoreHandle, scope: SlotScope): void {
     const record = this._stores.get(handle)
     if (record === undefined) {
-      this._stores.set(handle, { scope, refs: 1, instances: new Map() })
+      this._stores.set(handle, { scope, refs: 1, instances: new Map(), lifetimes: new Map() })
       return
     }
     record.refs += 1
@@ -452,7 +559,27 @@ export class SlotRegistry extends Service {
      * future call site cannot underflow the axis. */
     if (record === undefined) return
     record.refs -= 1
-    if (record.refs === 0) this._stores.delete(handle)
+    if (record.refs !== 0) return
+    this._stores.delete(handle)
+    for (const release of record.lifetimes.values()) release()
+  }
+}
+
+function copyUnique<T>(
+  kind: string,
+  target: Record<string, T>,
+  values: Readonly<Record<string, T>> | undefined,
+  finalProps: Set<string>,
+  propNameOf: (name: string) => string,
+): void {
+  if (values === undefined) return
+  for (const [name, value] of Object.entries(values)) {
+    const propName = propNameOf(name)
+    if (finalProps.has(propName)) {
+      throw new Error(`duplicate root standard ${kind} '${name}' at prop '${propName}'`)
+    }
+    finalProps.add(propName)
+    target[name] = value
   }
 }
 

+ 165 - 127
packages/client/ui-renderer/src/client/scoped-slots.tsx

@@ -4,15 +4,17 @@
  */
 import { Component, useMemo, useState, useSyncExternalStore, type FC, type ReactNode } from 'react'
 import {
-  SlotOwnershipError, StaleAuthorizationError,
+  SlotOwnershipError, StaleAuthorizationError, standardHookPropName,
   type ChainRenderOpts, type HostObservable, type LocaleFace, type RenderOpts,
-  type SessionMaybeProvideInfo, type SessionProvideInfo, type SlotRenderer, type SlotRendererHost,
-  type SlotScope, type StoredEntry, type Translate,
+  type ScopedStandardSourceBinding, type SessionAreaProps, type SessionProviderComponent, type SlotRenderer,
+  type SlotRendererHost, type SlotScope, type SlotScopeAdapter, type StandardSourceBinding,
+  type StoredEntry, type Translate,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import {
-  HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
-  observableHook, projectionHook, useHost, useSessionMaybeProvideInfo,
-} from './session-provider.tsx'
+  HostContext, RootStandardProvider, ScopeProvider, SlotAssemblyError,
+  keyedObservableHook, maybeObservableHook, observableHook, useHost, useRootBinding,
+  useScopeBinding,
+} from './bindings.tsx'
 
 type InjectedProps = Record<string, unknown>
 
@@ -89,23 +91,23 @@ function boundRenderSlotChain(host: SlotRendererHost, entry: StoredEntry): Rende
 
 /**
  * Inject results cache: root entries per entry, session entries per
- * (entry x provide bundle). WeakMap keys are entry/info objects (both
+ * (entry x scope binding). WeakMap keys are entry/binding objects (both
  * identity-stable per registration/session scope), so cache lifetime rides
  * the same axes as the values it memoizes.
  */
 const rootInjectCache = new WeakMap<StoredEntry, InjectedProps>()
-const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<SessionProvideInfo, InjectedProps>>()
-const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<SessionMaybeProvideInfo, InjectedProps>>()
+const sessionInjectCache = new WeakMap<StoredEntry, WeakMap<StandardSourceBinding, InjectedProps>>()
+const sessionMaybeInjectCache = new WeakMap<StoredEntry, WeakMap<StandardSourceBinding, InjectedProps>>()
 
 const EMPTY_INJECTED_PROPS: InjectedProps = {}
 
-function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined, actions: object | undefined): InjectedProps {
+function runInject(entry: StoredEntry, binding: StandardSourceBinding | undefined, actions: object | undefined): InjectedProps {
   const inject = entry.inject
   if (!inject) return EMPTY_INJECTED_PROPS
   // Declaration-derived positional arguments: sessionId for session scope,
   // baked actions when a store is declared.
   const args: unknown[] = []
-  if (info !== undefined) args.push(info.sessionId)
+  if (binding !== undefined) args.push(binding.key)
   if (actions !== undefined) args.push(actions)
   return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
 }
@@ -120,7 +122,7 @@ function bindInjectHooks(face: InjectedProps): InjectedProps {
   const { hooks: _hooks, ...rest } = face
   const bound: InjectedProps = rest
   for (const [name, source] of Object.entries(sources as Record<string, HostObservable<unknown>>)) {
-    const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
+    const hookName = standardHookPropName(name)
     bound[hookName] = observableHook(source)
   }
   return bound
@@ -144,7 +146,7 @@ function cachedSlotInject(face: object | undefined): BoundSlotInject {
   const props: InjectedProps = rest
   let factories: Record<string, SlotHookFactory> | undefined
   for (const [name, definition] of Object.entries(definitions as Record<string, unknown>)) {
-    const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
+    const hookName = standardHookPropName(name)
     if (typeof definition === 'function') {
       factories ??= {}
       factories[name] = definition as SlotHookFactory
@@ -167,7 +169,7 @@ function bindSlotHookFactories(
 ): InjectedProps {
   const hooks: InjectedProps = {}
   for (const [name, factory] of Object.entries(factories)) {
-    const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
+    const hookName = standardHookPropName(name)
     hooks[hookName] = factory(standard, hookContext)
   }
   return hooks
@@ -182,34 +184,34 @@ function cachedRootInject(entry: StoredEntry, actions: object | undefined): Inje
   return props
 }
 
-function cachedSessionInject(entry: StoredEntry, info: SessionProvideInfo, actions: object | undefined): InjectedProps {
-  let perInfo = sessionInjectCache.get(entry)
-  if (!perInfo) {
-    perInfo = new WeakMap()
-    sessionInjectCache.set(entry, perInfo)
+function cachedSessionInject(entry: StoredEntry, binding: StandardSourceBinding, actions: object | undefined): InjectedProps {
+  let perBinding = sessionInjectCache.get(entry)
+  if (!perBinding) {
+    perBinding = new WeakMap()
+    sessionInjectCache.set(entry, perBinding)
   }
-  let props = perInfo.get(info)
+  let props = perBinding.get(binding)
   if (!props) {
-    props = runInject(entry, info, actions)
-    perInfo.set(info, props)
+    props = runInject(entry, binding, actions)
+    perBinding.set(binding, props)
   }
   return props
 }
 
 function cachedSessionMaybeInject(
   entry: StoredEntry,
-  info: SessionMaybeProvideInfo,
+  binding: StandardSourceBinding,
   actions: object | undefined,
 ): InjectedProps {
-  let perInfo = sessionMaybeInjectCache.get(entry)
-  if (!perInfo) {
-    perInfo = new WeakMap()
-    sessionMaybeInjectCache.set(entry, perInfo)
+  let perBinding = sessionMaybeInjectCache.get(entry)
+  if (!perBinding) {
+    perBinding = new WeakMap()
+    sessionMaybeInjectCache.set(entry, perBinding)
   }
-  let props = perInfo.get(info)
+  let props = perBinding.get(binding)
   if (!props) {
-    props = runInject(entry, info, actions)
-    perInfo.set(info, props)
+    props = runInject(entry, binding, actions)
+    perBinding.set(binding, props)
   }
   return props
 }
@@ -332,54 +334,76 @@ class SlotErrorBoundary extends Component<
   }
 }
 
-interface StandardPropsCache {
-  readonly root: InjectedProps
-  readonly session: WeakMap<SessionMaybeProvideInfo, InjectedProps>
-  readonly sessionMaybe: WeakMap<SessionMaybeProvideInfo, InjectedProps>
-}
+const rootStandardCache = new WeakMap<StandardSourceBinding, InjectedProps>()
+const sessionStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
+const sessionMaybeStandardCache = new WeakMap<StandardSourceBinding, WeakMap<StandardSourceBinding, InjectedProps>>()
 
-const standardPropsCache = new WeakMap<SlotRendererHost, StandardPropsCache>()
+/** Materialize one binding into stable framework Hook and plain-prop seats. */
+function materializeStandardBinding(binding: StandardSourceBinding, optional: boolean): InjectedProps {
+  const standard: InjectedProps = { ...binding.props }
+  for (const [name, source] of Object.entries(binding.hooks)) {
+    if (source === undefined && !optional) {
+      throw new SlotAssemblyError(`strict standard hook '${name}' has no source`)
+    }
+    standard[standardHookPropName(name)] = optional
+      ? maybeObservableHook(source)
+      : observableHook(source as HostObservable<unknown>)
+  }
+  for (const [name, source] of Object.entries(binding.keyedHooks)) {
+    if (source === undefined && !optional) {
+      throw new SlotAssemblyError(`strict keyed standard hook '${name}' has no source resolver`)
+    }
+    standard[standardHookPropName(name)] = keyedObservableHook(source)
+  }
+  return standard
+}
 
 /** Stable official-props object used by contextual Hook factories. */
 function standardProps(
-  host: SlotRendererHost,
   scope: SlotScope,
-  info: SessionMaybeProvideInfo | undefined,
+  rootBinding: StandardSourceBinding,
+  scopeBinding: StandardSourceBinding | undefined,
 ): InjectedProps {
-  let cache = standardPropsCache.get(host)
-  if (cache === undefined) {
-    cache = {
-      root: {
-        useSessions: observableHook(host.sessions.list),
-        useWorkspaces: observableHook(host.workspaces.list),
-      },
-      session: new WeakMap(),
-      sessionMaybe: new WeakMap(),
-    }
-    standardPropsCache.set(host, cache)
-  }
-  if (scope === 'root') return cache.root
-  if (info === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without session provide info`)
-  const byInfo = scope === 'session' ? cache.session : cache.sessionMaybe
-  let standard = byInfo.get(info)
+  let root = rootStandardCache.get(rootBinding)
+  if (root === undefined) {
+    root = materializeStandardBinding(rootBinding, false)
+    rootStandardCache.set(rootBinding, root)
+  }
+  if (scope === 'root') return root
+  if (scopeBinding === undefined) throw new SlotAssemblyError(`scope '${scope}' rendered without a standard-source binding`)
+  const cache = scope === 'session' ? sessionStandardCache : sessionMaybeStandardCache
+  let perScope = cache.get(rootBinding)
+  if (perScope === undefined) {
+    perScope = new WeakMap()
+    cache.set(rootBinding, perScope)
+  }
+  let standard = perScope.get(scopeBinding)
   if (standard !== undefined) return standard
-  standard = { ...cache.root }
-  for (const [name, source] of Object.entries(info.hooks)) {
-    const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
-    if (scope === 'session-maybe') {
-      standard[hookName] = maybeObservableHook(source)
-    } else {
-      if (source === undefined) throw new SlotAssemblyError(`strict session hook '${name}' has no source`)
-      standard[hookName] = observableHook(source)
-    }
+  standard = {
+    ...root,
+    ...materializeStandardBinding(scopeBinding, scope === 'session-maybe'),
   }
-  Object.assign(standard, info.props)
-  standard['sessionId'] = info.sessionId
-  standard['useProjection'] = projectionHook(info)
-  byInfo.set(info, standard)
+  perScope.set(scopeBinding, standard)
   return standard
 }
 
+const scopeAreaCache = new WeakMap<SlotScopeAdapter, SessionProviderComponent>()
+
+/** Bind one domain-owned scope area renderer to the current scope binding. */
+function scopeAreaProvider(adapter: SlotScopeAdapter): SessionProviderComponent {
+  let Provider = scopeAreaCache.get(adapter)
+  if (Provider !== undefined) return Provider
+  if (adapter.renderArea === undefined) {
+    throw new SlotAssemblyError("scope 'session' adapter does not provide its area renderer")
+  }
+  const renderArea = adapter.renderArea.bind(adapter)
+  Provider = function ScopeAreaProvider(props: SessionAreaProps): ReactNode {
+    return renderArea(useScopeBinding(), props)
+  }
+  scopeAreaCache.set(adapter, Provider)
+  return Provider
+}
+
 /**
  * Standard-kit synthesis shared by both scope branches: the global
  * useSessions/useWorkspaces hooks, the per-session provide bundle (every
@@ -396,13 +420,14 @@ function standardKit(
   host: SlotRendererHost,
   entry: StoredEntry,
   scope: SlotScope,
-  info: SessionMaybeProvideInfo | undefined,
+  rootBinding: StandardSourceBinding,
+  scopeBinding: StandardSourceBinding | undefined,
 ): {
   kit: InjectedProps
   standard: InjectedProps
   actions: object | undefined
 } {
-  const standard = standardProps(host, scope, info)
+  const standard = standardProps(scope, rootBinding, scopeBinding)
   const kit: InjectedProps = { ...standard }
   if (entry.locale !== undefined) {
     const face = host.locale
@@ -414,9 +439,10 @@ function standardKit(
     }
     kit['t'] = localeSeat(face, entry.locale)
   }
-  const store = scope === 'session-maybe' && info?.sessionId === undefined
+  const scopedStoreBinding = scopeBinding?.key === undefined
     ? undefined
-    : host.storeOf(entry, info?.sessionId)
+    : scopeBinding as ScopedStandardSourceBinding
+  const store = host.storeOf(entry, scopedStoreBinding)
   if (store !== undefined) {
     // The instance IS an observable snapshot source (contract getSnapshot/
     // subscribe); the useStore hook binds here, cached per instance.
@@ -430,11 +456,14 @@ function standardKit(
     if (Object.values(entry.children).some(spec => spec.kind === 'chain')) {
       kit['renderSlotChain'] = boundRenderSlotChain(host, entry)
     }
-    // SessionProvider standard seat: entries declaring a session-scope child
-    // render the session area, so the framework hands them the self-wired
-    // provider (module-level component = stable reference; no value import).
+    // The session owner supplies area semantics; the renderer only binds its
+    // adapter to the current generic scope source.
     if (Object.values(entry.children).some(spec => spec.scope === 'session')) {
-      kit['SessionProvider'] = SessionProvider
+      const adapter = host.scope('session')
+      if (adapter === undefined) {
+        throw new SlotAssemblyError("entry declares a session child without an installed 'session' scope adapter")
+      }
+      kit['SessionProvider'] = scopeAreaProvider(adapter)
     }
   }
   return { kit, standard, actions: store?.actions }
@@ -499,35 +528,37 @@ function renderEntry(
   )
 }
 
-function SessionEntry({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: {
+function SessionEntry({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
   entry: StoredEntry
   ownerProps: object
-  info: SessionProvideInfo
+  binding: StandardSourceBinding & { readonly key: string }
   slotKey: string
   slotInjected: BoundSlotInject
   hookContext: unknown
   hasHookContext: boolean
 }) {
   const host = useHost()
+  const rootBinding = useRootBinding()
   const Comp = entry.component as FC<InjectedProps>
-  const { kit, standard, actions } = standardKit(host, entry, 'session', info)
-  const injected = cachedSessionInject(entry, info, actions)
+  const { kit, standard, actions } = standardKit(host, entry, 'session', rootBinding, binding)
+  const injected = cachedSessionInject(entry, binding, actions)
   return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
 }
 
-function SessionMaybeEntryBody({ entry, ownerProps, info, slotKey, slotInjected, hookContext, hasHookContext }: {
+function SessionMaybeEntryBody({ entry, ownerProps, binding, slotKey, slotInjected, hookContext, hasHookContext }: {
   entry: StoredEntry
   ownerProps: object
-  info: SessionMaybeProvideInfo
+  binding: StandardSourceBinding
   slotKey: string
   slotInjected: BoundSlotInject
   hookContext: unknown
   hasHookContext: boolean
 }) {
   const host = useHost()
+  const rootBinding = useRootBinding()
   const Comp = entry.component as FC<InjectedProps>
-  const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', info)
-  const injected = cachedSessionMaybeInject(entry, info, actions)
+  const { kit, standard, actions } = standardKit(host, entry, 'session-maybe', rootBinding, binding)
+  const injected = cachedSessionMaybeInject(entry, binding, actions)
   return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
 }
 
@@ -552,7 +583,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
   hookContext: unknown
   hasHookContext: boolean
 }) {
-  const info = useSessionMaybeProvideInfo()
+  const binding = useScopeBinding()
   // The child key is an incarnation counter, NOT the session id: adoption
   // must keep the key constant across undefined → first id. Bookkeeping
   // lives in this stable (unkeyed) wrapper via the render-phase setState
@@ -561,16 +592,16 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
   // guard conditions make it convergent — StrictMode-safe).
   const [state, setState] = useState<MaybeIncarnation>(FIRST_INCARNATION)
   let { adopted, epoch } = state
-  if (info.sessionId !== undefined && adopted === undefined) {
+  if (binding.key !== undefined && adopted === undefined) {
     // Adoption: same epoch — no remount.
-    adopted = info.sessionId
+    adopted = binding.key
     setState({ adopted, epoch })
-  } else if (adopted !== undefined && info.sessionId !== undefined && info.sessionId !== adopted) {
+  } else if (adopted !== undefined && binding.key !== undefined && binding.key !== adopted) {
     // Post-adoption session switch: next incarnation, born already adopted.
-    adopted = info.sessionId
+    adopted = binding.key
     epoch += 1
     setState({ adopted, epoch })
-  } else if (adopted !== undefined && info.sessionId === undefined) {
+  } else if (adopted !== undefined && binding.key === undefined) {
     // Back to no-session: next incarnation, born blank (adopts anew later).
     adopted = undefined
     epoch += 1
@@ -581,7 +612,7 @@ function SessionMaybeEntry({ entry, ownerProps, slotKey, slotInjected, hookConte
       key={epoch}
       entry={entry}
       ownerProps={ownerProps}
-      info={info}
+      binding={binding}
       slotKey={slotKey}
       slotInjected={slotInjected}
       hookContext={hookContext}
@@ -609,8 +640,9 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH
   hasHookContext: boolean
 }) {
   const host = useHost()
+  const rootBinding = useRootBinding()
   const Comp = entry.component as FC<InjectedProps>
-  const { kit, standard, actions } = standardKit(host, entry, 'root', undefined)
+  const { kit, standard, actions } = standardKit(host, entry, 'root', rootBinding, undefined)
   const injected = cachedRootInject(entry, actions)
   return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
 }
@@ -624,16 +656,18 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
   hasHookContext: boolean
   onEntryError: (error: unknown) => void
 }) {
-  const info = useSessionMaybeProvideInfo()
-  if (info.sessionId === undefined) return null
+  const binding = useScopeBinding()
+  if (binding.key === undefined) {
+    throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
+  }
   // Per-session remount rides this key; per-entry remount rides the outer
   // element's entry-identity key (the outlet's guarded() call).
   return (
-    <SlotErrorBoundary slotKey={slotKey} key={info.sessionId} onEntryError={onEntryError}>
+    <SlotErrorBoundary slotKey={slotKey} key={binding.key} onEntryError={onEntryError}>
       <SessionEntry
         entry={entry}
         ownerProps={ownerProps}
-        info={info as SessionProvideInfo}
+        binding={binding as StandardSourceBinding & { readonly key: string }}
         slotKey={slotKey}
         slotInjected={slotInjected}
         hookContext={hookContext}
@@ -665,7 +699,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
   // Locale revision tick: a locale switch re-renders every outlet, and entry
   // bodies re-derive their `t` seat at the new revision (fresh identity).
   useLocaleRevision(host.locale)
-  const sessionInfo = useSessionMaybeProvideInfo()
+  const scopeBinding = useScopeBinding()
   // Anchor contract: every slot render site exposes a stable
   // `[data-slot="<key>"]` wrapper — the addressable seam dynamic styles
   // target — and `display:contents` keeps it layout-neutral. The wrapper
@@ -674,7 +708,7 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
   // never flickers with registration churn.
   return (
     <div data-slot={slotKey} style={ANCHOR_STYLE}>
-      {renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)}
+      {renderOutletContent(host, slotKey, ownerProps, opts, scopeBinding)}
     </div>
   )
 }
@@ -685,20 +719,20 @@ function renderOutletContent(
   slotKey: string,
   ownerProps: object,
   opts: (RenderOpts & ChainRenderOpts) | undefined,
-  sessionInfo: SessionMaybeProvideInfo,
+  scopeBinding: StandardSourceBinding,
 ): ReactNode {
   const spec = host.specOf(slotKey)
   // Undeclared (or no-longer-declared) keys render empty: a declaring entry's
   // unload returns the slot to the undeclared state while retained elements
   // may still be mounted — natural empty, not an ownership failure.
   if (!spec) return null
-  const strictSessionAbsent = spec.scope === 'session' && sessionInfo.sessionId === undefined
-  if (strictSessionAbsent && (spec.kind !== 'chain' || !opts?.overlay)) {
-    return <>{opts?.fallback ?? null}</>
+  if (spec.kind === 'chain' && opts?.fallbackOnly === true) {
+    return renderChainResult(slotKey, null, opts)
   }
-  // An absent strict overlay chain follows its ordinary empty-election path,
-  // preserving the Fragment/fallback-wrapper shape across session arrival.
-  const entries = strictSessionAbsent ? [] : host.entriesOf(slotKey)
+  if (spec.scope === 'session' && scopeBinding.key === undefined) {
+    throw new SlotAssemblyError(`strict session slot '${slotKey}' rendered without a scope binding`)
+  }
+  const entries = host.entriesOf(slotKey)
   const slotInjected = cachedSlotInject(spec.inject)
 
   // The boundary must wrap the Entry ELEMENT, not live inside it: inject
@@ -799,25 +833,7 @@ function renderOutletContent(
         break
       }
     }
-    if (opts?.overlay) {
-      // Overlay chain (ChainRenderOpts.overlay): the fallback stays mounted
-      // through elections — hidden via inline display:none (decisive over any
-      // author CSS), shown via display:contents so the wrapper never affects
-      // the owner's layout. The wrapper's tree position is constant, so React
-      // reconciles instead of remounting and fallback state survives takeover.
-      return (
-        <>
-          <div
-            data-chain-overlay-fallback={slotKey}
-            style={{ display: elected === null ? 'contents' : 'none' }}
-          >
-            {opts.fallback ?? null}
-          </div>
-          {elected}
-        </>
-      )
-    }
-    return elected ?? <>{opts?.fallback ?? null}</>
+    return renderChainResult(slotKey, elected, opts)
   }
   // list: one row per id cell — the cell's shadowing winner, or the crash
   // face once every entry of the cell abdicated (a dry cell must not
@@ -850,6 +866,26 @@ function renderOutletContent(
   )
 }
 
+/** Render a chain election while preserving the overlay fallback's tree position. */
+function renderChainResult(
+  slotKey: string,
+  elected: ReactNode,
+  opts: (RenderOpts & ChainRenderOpts) | undefined,
+): ReactNode {
+  if (!opts?.overlay) return elected ?? <>{opts?.fallback ?? null}</>
+  return (
+    <>
+      <div
+        data-chain-overlay-fallback={slotKey}
+        style={{ display: elected === null ? 'contents' : 'none' }}
+      >
+        {opts.fallback ?? null}
+      </div>
+      {elected}
+    </>
+  )
+}
+
 /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank. */
 function RootOutlet({ ownerProps }: { ownerProps: object }) {
   const host = useHost()
@@ -889,7 +925,7 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
 }
 
 /**
- * Build the renderer the shell installs into the runtime SlotRegistry
+ * Build the renderer installed into the `ui-renderer` SlotRegistry
  * (ctx.slots.install(createSlotRenderer()) at boot; the service owns the
  * install/renderSlot contract and the double-install/not-installed throws).
  * @returns the renderer.
@@ -899,9 +935,11 @@ export function createSlotRenderer(): SlotRenderer {
     renderRoot(host, ownerProps) {
       return (
         <HostContext.Provider value={host}>
-          <SessionMaybeProvider>
-            <RootOutlet ownerProps={ownerProps} />
-          </SessionMaybeProvider>
+          <RootStandardProvider>
+            <ScopeProvider scope="session-maybe">
+              <RootOutlet ownerProps={ownerProps} />
+            </ScopeProvider>
+          </RootStandardProvider>
         </HostContext.Provider>
       )
     },

+ 0 - 160
packages/client/ui-renderer/src/client/session-provider.tsx

@@ -1,160 +0,0 @@
-/** Internal React bindings for the renderer host and active session provide bundle. */
-import { createContext, useContext, type ReactNode } from 'react'
-import type {
-  HostObservable, MaybeSnapshotSelectorHook, SessionMaybeProvideInfo, SessionProvideInfo,
-  SlotRendererHost, SnapshotSelectorHook,
-} from '@deepseek-ai/dsh-client-ui-slots'
-import { bindSnapshotSelector } from './bind.ts'
-
-/**
- * A missing-provider assembly error: the shell wired the tree wrong. The slot
- * error boundary rethrows this class so misassembly stays fail-loud while
- * registrant errors (inject factories, entry components) are contained
- * per entry.
- */
-export class SlotAssemblyError extends Error {}
-
-/** In-package renderer host context. */
-export const HostContext = createContext<SlotRendererHost | null>(null)
-
-/**
- * Read the installed renderer host; throws outside the rendered root tree
- * (framework components must not render detached from the renderer).
- * @returns the host API.
- */
-export function useHost(): SlotRendererHost {
-  const host = useContext(HostContext)
-  if (!host) throw new SlotAssemblyError('slot machinery rendered outside the installed renderer tree')
-  return host
-}
-
-const BindingContext = createContext<SessionMaybeProvideInfo | null>(null)
-
-/** Read the current-session-optional bundle supplied at the root. */
-export function useSessionMaybeProvideInfo(): SessionMaybeProvideInfo {
-  const info = useContext(BindingContext)
-  if (!info) throw new SlotAssemblyError('session-aware slot rendered outside the root binding provider')
-  return info
-}
-
-/**
- * Read the enclosing session provide bundle; throws outside a SessionProvider
- * subtree (session slots must not render without a session).
- * @returns the enclosing bundle.
- */
-export function useSessionProvideInfo(): SessionProvideInfo {
-  const info = useSessionMaybeProvideInfo()
-  if (info.sessionId === undefined) throw new SlotAssemblyError('strict session slot rendered without a session')
-  return info as SessionProvideInfo
-}
-
-/**
- * Identity-stable selector hook per host observable. uSES resubscribes when
- * the subscribe reference changes, so the bound hook must be created once per
- * source — cached here by source identity (sources are host-owned singletons).
- * @param source - host-provided observable.
- * @returns the cached selector hook.
- */
-export function observableHook<T>(source: HostObservable<T>): SnapshotSelectorHook<T> {
-  let hook = hookCache.get(source)
-  if (hook === undefined) {
-    hook = bindSnapshotSelector(source)
-    hookCache.set(source, hook)
-  }
-  return hook as SnapshotSelectorHook<T>
-}
-const hookCache = new WeakMap<object, unknown>()
-
-const absentSource: HostObservable<undefined> = {
-  getSnapshot: () => undefined,
-  subscribe: () => () => {},
-}
-
-/** Bind a source that disappears with the current session to an optional selector hook. */
-export function maybeObservableHook<T>(source: HostObservable<T> | undefined): MaybeSnapshotSelectorHook<T> {
-  if (source !== undefined) return observableHook(source)
-  return useAbsentSnapshot
-}
-
-function useAbsentSnapshot<S>(_selector: (snapshot: never) => S, _equal?: (a: S, b: S) => boolean): S | undefined {
-  // The uSES subscription must still run (hook-order stability); the absent
-  // source always snapshots undefined, returned explicitly.
-  observableHook(absentSource)(() => undefined)
-  return undefined
-}
-
-/**
- * The useProjection framework seat (docs/subsystems/session-projection.md), one bound
- * function per provide bundle (cached by info identity — components may hold
- * it across renders). Key-addressed: the key resolves a per-session value
- * face off the projection store; the bound selector hook comes from the same
- * per-source cache as every other kit hook, so exactly one uSES subscription
- * runs per call and the subscribe reference stays stable per key. A key no
- * baseline or frame has carried (or a no-session bundle) reads `undefined` —
- * capability absence — keeping the hook order constant.
- */
-export function projectionHook(info: SessionMaybeProvideInfo): (
-  key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean,
-) => unknown {
-  let hook = projectionHookCache.get(info)
-  if (hook === undefined) {
-    hook = (key, selector, eq) => {
-      // The no-session (faceless) branch binds the shared absent source so
-      // the caller's selector still runs over `undefined` (absence flows
-      // through the selector) and the uSES call count stays constant.
-      const useValue = observableHook(info.projections?.faceOf(key) ?? absentSource)
-      // Whole values are finished wire payloads (reference changes only when
-      // a frame or baseline lands), so the identity selector needs no
-      // equality function.
-      return useValue(selector ?? (value => value), eq)
-    }
-    projectionHookCache.set(info, hook)
-  }
-  return hook
-}
-const projectionHookCache = new WeakMap<SessionMaybeProvideInfo, (
-  key: string, selector?: (value: unknown) => unknown, eq?: (a: unknown, b: unknown) => boolean,
-) => unknown>()
-
-/**
- * Root-level binding provider. It follows current selection without a key;
- * per-entry identity is the outlet's adoption bookkeeping (SessionMaybeEntry):
- * a blank-born incarnation adopts the first session without remounting, and
- * every later transition (switch or loss) remounts like a strict entry.
- */
-export function SessionMaybeProvider({ children }: { children: ReactNode }) {
-  const host = useHost()
-  const info = observableHook(host.sessions.provideInfo)(s => s)
-  return (
-    <BindingContext.Provider value={info}>
-      {children}
-    </BindingContext.Provider>
-  )
-}
-
-/** SessionProvider API: render-prop body plus the no-session branch. */
-export interface SessionProviderProps {
-  /** No-session body (also covers a current id whose session cannot be resolved). */
-  empty?: (() => ReactNode) | undefined
-  /** Session body; remounted per session via key={sessionId}. */
-  children: (sessionId: string) => ReactNode
-}
-
-/**
- * Framework-wired session area: subscribes to the host's current provide
- * source and remounts the body under `key={sessionId}` so a session switch
- * rebuilds the session subtree. This dependency-inverted layer uses plain
- * string ids; `PropsRuntime` applies the branded type at the component
- * boundary.
- */
-export function SessionProvider({ empty, children }: SessionProviderProps) {
-  const host = useHost()
-  const info = observableHook(host.sessions.provideInfo)(s => s)
-  const id = info.sessionId
-  if (id === undefined) return <>{empty?.() ?? null}</>
-  return (
-    <BindingContext.Provider value={info} key={id}>
-      {children(id)}
-    </BindingContext.Provider>
-  )
-}

+ 20 - 3
packages/client/ui-renderer/src/invariant.ts

@@ -4,7 +4,11 @@
  */
 
 /* jscpd:ignore-start */
+/* oxlint-disable typescript/no-redundant-type-constituents --
+ * `keyof SlotMap & string` is the declaration-merge key pattern: SlotMap is
+ * empty in this compilation unit but consumers merge concrete keys into it. */
 import type { Context } from '@deepseek-ai/cordis'
+import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots'
 import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
 
 const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-renderer'
@@ -15,10 +19,23 @@ export const name = 'client-ui-renderer-invariant'
 export const inject = ['invariants']
 
 /**
- * No runtime invariant: the package installs the render adapter and provides a
- * mount callback but owns no event stream or mutable cross-plugin data relation.
+ * Verify that each `slots/changed` dispatch observes its mutation already
+ * applied to the renderer-owned slot registry.
  */
-const install: InvariantInstaller = () => {}
+const install: InvariantInstaller = (ctx, fail) => {
+  ctx.on('internal/dispatch', (_mode, eventName, args) => {
+    if (eventName !== 'slots/changed') return
+    const key: unknown = args[0]
+    if (typeof key !== 'string' || key === '') {
+      fail("'slots/changed' dispatched without a slot key argument")
+      return
+    }
+    const slots = ctx.get('slots')
+    if (slots !== undefined && slots.getVersion(key as keyof SlotMap & string) === 0) {
+      fail(`'slots/changed' fired for "${key}" before any mutation bumped its version — emission must follow the applied mutation`)
+    }
+  }, { global: true })
+}
 
 /**
  * Register this package's invariant companion.

+ 4 - 31
packages/client/ui-renderer/tests/app.client.spec.tsx

@@ -1,9 +1,8 @@
 // @vitest-environment jsdom
-import { afterEach, describe, expect, it, vi } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
 import { cleanup, render } from '@testing-library/react'
 import { Context } from '@deepseek-ai/cordis'
 import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime'
-import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
 import { buildRenderApp } from '../src/client/app.tsx'
 
 let runtime: SlotTestRuntime | undefined
@@ -12,8 +11,6 @@ afterEach(async () => {
   cleanup()
   await runtime?.dispose()
   runtime = undefined
-  document.title = ''
-  vi.unstubAllEnvs()
 })
 
 async function bench() {
@@ -23,8 +20,9 @@ async function bench() {
 }
 
 describe('buildRenderApp', () => {
-  it('fails loud when the sessions service is unavailable', () => {
-    expect(() => buildRenderApp({ ctx: new Context() })).toThrow('sessions service unavailable')
+  it('fails loud when the slot registry is unavailable', () => {
+    const renderApp = buildRenderApp({ ctx: new Context() })
+    expect(() => renderApp()).toThrow()
   })
 
   it('renders the root slot tree', async () => {
@@ -33,29 +31,4 @@ describe('buildRenderApp', () => {
     expect(view.getByTestId('frame')).toBeTruthy()
   })
 
-  it('projects the selected durable session title', async () => {
-    vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
-    document.title = 'stale title'
-    const b = await bench()
-    render(<>{b.renderApp()}</>)
-    expect(document.title).toBe('Product')
-    await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
-    expect(document.title).toBe('First — Product')
-    await b.runtime.sessions.setCurrent(undefined)
-    expect(document.title).toBe('Product')
-    await b.runtime.sessions.add({ id: 's2' })
-    expect(document.title).toBe('Product')
-  })
-
-  it('falls back when the selected id has no list row', async () => {
-    vi.stubEnv('DSH_CLIENT_TITLE', 'Product')
-    document.title = 'stale title'
-    const b = await bench()
-    await b.runtime.sessions.add({ id: 's1', summary: { title: 'First' } })
-    render(<>{b.renderApp()}</>)
-    expect(document.title).toBe('First — Product')
-    b.runtime.sessions.list.update((draft) => { draft.current = 'ghost' as SessionId })
-    await b.runtime.flush()
-    expect(document.title).toBe('Product')
-  })
 })

+ 6 - 6
packages/client/runtime/tests/invariant.client.spec.ts → packages/client/ui-renderer/tests/invariant.client.spec.ts

@@ -1,26 +1,26 @@
 /**
- * Runtime invariant companion: the 'slots/changed' emission-order audit —
+ * Renderer invariant companion: the 'slots/changed' emission-order audit —
  * a fired key must already carry a bumped version (emission follows the
  * applied mutation), bogus payloads fail loud, foreign events pass.
  */
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it } from 'vitest'
 import InvariantRegistry from '@deepseek-ai/dsh-invariants'
-import * as RuntimeInvariant from '../src/invariant.ts'
-import { SlotRegistry } from '../src/client/slots.ts'
+import * as RendererInvariant from '../src/invariant.ts'
+import { SlotRegistry } from '../src/client/registry.ts'
 
 async function setup(): Promise<Context> {
   const ctx = new Context()
   await ctx.plugin(InvariantRegistry, { enabled: true })
-  await ctx.plugin(RuntimeInvariant).await()
+  await ctx.plugin(RendererInvariant).await()
   return ctx
 }
 
 const emit = (ctx: Context, event: string, ...args: unknown[]): void => {
-  ;(ctx.emit as (event: string, ...args: unknown[]) => void)(event, ...args)
+  Reflect.apply(ctx.emit.bind(ctx), undefined, [event, ...args])
 }
 
-describe('runtime slots/changed invariant', () => {
+describe('renderer slots/changed invariant', () => {
   it('passes foreign events and a legitimate mutation-then-emission sequence', async () => {
     const ctx = await setup()
     expect(() => { emit(ctx, 'unrelated/event', 'x') }).not.toThrow()

+ 107 - 49
packages/client/runtime/tests/slots-service.client.spec.ts → packages/client/ui-renderer/tests/registry.client.spec.ts

@@ -8,13 +8,14 @@
 import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import type { FC } from 'react'
-import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
-import { SlotRegistry } from '../src/client/slots.ts'
+import type { ScopedStandardSourceBinding, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots'
+import { SlotRegistry } from '../src/client/registry.ts'
 
 // Test-only slot keys (merged so the typed entries/spec faces accept them).
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {
     't.host': { kind: 'single'; scope: 'root' }
+    't.maybe': { kind: 'single'; scope: 'session-maybe' }
     't.panel': { kind: 'single'; scope: 'session' }
     't.rows': { kind: 'list'; scope: 'root' }
   }
@@ -85,27 +86,21 @@ function captureHost(bench: Bench, children?: object): SlotRendererHost {
     renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
   })
   bench.erased.register({ name: 'root', ...(children !== undefined ? { children } : {}) }, C)
-  bench.ctx.reflect.provide('sessions', fakeSessions())
-  bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
   bench.erased.renderSlot('root', {})
   if (host === undefined) throw new Error('renderer never received the host')
   return host
 }
 
-/** Minimal independent Workspace list source for the renderer host contract. */
-function fakeWorkspaces() {
-  const state = { items: [], phase: 'ready' as const }
-  return { list: { getSnapshot: () => state, subscribe: () => () => undefined } }
-}
-
-/** Minimal sessions face for the host contract (list observable + current provide projection). */
-function fakeSessions() {
-  const state = { ids: [], byId: {}, current: undefined as string | undefined }
-  const absentInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
-  return {
-    list: { getSnapshot: () => state, subscribe: () => () => undefined },
-    currentProvideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => undefined },
+function scopedBinding(_ctx: Context, key: string) {
+  const ctx = new Context()
+  const binding: ScopedStandardSourceBinding = {
+    key,
+    ctx,
+    hooks: {},
+    keyedHooks: {},
+    props: {},
   }
+  return { binding, fiber: ctx.fiber }
 }
 
 describe("built-in 'root'", () => {
@@ -404,8 +399,6 @@ describe('declaration injection', () => {
     const bench = await boot()
     let host: SlotRendererHost | undefined
     bench.erased.install({ renderRoot: (value: SlotRendererHost) => { host = value; return null } })
-    bench.ctx.reflect.provide('sessions', fakeSessions())
-    bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
     const disposeFrame = bench.erased.register({
       name: 'root', children: { 't.host': { kind: 'single', scope: 'root' } },
     }, C)
@@ -422,8 +415,10 @@ describe('declaration injection', () => {
     }, C)
     bench.erased.register({ name: 't.panel', store: handle }, C)
     const panelEntry = host.entriesOf('t.panel')[0]
-    expect(host.storeOf(panelEntry as never, 's1')).toBeDefined()
+    const scope = scopedBinding(bench.ctx, 's1')
+    expect(host.storeOf(panelEntry as never, scope.binding)).toBeDefined()
     expect(handle.create).toHaveBeenLastCalledWith('s1')
+    await scope.fiber.dispose()
   })
 })
 
@@ -456,19 +451,9 @@ describe('renderer install seam', () => {
     const renderRoot = vi.fn(() => 'tree')
     bench.erased.install({ renderRoot })
     bench.erased.register({ name: 'root' }, C)
-    bench.ctx.reflect.provide('sessions', fakeSessions())
-    bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
     expect(bench.erased.renderSlot('root', {})).toBe('tree')
     expect(renderRoot).toHaveBeenCalledTimes(1)
   })
-
-  it('fails before rendering when the Workspace object layer is absent', async () => {
-    const bench = await boot()
-    bench.erased.install({ renderRoot: () => null })
-    bench.erased.register({ name: 'root' }, C)
-    bench.ctx.reflect.provide('sessions', fakeSessions())
-    expect(() => bench.erased.renderSlot('root', {})).toThrow(/workspaces service mounted/)
-  })
 })
 
 describe('host face', () => {
@@ -488,17 +473,65 @@ describe('host face', () => {
     expect(host.entriesOf('t.host')).toHaveLength(0)
   })
 
-  it('exposes the session list and the atomic current provide projection', async () => {
+  it('publishes and retracts domain-owned root standard sources atomically', async () => {
     const bench = await boot()
     const host = captureHost(bench)
-    expect(host.sessions.list.getSnapshot()).toMatchObject({ ids: [] })
-    expect(host.sessions.provideInfo.getSnapshot()).toMatchObject({ sessionId: undefined })
+    const source = { getSnapshot: () => 1, subscribe: () => () => undefined }
+    const changed = vi.fn()
+    host.root.subscribe(changed)
+    const dispose = bench.svc.provideRoot({ hooks: { sessions: source }, props: { ready: true } })
+    expect(host.root.getSnapshot()).toEqual({
+      key: undefined,
+      hooks: { sessions: source },
+      keyedHooks: {},
+      props: { ready: true },
+    })
+    expect(changed).toHaveBeenCalledOnce()
+    dispose()
+    expect(host.root.getSnapshot()).toEqual({ key: undefined, hooks: {}, keyedHooks: {}, props: {} })
+    expect(changed).toHaveBeenCalledTimes(2)
   })
 
-  it('exposes the independent Workspace list source', async () => {
+  it('rejects duplicate final root prop names without publishing a partial binding', async () => {
+    const bench = await boot()
+    const host = captureHost(bench)
+    const source = { getSnapshot: () => 1, subscribe: () => () => undefined }
+    bench.svc.provideRoot({ hooks: { feature: source } })
+    const before = host.root.getSnapshot()
+    expect(() => bench.svc.provideRoot({ hooks: { feature: source } }))
+      .toThrow("duplicate root standard hook 'feature' at prop 'useFeature'")
+    expect(() => bench.svc.provideRoot({ keyedHooks: { feature: () => source } }))
+      .toThrow("duplicate root standard keyed hook 'feature' at prop 'useFeature'")
+    expect(() => bench.svc.provideRoot({ props: { useFeature: true } }))
+      .toThrow("duplicate root standard prop 'useFeature' at prop 'useFeature'")
+    expect(host.root.getSnapshot()).toBe(before)
+  })
+
+  it('publishes scope-adapter install and disposal revisions', async () => {
     const bench = await boot()
     const host = captureHost(bench)
-    expect(host.workspaces.list.getSnapshot()).toEqual({ items: [], phase: 'ready' })
+    const absent = { key: undefined, hooks: {}, keyedHooks: {}, props: {} }
+    const adapter = {
+      current: { getSnapshot: () => absent, subscribe: () => () => undefined },
+      resolve: () => undefined,
+    }
+    const changed = vi.fn()
+    host.scopeRevision.subscribe(changed)
+    const owner = bench.ctx.plugin({
+      name: 'session-scope-owner',
+      inject: ['slots'],
+      apply: (ctx: Context) => { ctx.slots.installScope('session', adapter) },
+    })
+    await owner.await()
+    expect(host.scopeRevision.getSnapshot()).toBe(1)
+    expect(changed).toHaveBeenCalledOnce()
+    expect(host.scope('session')).toBe(adapter)
+    expect(host.scope('session-maybe')).toBe(adapter)
+    expect(() => { bench.svc.installScope('session', adapter) }).toThrow(/already has an adapter/)
+    await owner.dispose()
+    expect(host.scopeRevision.getSnapshot()).toBe(2)
+    expect(changed).toHaveBeenCalledTimes(2)
+    expect(host.scope('session')).toBeUndefined()
   })
 })
 
@@ -508,6 +541,7 @@ describe('store instance axis', () => {
     const bench = await boot()
     const host = captureHost(bench, {
       't.host': { kind: 'single', scope: 'root' },
+      't.maybe': { kind: 'single', scope: 'session-maybe' },
       't.rows': { kind: 'list', scope: 'root' },
       't.panel': { kind: 'single', scope: 'session' },
     })
@@ -534,13 +568,16 @@ describe('store instance axis', () => {
     const { handle } = fakeHandle()
     bench.erased.register({ name: 't.panel', store: handle }, C)
     const [entry] = host.entriesOf('t.panel')
-    const s1 = host.storeOf(entry as never, 's1')
-    const s2 = host.storeOf(entry as never, 's2')
+    const scope1 = scopedBinding(bench.ctx, 's1')
+    const scope2 = scopedBinding(bench.ctx, 's2')
+    const s1 = host.storeOf(entry as never, scope1.binding)
+    const s2 = host.storeOf(entry as never, scope2.binding)
     expect(s1).not.toBe(s2)
-    expect(host.storeOf(entry as never, 's1')).toBe(s1) // cached per key
+    expect(host.storeOf(entry as never, scope1.binding)).toBe(s1) // cached per key
     expect(handle.create).toHaveBeenCalledWith('s1')
     expect(handle.create).toHaveBeenCalledWith('s2')
     expect(() => host.storeOf(entry as never, undefined)).toThrow(/requires a session id/)
+    await Promise.all([scope1.fiber.dispose(), scope2.fiber.dispose()])
   })
 
   it('mints a fresh handle per register for the factory (exclusive) form', async () => {
@@ -569,21 +606,44 @@ describe('store instance axis', () => {
     // resolution is covered through the cascade spec below.
   })
 
-  it('pruneStoreScope clears persisted state per dead session, including never-materialized ones', async () => {
+  it('clears a materialized per-session instance with its binding lifetime', async () => {
     const { bench, host } = await storeBench()
     const { handle, created } = fakeHandle()
     bench.erased.register({ name: 't.panel', store: handle }, C)
     const [entry] = host.entriesOf('t.panel')
-    const s1 = host.storeOf(entry as never, 's1')
+    const scope = scopedBinding(bench.ctx, 's1')
+    const s1 = host.storeOf(entry as never, scope.binding)
     expect(s1).toBe(created[0]) // the resolved instance is the fake the handle minted
-    bench.svc.pruneStoreScope('s1')
+    await scope.fiber.dispose()
     expect(created[0]?.clearPersisted).toHaveBeenCalledTimes(1)
-    expect(host.storeOf(entry as never, 's1')).not.toBe(s1) // instance dropped, next resolve mints anew
-    // Never-rendered dead session: a transient instance is created just to clear storage.
-    const before = created.length
-    bench.svc.pruneStoreScope('s-never')
-    expect(created.length).toBe(before + 1)
-    expect(created[created.length - 1]?.clearPersisted).toHaveBeenCalledTimes(1)
+    const replacement = scopedBinding(bench.ctx, 's1')
+    expect(host.storeOf(entry as never, replacement.binding)).not.toBe(s1)
+    await replacement.fiber.dispose()
+  })
+
+  it('clears session-maybe state through binding disposal and creates a fresh instance on reuse', async () => {
+    const { bench, host } = await storeBench()
+    bench.svc.installScope('session', {
+      current: {
+        getSnapshot: () => ({ key: undefined, hooks: {}, keyedHooks: {}, props: {} }),
+        subscribe: () => () => undefined,
+      },
+      resolve: () => undefined,
+    })
+    const { handle, created } = fakeHandle()
+    bench.erased.register({ name: 't.maybe', store: handle }, C)
+    const [entry] = host.entriesOf('t.maybe')
+    const scope = scopedBinding(bench.ctx, 's1')
+    const before = host.storeOf(entry as never, scope.binding)
+
+    await scope.fiber.dispose()
+
+    expect(created[0]?.clearPersisted).toHaveBeenCalledOnce()
+    const replacement = scopedBinding(bench.ctx, 's1')
+    const after = host.storeOf(entry as never, replacement.binding)
+    expect(after).not.toBe(before)
+    expect(handle.create).toHaveBeenLastCalledWith('s1')
+    await replacement.fiber.dispose()
   })
 })
 
@@ -594,8 +654,6 @@ describe('entry-unload cascade', () => {
     bench.erased.install({
       renderRoot: (h: SlotRendererHost) => { host = h; return 'rendered' },
     })
-    bench.ctx.reflect.provide('sessions', fakeSessions())
-    bench.ctx.reflect.provide('workspaces', fakeWorkspaces())
     // The declarer here is NOT the root occupant: root stays occupied by a
     // separate entry so disposing the declarer only kills its children.
     const disposeRoot = bench.erased.register({ name: 'root' }, C)

+ 18 - 8
packages/client/ui-renderer/tests/scoped-slots-real-core.client.spec.tsx

@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
 import { act, render } from '@testing-library/react'
 import {
   SlotCore, StaleAuthorizationError, type PropsRenderSlots, type SlotRendererHost,
+  type SlotScopeAdapter, type StandardSourceBinding,
 } from '@deepseek-ai/dsh-client-ui-slots'
 import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
 
@@ -28,7 +29,20 @@ type FrameSlots = PropsRenderSlots<'spec.single' | 'spec.list'>
 
 /** Passthrough host over the real core (store/session seats unused here). */
 function hostOver(core: SlotCore): SlotRendererHost {
-  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
+  const absentBinding: StandardSourceBinding = {
+    key: undefined,
+    hooks: {},
+    keyedHooks: {},
+    props: {},
+  }
+  const bindingSource = {
+    getSnapshot: () => absentBinding,
+    subscribe: () => () => {},
+  }
+  const sessionAdapter: SlotScopeAdapter = {
+    current: bindingSource,
+    resolve: () => undefined,
+  }
   return {
     subscribe: (key, fn) => core.subscribe(key, fn),
     getVersion: key => core.getVersion(key),
@@ -38,13 +52,9 @@ function hostOver(core: SlotCore): SlotRendererHost {
     specOf: key => core.specDynamic(key),
     isLive: entry => core.isLive(entry),
     storeOf: () => undefined,
-    sessions: {
-      list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-      provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
-    },
-    workspaces: {
-      list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-    },
+    root: bindingSource,
+    scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} },
+    scope: () => sessionAdapter,
   }
 }
 

+ 135 - 52
packages/client/ui-renderer/tests/scoped-slots.client.spec.tsx

@@ -5,26 +5,31 @@
  * binding, session pair, global useSessions, store pair), inject execution
  * point (inside component bodies, contained per entry) and parameter
  * derivation, and cache granularity (entry x scope key). Ledger semantics
- * (declaration conflicts, store instance accounting) belong to the runtime
+ * (declaration conflicts, store instance accounting) belong to the renderer
  * SlotRegistry suite, not here.
  */
 import { describe, expect, it, vi } from 'vitest'
 import { act, fireEvent, render } from '@testing-library/react'
 import { useEffect, useState, type ReactNode } from 'react'
+import { Context } from '@deepseek-ai/cordis'
 import {
   SlotOwnershipError, StaleAuthorizationError,
-  type ActionsDecl, type SlotEntryDef, type SlotSpec, type StoreHandle, type StoredEntry,
+  type ActionsDecl, type SessionProviderComponent, type SlotEntryDef,
+  type SlotSpec, type StoreHandle, type StoredEntry,
 } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionMaybeProvideInfo } from '@deepseek-ai/dsh-client-ui-slots'
 import type {
-  RenderOpts, SessionProvideInfo, SlotRendererHost, StoreInstanceLike,
+  RenderOpts, ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter,
+  StandardSourceBinding, StoreInstanceLike,
 } from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
-import { SessionProvider } from '../src/client/session-provider.tsx'
 
 type AnyProps = Record<string, unknown>
 type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
-type RenderSlotChainFn = (key: string, owner: object, opts?: { fallback?: ReactNode; overlay?: boolean }) => ReactNode
+type RenderSlotChainFn = (
+  key: string,
+  owner: object,
+  opts?: { fallback?: ReactNode; fallbackOnly?: boolean; overlay?: boolean },
+) => ReactNode
 type DeclaredSpec = SlotSpec<SlotEntryDef>
 /** Entry literal helper: fake entries default the mandatory options bag. */
 const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }): StoredEntry =>
@@ -35,7 +40,7 @@ const entryOf = (partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry
  * create(scopeKey?) + instance with clearPersisted): the machinery consumes
  * only the StoreInstanceLike face (bare snapshot source + baked actions),
  * but entry.store is typed to the full contract — the real defineStore lives
- * in runtime, which UI-renderer tests must not import (dependency direction).
+ * in client-store, which UI-renderer tests must not import (dependency direction).
  */
 function miniStore<T extends object>(
   init: () => T,
@@ -76,11 +81,12 @@ function observable<T>(initial: T) {
 /**
  * Behavioral SlotRendererHost fake: registration mutates entries, bumps the
  * key version, and notifies synchronously (batching semantics belong to the
- * runtime host, not this package's outlets). Store instances resolve through
+ * registry host, not this package's outlets). Store instances resolve through
  * the entry's real handle, cached per (entry x scope key) like the real
- * ledger; session cells are identity-stable per id.
+ * ledger; session bindings are identity-stable per id.
  */
 function makeHost() {
+  const scopeCtx = new Context()
   const entries = new Map<string, StoredEntry[]>()
   const specs = new Map<string, DeclaredSpec>()
   const versions = new Map<string, number>()
@@ -90,11 +96,31 @@ function makeHost() {
   const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
   const list = observable<{ ids: string[] }>({ ids: [] })
   const workspaces = observable<{ ids: string[] }>({ ids: [] })
-  const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: {}, props: {} }
-  const provide = observable<SessionMaybeProvideInfo>(absentInfo)
+  const absentBinding: StandardSourceBinding = {
+    key: undefined,
+    hooks: { session: undefined },
+    keyedHooks: {},
+    props: { sessionId: undefined },
+  }
+  const currentBinding = observable<StandardSourceBinding>(absentBinding)
   let currentId: string | undefined
-  const infos = new Map<string, SessionProvideInfo>()
+  const bindings = new Map<string, ScopedStandardSourceBinding>()
   const sessionSources = new Map<string, ReturnType<typeof observable<unknown>>>()
+  const root = observable<StandardSourceBinding>({
+    key: undefined,
+    hooks: { sessions: list, workspaces },
+    keyedHooks: {},
+    props: {},
+  })
+  const sessionAdapter: SlotScopeAdapter = {
+    current: currentBinding,
+    resolve: key => bindings.get(key),
+    renderArea: (binding, { empty, children }) => binding.key === undefined
+      ? <>{empty?.() ?? null}</>
+      : <>{children}</>,
+  }
+  const scopeRevision = observable(0)
+  let activeScopeAdapter = sessionAdapter
 
   const bump = (key: string) => {
     versions.set(key, (versions.get(key) ?? 0) + 1)
@@ -133,40 +159,38 @@ function makeHost() {
     },
     specOf: key => specs.get(key),
     isLive: entry => live.has(entry),
-    storeOf: (entry, scopeKey) => {
+    storeOf: (entry, scopeBinding) => {
       if (entry.store === undefined) return undefined
       let perScope = storeCache.get(entry)
       if (!perScope) {
         perScope = new Map()
         storeCache.set(entry, perScope)
       }
-      const cacheKey = scopeKey ?? ''
+      const cacheKey = scopeBinding?.key ?? ''
       let instance = perScope.get(cacheKey)
       if (!instance) {
         // Fake entries always carry engine handles (never factories), and the
         // engine create() takes the scope key (persist suffixing).
         const handle = entry.store as { create(scopeKey?: string): StoreInstanceLike }
-        instance = handle.create(scopeKey)
+        instance = handle.create(scopeBinding?.key)
         perScope.set(cacheKey, instance)
       }
       return instance
     },
-    sessions: {
-      list,
-      provideInfo: provide,
-    },
-    workspaces: { list: workspaces },
+    root,
+    scopeRevision,
+    scope: () => activeScopeAdapter,
   }
   return {
     host,
     list,
     workspaces,
-    // Driver surface: set(id) publishes the resolved bundle (or the absent
-    // projection) through the provide source.
+    // Driver surface: set(id) publishes the resolved binding (or the absent
+    // projection) through the scope adapter.
     current: {
       set: (id: string | undefined) => {
         currentId = id
-        provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
+        currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
       },
     },
     declare: (key: string, spec: DeclaredSpec) => { specs.set(key, spec); bump(key) },
@@ -188,33 +212,46 @@ function makeHost() {
         bump(key)
       }
     },
-    addSession: (id: string, initial: unknown = { sid: id }): SessionProvideInfo => {
-      // Bare source per bundle (identity-stable): the machinery binds useSession from it.
+    addSession: (id: string, initial: unknown = { sid: id }): ScopedStandardSourceBinding => {
+      // Bare source per binding (identity-stable): the machinery binds useSession from it.
       const session = observable<unknown>(initial)
-      const info: SessionProvideInfo = {
-        sessionId: id,
+      const binding: ScopedStandardSourceBinding = {
+        key: id,
+        ctx: scopeCtx,
         hooks: { session },
-        props: {},
+        keyedHooks: {},
+        props: { sessionId: id },
       }
       sessionSources.set(id, session)
-      infos.set(id, info)
-      if (currentId === id) provide.set(info)
-      return info
+      bindings.set(id, binding)
+      if (currentId === id) currentBinding.set(binding)
+      return binding
     },
     setSession: (id: string, snapshot: unknown) => {
       const source = sessionSources.get(id)
       if (source === undefined) throw new Error(`unknown test session: ${id}`)
       source.set(snapshot)
     },
+    replaceScope: (adapter: SlotScopeAdapter) => {
+      activeScopeAdapter = adapter
+      scopeRevision.set(scopeRevision.getSnapshot() + 1)
+    },
   }
 }
 
 type Fake = ReturnType<typeof makeHost>
 
 /** Mount a root entry whose component renders `body` with its kit renderSlot. */
-function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (renderSlot: RenderSlotFn) => ReactNode) {
+function mountRoot(
+  h: Fake,
+  children: Record<string, DeclaredSpec>,
+  body: (renderSlot: RenderSlotFn, SessionProvider: SessionProviderComponent) => ReactNode,
+) {
   const dispose = h.add('root', {
-    component: (props: { renderSlot: RenderSlotFn }) => <>{body(props.renderSlot)}</>,
+    component: (props: {
+      renderSlot: RenderSlotFn
+      SessionProvider: SessionProviderComponent
+    }) => <>{body(props.renderSlot, props.SessionProvider)}</>,
     children,
   })
   const renderer = createSlotRenderer()
@@ -225,6 +262,7 @@ function mountRoot(h: Fake, children: Record<string, DeclaredSpec>, body: (rende
 const SINGLE_ROOT: DeclaredSpec = { kind: 'single', scope: 'root' }
 const SINGLE_SESSION: DeclaredSpec = { kind: 'single', scope: 'session' }
 const CHAIN_ROOT: DeclaredSpec = { kind: 'chain', scope: 'root' }
+const CHAIN_SESSION: DeclaredSpec = { kind: 'chain', scope: 'session' }
 
 /** Chain entry literal: top-level select, priority in the options bag (the StoredEntry chain shape). */
 const chainEntryOf = (partial: {
@@ -566,6 +604,31 @@ describe('overlay chains (ChainRenderOpts.overlay)', () => {
     expect(mounted).toHaveBeenCalledTimes(1)
   })
 
+  it('keeps an explicit fallback-only strict chain mounted until its Session scope exists', () => {
+    const h = makeHost()
+    h.declare('k.chain', CHAIN_SESSION)
+    h.addSession('s1')
+    const select = vi.fn(() => null)
+    h.add('k.chain', chainEntryOf({ component: () => <b>never</b>, select }))
+    let fallbackOnly = true
+    const { view } = mountChainRoot(h, { 'k.chain': CHAIN_SESSION },
+      renderSlotChain => renderSlotChain(
+        'k.chain',
+        {},
+        { fallback: <input aria-label="resident" />, fallbackOnly, overlay: true },
+      ))
+    const input = view.getByRole('textbox', { name: 'resident' })
+    expect(select).not.toHaveBeenCalled()
+
+    fallbackOnly = false
+    act(() => {
+      h.current.set('s1')
+      h.add('root', { component: () => null })
+    })
+    expect(view.getByRole('textbox', { name: 'resident' })).toBe(input)
+    expect(select).toHaveBeenCalledOnce()
+  })
+
   it('leaves non-overlay chains on the unmount path: a takeover discards fallback state', () => {
     const h = makeHost()
     h.declare('k.chain', CHAIN_ROOT)
@@ -656,9 +719,9 @@ describe('standard-kit synthesis', () => {
         return null
       },
     })
-    mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
+    mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => (
       <SessionProvider empty={() => <i>empty</i>}>
-        {() => renderSlot('k.session', {})}
+        {renderSlot('k.session', {})}
       </SessionProvider>
     ))
     act(() => { h.current.set('s1') })
@@ -698,11 +761,11 @@ describe('standard-kit synthesis', () => {
         return <b data-turn={label}>{String(useTurnData('tail'))}</b>
       },
     })
-    const { view } = mountRoot(h, { 'k.session': sessionSpec }, renderSlot => (
-      <SessionProvider>{() => <>
+    const { view } = mountRoot(h, { 'k.session': sessionSpec }, (renderSlot, SessionProvider) => (
+      <SessionProvider><>
         {renderSlot('k.session', { label: 'one' }, { hookContext: 1 })}
         {renderSlot('k.session', { label: 'two' }, { hookContext: 2 })}
-      </>}
+      </>
       </SessionProvider>
     ))
     act(() => { h.current.set('s1') })
@@ -746,11 +809,11 @@ describe('standard-kit synthesis', () => {
     h.add('root', {
       component: (props: AnyProps) => {
         rootSeen.push(props)
-        const Provider = props['SessionProvider'] as typeof SessionProvider
+        const Provider = props['SessionProvider'] as SessionProviderComponent
         const renderSlot = props['renderSlot'] as RenderSlotFn
         return (
           <Provider empty={() => <i>empty</i>}>
-            {() => renderSlot('k.session', {})}
+            {renderSlot('k.session', {})}
           </Provider>
         )
       },
@@ -773,15 +836,15 @@ describe('standard-kit synthesis', () => {
     expect(seen2.at(-1)!['SessionProvider']).toBeUndefined()
   })
 
-  it('renders nothing for a strict session slot while no session is current', () => {
-    // Strict session entries decline (render null) without a session; the
-    // loud path is reserved for a missing root binding provider.
+  it('fails loud for a strict session slot while no session is current', () => {
     const h = makeHost()
     h.declare('k.session', SINGLE_SESSION)
     h.add('k.session', { component: () => <b>x</b> })
-    const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION },
-      renderSlot => renderSlot('k.session', {}))
-    expect(view.container.querySelector('b')).toBeNull()
+    const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+    expect(() => mountRoot(h, { 'k.session': SINGLE_SESSION },
+      renderSlot => renderSlot('k.session', {})))
+      .toThrow("strict session slot 'k.session' rendered without a scope binding")
+    spy.mockRestore()
   })
 
   it('delivers the store pair for store-declaring entries and writes through baked actions', () => {
@@ -822,8 +885,8 @@ describe('standard-kit synthesis', () => {
       },
       store: handle,
     })
-    const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
-      <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
+    const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => (
+      <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
     ))
     act(() => { h.current.set('s1') })
     act(() => { setDraft('draft-one') })
@@ -877,8 +940,8 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
       component: ({ sid }: { sid?: string }) => <b>{sid}</b>,
       inject: inject,
     })
-    const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, renderSlot => (
-      <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
+    const { view } = mountRoot(h, { 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => (
+      <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
     ))
     act(() => { h.current.set('s1') })
     expect(view.container.textContent).toBe('s1')
@@ -912,9 +975,9 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
       inject: sessionInject,
       store: handle,
     })
-    mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, renderSlot => <>
+    mountRoot(h, { 'k.single': SINGLE_ROOT, 'k.session': SINGLE_SESSION }, (renderSlot, SessionProvider) => <>
       {renderSlot('k.single', {})}
-      <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>
+      <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
     </>)
     act(() => { h.current.set('s1') })
     // The inject-received actions are the same baked callbacks the component
@@ -1029,4 +1092,24 @@ describe('session-maybe adoption identity', () => {
     act(() => { h.current.set('s1') })
     expect(view.container.textContent).toBe('s1#1')
   })
+
+  it('rebinds mounted scope consumers when the installed adapter changes', () => {
+    const h = makeHost()
+    const { view } = mountMaybeCounter(h)
+    expect(view.container.textContent).toBe('blank#1')
+    const binding: ScopedStandardSourceBinding = {
+      key: 'replacement',
+      ctx: new Context(),
+      hooks: { session: observable({ sid: 'replacement' }) },
+      keyedHooks: {},
+      props: { sessionId: 'replacement' },
+    }
+    act(() => {
+      h.replaceScope({
+        current: observable(binding),
+        resolve: key => key === binding.key ? binding : undefined,
+      })
+    })
+    expect(view.container.textContent).toBe('replacement#1')
+  })
 })

+ 91 - 42
packages/client/ui-renderer/tests/session-provider.client.spec.tsx

@@ -1,11 +1,17 @@
 // @vitest-environment jsdom
-import { useEffect, useRef } from 'react'
+import { Fragment, useEffect, useRef } from 'react'
+import { Context } from '@deepseek-ai/cordis'
 import { describe, expect, it, vi } from 'vitest'
 import { act, render } from '@testing-library/react'
-import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SessionProvideInfo, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {
+  SessionProviderComponent, StoredEntry,
+} from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
+} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
-import { SessionProvider } from '../src/client/session-provider.tsx'
+
+type SessionBinding = ScopedStandardSourceBinding
 
 function observable<T>(initial: T) {
   let value = initial
@@ -18,19 +24,52 @@ function observable<T>(initial: T) {
 }
 
 /**
- * Minimal host: SessionProvider only reads sessions.provideInfo, but it must
+ * Minimal host: SessionProvider only reads the Session scope adapter, but it must
  * render inside the renderer tree (HostContext), so the harness mounts a real
  * root entry whose body is the test's render-prop provider.
  */
-function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.ReactNode) => React.ReactNode }) {
-  const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
-  const provide = observable<SessionMaybeProvideInfo>(absentInfo)
+function makeHost(
+  bodies: {
+    root: (
+      rp: (key: string, owner: object) => React.ReactNode,
+      SessionProvider: SessionProviderComponent,
+    ) => React.ReactNode
+  },
+  options: { installRenderArea?: boolean } = {},
+) {
+  const scopeCtx = new Context()
+  const absentBinding: StandardSourceBinding = {
+    key: undefined,
+    hooks: { session: undefined },
+    keyedHooks: {},
+    props: { sessionId: undefined },
+  }
+  const currentBinding = observable<StandardSourceBinding>(absentBinding)
   let currentId: string | undefined
-  const infos = new Map<string, SessionProvideInfo>()
+  const bindings = new Map<string, SessionBinding>()
   const sessionEntries: StoredEntry[] = []
+  const root = observable<StandardSourceBinding>({
+    key: undefined,
+    hooks: {},
+    keyedHooks: {},
+    props: {},
+  })
+  const sessionAdapter: SlotScopeAdapter = {
+    current: currentBinding,
+    resolve: key => bindings.get(key),
+    ...(options.installRenderArea === false
+      ? {}
+      : {
+        renderArea: (binding, { empty, children }) => binding.key === undefined
+          ? <>{empty?.() ?? null}</>
+          : <Fragment key={binding.key}>{children}</Fragment>,
+      }),
+  }
   const rootEntry: StoredEntry = {
-    component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
-      <>{bodies.root(props.renderSlot)}</>,
+    component: (props: {
+      renderSlot: (key: string, owner: object) => React.ReactNode
+      SessionProvider: SessionProviderComponent
+    }) => <>{bodies.root(props.renderSlot, props.SessionProvider)}</>,
     options: {},
     children: { 'k.session': { kind: 'single', scope: 'session' } },
   }
@@ -45,37 +84,37 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
     specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
     isLive: () => true,
     storeOf: () => undefined,
-    sessions: {
-      list: observable<unknown>({ ids: [] }),
-      provideInfo: provide,
-    },
-    workspaces: { list: observable<unknown>({ items: [] }) },
+    root,
+    scopeRevision: observable(0),
+    scope: () => sessionAdapter,
   }
   return {
     host,
-    // Driver surface: set(id) publishes the resolved bundle (or the absent
-    // projection) through the provide source.
+    // Driver surface: set(id) publishes the resolved binding (or the absent
+    // projection) through the scope adapter.
     current: {
       set: (id: string | undefined) => {
         currentId = id
-        provide.set((id === undefined ? undefined : infos.get(id)) ?? absentInfo)
+        currentBinding.set((id === undefined ? undefined : bindings.get(id)) ?? absentBinding)
       },
     },
     addSession: (id: string) => {
-      // Bare source per bundle (identity-stable): the machinery binds useSession from it.
-      const info: SessionProvideInfo = {
-        sessionId: id,
+      // Bare source per binding (identity-stable): the machinery binds useSession from it.
+      const binding: SessionBinding = {
+        key: id,
+        ctx: scopeCtx,
         hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
-        props: {},
+        keyedHooks: {},
+        props: { sessionId: id },
       }
-      infos.set(id, info)
-      if (currentId === id) provide.set(info)
-      return info
+      bindings.set(id, binding)
+      if (currentId === id) currentBinding.set(binding)
+      return binding
     },
-    /** Swap one session's bundle in place (roster-change stand-in); republish when current. */
-    replaceSession: (info: SessionProvideInfo) => {
-      infos.set(info.sessionId, info)
-      if (currentId === info.sessionId) provide.set(info)
+    /** Swap one session's binding in place (roster-change stand-in); republish when current. */
+    replaceSession: (binding: SessionBinding) => {
+      bindings.set(binding.key, binding)
+      if (currentId === binding.key) currentBinding.set(binding)
     },
     registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
   }
@@ -84,9 +123,9 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
 describe('SessionProvider', () => {
   it('renders empty without a current session, switches to the body on select, falls back on an unresolvable id', () => {
     const h = makeHost({
-      root: () => (
+      root: (_renderSlot, SessionProvider) => (
         <SessionProvider empty={() => <span>empty</span>}>
-          {id => <div data-testid="body">{id}</div>}
+          <div data-testid="body">session</div>
         </SessionProvider>
       ),
     })
@@ -94,14 +133,14 @@ describe('SessionProvider', () => {
     const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
     expect(view.container.textContent).toBe('empty')
     act(() => { h.current.set('s1') })
-    expect(view.container.textContent).toBe('s1')
+    expect(view.container.textContent).toBe('session')
     act(() => { h.current.set('ghost') })   // listed nowhere: cell() misses
     expect(view.container.textContent).toBe('empty')
   })
 
   it('renders null empty state when the empty prop is omitted', () => {
     const h = makeHost({
-      root: () => <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
+      root: (_renderSlot, SessionProvider) => <SessionProvider><b>session</b></SessionProvider>,
     })
     const view = render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
     expect(view.container.textContent).toBe('')
@@ -118,7 +157,13 @@ describe('SessionProvider', () => {
       return <div>{id}</div>
     }
     const h = makeHost({
-      root: () => <SessionProvider>{id => <Body id={id} />}</SessionProvider>,
+      root: (renderSlot, SessionProvider) => (
+        <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
+      ),
+    })
+    h.registerSession({
+      component: (props: { sessionId?: string }) => <Body id={props.sessionId ?? 'missing'} />,
+      options: {},
     })
     h.addSession('s1')
     h.addSession('s2')
@@ -135,7 +180,9 @@ describe('SessionProvider', () => {
   it('delivers the resolved cell to session slots under it (observable behavior, not context internals)', () => {
     const seen: Record<string, unknown>[] = []
     const h = makeHost({
-      root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
+      root: (renderSlot, SessionProvider) => (
+        <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
+      ),
     })
     h.addSession('s1')
     h.addSession('s2')
@@ -160,7 +207,9 @@ describe('SessionProvider', () => {
   it('republishes a mounted session entry when its provide bundle changes under the same id', () => {
     const seen: unknown[] = []
     const h = makeHost({
-      root: renderSlot => <SessionProvider>{() => renderSlot('k.session', {})}</SessionProvider>,
+      root: (renderSlot, SessionProvider) => (
+        <SessionProvider>{renderSlot('k.session', {})}</SessionProvider>
+      ),
     })
     const original = h.addSession('s1')
     h.registerSession({
@@ -173,17 +222,17 @@ describe('SessionProvider', () => {
     render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
     act(() => { h.current.set('s1') })
     expect(seen.at(-1)).toBeUndefined()
-    // A provider-roster change rematerializes the bundle; the provide source
+    // A provider-roster change rematerializes the binding; the scope source
     // must carry it to already-mounted entries without a selection change.
     act(() => { h.replaceSession({ ...original, props: { feature: 'now-live' } }) })
     expect(seen.at(-1)).toBe('now-live')
   })
 
-  it('fails loud when mounted outside the renderer tree (no host channel)', () => {
+  it('fails loud when the Session scope owner omits its area renderer', () => {
+    const h = makeHost({ root: () => null }, { installRenderArea: false })
     const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
-    expect(() => render(
-      <SessionProvider>{id => <b>{id}</b>}</SessionProvider>,
-    )).toThrow(/outside the installed renderer tree/)
+    expect(() => render(<>{createSlotRenderer().renderRoot(h.host, {})}</>))
+      .toThrow(/does not provide its area renderer/)
     spy.mockRestore()
   })
 })

+ 20 - 9
packages/client/ui-renderer/tests/stale-authorization.client.spec.tsx

@@ -9,7 +9,9 @@ import type { ReactNode } from 'react'
 import {
   StaleAuthorizationError, type SlotEntryDef, type SlotSpec, type StoredEntry,
 } from '@deepseek-ai/dsh-client-ui-slots'
-import type { RenderOpts, SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import type {
+  RenderOpts, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
+} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
 
 type RenderSlotFn = (key: string, owner: object, opts?: RenderOpts) => ReactNode
@@ -21,7 +23,20 @@ function makeHost() {
   const versions = new Map<string, number>()
   const subs = new Map<string, Set<() => void>>()
   const live = new Set<StoredEntry>()
-  const absentInfo = { sessionId: undefined, hooks: {}, props: {} }
+  const absentBinding: StandardSourceBinding = {
+    key: undefined,
+    hooks: {},
+    keyedHooks: {},
+    props: {},
+  }
+  const bindingSource = {
+    getSnapshot: () => absentBinding,
+    subscribe: () => () => {},
+  }
+  const sessionAdapter: SlotScopeAdapter = {
+    current: bindingSource,
+    resolve: () => undefined,
+  }
   const bump = (key: string) => {
     versions.set(key, (versions.get(key) ?? 0) + 1)
     for (const fn of [...(subs.get(key) ?? [])]) fn()
@@ -42,13 +57,9 @@ function makeHost() {
     specOf: () => ({ kind: 'single', scope: 'root' }),
     isLive: entry => live.has(entry),
     storeOf: () => undefined,
-    sessions: {
-      list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-      provideInfo: { getSnapshot: () => absentInfo, subscribe: () => () => {} },
-    },
-    workspaces: {
-      list: { getSnapshot: () => ({}), subscribe: () => () => {} },
-    },
+    root: bindingSource,
+    scopeRevision: { getSnapshot: () => 0, subscribe: () => () => {} },
+    scope: () => sessionAdapter,
   }
   return {
     host,

+ 21 - 8
packages/client/ui-renderer/tests/ui-renderer.client.spec.tsx

@@ -2,9 +2,8 @@
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { act, cleanup } from '@testing-library/react'
 import { Context } from '@deepseek-ai/cordis'
-import { SlotRegistry } from '@deepseek-ai/dsh-client-runtime/client'
-import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime'
-import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime'
+import { SlotRegistry } from '../src/client/registry.ts'
+import type { SlotScopeAdapter, StandardSourceBinding } from '../src/client/index.ts'
 import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-renderer'
 import * as UiRenderer from '../src/client/index.ts'
 
@@ -17,16 +16,30 @@ afterEach(() => {
   document.body.innerHTML = ''
 })
 
-const stabilize: Stabilizer = async (fn) => { await act(async () => { await fn() }) }
+const stabilize = async (fn: () => void | Promise<void>): Promise<void> => {
+  await act(async () => { await fn() })
+}
 
 async function bench() {
   const ctx = new Context()
-  await ctx.plugin(SlotRegistry).await()
-  const slots = ctx.get('slots') as SlotRegistry
-  ctx.provide('sessions', new TestSessions(stabilize, ctx))
-  ctx.provide('workspaces', new TestWorkspaces(stabilize))
   const fiber = ctx.plugin({ inject: [...UiRenderer.inject], apply: UiRenderer.apply })
   await fiber.await()
+  const slots = ctx.get('slots') as SlotRegistry
+  const absentBinding: StandardSourceBinding = {
+    key: undefined,
+    hooks: {},
+    keyedHooks: {},
+    props: {},
+  }
+  const current = {
+    getSnapshot: () => absentBinding,
+    subscribe: () => () => {},
+  }
+  const adapter: SlotScopeAdapter = {
+    current,
+    resolve: () => undefined,
+  }
+  slots.installScope('session', adapter)
   return { ctx, slots, fiber }
 }
 

+ 55 - 41
packages/client/ui-renderer/tests/use-projection.client.spec.tsx

@@ -4,16 +4,21 @@
  * docs/subsystems/session-projection.md): the fifth
  * framework hook seat rides the same provide channel as useSession — a
  * session slot component receives `useProjection` in its kit, key-addressed
- * over the bundle's projection face; unresolved keys (no value, no face, no
- * session) uniformly read `undefined`; live value changes re-render; the
- * selector overload runs over the whole value.
+ * over the binding's projection source family; unresolved keys and absent
+ * sessions read `undefined`; live value changes re-render; the selector
+ * overload runs over the whole value.
  */
 import { describe, expect, it } from 'vitest'
 import { act, render } from '@testing-library/react'
-import type { SessionMaybeProvideInfo, StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-renderer/client'
+import { Context } from '@deepseek-ai/cordis'
+import type { StoredEntry } from '@deepseek-ai/dsh-client-ui-slots'
+import type {
+  ScopedStandardSourceBinding, SlotRendererHost, SlotScopeAdapter, StandardSourceBinding,
+} from '@deepseek-ai/dsh-client-ui-renderer/client'
 import { createSlotRenderer } from '../src/client/scoped-slots.tsx'
 
+type SessionBinding = ScopedStandardSourceBinding
+
 function observable<T>(initial: T) {
   let value = initial
   const subs = new Set<() => void>()
@@ -27,25 +32,51 @@ function observable<T>(initial: T) {
 type UseProjectionProp = (key: string, selector?: (v: unknown) => unknown) => unknown
 
 function makeHost() {
-  const absentInfo: SessionMaybeProvideInfo = { sessionId: undefined, hooks: { session: undefined }, props: {} }
-  const provide = observable<SessionMaybeProvideInfo>(absentInfo)
+  const scopeCtx = new Context()
+  const absentBinding: StandardSourceBinding = {
+    key: undefined,
+    hooks: { session: undefined },
+    keyedHooks: { projection: undefined },
+    props: { sessionId: undefined },
+  }
+  const currentBinding = observable<StandardSourceBinding>(absentBinding)
   const cells = new Map<string, ReturnType<typeof observable<unknown>>>()
-  /** Store-parallel face: always defined per key; an unseen key snapshots undefined. */
+  /** Store-parallel source family: an unseen key snapshots undefined. */
   const absent = { getSnapshot: () => undefined, subscribe: () => () => {} }
   const sessionEntries: StoredEntry[] = []
-  let withFace = true
+  const bindings = new Map<string, SessionBinding>()
   const rootEntry: StoredEntry = {
     component: (props: { renderSlot: (key: string, owner: object) => React.ReactNode }) =>
       <>{props.renderSlot('k.session', {})}</>,
     options: {},
     children: { 'k.session': { kind: 'single', scope: 'session' } },
   }
-  const info = (id: string): SessionMaybeProvideInfo => ({
-    sessionId: id,
-    hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
+  const binding = (id: string): SessionBinding => {
+    const cached = bindings.get(id)
+    if (cached !== undefined) return cached
+    const value: SessionBinding = {
+      key: id,
+      ctx: scopeCtx,
+      hooks: { session: { getSnapshot: () => ({ sid: id }), subscribe: () => () => {} } },
+      keyedHooks: { projection: key => cells.get(key) ?? absent },
+      props: { sessionId: id },
+    }
+    bindings.set(id, value)
+    return value
+  }
+  const root = observable<StandardSourceBinding>({
+    key: undefined,
+    hooks: {},
+    keyedHooks: {},
     props: {},
-    ...(withFace ? { projections: { faceOf: (key: string) => cells.get(key) ?? absent } } : {}),
   })
+  const sessionAdapter: SlotScopeAdapter = {
+    current: currentBinding,
+    resolve: binding,
+    renderArea: (scopeBinding, { empty, children }) => scopeBinding.key === undefined
+      ? <>{empty?.() ?? null}</>
+      : <>{children}</>,
+  }
   const host: SlotRendererHost = {
     subscribe: () => () => {},
     getVersion: () => 0,
@@ -57,19 +88,19 @@ function makeHost() {
     specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
     isLive: () => true,
     storeOf: () => undefined,
-    sessions: {
-      list: observable<unknown>({ ids: [] }),
-      provideInfo: provide,
-    },
-    workspaces: { list: observable<unknown>({ items: [] }) },
+    root,
+    scopeRevision: observable(0),
+    scope: () => sessionAdapter,
   }
   return {
     host,
     cells,
-    // Same driver surface as before the atomic provide source: set(id)
-    // publishes the resolved bundle (or the absent projection) through it.
-    current: { set: (id: string | undefined) => { provide.set(id === undefined ? absentInfo : info(id)) } },
-    dropFace: () => { withFace = false },
+    // The driver publishes the resolved binding or the absent projection.
+    current: {
+      set: (id: string | undefined) => {
+        currentBinding.set(id === undefined ? absentBinding : binding(id))
+      },
+    },
     registerSession: (entry: StoredEntry) => { sessionEntries.push(entry) },
   }
 }
@@ -90,8 +121,8 @@ describe('useProjection standard-kit delivery', () => {
       },
       options: {},
     })
+    h.current.set('s1')
     render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
-    act(() => { h.current.set('s1') })
     expect(reads.at(-1)).toEqual({ marks: { marks: ['a'] }, ghost: undefined })
     // Live change re-renders with the new whole value.
     act(() => { cell.set({ marks: ['a', 'b'] }) })
@@ -110,25 +141,8 @@ describe('useProjection standard-kit delivery', () => {
       },
       options: {},
     })
+    h.current.set('s1')
     render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
-    act(() => { h.current.set('s1') })
     expect(reads.slice(-2)).toEqual([2, 'absent'])
   })
-
-  it('treats a bundle without the projections face as all-absent (capability absence)', () => {
-    const h = makeHost()
-    h.cells.set('test/marks', observable<unknown>({ marks: ['a'] }))
-    h.dropFace()
-    const reads: unknown[] = []
-    h.registerSession({
-      component: (props: { useProjection: UseProjectionProp }) => {
-        reads.push(props.useProjection('test/marks'))
-        return null
-      },
-      options: {},
-    })
-    render(<>{createSlotRenderer().renderRoot(h.host, {})}</>)
-    act(() => { h.current.set('s1') })
-    expect(reads.at(-1)).toBeUndefined()
-  })
 })

+ 0 - 3
packages/client/ui-renderer/tsconfig.json

@@ -14,9 +14,6 @@
     {
       "path": "../ui-slots"
     },
-    {
-      "path": "../runtime"
-    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }

+ 1 - 0
packages/client/ui-slots/package.json

@@ -27,6 +27,7 @@
   },
   "license": "MIT",
   "devDependencies": {
+    "@deepseek-ai/dsh-client-store": "workspace:^",
     "@deepseek-ai/dsh-invariants": "workspace:^",
     "@types/react": "~18.3.1",
     "@deepseek-ai/cordis": "workspace:^"

+ 16 - 20
packages/client/ui-slots/src/index.ts

@@ -177,28 +177,29 @@ export type ScopeOf<K extends keyof SlotMap & string> = SlotMap[K]['scope']
 
 /**
  * Framework standard kit delivered to every session-scope slot component.
- * Declared EMPTY here (zero-dependency layer): the runtime package merges the
- * real members (`useSession` bound to the conversation snapshot and the
- * framework-supplied `sessionId`) exactly as consumers merge SlotMap keys.
+ * Declared empty here (zero-dependency layer): `ui-session` merges the
+ * Session lifecycle hook, projection hook, and Session identity; domain UI
+ * adapters merge their own standard hooks exactly as consumers merge SlotMap keys.
  */
 export interface SessionStandardProps {}
 
 /**
  * Framework standard kit delivered to current-session-optional slots. Its
  * hooks stay callable while no session is selected and return `undefined`
- * until one becomes current; concrete members merge in at runtime packages.
+ * until one becomes current; `ui-session` and domain UI adapters merge the
+ * concrete members.
  */
 export interface SessionMaybeStandardProps {}
 
 /**
  * Framework standard kit delivered to EVERY slot component (the global seat).
- * Declared empty here; the runtime package merges the global object-layer
- * selector hooks that shared page composition consumes.
+ * Declared empty here; each owning UI adapter merges the global selector hooks
+ * that shared page composition consumes.
  */
 export interface GlobalStandardProps {}
 
 /**
- * The session id type as the runtime's SessionStandardProps merge declares it
+ * The session id type as `ui-session`'s SessionStandardProps merge declares it
  * (branded); falls back to `string` in programs without the merge (this
  * package's own tests).
  */
@@ -233,6 +234,8 @@ export interface RenderOpts<EntryKey extends string = string> {
 export interface ChainRenderOpts {
   /** The owner's fallback body, rendered when every entry's selector declines. */
   fallback?: ReactNode
+  /** Render only the owner fallback without resolving or dispatching the chain's scope. */
+  fallbackOnly?: boolean
   /**
    * Keep the fallback permanently mounted: an election hides it (wrapped,
    * display:none) instead of unmounting it, and the all-decline case shows it
@@ -302,25 +305,18 @@ type RenderSlotFn<S extends keyof SlotMap & string> =
 export type MatchedShare<E extends SlotEntryDef, M> =
   E['kind'] extends 'chain' ? { matched: M } : object
 
-/**
- * Conversation-session selector hook alias for props contracts. Wide by
- * default at this dependency-inverted layer; the runtime narrows at its
- * export outlet (`UseSession<ConversationSnapshot>`).
- */
-export type UseSession<Snap extends object = object> = SnapshotSelectorHook<Snap>
-
-/** Props of the standard-kit SessionProvider seat (render-prop form). */
+/** Props of the standard-kit SessionProvider seat. */
 export interface SessionAreaProps {
   /** No-session body (also covers a current id whose session cannot be resolved). */
   empty?: (() => ReactNode) | undefined
-  /** Session body; the framework remounts it per session (key=sessionId). */
-  children: (sessionId: SessionIdOf) => ReactNode
+  /** Session body; the framework remounts it per session identity. */
+  children: ReactNode
 }
 
 /**
- * Framework-wired session area component. It subscribes to runtime-owned
- * session selection and is injected into entries that declare session-scoped
- * children; business code does not import it directly.
+ * Framework-wired session area component. `ui-session` supplies the current
+ * Controller binding through the renderer scope adapter; entries that declare
+ * session-scoped children receive this component without importing it.
  */
 export type SessionProviderComponent = (props: SessionAreaProps) => ReactNode
 

+ 1 - 1
packages/client/ui-slots/src/invariant.ts

@@ -16,7 +16,7 @@ export const inject = ['invariants']
 
 /**
  * No runtime invariant: a zero-dependency pure registry core — it emits no
- * cordis events itself (the runtime SlotRegistry wrapper owns the event
+ * cordis events itself (the `ui-renderer` SlotRegistry owns the event
  * bridge and its invariants); define/register/dispose sequencing is asserted
  * directly by this package's behavior specs.
  */

+ 74 - 55
packages/client/ui-slots/src/renderer.ts

@@ -1,6 +1,10 @@
 /** React-free contracts between the slot host and an installed renderer. */
+import type { Context } from '@deepseek-ai/cordis'
 import type { ReactNode } from 'react'
-import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
+import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store'
+import type {
+  SessionAreaProps, SlotEntryDef, SlotScope, SlotSpec, StoredEntry, Translate,
+} from './index.ts'
 
 /**
  * The locale face the render machinery consumes: namespace binding plus an
@@ -9,7 +13,7 @@ import type { SlotEntryDef, SlotSpec, StoredEntry, Translate } from './index.ts'
  * active-locale or registry change; the renderer re-derives each entry's `t`
  * from (namespace, revision), so a locale switch hands out NEW function
  * references and memoized components re-render naturally. Implemented by the
- * locale plugin, installed through the runtime SlotRegistry (installLocale).
+ * locale plugin, installed through the `ui-renderer` SlotRegistry.
  * Install before the first render that needs the seat: outlets bind their
  * revision subscription at mount, and a face appearing later has no channel
  * to notify already-mounted outlets (the locale plugin is immediately-tier
@@ -27,10 +31,16 @@ export interface LocaleFace extends HostObservable<{ revision: number }> {
   bind(ns: string): Translate
 }
 
-/** Minimal observable API for host-provided standard-kit data sources. */
-export interface HostObservable<T> {
-  getSnapshot(): T
-  subscribe(fn: () => void): () => void
+/** Observable currency shared by domain sources, stores, and the renderer. */
+export type HostObservable<T> = ObservableSnapshot<T>
+
+/**
+ * Convert one standard source name to its rendered Hook prop name.
+ * @param name - registered fixed or keyed source name.
+ * @returns the `use<Name>` prop exposed to Slot components.
+ */
+export function standardHookPropName(name: string): string {
+  return `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
 }
 
 /**
@@ -51,41 +61,57 @@ export interface StoreInstanceLike {
   readonly actions: Record<string, (...params: never[]) => void>
 }
 
+/** Resolve one member of an open-key standard hook family. */
+export type KeyedStandardSource = (key: string) => HostObservable<unknown> | undefined
+
 /**
- * Per-session standard props resolved per session id (identity-stable per
- * session scope; a recreated scope yields a new info). Plugins contribute
- * members through the runtime `sessions.provide` contract; the render side binds
- * every `hooks` source into a `use<Name>` selector hook (hooks never appear
- * on the host contract) and spreads `props` verbatim. The runtime itself
- * contributes the first entry (`'session'` → `useSession`).
+ * Framework-neutral inputs from which the renderer materializes standard
+ * props. A scope adapter keeps member names present while its binding is
+ * absent so optional slots retain a stable Hook call order.
  */
-export interface SessionMaybeProvideInfo {
-  /** Current session id, absent while the application is in no-session mode. */
-  sessionId: string | undefined
+export interface StandardSourceBinding {
+  /** Scope identity; absent for root data and an optional scope with no selection. */
+  readonly key: string | undefined
+  /** Fixed-name sources; each `name` becomes a `useName` selector Hook. */
+  readonly hooks: Readonly<Record<string, HostObservable<unknown> | undefined>>
+  /** Open-key source families; each `name` becomes a `useName(key, selector)` Hook. */
+  readonly keyedHooks: Readonly<Record<string, KeyedStandardSource | undefined>>
+  /** Stable plain values copied into standard props. */
+  readonly props: Readonly<Record<string, unknown>>
+}
+
+/** Materialized binding for one live non-root scope. */
+export interface ScopedStandardSourceBinding extends StandardSourceBinding {
+  readonly key: string
+  readonly ctx: Context
+}
+
+/** One installed source of bindings for a non-root Slot scope. */
+export interface SlotScopeAdapter {
+  /** Binding that follows the current selection, including its absent projection. */
+  readonly current: HostObservable<StandardSourceBinding>
   /**
-   * Static hook roster. Each value is absent with the session; keys remain so
-   * session-maybe entries always receive the same hook-shaped standard kit.
+   * Resolve an already-materialized binding.
+   * @param key - scope identity.
+   * @returns the binding, or `undefined` when the identity is unavailable.
    */
-  hooks: Record<string, HostObservable<unknown> | undefined>
-  /** Static plain-member roster; values are undefined with the session. */
-  props: Record<string, unknown>
+  resolve(key: string): ScopedStandardSourceBinding | undefined
   /**
-   * Key-addressed projection value sources (the useProjection framework seat;
-   * session-projection subsystem page: docs/subsystems/session-projection.md).
-   * Unlike `hooks`, the key space is open — values
-   * arrive from host-computed push frames — so the render side binds per
-   * resolved key instead of per static roster member. Faces are always
-   * defined per key (absence is an `undefined` snapshot); the whole member is
-   * absent with the session.
+   * Render the scope owner's area seat over the current binding. The renderer
+   * binds this function to the standard `SessionProvider` prop without owning
+   * Session selection semantics.
+   * @param binding - current scope binding, including its absent projection.
+   * @param props - render-prop body and empty branch.
+   * @returns rendered scope area.
    */
-  projections?: { faceOf(key: string): HostObservable<unknown> } | undefined
+  renderArea?(binding: StandardSourceBinding, props: SessionAreaProps): ReactNode
 }
 
-/** Definite per-session standard props resolved for strict session slots. */
-export interface SessionProvideInfo extends SessionMaybeProvideInfo {
-  sessionId: string
-  /** Bare observable sources, keyed by hook base name ('session' → useSession). */
-  hooks: Record<string, HostObservable<unknown>>
+/** Root standard-source contribution installed by one domain UI package. */
+export interface RootStandardSourceContribution {
+  readonly hooks?: Readonly<Record<string, HostObservable<unknown>>>
+  readonly keyedHooks?: Readonly<Record<string, KeyedStandardSource>>
+  readonly props?: Readonly<Record<string, unknown>>
 }
 
 /** renderSlot dispatch options at the machinery level. */
@@ -97,7 +123,7 @@ export interface RenderOpts {
   hookContext?: unknown
 }
 
-/** Host API the runtime SlotRegistry presents to the installed renderer. */
+/** Host API the `ui-renderer` SlotRegistry presents to its React renderer. */
 export interface SlotRendererHost {
   /**
    * Subscribe to a key's registration changes (microtask-batched).
@@ -155,28 +181,21 @@ export interface SlotRendererHost {
    * Resolve (create or return cached) the store instance for an entry's
    * declared handle under a scope key; lifecycle rides the ledger axis.
    * @param entry - entry whose declaration carries the handle.
-   * @param scopeKey - session id for session-scope slots, undefined for root scope.
+   * @param scopeBinding - exact Session binding for scoped slots, undefined for root scope.
    * @returns the instance, or undefined when the entry declares no store.
    */
-  storeOf(entry: StoredEntry, scopeKey: string | undefined): StoreInstanceLike | undefined
-  /** Session-side standard-kit sources. */
-  sessions: {
-    /** Session list source backing the useSessions standard hook. */
-    list: HostObservable<unknown>
-    /**
-     * Atomic current-session provide projection used by SessionProvider:
-     * selection changes and provider-roster changes publish through this one
-     * source, so a stable current id cannot strand mounted entries on an
-     * obsolete hook/prop schema. Carries the static roster with sessionId
-     * undefined while no current session resolves.
-     */
-    provideInfo: HostObservable<SessionMaybeProvideInfo>
-  }
-  /** Workspace-side standard-kit sources. */
-  workspaces: {
-    /** Workspace list source backing the useWorkspaces standard hook. */
-    list: HostObservable<unknown>
-  }
+  storeOf(entry: StoredEntry, scopeBinding: ScopedStandardSourceBinding | undefined): StoreInstanceLike | undefined
+  /** Root standard data assembled from domain-owned contributions. */
+  readonly root: HostObservable<StandardSourceBinding>
+  /** Monotonic source updated whenever the installed scope-adapter roster changes. */
+  readonly scopeRevision: HostObservable<number>
+  /**
+   * Resolve the adapter installed for one non-root scope.
+   * `session` and `session-maybe` intentionally resolve the same adapter.
+   * @param scope - Slot scope.
+   * @returns adapter, or `undefined` when the composition omitted its owner.
+   */
+  scope(scope: Exclude<SlotScope, 'root'>): SlotScopeAdapter | undefined
   /**
    * Installed locale face backing the `t` standard seat (absent until the
    * locale plugin installs one; rendering an entry that declared `locale:`
@@ -185,7 +204,7 @@ export interface SlotRendererHost {
   locale?: LocaleFace | undefined
 }
 
-/** The installation contract: runtime owns install()/renderSlot(); ui-renderer implements rendering. */
+/** The installation contract between the `ui-renderer` SlotRegistry and its React renderer. */
 export interface SlotRenderer {
   /**
    * Render the root slot tree over the host API (the only ctx-level entry).

+ 17 - 132
packages/client/ui-slots/src/store.ts

@@ -1,132 +1,17 @@
-/** Framework-neutral store contracts for slot registrations and the runtime engine. */
-
-/**
- * Typed selector hook over a snapshot source. Canonical shape for the whole
- * slot system (ui-renderer's engine hook is structurally identical; the
- * framework is the only party that ever constructs one).
- */
-export type SnapshotSelectorHook<T> = <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S
-
-/**
- * Selector hook over a source that follows the current session. The hook is
- * always present, while its selected value is absent whenever no session is
- * current. This keeps hook call sites stable across no-session/session
- * transitions without pretending that a session snapshot exists.
- */
-export type MaybeSnapshotSelectorHook<T> =
-  <S>(sel: (s: T) => S, eq?: (a: S, b: S) => boolean) => S | undefined
-
-/**
- * Action declaration table: pure immer-draft transforms over the store state,
- * declared as the store's complete write set (the audit face — components can
- * only write through these).
- */
-/* oxlint-disable-next-line typescript/no-explicit-any --
- * any[] (not unknown[]): each action carries its own parameter list, and
- * unknown[] would reject every concrete signature under strict parameter
- * contravariance. Params are re-inferred per action by BakedActions. */
-export type ActionsDecl<T> = Record<string, (draft: T, ...params: any[]) => void>
-
-/**
- * Draft-stripped callback form of an actions table: what components
- * (`props.actions`) and inject factories receive — the framework bakes the
- * draft parameter away by binding each action to the resolved instance.
- */
-export type BakedActions<T, A extends ActionsDecl<T>> = {
-  [K in keyof A]: A[K] extends (draft: T, ...params: infer P) => void ? (...params: P) => void : never
-}
-
-/**
- * Store declaration spec: initial-state factory (a lambda so every instance
- * gets a fresh state), optional persistence key (mechanical, framework-run),
- * and the actions write set.
- */
-export interface StoreSpec<T, A extends ActionsDecl<T>> {
-  init: () => T
-  persist?: string
-  actions: A
-}
-
-/**
- * Live engine instance: the create() product consumed by the render machinery
- * and by tests. A bare snapshot source plus the baked write set — no React
- * hook rides the engine product (the engine lives in the React-free runtime);
- * the render machinery binds the `useStore` hook from this source on its own
- * side, cached per instance. Production components and render paths never
- * call create() themselves — instance lifecycle is the framework's.
- */
-export interface StoreInstance<T, A extends ActionsDecl<T>> {
-  readonly actions: BakedActions<T, A>
-  getSnapshot(): T
-  /**
-   * Subscribe to state changes (uSES subscribe side).
-   * @param fn - change callback.
-   * @returns unsubscribe.
-   */
-  subscribe(fn: () => void): () => void
-  /**
-   * Drop this instance's persisted value (no-op for non-persist specs). The
-   * framework calls it when the owning scope dies for good — a pruned session
-   * must not leave orphaned storage keys behind.
-   */
-  clearPersisted(): void
-}
-
-/**
- * Store handle: spec + state/actions types + shared identity + instance
- * factory in one value. Handles are constructed in apply world (shared across
- * registrations of one plugin) or by the framework from a registrant's
- * factory (exclusive). Never export a handle at module level — module-cache
- * identity is a disguised singleton across plugin reloads.
- */
-export interface StoreHandle<T, A extends ActionsDecl<T>> {
-  readonly spec: StoreSpec<T, A>
-  /**
-   * Create a live engine instance (framework machinery and tests only).
-   * @param scopeKey - session id for session-scope instances; suffixes the
-   * persist key so per-session instances persist independently (root-scope
-   * instances omit it).
-   * @returns a fresh instance seeded from `spec.init()`.
-   */
-  create(scopeKey?: string): StoreInstance<T, A>
-}
-
-/**
- * Exclusive-store registration form: the registrant passes the factory itself
- * and the framework calls it per entry x scope (no shared identity exists).
- */
-/* oxlint-disable-next-line typescript/no-explicit-any --
- * erased position accepting every StoreHandle instantiation; T/A are
- * recovered per use site by conditional inference (HandleOf/BoundActions/
- * PropsStore). */
-export type StoreFactory = () => StoreHandle<any, any>
-
-/** The register `store` option position: a shared handle or an exclusive factory. */
-// oxlint-disable-next-line typescript/no-explicit-any -- same erased-constraint position as StoreFactory (see above).
-export type StoreDecl = StoreHandle<any, any> | StoreFactory
-
-/** Normalize a store declaration to its handle type (factories yield their return). */
-export type HandleOf<H> = H extends () => infer R ? R : H
-
-/**
- * Handle-keyed baked actions: the `actions` parameter of an inject factory
- * whose registration declared a store — the same baked callback set the
- * component receives via {@link PropsStore}.
- */
-export type BoundActions<H> = H extends StoreHandle<infer T, infer A> ? BakedActions<T, A> : never
-
-/**
- * The store props share, derived from the declared handle: a typed selector
- * hook plus the baked write set. Components never see the instance itself
- * (no update/set — reads via useStore, writes via the declared actions only).
- */
-export type PropsStore<H> = H extends StoreHandle<infer T, infer A>
-  ? { useStore: SnapshotSelectorHook<T>; actions: BakedActions<T, A> }
-  : object
-
-/**
- * The defineStore contract (implementation lives in the runtime package,
- * bound to the snapshot-store engine): spec in, handle out, with T inferred
- * from `init` and the actions table constrained by T.
- */
-export type DefineStore = <T, A extends ActionsDecl<T>>(spec: StoreSpec<T, A>) => StoreHandle<T, A>
+/** Slot-facing re-exports of the React-free store contracts. */
+
+export type {
+  ActionsDecl,
+  BakedActions,
+  BoundActions,
+  DefineStore,
+  HandleOf,
+  MaybeSnapshotSelectorHook,
+  PropsStore,
+  SnapshotSelectorHook,
+  StoreDecl,
+  StoreFactory,
+  StoreHandle,
+  StoreInstance,
+  StoreSpec,
+} from '@deepseek-ai/dsh-client-store'

+ 1 - 1
packages/client/ui-slots/tests/core.client.spec.ts

@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
 import type { SlotComponent, StoreHandle } from '@deepseek-ai/dsh-client-ui-slots'
 import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
 
-// 'root' is NOT merged here: the runtime package owns the built-in row, and
+// 'root' is NOT merged here: ui-renderer owns the built-in row, and
 // the client aggregate program would see both merges collide.
 declare module '@deepseek-ai/dsh-client-ui-slots' {
   interface SlotMap {

+ 4 - 4
packages/client/ui-slots/tests/type-chain.client.spec.tsx

@@ -11,7 +11,7 @@ import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots'
 
 // Only package-unique SlotMap keys are merged here. The standard-kit
 // interfaces (SessionStandardProps/GlobalStandardProps) are NOT re-merged:
-// the runtime package owns the real members, and in the client aggregate
+// the owning UI adapters provide the real members, and in the client aggregate
 // program a toy merge would collide with them — samples below stay
 // shape-agnostic about kit member payloads for the same reason.
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -137,8 +137,8 @@ describe('terminal-design type chain', () => {
       core.register({ name: 'chain.conv', store: chat }, Details)
 
       // Owner + store shares arrive typed on the component face. Standard-kit
-      // member payloads are the runtime merge's property — not probed here
-      // (the runtime package's own tests cover them).
+      // member payloads are the owning adapters' property — not probed here
+      // (their package tests cover them).
       fp.renderSlot('chain.side', { collapsed: false, width: 280 })
       const draft: string = cp.useStore(s => s.draft)
       cp.actions.select({ id: 'm1' })
@@ -283,7 +283,7 @@ describe('terminal-design type chain', () => {
       acts.setDraft(1)
 
       // SessionProvider seat: derives from a session-scope child declaration.
-      fp.SessionProvider({ empty: () => null, children: () => null })
+      fp.SessionProvider({ empty: () => null, children: null })
       const sideOnly: PropsRenderSlots<'chain.side'> = null as never
       // @ts-expect-error only root-scope children declared → no SessionProvider seat
       void sideOnly.SessionProvider

+ 3 - 0
packages/client/ui-slots/tsconfig.json

@@ -8,6 +8,9 @@
     "src"
   ],
   "references": [
+    {
+      "path": "../store"
+    },
     {
       "path": "../../runtime-diagnostics/invariants"
     }

+ 7 - 1
packages/test-support/client-runtime/src/index.ts

@@ -372,7 +372,13 @@ export class SlotTestRuntime {
     }
     const entry = this.host.entriesOf(key)[0]
     if (entry === undefined) throw new Error(`storeOf('${key}'): no registration on the ledger`)
-    const instance = this.host.storeOf(entry, scopeKey)
+    const scopeBinding = scopeKey === undefined
+      ? undefined
+      : this.host.scope('session')?.resolve(scopeKey)
+    if (scopeKey !== undefined && scopeBinding === undefined) {
+      throw new Error(`storeOf('${key}'): no live Session binding for '${scopeKey}'`)
+    }
+    const instance = this.host.storeOf(entry, scopeBinding)
     if (instance === undefined) throw new Error(`storeOf('${key}'): the entry declares no store`)
     return instance
   }

+ 1 - 1
packages/test-support/client-runtime/tests/runtime.client.spec.tsx

@@ -33,7 +33,7 @@ function Frame({ renderSlot, SessionProvider }: FrameProps) {
     <>
       {renderSlot('trt.panel', { label: 'from-owner' }, { fallback: <i>no panel</i> })}
       <SessionProvider empty={() => <i>no session</i>}>
-        {() => renderSlot('trt.chat', {})}
+        {renderSlot('trt.chat', {})}
       </SessionProvider>
       {renderSlot('trt.rows', {})}
     </>