Kaynağa Gözat

feat: slot system + entries/priority/errorreport + typert generator

imccyu 1 ay önce
ebeveyn
işleme
0367506471
28 değiştirilmiş dosya ile 774 ekleme ve 162 silme
  1. 2 2
      packages/client/ui-conversation/src/client/contract/views.ts
  2. 1 1
      packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css
  3. 14 4
      packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx
  4. 34 16
      packages/client/ui-conversation/tests/skeleton.client.spec.tsx
  5. 11 0
      packages/client/ui-layout/src/client/AppFrame.module.css
  6. 4 1
      packages/client/ui-layout/src/client/AppFrame.tsx
  7. 1 1
      packages/client/ui-settings-general/src/client/GeneralSection.module.css
  8. 31 3
      packages/client/ui-sidebar/src/client/SidebarRoot.module.css
  9. 8 3
      packages/client/ui-sidebar/src/client/SidebarRoot.tsx
  10. 8 1
      packages/client/ui-sidebar/src/client/contract/slots.ts
  11. 5 1
      packages/client/ui-sidebar/src/client/index.ts
  12. 4 1
      packages/client/ui-sidebar/tests/apply.client.spec.tsx
  13. 19 2
      packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx
  14. 227 21
      packages/client/ui-slots/src/index.ts
  15. 21 0
      packages/client/ui-slots/src/renderer.ts
  16. 117 29
      packages/client/web-react/src/scoped-slots.tsx
  17. 2 0
      packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx
  18. 29 5
      packages/client/web-react/tests/scoped-slots.client.spec.tsx
  19. 4 0
      packages/client/web-react/tests/session-provider.client.spec.tsx
  20. 4 0
      packages/client/web-react/tests/stale-authorization.client.spec.tsx
  21. 4 0
      packages/client/web-react/tests/use-projection.client.spec.tsx
  22. 26 1
      packages/core/tools/tests/gen-tool-catalog.spec.ts
  23. 6 1
      packages/host/apiproxy/src/api-proxy.ts
  24. 5 1
      packages/host/apiproxy/src/api/index.ts
  25. 13 9
      packages/test-support/client-runtime/src/index.ts
  26. 34 7
      packages/typert/generator/src/analyzer.ts
  27. 114 51
      packages/typert/generator/src/cordis-catalog.ts
  28. 26 1
      packages/typert/generator/tests/cordis-catalog.spec.ts

+ 2 - 2
packages/client/ui-conversation/src/client/contract/views.ts

@@ -14,14 +14,14 @@ export interface ViewTab { id: string; label: string }
 
 /**
  * Per-session state shared by conversation, chat-view, and details slots.
- * Unknown persisted view ids fall back to the first registered view.
+ * Unknown persisted view ids fall back to the stable Chat view.
  */
 export interface ChatStoreState {
   /** Details-linkage channel (conversation writes, details reads). */
   selection: SelectionTarget | null
   /** Composer draft (persisted; survives session switches and reloads). */
   draft: string
-  /** Active conversation view id ('conversation.view' entry id); null falls back to the first view. */
+  /** Active conversation view id ('conversation.view' entry id); null falls back to Chat. */
   view: string | null
   /**
    * One-shot inspect handoff: chat writes the call to reveal, the trajectory

+ 1 - 1
packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css

@@ -280,7 +280,7 @@
   overflow-y: auto;
 }
 
-.scrollBody:has([data-conversation-composer-overlay]) > .viewArea {
+.scrollBody:has([data-conversation-composer-overlay]) > :global([data-slot='conversation.session']) > .viewArea {
   flex: 1 1 0;
   min-height: 0;
   overflow: hidden;

+ 14 - 4
packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx

@@ -6,6 +6,7 @@ import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/d
 import type {
   ConversationSessionHeaderSlotProps, ConversationSessionSlotProps,
 } from '../contract/slots.ts'
+import type { ViewTab } from '../contract/views.ts'
 import css from './ConversationRoot.module.css'
 
 /** Full props composed from the strict session body contract. */
@@ -19,6 +20,15 @@ interface Breadcrumb {
   readonly displayTitle: string
 }
 
+const DEFAULT_VIEW_ID = 'chat'
+
+/** Resolve by id and keep stale persisted selections on the stable Chat fallback. */
+function resolveActiveView(tabs: readonly ViewTab[], selectedId: string | null): ViewTab | undefined {
+  const requestedId = selectedId ?? DEFAULT_VIEW_ID
+  return tabs.find(view => view.id === requestedId)
+    ?? tabs.find(view => view.id === DEFAULT_VIEW_ID)
+}
+
 function deriveAncestry(list: SessionListState, id: SessionId): readonly Breadcrumb[] {
   const chain: Breadcrumb[] = []
   const seen = new Set<SessionId>()
@@ -54,8 +64,8 @@ export function ConversationSessionHeader({
 }: ConversationSessionHeaderProps) {
   useSyncExternalStore(views.subscribe, views.version)
   const tabs = views.list()
-  const activeId = useStore(s => s.view) ?? 'chat'
-  const active = tabs.find(view => view.id === activeId) ?? tabs[0]
+  const selectedId = useStore(s => s.view)
+  const active = resolveActiveView(tabs, selectedId)
   const ancestry = useSessions(s => deriveAncestry(s, sessionId), equalBreadcrumbs)
   const composerPhase = useSession(s => s.composerPhase)
   const blank = useSession(s => s.blank)
@@ -131,8 +141,8 @@ export function ConversationSession({
 }: ConversationSessionProps) {
   useSyncExternalStore(views.subscribe, views.version)
   const tabs = views.list()
-  const activeId = useStore(s => s.view) ?? 'chat'
-  const active = tabs.find(view => view.id === activeId) ?? tabs[0]
+  const selectedId = useStore(s => s.view)
+  const active = resolveActiveView(tabs, selectedId)
   const composerPhase = useSession(s => s.composerPhase)
   const blank = useSession(s => s.blank)
   const inputState = useInput(s => s)

+ 34 - 16
packages/client/ui-conversation/tests/skeleton.client.spec.tsx

@@ -27,6 +27,7 @@ import type { InputBarProps } from '../src/client/skeleton/InputBar.tsx'
 import type {
   ComposerBarOwnerProps,
 } from '../src/client/contract/slots.ts'
+import type { ViewTab } from '../src/client/contract/views.ts'
 
 /** Machine-backed wiring over a sink spy. */
 function fakeWiring() {
@@ -96,6 +97,8 @@ function mount(
     summaryOrigin?: 'subagent'
     /** A composer block another plugin raised for this session. */
     composerBlock?: { reason: string }
+    /** Mutable view ledger used by registration-order regressions. */
+    viewTabs?: ViewTab[]
   } = {},
 ) {
   const root = sid('root')
@@ -123,6 +126,15 @@ function mount(
   const stop = vi.fn()
   const open = vi.fn()
   const slotCalls: string[] = []
+  const viewTabs = options.viewTabs ?? [
+    { id: 'chat', label: 'Chat' },
+    { id: 'trajectory', label: 'Trajectory' },
+  ]
+  const views = {
+    list: () => viewTabs,
+    subscribe: () => () => {},
+    version: () => 1,
+  }
   /** Owner share handed to the two composer tool-row seats, per render. */
   const seatOwners: { key: string; owner: unknown }[] = []
   let pickerOwner: unknown
@@ -146,14 +158,7 @@ function mount(
           useStore={bindSnapshotSelector(chat)}
           actions={chat.actions}
           renderSlot={renderSlot as never}
-          views={{
-            list: () => [
-              { id: 'chat', label: 'Chat' },
-              { id: 'trajectory', label: 'Trajectory' },
-            ],
-            subscribe: () => () => {},
-            version: () => 1,
-          }}
+          views={views}
           open={open}
           t={t}
         />
@@ -173,14 +178,7 @@ function mount(
           useStore={bindSnapshotSelector(chat)}
           actions={chat.actions}
           renderSlot={renderSlot as never}
-          views={{
-            list: () => [
-              { id: 'chat', label: 'Chat' },
-              { id: 'trajectory', label: 'Trajectory' },
-            ],
-            subscribe: () => () => {},
-            version: () => 1,
-          }}
+          views={views}
           releaseSessionImages={vi.fn()}
           bindDraftMirror={write => wiring.bindMirror(write)}
         />
@@ -442,6 +440,26 @@ describe('ConversationRoot resident composer', () => {
     expect(b.view.getByRole('textbox')).toBeTruthy()
   })
 
+  it('keeps the Chat fallback selected by id when a view is inserted before it', () => {
+    const viewTabs: ViewTab[] = [
+      { id: 'chat', label: 'Chat' },
+      { id: 'trajectory', label: 'Trajectory' },
+    ]
+    const b = mount(conversationSnapshot(), undefined, undefined, { viewTabs })
+    // A removed dynamic view leaves its persisted id behind. The visible
+    // fallback is Chat and must stay Chat when another lower-order view lands.
+    act(() => { b.chat.actions.setView('removed-view') })
+    expect(b.view.getByTestId('view-chat')).toBeTruthy()
+
+    viewTabs.unshift({ id: 'new-view', label: 'New view' })
+    b.rerender()
+
+    expect(b.view.getByTestId('view-chat')).toBeTruthy()
+    expect(b.view.queryByTestId('view-new-view')).toBeNull()
+    expect(b.view.getByRole('tab', { name: 'Chat' }).getAttribute('aria-selected')).toBe('true')
+    expect(b.view.getByRole('tab', { name: 'New view' }).getAttribute('aria-selected')).toBe('false')
+  })
+
   it('rolls the pending workspace label back when switching fails', async () => {
     const selectWorkspace = vi.fn(async () => { throw new Error('connect failed') })
     const b = mount(

+ 11 - 0
packages/client/ui-layout/src/client/AppFrame.module.css

@@ -106,3 +106,14 @@
   background: var(--dsw-alias-button-floating-hover);
   border-color: var(--dsw-alias-border-l3);
 }
+
+.overlayLayer {
+  position: absolute;
+  inset: 0;
+  z-index: 20;
+  pointer-events: none;
+}
+
+.overlayLayer > * {
+  pointer-events: auto;
+}

+ 4 - 1
packages/client/ui-layout/src/client/AppFrame.tsx

@@ -20,7 +20,7 @@ import css from './AppFrame.module.css'
 /** Full composed props: runtime share + child-slot render share + store share. */
 export type AppFrameProps =
   & PropsRuntime<'root'>
-  & PropsRenderSlots<'sidebar' | 'conversation' | 'details'>
+  & PropsRenderSlots<'sidebar' | 'conversation' | 'details' | 'shell.overlay'>
   & PropsStore<ReturnType<typeof createLayoutStore>>
 
 /** Center column grid item (session-body building block). */
@@ -190,6 +190,9 @@ export function AppFrame({
         <CenterColumn>{renderSlot('conversation', {})}</CenterColumn>
         <DetailsColumn>{renderSlot('details', {})}</DetailsColumn>
       </>
+      <div className={css.overlayLayer} data-shell-overlay>
+        {renderSlot('shell.overlay', {})}
+      </div>
       {/* The collapsed rail is fixed-width: no resize handle while closed. */}
       {!sidebarCollapsed && <DragHandle side="sidebar" left={cols.sidebar} onStart={onSidebarStart} onDrag={onSidebarDrag} onEnd={onDragEnd} />}
       {cols.details > 0 && <DragHandle side="details" left={viewport - cols.details} onStart={onDetailsStart} onDrag={onDetailsDrag} onEnd={onDragEnd} />}

+ 1 - 1
packages/client/ui-settings-general/src/client/GeneralSection.module.css

@@ -7,6 +7,6 @@
   width: 100%;
 }
 
-.section > :last-child {
+.section > :global([data-slot='settings.general.item']) > :last-child {
   border-bottom: none;
 }

+ 31 - 3
packages/client/ui-sidebar/src/client/SidebarRoot.module.css

@@ -227,11 +227,39 @@
   padding-left: 0;
 }
 
-/* Foot seat: a pure layout socket pinned under the region; the ui-settings
-   trigger row inside owns its own geometry (38px wide row / 36px rail
-   circle) and hover chrome. */
+/* Footer seats: Settings fills the left side and additive actions sit on the
+   right. Each occupant owns its button geometry and hover chrome. */
 .footArea {
   flex: none;
+  display: flex;
+  align-items: flex-end;
+  gap: 8px;
+}
+
+.settingsArea {
+  flex: 1;
+  min-width: 0;
+}
+
+.footerActions {
+  flex: none;
+  display: flex;
+  align-items: flex-end;
+}
+
+/* The 56px rail cannot hold two controls side by side. Keep both reachable in
+   the same footer, stacked in their original order. */
+.collapsed .footArea {
+  flex-direction: column;
+  align-items: center;
+  gap: 0;
+}
+
+.collapsed .settingsArea,
+.collapsed .footerActions {
+  flex: none;
+  display: flex;
+  justify-content: center;
 }
 
 @media (prefers-reduced-motion: reduce) {

+ 8 - 3
packages/client/ui-sidebar/src/client/SidebarRoot.tsx

@@ -6,7 +6,7 @@
  * snap to the 56px rail (one icon each, same top-down order) fading in as the
  * slide ends. The workspace/session browsing region between the New Session
  * button and the foot is the `sidebar.workspaces` registrant's, and the foot
- * is the `sidebar.settings` registrant's; the shell hands them the wide flag
+ * holds `sidebar.settings` plus `sidebar.footer.action`; the shell hands them the wide flag
  * (plus an expand request callback for the browser).
  *
  * The column also owns whether the scroll regions nested in it draw a
@@ -177,9 +177,14 @@ export function SidebarRoot({
         })}
       </div>
 
-      {/* Foot seat: ui-settings registers the trigger row + panel here. */}
+      {/* Footer: Settings stays on the left; optional actions sit beside it. */}
       <div className={css.footArea}>
-        {renderSlot('sidebar.settings', { wide })}
+        <div className={css.settingsArea}>
+          {renderSlot('sidebar.settings', { wide })}
+        </div>
+        <div className={css.footerActions}>
+          {renderSlot('sidebar.footer.action', { wide })}
+        </div>
       </div>
     </div>
   )

+ 8 - 1
packages/client/ui-sidebar/src/client/contract/slots.ts

@@ -50,6 +50,12 @@ export interface SidebarSettingsOwnerProps {
   wide: boolean
 }
 
+/** Owner share of an action rendered beside Settings at the sidebar foot. */
+export interface SidebarFooterActionOwnerProps {
+  /** Whether the sidebar renders wide content (false = 56px rail). */
+  wide: boolean
+}
+
 /**
  * Registrant-private injected share (arrives via the register inject
  * factory). The shell keeps only its own controls: starting a Session from
@@ -72,5 +78,6 @@ export type SidebarRootInjected = {
  * seat. No store is registered.
  */
 export type SidebarRootComponentProps =
-  PropsRuntime<'sidebar'> & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings'>
+  PropsRuntime<'sidebar'>
+  & PropsRenderSlots<'sidebar.workspaces' | 'sidebar.settings' | 'sidebar.footer.action'>
   & SidebarRootInjected & PropsLocale<'sidebar'>

+ 5 - 1
packages/client/ui-sidebar/src/client/index.ts

@@ -6,7 +6,10 @@ import type { SidebarRootInjected } from './contract/slots.ts'
 import { SidebarRoot } from './SidebarRoot.tsx'
 import { en, zh, type SidebarKey } from './locales.ts'
 
-export type { SidebarRootComponentProps, SidebarRootInjected, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from './contract/slots.ts'
+export type {
+  SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarRootInjected,
+  SidebarSectionOwnerProps, SidebarSettingsOwnerProps,
+} from './contract/slots.ts'
 export type { SidebarKey } from './locales.ts'
 
 declare module '@deepseek-ai/dsh-client-ui-slots' {
@@ -44,6 +47,7 @@ export function apply(ctx: ClientContext): void {
       children: {
         'sidebar.workspaces': { kind: 'single', scope: 'root' },
         'sidebar.settings': { kind: 'single', scope: 'root' },
+        'sidebar.footer.action': { kind: 'list', scope: 'root' },
       },
       inject: injectProps,
     }, SidebarRoot),

+ 4 - 1
packages/client/ui-sidebar/tests/apply.client.spec.tsx

@@ -31,11 +31,13 @@ describe('ui-sidebar apply', () => {
     expect(inject).toEqual(['slots', 'layout', 'sessions', 'workspaces', 'locale'])
   })
 
-  it('registers the shell and declares the browsing-region hole', async () => {
+  it('registers the shell and declares its child seats', async () => {
     const b = await bench()
     await b.ctx.plugin({ inject: [...inject], apply }).await()
     expect(b.slots.entries('sidebar')).toHaveLength(1)
     expect(b.slots.spec('sidebar.workspaces')).toEqual({ kind: 'single', scope: 'root' })
+    expect(b.slots.spec('sidebar.settings')).toEqual({ kind: 'single', scope: 'root' })
+    expect(b.slots.spec('sidebar.footer.action')).toEqual({ kind: 'list', scope: 'root' })
     // Copy rides the standard locale seat, not the inject face.
     expect(b.slots.entries('sidebar')[0]!.locale).toBe('sidebar')
     const injected = (b.slots.entries('sidebar')[0]!.inject as () => SidebarRootInjected)()
@@ -61,5 +63,6 @@ describe('ui-sidebar apply', () => {
     await fiber.dispose()
     expect(b.slots.entries('sidebar')).toHaveLength(0)
     expect(b.slots.spec('sidebar.workspaces')).toBeUndefined()
+    expect(b.slots.spec('sidebar.footer.action')).toBeUndefined()
   })
 })

+ 19 - 2
packages/client/ui-sidebar/tests/sidebar-root.client.spec.tsx

@@ -1,7 +1,10 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import { cleanup, fireEvent, render, screen } from '@testing-library/react'
-import type { SidebarRootComponentProps, SidebarSectionOwnerProps, SidebarSettingsOwnerProps } from '../src/client/contract/slots.ts'
+import type {
+  SidebarFooterActionOwnerProps, SidebarRootComponentProps, SidebarSectionOwnerProps,
+  SidebarSettingsOwnerProps,
+} from '../src/client/contract/slots.ts'
 import { SidebarRoot } from '../src/client/SidebarRoot.tsx'
 import { en } from '../src/client/locales.ts'
 
@@ -23,17 +26,25 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
   const toggleSidebar = vi.fn()
   let regionOwner: SidebarSectionOwnerProps | undefined
   let settingsOwner: SidebarSettingsOwnerProps | undefined
+  let footerActionOwner: SidebarFooterActionOwnerProps | undefined
   let current = { collapsed, width }
   const root = () => (
     <SidebarRoot
       collapsed={current.collapsed} width={current.width}
       useSessions={neverHook} useWorkspaces={neverHook}
       startSession={startSession} toggleSidebar={toggleSidebar} t={t}
-      renderSlot={((key: string, owner: SidebarSectionOwnerProps | SidebarSettingsOwnerProps) => {
+      renderSlot={((
+        key: string,
+        owner: SidebarFooterActionOwnerProps | SidebarSectionOwnerProps | SidebarSettingsOwnerProps,
+      ) => {
         if (key === 'sidebar.settings') {
           settingsOwner = owner
           return <div data-testid="settings-seat" data-wide={owner.wide} />
         }
+        if (key === 'sidebar.footer.action') {
+          footerActionOwner = owner
+          return <div data-testid="footer-action-seat" data-wide={owner.wide} />
+        }
         regionOwner = owner as SidebarSectionOwnerProps
         return <div data-testid="region" data-wide={owner.wide} />
       }) as SidebarRootComponentProps['renderSlot']}
@@ -51,6 +62,10 @@ function mountShell({ collapsed = false, width = 300 }: { collapsed?: boolean; w
       if (settingsOwner === undefined) throw new Error('settings owner not rendered')
       return settingsOwner
     },
+    footerActionOwner: () => {
+      if (footerActionOwner === undefined) throw new Error('footer action owner not rendered')
+      return footerActionOwner
+    },
     rerender(next: Partial<typeof current>) {
       current = { ...current, ...next }
       view.rerender(root())
@@ -75,6 +90,7 @@ describe('SidebarRoot shell', () => {
     expect(b.regionOwner().wide).toBe(true)
     // The settings seat rides the same wide flag (ui-settings renders the row).
     expect(b.settingsOwner().wide).toBe(true)
+    expect(b.footerActionOwner().wide).toBe(true)
     // Expanded: the request is a no-op (no accidental collapse).
     b.regionOwner().expandSidebar()
     expect(b.toggleSidebar).not.toHaveBeenCalled()
@@ -89,6 +105,7 @@ describe('SidebarRoot shell', () => {
     vi.advanceTimersByTime(200)
     b.rerender({})
     expect(b.regionOwner().wide).toBe(false)
+    expect(b.footerActionOwner().wide).toBe(false)
     expect(screen.getByTestId('region')).toBeTruthy()
     b.regionOwner().expandSidebar()
     expect(b.toggleSidebar).toHaveBeenCalledOnce()

+ 227 - 21
packages/client/ui-slots/src/index.ts

@@ -473,21 +473,40 @@ export type InjectParams<K extends keyof SlotMap & string, H> =
  */
 export type SlotLabel = string | (() => string)
 
-/** Kind shape fields carried in register options (keyed dispatch key; list id/order/label; chain select/priority). */
+/**
+ * Kind shape fields carried in register options (keyed dispatch key; list
+ * id/order/label; chain select/priority; non-chain priority = cell shadowing rank).
+ */
 export type KindOptions<
   K extends keyof SlotMap & string,
   EntryKey extends EntryKeyOf<K>,
   M = never,
 > =
-  SlotMap[K]['kind'] extends 'keyed' ? { key: EntryKey }
-    : SlotMap[K]['kind'] extends 'list' ? { id: string; order?: number; label?: SlotLabel }
+  SlotMap[K]['kind'] extends 'keyed' ? {
+    key: EntryKey
+    /** Cell shadowing rank (ascending, default 0, lowest renders; same key + same priority throws — see {@link SlotCore.register}). */
+    priority?: number
+  }
+    : SlotMap[K]['kind'] extends 'list' ? {
+      id: string
+      order?: number
+      label?: SlotLabel
+      /** Cell shadowing rank (ascending, default 0, lowest renders; same id + same priority throws — see {@link SlotCore.register}). */
+      priority?: number
+    }
       : SlotMap[K]['kind'] extends 'chain' ? {
         /** Routing selector, mandatory on chain entries; `M` (the component's `matched` prop) infers from its return. */
         select: ChainSelect<SlotMap[K] extends { owner: infer O extends object } ? O : object, M>
         /** Explicit chain position (ascending, default 0, lower tries first); ties keep registration = assembly order. */
         priority?: number
       }
-        : object
+        : {
+          /**
+           * Cell shadowing rank (ascending, default 0, lowest renders; a
+           * same-priority second registration throws — see {@link SlotCore.register}).
+           */
+          priority?: number
+        }
 
 /**
  * Compile-time presence check: an entry declaring children MUST consume
@@ -596,6 +615,8 @@ interface SlotRecord {
   spec: SlotSpec<SlotEntryDef> | undefined
   /** Diagnostics: which slot's entry declared this key ('(built-in)' for root). */
   declaredBy: string | undefined
+  /** Live parent declaration, absent for root slots. */
+  parent: string | undefined
   /** Monotonic declaration lifetime, distinct from ordinary entry mutations. */
   declarationEpoch: number
   entries: readonly StoredEntry[]
@@ -606,6 +627,38 @@ interface SlotRecord {
 
 const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
 
+/** JSON-safe live occupant returned by slot inspection. */
+export interface LiveSlotOccupant {
+  /** Plugin or package that registered the entry, when known. */
+  registrant?: string
+  /** Keyed-slot cell. */
+  key?: string
+  /** List-slot cell. */
+  id?: string
+  /** List display order. */
+  order?: number
+  /** Shadowing or chain priority. */
+  priority: number
+  /** Whether the renderer currently selects this entry. */
+  active: boolean
+}
+
+/** JSON-safe live slot declaration tree. */
+export interface LiveSlotNode {
+  /** Exact SlotMap key. */
+  name: string
+  /** Slot cardinality. */
+  kind: SlotKind
+  /** Runtime data scope. */
+  scope: SlotScope
+  /** Diagnostic owner of this declaration. */
+  declaredBy?: string
+  /** Current registrations in ledger order. */
+  occupants: LiveSlotOccupant[]
+  /** Slots declared by entries mounted in this slot. */
+  children: LiveSlotNode[]
+}
+
 /**
  * Pure slot registry (no cordis; event emission and the renderer installation contract
  * live in the runtime Service wrapper).
@@ -618,7 +671,9 @@ const NO_ENTRIES: readonly StoredEntry[] = Object.freeze([])
  * fire); {@link SlotCore.subscribeDeclaration} fires synchronously for each
  * declaration lifetime boundary; {@link SlotCore.subscribe} notifications
  * batch per microtask, so N same-tick mutations produce one notification per
- * touched key.
+ * touched key. Entry crash reports ({@link SlotCore.reportEntryError}) ride
+ * the same mutation channel when they abdicate, then notify
+ * {@link SlotCore.onEntryError} synchronously.
  */
 export class SlotCore {
   private records = new Map<string, SlotRecord>()
@@ -629,6 +684,16 @@ export class SlotCore {
   // reference skips a lookup (and an unreachable missing-record branch) at flush.
   private dirty = new Set<SlotRecord>()
   private flushScheduled = false
+  /**
+   * Entries retired by an abdicating crash report
+   * ({@link SlotCore.reportEntryError}): excluded from
+   * {@link SlotCore.entriesOfSlot} projections for the rest of their
+   * registration's life, while the registration itself stays on the ledger
+   * (disposal authority remains with the registrant).
+   */
+  private abdicated = new WeakSet<StoredEntry>()
+  private entryErrorListeners
+    = new Set<(key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void>()
 
   constructor() {
     // The a-priori root hole. No markDirty: nothing can observe construction.
@@ -646,11 +711,18 @@ export class SlotCore {
    * re-checks nothing): registering into an undeclared slot throws; declaring
    * an already-declared child key throws (one declarer per slot — the message
    * names the first declarer); mounting one shared store handle under slots
-   * of different scopes throws. Kind constraints: single — duplicate
-   * registration throws; keyed — missing/duplicate `key` throws; list —
-   * missing/duplicate `id` throws; chain — missing `select` throws (the
+   * of different scopes throws. Kind constraints: keyed — missing `key`
+   * throws; list — missing `id` throws; chain — missing `select` throws (the
    * selector is the entry's routing seat, see {@link ChainSelect}).
    *
+   * Shadowing (single/keyed/list): entries sharing one cell (single — the
+   * slot itself; keyed — same `key`; list — same `id`) coexist at distinct
+   * priorities, sorted ascending with ties keeping registration order; the
+   * cell's lowest live entry renders ({@link SlotCore.entriesOfSlot}). A
+   * second registration at an occupied cell's exact priority (default 0)
+   * throws naming the occupant, so priority-less composition keeps the
+   * historical one-occupant-per-cell fail-loud.
+   *
    * Lifecycle: the disposer removes the contribution AND collapses every
    * declared child slot (child entries clear recursively; their stale
    * disposers become no-ops) — one lifecycle axis, no dangling state.
@@ -719,23 +791,33 @@ export class SlotCore {
     }
     const spec = rec.spec
     // Kind constraints stay runtime checks for dynamically-composed callers;
-    // typed callers already satisfied KindOptions statically.
+    // typed callers already satisfied KindOptions statically. Cell occupancy
+    // clashes only at the exact priority: a different priority shadows.
+    const priority = options.priority ?? 0
+    const occupantHint = (occupant: StoredEntry) =>
+      `at priority ${priority}${occupant.registrant !== undefined ? ` (registered by ${occupant.registrant})` : ''} — register at a different priority to shadow it (lowest renders)`
     switch (spec.kind) {
-      case 'single':
-        if (rec.entries.length > 0) throw new Error(`single slot "${options.name}" already has a registration`)
+      case 'single': {
+        const occupant = rec.entries.find(e => (e.options.priority ?? 0) === priority)
+        if (occupant) throw new Error(`single slot "${options.name}" already has a registration ${occupantHint(occupant)}`)
         break
-      case 'keyed':
+      }
+      case 'keyed': {
         if (options.key === undefined) throw new Error(`keyed slot "${options.name}" requires options.key`)
-        if (rec.entries.some(e => e.options.key === options.key)) {
-          throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}"`)
+        const occupant = rec.entries.find(e => e.options.key === options.key && (e.options.priority ?? 0) === priority)
+        if (occupant) {
+          throw new Error(`keyed slot "${options.name}" already has an entry for key "${options.key}" ${occupantHint(occupant)}`)
         }
         break
-      case 'list':
+      }
+      case 'list': {
         if (options.id === undefined) throw new Error(`list slot "${options.name}" requires options.id`)
-        if (rec.entries.some(e => e.options.id === options.id)) {
-          throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}"`)
+        const occupant = rec.entries.find(e => e.options.id === options.id && (e.options.priority ?? 0) === priority)
+        if (occupant) {
+          throw new Error(`list slot "${options.name}" already has an entry with id "${options.id}" ${occupantHint(occupant)}`)
         }
         break
+      }
       case 'chain':
         if (options.select === undefined) throw new Error(`chain slot "${options.name}" requires options.select`)
         break
@@ -777,10 +859,13 @@ export class SlotCore {
       ...(options.registrant !== undefined ? { registrant: options.registrant } : {}),
     }
     const next = [...rec.entries, entry]
-    // Stable sorts: ascending, ties keep registration sequence (list rides
-    // `order`, chain rides `priority` — lower priority tries first).
-    if (spec.kind === 'list') next.sort((a, b) => (a.options.order ?? 0) - (b.options.order ?? 0))
-    if (spec.kind === 'chain') next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
+    // Stable sorts: priority ascending for every kind, ties keep registration
+    // sequence — a cell's winner is its first occurrence, chain tries lower
+    // priority first. List refines equal priorities by explicit `order` so the
+    // raw ledger keeps its display sequence for priority-less compositions.
+    next.sort(spec.kind === 'list'
+      ? (a, b) => ((a.options.priority ?? 0) - (b.options.priority ?? 0)) || ((a.options.order ?? 0) - (b.options.order ?? 0))
+      : (a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
     rec.entries = next
     this.markDirty(options.name, rec)
     if (options.children) {
@@ -789,6 +874,7 @@ export class SlotCore {
         const childRec = this.record(childKey)
         childRec.spec = childSpec
         childRec.declaredBy = `an entry in "${options.name}"${options.registrant ? ` (${options.registrant})` : ''}`
+        childRec.parent = options.name
         childRec.declarationEpoch += 1
         declarations.push([childKey, childRec])
       }
@@ -835,6 +921,36 @@ export class SlotCore {
     return this.records.get(key)?.entries ?? NO_ENTRIES
   }
 
+  /**
+   * Project a key's entries to its shadowing winners: the first live
+   * (non-abdicated) entry of each cell in priority order — single: the slot
+   * is one cell; keyed: one cell per `key`; list: one cell per `id` (winners
+   * keep ledger sequence; list renderers still refine display by `order`).
+   * Chain keys return the raw entries unchanged: election consumes every
+   * entry, shadowing does not apply. The raw {@link SlotCore.entries} view
+   * stays the inspection surface. Builds a fresh array per call — a render
+   * body read, not a uSES getSnapshot source.
+   * @param key - slot key (dynamic: the render machinery holds keys as strings).
+   * @returns the winning entry per occupied cell (empty while undeclared).
+   */
+  entriesOfSlot(key: string): readonly StoredEntry[] {
+    const rec = this.records.get(key)
+    if (!rec?.spec) return NO_ENTRIES
+    const kind = rec.spec.kind
+    if (kind === 'chain') return rec.entries
+    const heads: StoredEntry[] = []
+    const seenCells = new Set<string | undefined>()
+    for (const entry of rec.entries) {
+      if (this.abdicated.has(entry)) continue
+      // Single-kind entries all share the one undefined cell.
+      const cell = kind === 'keyed' ? entry.options.key : kind === 'list' ? entry.options.id : undefined
+      if (seenCells.has(cell)) continue
+      seenCells.add(cell)
+      heads.push(entry)
+    }
+    return heads
+  }
+
   /**
    * Look up a slot's declared spec, narrowed by the SlotMap key.
    * @param key - SlotMap key.
@@ -855,6 +971,53 @@ export class SlotCore {
     return this.records.get(key)?.spec
   }
 
+  /**
+   * Export the current declaration topology without components or executable hooks.
+   * @param root - exact Slot key to select; omitted returns every live root.
+   * @returns selected live Slot trees, or an empty array when `root` is unavailable.
+   */
+  snapshot(root?: string): LiveSlotNode[] {
+    const build = (name: string, seen: Set<string>): LiveSlotNode | undefined => {
+      const record = this.records.get(name)
+      if (record?.spec === undefined || seen.has(name)) return undefined
+      const branch = new Set(seen)
+      branch.add(name)
+      const active = new Set(this.entriesOfSlot(name))
+      const children = [...this.records.entries()]
+        .filter(([, candidate]) => candidate.spec !== undefined && candidate.parent === name)
+        .flatMap(([child]) => {
+          const node = build(child, branch)
+          return node === undefined ? [] : [node]
+        })
+      return {
+        name,
+        kind: record.spec.kind,
+        scope: record.spec.scope,
+        ...record.declaredBy === undefined ? {} : { declaredBy: record.declaredBy },
+        occupants: record.entries.map(entry => ({
+          ...entry.registrant === undefined ? {} : { registrant: entry.registrant },
+          ...entry.options.key === undefined ? {} : { key: entry.options.key },
+          ...entry.options.id === undefined ? {} : { id: entry.options.id },
+          ...entry.options.order === undefined ? {} : { order: entry.options.order },
+          priority: entry.options.priority ?? 0,
+          active: active.has(entry),
+        })),
+        children,
+      }
+    }
+    if (root !== undefined) {
+      const node = build(root, new Set())
+      return node === undefined ? [] : [node]
+    }
+    return [...this.records.entries()]
+      .filter(([, record]) => record.spec !== undefined
+        && (record.parent === undefined || this.records.get(record.parent)?.spec === undefined))
+      .flatMap(([name]) => {
+        const node = build(name, new Set())
+        return node === undefined ? [] : [node]
+      })
+  }
+
   /**
    * Read the declaration lifetime of a key. Entry additions and removals do
    * not change it; declaration creation and collapse each advance it.
@@ -916,6 +1079,47 @@ export class SlotCore {
     return () => { this.mutateListeners.delete(fn) }
   }
 
+  /**
+   * Renderer crash report from an entry boundary. Always notifies
+   * {@link SlotCore.onEntryError} listeners; with `info.abdicate` set (the
+   * shadowing kinds — single/keyed/list) it first retires the entry from its
+   * cell, one-shot: the record's version bumps through the ordinary mutation
+   * channel so outlets re-project onto the cell's next survivor, and a
+   * repeat abdicating report no-ops entirely. Chain crashes report with
+   * `abdicate: false` — election alternatives resolve at select time, so the
+   * entry keeps its cell and only the notification fires. The registration
+   * itself stays on the ledger either way — raw {@link SlotCore.entries}
+   * still lists the entry and its disposer keeps working.
+   * @param key - slot key the entry rendered under.
+   * @param entry - the crashed entry.
+   * @param error - the crash cause, forwarded to listeners verbatim.
+   * @param info - `abdicate`: whether the crash retires the entry from its cell.
+   */
+  reportEntryError(key: string, entry: StoredEntry, error: unknown, info: { abdicate: boolean }): void {
+    if (info.abdicate) {
+      if (this.abdicated.has(entry)) return
+      this.abdicated.add(entry)
+      const rec = this.records.get(key)
+      if (rec !== undefined) this.markDirty(key, rec)
+    }
+    for (const fn of [...this.entryErrorListeners]) fn(key, entry, error, { abdicated: info.abdicate })
+  }
+
+  /**
+   * Observe entry boundary crashes (every render-time entry failure the
+   * boundaries contain, abdicating or not) — the supervision seam for hosts
+   * mirroring contribution health. Fires synchronously per report, after the
+   * registry mutated for abdicating crashes (same listener discipline as
+   * {@link SlotCore.onMutate}).
+   * @param fn - called with the slot key, the crashed entry, the crash
+   * cause, and `abdicated`: whether the crash retired the entry from its cell.
+   * @returns unsubscribe.
+   */
+  onEntryError(fn: (key: string, entry: StoredEntry, error: unknown, info: { abdicated: boolean }) => void): () => void {
+    this.entryErrorListeners.add(fn)
+    return () => { this.entryErrorListeners.delete(fn) }
+  }
+
   /**
    * Cascade for a removed entry: release its store mount and collapse every
    * child slot it declared — specs clear, contributions empty (their stale
@@ -935,6 +1139,7 @@ export class SlotCore {
       const doomed = childRec.entries
       childRec.spec = undefined
       childRec.declaredBy = undefined
+      childRec.parent = undefined
       childRec.declarationEpoch += 1
       childRec.entries = NO_ENTRIES
       this.markDirty(childKey, childRec)
@@ -949,6 +1154,7 @@ export class SlotCore {
       rec = {
         spec: undefined,
         declaredBy: undefined,
+        parent: undefined,
         declarationEpoch: 0,
         entries: NO_ENTRIES,
         version: 0,

+ 21 - 0
packages/client/ui-slots/src/renderer.ts

@@ -118,6 +118,27 @@ export interface SlotRendererHost {
    * @returns entries in registration (list: order) sequence.
    */
   entriesOf(key: string): readonly StoredEntry[]
+  /**
+   * Shadowing winners per cell for a key — the render read for single/keyed/
+   * list dispatch: the first live (non-abdicated) entry of each cell in
+   * priority order; chain keys pass through unchanged (election consumes
+   * every entry). Fresh array per call — a render-body read, not a uSES
+   * getSnapshot source.
+   * @param key - slot key.
+   * @returns the winning entry per occupied cell.
+   */
+  entriesOfSlot(key: string): readonly StoredEntry[]
+  /**
+   * Report an entry boundary crash. With `info.abdicate` (shadowing kinds)
+   * the entry retires from its cell, one-shot, so the next survivor renders;
+   * chain crashes report without abdicating. The registration stays on the
+   * ledger either way.
+   * @param key - slot key the entry rendered under.
+   * @param entry - the crashed entry.
+   * @param error - the crash cause.
+   * @param info - `abdicate`: whether the crash retires the entry from its cell.
+   */
+  reportEntryError(key: string, entry: StoredEntry, error: unknown, info: { abdicate: boolean }): void
   /**
    * Declared runtime spec from the declarations ledger.
    * @param key - slot key.

+ 117 - 29
packages/client/web-react/src/scoped-slots.tsx

@@ -283,12 +283,14 @@ function useLocaleRevision(face: LocaleFace | undefined): number {
 }
 
 /**
- * Entry-identity React keys for chain boundaries. A chain outlet renders ONE
- * elected entry through an error boundary; without a key, a boundary that
- * failed on entry A would survive a re-election and keep a healthy entry B
- * blacked out. Keying by entry identity remounts the boundary fresh whenever
- * the election changes (entries are identity-stable per registration, so the
- * key is stable while the same entry stays elected).
+ * Entry-identity React keys for entry boundaries. An outlet renders one
+ * winner per position (single/keyed/list cell head, chain election) through
+ * an error boundary; without a key, a boundary that failed on entry A would
+ * survive a winner change (re-election, shadowing fallback after an
+ * abdication, HMR re-registration) and keep a healthy entry B blacked out.
+ * Keying by entry identity remounts the boundary fresh whenever the winner
+ * changes (entries are identity-stable per registration, so the key is
+ * stable while the same entry stays the winner).
  */
 let nextEntryKey = 0
 const entryKeys = new WeakMap<StoredEntry, number>()
@@ -306,9 +308,14 @@ function entryKeyOf(entry: StoredEntry): number {
  * Per-entry isolation: one registrant crashing (component render or inject
  * factory) must not take down siblings. Assembly errors (missing providers)
  * rethrow — a miswired shell must fail loud, not degrade into fallbacks.
+ * Every catch reports through `onEntryError` (the ledger's supervision
+ * seam); for shadowing kinds the report abdicates the entry, the outlet
+ * re-renders onto the cell's next survivor, and this boundary's crash face
+ * only shows until that re-render lands (permanently once the cell is dry —
+ * the outlet then owns the crash face).
  */
 class SlotErrorBoundary extends Component<
-  { slotKey: string; children: ReactNode }, { failed: boolean }
+  { slotKey: string; onEntryError: (error: unknown) => void; children: ReactNode }, { failed: boolean }
 > {
   override state = { failed: false }
   static getDerivedStateFromError(error: unknown): { failed: boolean } {
@@ -317,6 +324,7 @@ class SlotErrorBoundary extends Component<
   }
   override componentDidCatch(error: unknown): void {
     console.error(`slot entry crashed in '${this.props.slotKey}':`, error)
+    this.props.onEntryError(error)
   }
   override render(): ReactNode {
     if (this.state.failed) return <div data-slot-error={this.props.slotKey} />
@@ -607,18 +615,21 @@ function RootEntry({ entry, ownerProps, slotKey, slotInjected, hookContext, hasH
   return renderEntry(slotKey, Comp, kit, standard, injected, slotInjected, ownerProps, hookContext, hasHookContext)
 }
 
-function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext }: {
+function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookContext, hasHookContext, onEntryError }: {
   slotKey: string
   entry: StoredEntry
   ownerProps: object
   slotInjected: BoundSlotInject
   hookContext: unknown
   hasHookContext: boolean
+  onEntryError: (error: unknown) => void
 }) {
   const info = useSessionMaybeProvideInfo()
   if (info.sessionId === undefined) return null
+  // 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}>
+    <SlotErrorBoundary slotKey={slotKey} key={info.sessionId} onEntryError={onEntryError}>
       <SessionEntry
         entry={entry}
         ownerProps={ownerProps}
@@ -632,6 +643,14 @@ function StrictSessionEntry({ slotKey, entry, ownerProps, slotInjected, hookCont
   )
 }
 
+/**
+ * Anchor style shared by every outlet wrapper: `display:contents` keeps the
+ * wrapper out of layout (grid/flex parents see the slot's own children), so
+ * the anchor is purely addressable surface. Module-level constant — a stable
+ * reference so the wrapper never diffs its style prop.
+ */
+const ANCHOR_STYLE = { display: 'contents' } as const
+
 function SlotOutlet({ slotKey, ownerProps, opts }: {
   slotKey: string
   ownerProps: object
@@ -647,6 +666,27 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
   // bodies re-derive their `t` seat at the new revision (fresh identity).
   useLocaleRevision(host.locale)
   const sessionInfo = useSessionMaybeProvideInfo()
+  // 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
+  // rides the outlet, not the dispatch outcome: fallback, crash-face, and
+  // undeclared-empty states all render inside it, so the anchor's presence
+  // never flickers with registration churn.
+  return (
+    <div data-slot={slotKey} style={ANCHOR_STYLE}>
+      {renderOutletContent(host, slotKey, ownerProps, opts, sessionInfo)}
+    </div>
+  )
+}
+
+/** Kind dispatch behind the outlet anchor (single/keyed/list/chain, fallbacks, crash faces). */
+function renderOutletContent(
+  host: SlotRendererHost,
+  slotKey: string,
+  ownerProps: object,
+  opts: (RenderOpts & ChainRenderOpts) | undefined,
+  sessionInfo: SessionMaybeProvideInfo,
+): 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
@@ -667,6 +707,13 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
   const guarded = (entry: StoredEntry, key?: string | number, owner: object = ownerProps) => {
     const hasHookContext = opts !== undefined && Object.hasOwn(opts, 'hookContext')
     const hookContext = opts?.hookContext
+    // Shadowing kinds abdicate on crash (the cell falls to its next
+    // survivor); chain reports without abdicating — election alternatives
+    // resolve at select time, and retiring a crashed elected entry would
+    // change the static crash face.
+    const onEntryError = (error: unknown) => {
+      host.reportEntryError(slotKey, entry, error, { abdicate: spec.kind !== 'chain' })
+    }
     return spec.scope === 'session'
       ? (
         <StrictSessionEntry
@@ -676,11 +723,12 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
           slotInjected={slotInjected}
           hookContext={hookContext}
           hasHookContext={hasHookContext}
+          onEntryError={onEntryError}
           key={key}
         />
       )
       : (
-        <SlotErrorBoundary slotKey={slotKey} key={key}>
+        <SlotErrorBoundary slotKey={slotKey} key={key} onEntryError={onEntryError}>
           {spec.scope === 'session-maybe'
             ? (
               <SessionMaybeEntry
@@ -705,15 +753,22 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
         </SlotErrorBoundary>
       )
   }
+  // A cell whose every registration abdicated keeps the crash face: the
+  // shadowing collapse ran out of survivors, which is a failure state, not
+  // the owner's natural-empty fallback.
+  const deadCell = () => <div data-slot-error={slotKey} />
 
   if (spec.kind === 'single') {
-    const entry = entries[0]
-    if (!entry) return <>{opts?.fallback ?? null}</>
+    const entry = host.entriesOfSlot(slotKey)[0]
+    if (!entry) return entries.length > 0 ? deadCell() : <>{opts?.fallback ?? null}</>
     return guarded(entry, entryKeyOf(entry))
   }
   if (spec.kind === 'keyed') {
-    const entry = entries.find(e => e.options.key === opts?.entryKey)
-    if (!entry) return <>{opts?.fallback ?? null}</>
+    const entry = host.entriesOfSlot(slotKey).find(e => e.options.key === opts?.entryKey)
+    if (!entry) {
+      const occupied = entries.some(e => e.options.key === opts?.entryKey)
+      return occupied ? deadCell() : <>{opts?.fallback ?? null}</>
+    }
     return guarded(entry, entryKeyOf(entry))
   }
   if (spec.kind === 'chain') {
@@ -764,16 +819,35 @@ function SlotOutlet({ slotKey, ownerProps, opts }: {
     }
     return elected ?? <>{opts?.fallback ?? null}</>
   }
-  // list: registration order refined by explicit order, optional id filter.
-  const withListOptions = entries.map(entry => ({
+  // 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
+  // silently drop its row). Row sequence: registration order refined by
+  // explicit order, optional id filter, as before shadowing existed.
+  const winners = host.entriesOfSlot(slotKey)
+  const rows: { entry: StoredEntry | undefined; id: string | undefined; order: number }[] = winners.map(entry => ({
     entry,
     id: entry.options.id,
     order: entry.options.order ?? 0,
   }))
-  let list = [...withListOptions].sort((a, b) => a.order - b.order)
+  const rowIds = new Set(rows.map(row => row.id))
+  for (const entry of entries) {
+    if (rowIds.has(entry.options.id)) continue
+    rowIds.add(entry.options.id)
+    // Dry cells anchor their row at the cell head's declared order.
+    rows.push({ entry: undefined, id: entry.options.id, order: entry.options.order ?? 0 })
+  }
+  let list = [...rows].sort((a, b) => a.order - b.order)
   if (opts?.only !== undefined) list = list.filter(item => item.id === opts.only)
   if (list.length === 0) return <>{opts?.fallback ?? null}</>
-  return <>{list.map(item => guarded(item.entry, entryKeyOf(item.entry)))}</>
+  // Winner rows key by entry identity (see entryKeyOf); dry-cell rows key by
+  // id — the disjoint prefixes keep the two namespaces from colliding.
+  return (
+    <>
+      {list.map((item, i) => item.entry !== undefined
+        ? guarded(item.entry, `e${entryKeyOf(item.entry)}`)
+        : <div data-slot-error={slotKey} key={`x${item.id ?? i}`} />)}
+    </>
+  )
 }
 
 /** Root outlet: the shell's single ctx-level render entry — an unregistered 'root' is a boot-order failure, never a silent blank. */
@@ -784,19 +858,33 @@ function RootOutlet({ ownerProps }: { ownerProps: object }) {
     () => host.getVersion('root'),
   )
   useLocaleRevision(host.locale)
-  const entry = host.entriesOf('root')[0]
-  if (!entry) throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
+  const entry = host.entriesOfSlot('root')[0]
+  if (!entry) {
+    // Registrations exist but every one abdicated: the shadowing collapse ran
+    // dry, so the crash face replaces the tree (registered-but-broken is a
+    // crash, not the boot-order assembly failure below).
+    if (host.entriesOf('root').length > 0) return <div data-slot-error="root" />
+    throw new SlotAssemblyError("renderSlot('root') before any 'root' registration (boot order)")
+  }
+  // Same anchor contract as SlotOutlet: 'root' is a slot like any other, and
+  // display:contents keeps the wrapper out of the shell's layout.
   return (
-    <SlotErrorBoundary slotKey="root" key={entryKeyOf(entry)}>
-      <RootEntry
-        entry={entry}
-        ownerProps={ownerProps}
+    <div data-slot="root" style={ANCHOR_STYLE}>
+      <SlotErrorBoundary
         slotKey="root"
-        slotInjected={EMPTY_SLOT_INJECT}
-        hookContext={undefined}
-        hasHookContext={false}
-      />
-    </SlotErrorBoundary>
+        key={entryKeyOf(entry)}
+        onEntryError={(error) => { host.reportEntryError('root', entry, error, { abdicate: true }) }}
+      >
+        <RootEntry
+          entry={entry}
+          ownerProps={ownerProps}
+          slotKey="root"
+          slotInjected={EMPTY_SLOT_INJECT}
+          hookContext={undefined}
+          hasHookContext={false}
+        />
+      </SlotErrorBoundary>
+    </div>
   )
 }
 

+ 2 - 0
packages/client/web-react/tests/scoped-slots-real-core.client.spec.tsx

@@ -31,6 +31,8 @@ function hostOver(core: SlotCore): SlotRendererHost {
     subscribe: (key, fn) => core.subscribe(key, fn),
     getVersion: key => core.getVersion(key),
     entriesOf: key => core.entries(key),
+    entriesOfSlot: key => core.entriesOfSlot(key),
+    reportEntryError: (key, entry, error, info) => { core.reportEntryError(key, entry, error, info) },
     specOf: key => core.specDynamic(key),
     isLive: entry => core.isLive(entry),
     storeOf: () => undefined,

+ 29 - 5
packages/client/web-react/tests/scoped-slots.client.spec.tsx

@@ -83,6 +83,7 @@ function makeHost() {
   const versions = new Map<string, number>()
   const subs = new Map<string, Set<() => void>>()
   const live = new Set<StoredEntry>()
+  const abdicated = new Set<StoredEntry>()
   const storeCache = new Map<StoredEntry, Map<string, StoreInstanceLike>>()
   const list = observable<{ ids: string[] }>({ ids: [] })
   const workspaces = observable<{ ids: string[] }>({ ids: [] })
@@ -105,6 +106,28 @@ function makeHost() {
     },
     getVersion: key => versions.get(key) ?? 0,
     entriesOf: key => entries.get(key) ?? [],
+    entriesOfSlot: (key) => {
+      const all = entries.get(key) ?? []
+      const kind = specs.get(key)?.kind
+      if (kind === 'chain') return all
+      // Mirror the ledger projection: first live (non-abdicated) entry per
+      // cell (single — one cell; keyed — per key; list — per id).
+      const heads: StoredEntry[] = []
+      const seen = new Set<string | undefined>()
+      for (const entry of all) {
+        if (abdicated.has(entry)) continue
+        const cell = kind === 'keyed' ? entry.options.key : kind === 'list' ? entry.options.id : undefined
+        if (seen.has(cell)) continue
+        seen.add(cell)
+        heads.push(entry)
+      }
+      return heads
+    },
+    reportEntryError: (key, entry, _error, info) => {
+      if (!info.abdicate || abdicated.has(entry)) return
+      abdicated.add(entry)
+      bump(key)
+    },
     specOf: key => specs.get(key),
     isLive: entry => live.has(entry),
     storeOf: (entry, scopeKey) => {
@@ -147,11 +170,12 @@ function makeHost() {
     add: (key: string, partial: Omit<StoredEntry, 'options'> & { options?: StoredEntry['options'] }) => {
       const entry = entryOf(partial)
       const next = [...(entries.get(key) ?? []), entry]
-      // Mirror the ledger contract: chain entries arrive priority-sorted
-      // (stable, ascending) — outlets iterate entries() order as-is.
-      if (specs.get(key)?.kind === 'chain') {
-        next.sort((a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
-      }
+      // Mirror the ledger contract: entries arrive priority-sorted (stable,
+      // ascending; list refines equal priorities by order) — outlets iterate
+      // entries() order as-is.
+      next.sort(specs.get(key)?.kind === 'list'
+        ? (a, b) => ((a.options.priority ?? 0) - (b.options.priority ?? 0)) || ((a.options.order ?? 0) - (b.options.order ?? 0))
+        : (a, b) => (a.options.priority ?? 0) - (b.options.priority ?? 0))
       entries.set(key, next)
       live.add(entry)
       bump(key)

+ 4 - 0
packages/client/web-react/tests/session-provider.client.spec.tsx

@@ -46,6 +46,10 @@ function makeHost(bodies: { root: (rp: (key: string, owner: object) => React.Rea
     subscribe: () => () => {},
     getVersion: () => 0,
     entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
+    // Single-kind everywhere and no crashes in this suite: the projection is
+    // the raw view and crash reports never fire.
+    entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
+    reportEntryError: () => {},
     specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
     isLive: () => true,
     storeOf: () => undefined,

+ 4 - 0
packages/client/web-react/tests/stale-authorization.client.spec.tsx

@@ -35,6 +35,10 @@ function makeHost() {
     },
     getVersion: key => versions.get(key) ?? 0,
     entriesOf: key => entries.get(key) ?? [],
+    // Single-kind everywhere and no crashes in this suite: the projection is
+    // the raw view and crash reports never fire.
+    entriesOfSlot: key => entries.get(key) ?? [],
+    reportEntryError: () => {},
     specOf: () => ({ kind: 'single', scope: 'root' }),
     isLive: entry => live.has(entry),
     storeOf: () => undefined,

+ 4 - 0
packages/client/web-react/tests/use-projection.client.spec.tsx

@@ -49,6 +49,10 @@ function makeHost() {
     subscribe: () => () => {},
     getVersion: () => 0,
     entriesOf: key => key === 'root' ? [rootEntry] : sessionEntries,
+    // Single-kind everywhere and no crashes in this suite: the projection is
+    // the raw view and crash reports never fire.
+    entriesOfSlot: key => key === 'root' ? [rootEntry] : sessionEntries,
+    reportEntryError: () => {},
     specOf: key => key === 'k.session' ? { kind: 'single', scope: 'session' } : undefined,
     isLive: () => true,
     storeOf: () => undefined,

+ 26 - 1
packages/core/tools/tests/gen-tool-catalog.spec.ts

@@ -5,9 +5,11 @@
 import { describe, expect, it } from 'vitest'
 import {
   assertManifestComplete,
+  assertToolsHarvested,
   collectToolCatalog,
   render,
   type ToolCatalog,
+  type ToolPackage,
 } from '../../../../scripts/gen-tool-catalog.ts'
 
 /** JSON Schema shape enough to reach the values AST extraction can't. */
@@ -23,7 +25,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
   it('boots every shipped tool package and harvests its model-facing schemas', async () => {
     const catalog = await collectToolCatalog()
     const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
-    expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
+    expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_define', 'cordis_package_inspect', 'cordis_run', 'cordis_runtime_inspect', 'cordis_stop', 'cordis_undefine', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'schedule_create', 'schedule_delete', 'schedule_list', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
     // Every tool carries a JSON-Schema `parameters` object (what the model sees).
     for (const entry of catalog) {
       for (const schema of entry.schemas) {
@@ -91,6 +93,29 @@ describe('gen-tool-catalog assertManifestComplete', () => {
   })
 })
 
+describe('gen-tool-catalog assertToolsHarvested', () => {
+  const entry: ToolPackage = {
+    pkg: '@deepseek-ai/dsh-tool-demo',
+    dir: 'tool-demo',
+    source: 'packages/demo/tool-demo/src/index.ts',
+    requires: ['ctx.tools', 'ctx.somethingUnmounted'],
+    writes: ['tool/result'],
+    mount: () => Promise.resolve(),
+  }
+
+  it('accepts a boot that registered at least one tool', () => {
+    expect(() => { assertToolsHarvested(entry, 1) }).not.toThrow()
+  })
+
+  it('throws, naming the package and its requirements, when a boot registers nothing', () => {
+    // The failure this guards is silent by construction: the package is in the
+    // manifest, its plugin merely stays PENDING on an unmounted service, and the
+    // catalog would ship without its tools while every gate stays green.
+    expect(() => { assertToolsHarvested(entry, 0) }).toThrow(/@deepseek-ai\/dsh-tool-demo booted without registering a single tool/)
+    expect(() => { assertToolsHarvested(entry, 0) }).toThrow(/ctx.somethingUnmounted/)
+  })
+})
+
 describe('gen-tool-catalog render', () => {
   it('emits a package heading, a tool heading, and a json schema fence', () => {
     const catalog: ToolCatalog = [

+ 6 - 1
packages/host/apiproxy/src/api-proxy.ts

@@ -69,6 +69,11 @@ import { GoalError } from '@deepseek-ai/dsh-goal'
 import type { GoalRef as CoreGoalRef } from '@deepseek-ai/dsh-goal'
 // Type-only edges: resolve the command-change stream and `ctx.get('skills')`.
 import type {} from '@deepseek-ai/dsh-commands'
+// Type-only: the dynamic-package runner's forwarded-event declarations. Its
+// client-safe `./types` subpath deliberately, not the package root — the root
+// merges `ctx.dynamicCordisRunner`, and a dependency on that package would
+// rebuild the api-remotes cycle this direction exists to avoid.
+import type {} from '@deepseek-ai/dsh-cordis-host-runner/types'
 import type {} from '@deepseek-ai/dsh-skill'
 // The settings/credentials seams: brand guards run at this wire boundary; the
 // service reads stay optional (`ctx.get`) so a composition without either
@@ -1044,7 +1049,7 @@ function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceVie
 /**
  * Implement ApiProxy over a composed host context.
  * @param ctx - a context with the Host spine and Workspace registry mounted.
- * @param defaults - Agent model and project-directory defaults.
+ * @param defaults - host routing and project-directory defaults.
  * @returns the ApiProxy implementation.
  */
 export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {

+ 5 - 1
packages/host/apiproxy/src/api/index.ts

@@ -33,7 +33,11 @@ export interface ApiProxy {
   llm: LlmApi
   /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
   downloads: DownloadsApi
-  /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
+  /**
+   * Response entry for server requests; not a domain method.
+   * @param message - Client response carrying the server request's rpcId.
+   * @returns Transport receipt for the response delivery.
+   */
   respond(message: ClientResponse): Promise<RpcReceipt>
 }
 

+ 13 - 9
packages/test-support/client-runtime/src/index.ts

@@ -50,12 +50,13 @@ type ErasedRegister = (options: object, component: unknown) => () => void
 
 /**
  * One rendered slot's local view, from {@link SlotTestRuntime.renderSlot}:
- * the `data-slot` wrapper is the snapshot root (`expect(view.container)
- * .toMatchSnapshot()` captures exactly this slot's output), Testing Library
- * queries are bound inside it, and `update` re-renders with new owner props.
+ * the renderer's own `[data-slot]` outlet anchor is the snapshot root
+ * (`expect(view.container).toMatchSnapshot()` captures exactly this slot's
+ * output), Testing Library queries are bound inside it, and `update`
+ * re-renders with new owner props.
  */
 export interface SlotView<K extends keyof SlotMap & string> {
-  /** The `<div data-slot="<key>">` wrapper around the slot's rendered output. */
+  /** The renderer's `<div data-slot="<key>">` anchor around the slot's rendered output. */
   readonly container: HTMLElement
   /** Testing Library queries scoped to {@link SlotView.container}. */
   readonly view: BoundFunctions<typeof queries>
@@ -286,10 +287,10 @@ export class SlotTestRuntime {
   /**
    * Declare child slots under an auto-generated root frame — the single-slot
    * mounting path for local DOM snapshots. Each key later supplied through
-   * {@link SlotTestRuntime.renderSlot} renders inside its own
-   * `<div data-slot="<key>">` wrapper (the snapshot root). Mutually exclusive
-   * with {@link TestRoot.declare} ('root' is a single slot); one call per
-   * runtime.
+   * {@link SlotTestRuntime.renderSlot} renders inside the renderer's own
+   * `<div data-slot="<key>">` outlet anchor (the snapshot root — the frame
+   * adds no wrapper of its own). Mutually exclusive with
+   * {@link TestRoot.declare} ('root' is a single slot); one call per runtime.
    * @param children - child-slot declaration table (same contract as TestRoot.declare).
    * @returns completion of the act-wrapped registration.
    */
@@ -298,8 +299,11 @@ export class SlotTestRuntime {
     const cell = this.ownerCell
     const AutoFrame = (props: { renderSlot: (key: string, owner: object) => ReactNode }) => {
       useSyncExternalStore(cell.subscribe, cell.getVersion)
+      // Keyed Fragments only: the renderer's outlet anchor is the one
+      // `[data-slot]` element — the frame adding its own would nest
+      // duplicate anchors under the same key.
       return createElement(Fragment, null, cell.entries().map(([key, owner]) =>
-        createElement('div', { 'data-slot': key, key }, props.renderSlot(key, owner))))
+        createElement(Fragment, { key }, props.renderSlot(key, owner))))
     }
     await this.root.declare(children as never, AutoFrame as never)
   }

+ 34 - 7
packages/typert/generator/src/analyzer.ts

@@ -859,17 +859,39 @@ class FaceAnalyzer {
     const result: ServiceModel[] = []
     for (const member of context.members) {
       if (!ts.isPropertySignature(member) || member.type === undefined) continue
-      const symbol = this.symbolAtType(member.type)
-      if (symbol === undefined) continue
-      const symbolId = this.symbolId(symbol)
-      const exported = bySymbol.get(symbolId)?.find(record => record.model.name === symbol.name)
-        ?? bySymbol.get(symbolId)?.find(record => record.model.name !== 'default')
-        ?? bySymbol.get(symbolId)?.[0]
+      // An OPTIONAL key is not a service: `X | undefined` and `key?: X` both mark
+      // a value the launcher or boot code installs before the tree mounts (a root
+      // accessor, an environment snapshot), which no plugin provides and no
+      // consumer can reach with `inject`. Describing one as a service would answer
+      // "add the plugin that provides it" for a key where no such plugin exists.
+      if (member.questionToken !== undefined
+        || (ts.isUnionTypeNode(member.type)
+          && member.type.types.some(node => node.kind === ts.SyntaxKind.UndefinedKeyword))) continue
+      const authoredSymbol = this.symbolAtType(member.type)
+      if (authoredSymbol === undefined) continue
+      const authoredSymbolId = this.symbolId(authoredSymbol)
+      const exported = bySymbol.get(authoredSymbolId)?.find(record => record.model.name === authoredSymbol.name)
+        ?? bySymbol.get(authoredSymbolId)?.find(record => record.model.name !== 'default')
+        ?? bySymbol.get(authoredSymbolId)?.[0]
       if (exported === undefined) continue
-      const declaration = preferredDeclaration(symbol)
+      let symbol = authoredSymbol
+      let declaration = preferredDeclaration(symbol)
+      const aliases = new Set<ts.Symbol>()
+      while (declaration !== undefined && ts.isTypeAliasDeclaration(declaration)) {
+        if (aliases.has(symbol)) break
+        aliases.add(symbol)
+        const target = this.symbolAtType(declaration.type)
+        if (target === undefined) break
+        symbol = target
+        declaration = preferredDeclaration(symbol)
+      }
       if (declaration === undefined || (!ts.isClassDeclaration(declaration) && !ts.isInterfaceDeclaration(declaration))) {
         this.fail(member, `service ${memberName(member.name)} does not resolve to an exported class or interface`)
       }
+      const memberOwner = this.registrationForFile(member.getSourceFile().fileName)
+      const declarationOwner = this.registrationForFile(declaration.getSourceFile().fileName)
+      if (memberOwner?.name !== declarationOwner?.name) continue
+      const symbolId = this.symbolId(symbol)
       const model = this.ensureDeclaration(symbol, declaration)
       const exposed = model.members
         .filter(exposableMember)
@@ -1990,6 +2012,11 @@ class FaceAnalyzer {
         && member.initializer !== undefined
         && ts.isCallExpression(member.initializer)
         && this.isTypeMetaSymbol(member.initializer.expression, 'bindTypertRemote')) continue
+      if (ts.isMethodDeclaration(member) && member.body !== undefined
+        && members.some(candidate => candidate !== member
+          && (ts.isMethodDeclaration(candidate) || ts.isMethodSignature(candidate))
+          && memberName(candidate.name) === memberName(member.name)
+          && (!ts.isMethodDeclaration(candidate) || candidate.body === undefined))) continue
       const visibility = visibilityOf(member)
       const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword)
       if (visibility !== 'public' || isStatic || ts.isConstructorDeclaration(member)) continue

+ 114 - 51
packages/typert/generator/src/cordis-catalog.ts

@@ -12,13 +12,15 @@ import type {
   FaceModel,
   MemberModel,
   ParameterModel,
+  ServiceModel,
   SignatureModel,
   SourceDeclarationModel,
   SourceLocation,
+  TypertFace,
   TypeNodeId,
 } from './model.ts'
 
-type Mode = 'emit' | 'waterfall' | 'parallel' | 'serial'
+type Mode = 'emit' | 'bail' | 'waterfall' | 'parallel' | 'serial'
 
 /** The fenced-block info string for generated signature blocks (skipped by
  * doc-typecheck, since a bare signature fragment is not standalone-compilable). */
@@ -112,6 +114,10 @@ export interface CordisCatalogPolicy {
   readonly foundationTypeNames: ReadonlySet<string>
   /** Repository types deliberately documented outside the linked data catalog. */
   readonly typeLinkExemptions: Readonly<Record<string, string>>
+  /** Framework Services included in the model-facing runtime catalog but not the harness documentation partition. */
+  readonly runtimeServices?: readonly ServiceEntry[]
+  /** Harness Services omitted from the model-facing runtime catalog because dynamic Plugins must not call them. */
+  readonly runtimeServiceExclusions?: ReadonlySet<string>
   /** Manually curated framework events inherited by every plugin. */
   readonly inheritedEvents: readonly InheritedEntry[]
   /** Manually curated framework context members inherited by every plugin. */
@@ -129,7 +135,7 @@ export class CordisCatalogProjector {
   private readonly renderer: TypeGraphRenderer
 
   /**
-   * @param face - analyzed host face containing package business semantics.
+   * @param face - analyzed Host or Client face containing package business semantics.
    * @param sourceDeclarations - exported declarations available to the runtime type closure.
    * @param policy - caller-owned type classifications and inherited Cordis data.
    */
@@ -138,7 +144,6 @@ export class CordisCatalogProjector {
     private readonly sourceDeclarations: readonly SourceDeclarationModel[],
     private readonly policy: CordisCatalogPolicy,
   ) {
-    if (face.face !== 'host') throw new Error(`cordis catalog requires the host face, received ${face.face}`)
     this.renderer = new TypeGraphRenderer(face.graph)
   }
 
@@ -159,10 +164,13 @@ export class CordisCatalogProjector {
    * @returns the model-facing TypeScript catalog source.
    */
   renderRuntimeApi(model: CordisCatalogModel): string {
+    const services = [...model.services, ...(this.policy.runtimeServices ?? [])]
+      .filter(service => !this.policy.runtimeServiceExclusions?.has(service.key))
+      .sort((left, right) => left.key.localeCompare(right.key))
     return renderRuntimeApi(
-      model.services,
+      services,
       model.events,
-      this.runtimeTypes(model.services),
+      this.runtimeTypes(services),
       this.policy.inheritedServices,
     )
   }
@@ -173,6 +181,8 @@ export class CordisCatalogProjector {
     const typeLinkViolations: string[] = []
     for (const packageModel of this.face.packages) {
       for (const event of packageModel.events) {
+        const parsed = parseJsDoc(event.jsDoc ?? '')
+        if (parsed.deprecated) continue
         const source = pointer(event.location)
         const where = `event '${event.name}' (${source})`
         const node = this.renderer.node(event.signature)
@@ -180,11 +190,12 @@ export class CordisCatalogProjector {
           violations.push(`${where} is not represented by a callable type.`)
           continue
         }
-        checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations)
-        const parsed = parseJsDoc(event.jsDoc ?? '')
+        if (this.face.face === 'host') {
+          checkTypeLinks(where, signatureTypeNames(this.renderer, node.signature), this.policy, typeLinkViolations)
+        }
         const mode = event.mode
         if (!isMode(mode)) {
-          violations.push(`${where} is missing an @mode tag. Add '@mode emit|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
+          violations.push(`${where} is missing an @mode tag. Add '@mode emit|bail|waterfall|parallel|serial' to its JSDoc (see AGENTS.md).`)
         }
         const last = node.signature.parameters.at(-1)
         const hasNext = last?.name === 'next'
@@ -223,47 +234,91 @@ export class CordisCatalogProjector {
     return entries
   }
 
+  /**
+   * The services this projection describes, one per `ctx.<key>`: those whose
+   * Context merge sits one level under a package's `src` and whose declaration
+   * belongs to that same package.
+   *
+   * Interfaces qualify beside classes, because an interface-typed key
+   * (`lsp: LspService`) has its Service Definition — and, by repository
+   * convention, its member documentation — on the interface; requiring a class
+   * would drop a real injectable service from every catalog. The declaration may
+   * live in any file of the package (`types.ts` is the usual home), while a
+   * declaration from ANOTHER package is not this package's surface to document.
+   *
+   * One key can have both kinds of candidate across packages: `ctx.typert` is
+   * typed by a merge-extensible interface in `type-meta` and implemented by a
+   * class in `registry`. The CLASS wins — it carries the documentation and is the
+   * object a caller meets — and picking before validating is what keeps a
+   * discarded candidate's missing JSDoc from failing the gate.
+   */
+  private renderableServices(): ServiceModel[] {
+    const chosen = new Map<string, ServiceModel>()
+    for (const packageModel of this.face.packages) {
+      for (const service of packageModel.services) {
+        const declaration = this.renderer.declaration(service.symbol)
+        const owner = /^packages\/[^/]+\/[^/]+\/src\//.exec(service.location.file)?.[0]
+        if ((declaration.kind !== 'class' && declaration.kind !== 'interface')
+          || owner === undefined
+          || (this.face.face === 'host'
+            ? !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
+            : !/^packages\/[^/]+\/[^/]+\/src\/client\/.+\.tsx?$/.test(service.location.file))
+          || !declaration.location.file.startsWith(owner)) continue
+        const current = chosen.get(service.key)
+        if (current !== undefined && this.renderer.declaration(current.symbol).kind === 'class') continue
+        chosen.set(service.key, service)
+      }
+    }
+    return [...chosen.values()]
+  }
+
   private collectServices(): ServiceEntry[] {
     const entries: ServiceEntry[] = []
     const violations: string[] = []
     const typeLinkViolations: string[] = []
-    for (const packageModel of this.face.packages) {
-      for (const service of packageModel.services) {
-        const declaration = this.renderer.declaration(service.symbol)
-        if (declaration.kind !== 'class'
-          || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(service.location.file)
-          || declaration.location.file !== service.location.file) continue
-        const doc = parseJsDoc(declaration.jsDoc ?? '').doc
-        const source = pointer(declaration.location)
-        if (doc === '') {
-          violations.push(`service ctx.${service.key} (${source}): class ${declaration.name} has no JSDoc.`)
+    for (const service of this.renderableServices()) {
+      const declaration = this.renderer.declaration(service.symbol)
+      const parsedDeclaration = parseJsDoc(declaration.jsDoc ?? '')
+      if (parsedDeclaration.deprecated) continue
+      const doc = parsedDeclaration.doc
+      const source = pointer(declaration.location)
+      if (doc === '') {
+        violations.push(`service ctx.${service.key} (${source}): ${declaration.kind} ${declaration.name} has no JSDoc.`)
+      }
+      const methods: ServiceMethodEntry[] = []
+      for (const memberId of service.members) {
+        const member = this.renderer.member(memberId)
+        if (member.name.startsWith('[')) continue
+        const parsed = parseJsDoc(member.jsDoc ?? '')
+        if (parsed.deprecated) continue
+        if (member.kind === 'property') {
+          if (member.jsDoc === undefined) continue
+          methods.push({ signature: member.text, jsDoc: member.jsDoc })
+          continue
         }
-        const methods: ServiceMethodEntry[] = []
-        for (const memberId of service.members) {
-          const member = this.renderer.member(memberId)
-          if (member.kind !== 'method' || member.name.startsWith('[')) continue
-          const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})`
+        if (member.kind !== 'method') continue
+        const where = `service method ctx.${service.key}.${member.name} (${pointer(member.location)})`
+        if (this.face.face === 'host') {
           checkTypeLinks(where, signatureTypeNames(this.renderer, member.signature), this.policy, typeLinkViolations)
-          methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' })
-          if (member.jsDoc === undefined) {
-            violations.push(`${where} has no JSDoc.`)
-            continue
-          }
-          const parsed = parseJsDoc(member.jsDoc)
-          if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`)
-          checkParams(where, 'service', member.signature.parameters, parsed.params,
-            parameter => parameter.receiver, violations)
-          checkReturns(where, member.signature, parsed.returns, this.renderer, violations)
         }
-        entries.push({
-          key: service.key,
-          type: declaration.name,
-          abstract: declaration.abstract,
-          doc,
-          methods,
-          source,
-        })
+        methods.push({ signature: member.text, jsDoc: member.jsDoc ?? '' })
+        if (member.jsDoc === undefined) {
+          violations.push(`${where} has no JSDoc.`)
+          continue
+        }
+        if (parsed.doc === '') violations.push(`${where} has no description prose above its block tags.`)
+        checkParams(where, 'service', member.signature.parameters, parsed.params,
+          parameter => parameter.receiver, violations)
+        checkReturns(where, member.signature, parsed.returns, this.renderer, violations)
       }
+      entries.push({
+        key: service.key,
+        type: declaration.name,
+        abstract: declaration.abstract,
+        doc,
+        methods,
+        source,
+      })
     }
     reportViolations('gen-cordis-catalog', violations)
     reportTypeLinkViolations('gen-cordis-catalog', typeLinkViolations)
@@ -274,8 +329,8 @@ export class CordisCatalogProjector {
     const declarations = new Map<string, string>()
     const ambiguous = new Set<string>()
     for (const declaration of this.sourceDeclarations) {
-      if (declaration.face !== 'host' || declaration.kind === 'enum'
-        || !/^packages\/[^/]+\/[^/]+\/src\/[^/]+\.ts$/.test(declaration.location.file)) continue
+      if (declaration.face !== this.face.face || declaration.kind === 'enum'
+        || !/^packages\/[^/]+\/[^/]+\/src\/.+\.tsx?$/.test(declaration.location.file)) continue
       if (declarations.has(declaration.name)) {
         ambiguous.add(declaration.name)
         continue
@@ -298,31 +353,31 @@ export class CordisCatalogProjector {
  * @param policy - caller-owned type classifications and inherited Cordis data.
  * @returns the configured projector and its validated catalog model.
  */
-export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy): {
+export function projectCordisCatalog(scanRoot: string, policy: CordisCatalogPolicy, targetFace: TypertFace = 'host'): {
   readonly projector: CordisCatalogProjector
   readonly model: CordisCatalogModel
 } {
   const caches = new WorkspaceCaches()
   const discovery = new WorkspaceAnalyzer({
     root: scanRoot,
-    faces: ['host'],
+    faces: [targetFace],
     checkDiagnostics: false,
     caches,
   }).discoverPackages()
-  const packages = discovery.filter(candidate => candidate.faces.includes('host'))
+  const packages = discovery.filter(candidate => candidate.faces.includes(targetFace))
     .map(candidate => candidate.package)
   const workspace = new WorkspaceAnalyzer({
     root: scanRoot,
-    faces: ['host'],
+    faces: [targetFace],
     packages,
     checkDiagnostics: false,
     caches,
   }).analyzeInBatches()
-  const face = workspace.faces.find(candidate => candidate.face === 'host')
-  if (face === undefined) throw new Error('gen-cordis-catalog: Typert produced no host face')
+  const face = workspace.faces.find(candidate => candidate.face === targetFace)
+  if (face === undefined) throw new Error(`gen-cordis-catalog: Typert produced no ${targetFace} face`)
   const sourceDeclarations = new WorkspaceAnalyzer({
     root: scanRoot,
-    faces: ['host'],
+    faces: [targetFace],
     checkDiagnostics: false,
     caches,
   }).indexSourceDeclarations()
@@ -354,6 +409,7 @@ interface ParsedJsDoc {
   readonly doc: string
   readonly params: ReadonlyMap<string, string>
   readonly returns: string | null
+  readonly deprecated: boolean
 }
 
 function parseJsDoc(raw: string): ParsedJsDoc {
@@ -410,8 +466,14 @@ function parseJsDoc(raw: string): ParsedJsDoc {
 
   const params = new Map<string, string>()
   let returns: string | null = null
+  let deprecated = false
   let sink: ((text: string) => void) | undefined
   for (const line of lines) {
+    if (/^@deprecated(?:\s|$)/.test(line)) {
+      deprecated = true
+      sink = undefined
+      continue
+    }
     const param = /^@param\s+(\[?[\w$]+\]?)\s*(?:[-—–]\s*)?(.*)$/.exec(line)
     if (param !== null) {
       const name = (param[1] ?? '').replace(/^\[|\]$/g, '')
@@ -440,6 +502,7 @@ function parseJsDoc(raw: string): ParsedJsDoc {
     doc: blocks.join('\n\n').replace(/\{@link\s+([^}]+)\}/g, '$1').trim(),
     params,
     returns,
+    deprecated,
   }
 }
 
@@ -494,7 +557,7 @@ function pointer(location: SourceLocation): string {
 }
 
 function isMode(mode: string | undefined): mode is Mode {
-  return mode === 'emit' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial'
+  return mode === 'emit' || mode === 'bail' || mode === 'waterfall' || mode === 'parallel' || mode === 'serial'
 }
 
 function signatureTypeNames(renderer: TypeGraphRenderer, signature: SignatureModel): string[] {

+ 26 - 1
packages/typert/generator/tests/cordis-catalog.spec.ts

@@ -10,9 +10,14 @@ import { CORDIS_CATALOG_POLICY, EVENT_SCOPE_PAGE, REGION_BEGIN, REGION_END, SERV
 
 const workspaceRoot = resolve(import.meta.dirname, '../../../..')
 
+/** One workspace projection shared by both cases: analyzing it twice doubles a multi-minute run. */
+let cached: ReturnType<typeof projectCordisCatalog> | undefined
+const projection = (): ReturnType<typeof projectCordisCatalog> =>
+  (cached ??= projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY))
+
 describe('Typert-backed Cordis catalog', () => {
   it('reproduces every committed catalog artifact byte for byte', { timeout: 480_000 }, () => {
-    const { projector, model } = projectCordisCatalog(workspaceRoot, CORDIS_CATALOG_POLICY)
+    const { projector, model } = projection()
     const expected = (path: string): string => readFileSync(join(workspaceRoot, path), 'utf8')
 
     expect(renderInheritedPage(CORDIS_CATALOG_POLICY)).toBe(expected('docs/cordis-api/inherited.md'))
@@ -35,4 +40,24 @@ describe('Typert-backed Cordis catalog', () => {
       expected('packages/extensions/tool-cordis/src/api-catalog.ts'),
     )
   })
+
+  it('resolves each key to the declaration a caller meets, and drops keys no plugin provides', { timeout: 480_000 }, () => {
+    const byKey = new Map(projection().model.services.map(service => [service.key, service]))
+    // An interface-typed key is described by its Service Definition: that is where
+    // the contract and, by repository convention, the member JSDoc live.
+    expect(byKey.get('lsp')?.type).toBe('LspService')
+    // The Service Definition may sit anywhere in the package, including a nested
+    // contract directory (`src/api/`), while the Context merge stays in `src`.
+    expect(byKey.get('apiProxy')?.type).toBe('ApiProxy')
+    // Two packages describe `ctx.typert` — a merge-extensible interface in
+    // type-meta and the implementing class in registry. The class wins: it is the
+    // object a caller meets and it carries the documentation.
+    expect(byKey.get('typert')?.type).toBe('TypertRegistry')
+    // Optional keys are values a launcher installs before the tree mounts. No
+    // plugin provides them, so describing one as a service would answer "add the
+    // plugin that provides it" for a key where no such plugin exists.
+    expect(byKey.has('headlessIo')).toBe(false)
+    expect(byKey.has('dshHomePath')).toBe(false)
+    expect(byKey.has('launcherEnvironment')).toBe(false)
+  })
 })