diff --git a/packages/client/ui-settings/src/client/index.ts b/packages/client/ui-settings/src/client/index.ts
index 7cb3dfd6d4..f858be9c37 100644
--- a/packages/client/ui-settings/src/client/index.ts
+++ b/packages/client/ui-settings/src/client/index.ts
@@ -10,12 +10,12 @@
*/
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
import { deferRegistration } from '@deepseek-ai/dsh-client-ui-slots'
-import type { SettingsRootInjected } from './contract/slots.ts'
+import type { SettingsRootInjected, SettingsSectionRow } from './contract/slots.ts'
import { SettingsRoot } from './SettingsRoot.tsx'
export type {
SettingsHeaderOwnerProps, SettingsRootComponentProps, SettingsRootInjected,
- SettingsSectionOwnerProps, SettingsTriggerOwnerProps,
+ SettingsSectionOwnerProps, SettingsSectionRow, SettingsTriggerOwnerProps,
} from './contract/slots.ts'
/**
@@ -32,17 +32,31 @@ export const inject = ['slots']
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
+ // Ledger → nav-row projection as an observable source (uSES contract:
+ // getSnapshot returns the cached rows until the ledger version moves).
+ let rowsVersion = -1
+ let rows: readonly SettingsSectionRow[] = []
const injected = (): SettingsRootInjected => ({
- sectionsVersion: () => ctx.slots.getVersion('settings.section'),
- subscribeSections: listener => ctx.slots.subscribe('settings.section', listener),
- sections: () => ctx.slots.entries('settings.section')
- .map(e => ({
- /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
- id: e.options.id ?? '',
- order: e.options.order ?? 0,
- label: e.options.label ?? '',
- }))
- .sort((a, b) => a.order - b.order),
+ hooks: {
+ sections: {
+ getSnapshot: () => {
+ const version = ctx.slots.getVersion('settings.section')
+ if (version !== rowsVersion) {
+ rowsVersion = version
+ rows = ctx.slots.entries('settings.section')
+ .map(e => ({
+ /* v8 ignore next -- list-slot registration requires id (SlotCore rejects an entry without one) */
+ id: e.options.id ?? '',
+ order: e.options.order ?? 0,
+ label: e.options.label ?? '',
+ }))
+ .sort((a, b) => a.order - b.order)
+ }
+ return rows
+ },
+ subscribe: listener => ctx.slots.subscribe('settings.section', listener),
+ },
+ },
})
ctx.effect(() => {
const deferred = deferRegistration(ctx.slots, 'sidebar.settings', SettingsRoot, () =>
diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts
index d7fdfbd546..caec65f3f5 100644
--- a/packages/client/ui-settings/tests/apply.spec.ts
+++ b/packages/client/ui-settings/tests/apply.spec.ts
@@ -60,22 +60,25 @@ describe('ui-settings apply', () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
- const injected = injectedOf(b.slots)
+ const { sections } = injectedOf(b.slots).hooks
// The shell ships no sections of its own — registrants fill the ledger.
- expect(injected.sections()).toEqual([])
+ expect(sections.getSnapshot()).toEqual([])
b.slots.register({ name: 'settings.section', id: 'z', order: 20, label: 'Z' } as never, () => null)
// No order and no label: both projection defaults apply.
b.slots.register({ name: 'settings.section', id: 'a' } as never, () => null)
- expect(injected.sections()).toEqual([
+ const rows = sections.getSnapshot()
+ expect(rows).toEqual([
{ id: 'a', order: 0, label: '' },
{ id: 'z', order: 20, label: 'Z' },
])
- expect(injected.sectionsVersion()).toBe(b.slots.getVersion('settings.section'))
+ // Snapshot identity is stable until the ledger moves (uSES contract).
+ expect(sections.getSnapshot()).toBe(rows)
const listener = vi.fn()
- const off = injected.subscribeSections(listener)
+ const off = sections.subscribe(listener)
b.slots.register({ name: 'settings.section', id: 'b', order: 1, label: 'B' } as never, () => null)
await Promise.resolve()
expect(listener).toHaveBeenCalled()
+ expect(sections.getSnapshot()).not.toBe(rows)
off()
})
diff --git a/packages/client/ui-settings/tests/settings-root.spec.tsx b/packages/client/ui-settings/tests/settings-root.spec.tsx
index 9584d500e5..dd340dc2ea 100644
--- a/packages/client/ui-settings/tests/settings-root.spec.tsx
+++ b/packages/client/ui-settings/tests/settings-root.spec.tsx
@@ -1,5 +1,6 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest'
+import { useEffect, useState } from 'react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import type { SettingsRootComponentProps } from '../src/client/contract/slots.ts'
import { SettingsRoot } from '../src/client/SettingsRoot.tsx'
@@ -22,9 +23,9 @@ function mount({
{ id: 'models', order: 10, label: 'Models' },
],
}: { wide?: boolean; rows?: Row[] } = {}) {
- // Mutable row store standing in for the ledger; bump() plays a change.
+ // Mutable row source standing in for the bound useSections hook; bump()
+ // plays a ledger change through the same observable contract.
let current = rows
- let version = 0
const listeners = new Set<() => void>()
const renderSlot = vi.fn(
((key: string, _owner: unknown, opts?: { only?: string }) => {
@@ -38,19 +39,21 @@ function mount({
useSessions: unusedHook,
useWorkspaces: unusedHook,
wide,
- sectionsVersion: () => version,
- subscribeSections: (listener) => {
- listeners.add(listener)
- return () => { listeners.delete(listener) }
+ useSections: (select) => {
+ const [, force] = useState(0)
+ useEffect(() => {
+ const listener = () => { force(n => n + 1) }
+ listeners.add(listener)
+ return () => { listeners.delete(listener) }
+ }, [])
+ return select(current)
},
- sections: () => current,
renderSlot,
}
const view = render()
const bump = (next: Row[]) => {
act(() => {
current = next
- version += 1
for (const fn of [...listeners]) fn()
})
}
diff --git a/packages/client/ui-slots/src/index.ts b/packages/client/ui-slots/src/index.ts
index 1f30f7027b..7b980571ef 100644
--- a/packages/client/ui-slots/src/index.ts
+++ b/packages/client/ui-slots/src/index.ts
@@ -14,6 +14,7 @@
* consumer merges keys in and the intersection is what keeps them string-typed.
* The rule fires on the empty-map view, not on real redundancy. */
import type { ReactNode } from 'react'
+import type { HostObservable } from './renderer.ts'
import type { BoundActions, HandleOf, PropsStore, SnapshotSelectorHook, StoreDecl } from './store.ts'
export * from './store.ts'
@@ -214,11 +215,40 @@ export type PropsRenderSlots = {
*/
export type SlotComponent = (props: P) => ReactNode
+/**
+ * Registrant hooks compartment: bare observable sources (getSnapshot +
+ * subscribe pairs) supplied under the reserved `hooks` key of an inject
+ * face. The registrant-private twin of the `sessions.provide` hooks
+ * compartment: the renderer binds each source into a `use` selector
+ * hook, so the sources never reach the component and plugin-private reactive
+ * facts ride the same subscription machinery as the standard kit instead of
+ * hand-rolled component subscriptions.
+ */
+export type HooksSources = Record>
+
+/**
+ * Selector-hook share synthesized from a hooks compartment: each source
+ * `name` becomes a `use` selector hook over its snapshot type.
+ */
+export type PropsHooks = {
+ [N in keyof HS & string as `use${Capitalize}`]:
+ SnapshotSelectorHook ? T : never>
+}
+
+/**
+ * The component-side view of an inject face: the reserved `hooks`
+ * compartment (when declared) arrives as bound `use` selector hooks;
+ * every other member passes through verbatim.
+ */
+export type InjectFace =
+ I extends { hooks: infer HS extends HooksSources } ? Omit & PropsHooks : I
+
/**
* The four-share component props intersection: runtime share (SlotMap) +
* child-render share (children declaration) + store share (declared handle) +
- * the registrant's injected business face. Each share derives from its single
- * source of truth; components reference this composition, never re-type it.
+ * the registrant's injected business face (its hooks compartment bound, see
+ * {@link InjectFace}). Each share derives from its single source of truth;
+ * components reference this composition, never re-type it.
*/
export type ComposedProps<
K extends keyof SlotMap & string,
@@ -226,7 +256,7 @@ export type ComposedProps<
H,
I extends object,
M = never,
-> = PropsRuntime & PropsRenderSlots & PropsStore & I & MatchedShare
+> = PropsRuntime & PropsRenderSlots & PropsStore & InjectFace & MatchedShare
/**
* Inject factory parameter list, derived from the registration's declaration:
diff --git a/packages/client/web-react/src/scoped-slots.tsx b/packages/client/web-react/src/scoped-slots.tsx
index 3ef01d7390..5cea31cd6c 100644
--- a/packages/client/web-react/src/scoped-slots.tsx
+++ b/packages/client/web-react/src/scoped-slots.tsx
@@ -5,8 +5,8 @@
import { Component, useSyncExternalStore, type FC, type ReactNode } from 'react'
import {
SlotOwnershipError, StaleAuthorizationError,
- type ChainRenderOpts, type RenderOpts, type SessionMaybeProvideInfo, type SessionProvideInfo,
- type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
+ type ChainRenderOpts, type HostObservable, type RenderOpts, type SessionMaybeProvideInfo,
+ type SessionProvideInfo, type SlotRenderer, type SlotRendererHost, type SlotScope, type StoredEntry,
} from '@deepseek-ai/dsh-client-ui-slots'
import {
HostContext, SessionMaybeProvider, SessionProvider, SlotAssemblyError, maybeObservableHook,
@@ -96,7 +96,26 @@ function runInject(entry: StoredEntry, info: SessionMaybeProvideInfo | undefined
const args: unknown[] = []
if (info !== undefined) args.push(info.sessionId)
if (actions !== undefined) args.push(actions)
- return (inject as (...args: unknown[]) => InjectedProps)(...args)
+ return bindInjectHooks((inject as (...args: unknown[]) => InjectedProps)(...args))
+}
+
+/**
+ * Bind an inject face's reserved `hooks` compartment (bare observable
+ * sources, see HooksSources) into `use` selector hooks — the
+ * registrant-private twin of the provide-bundle binding in standardKit.
+ * Runs once per cached inject result; hook identity rides observableHook's
+ * per-source cache.
+ */
+function bindInjectHooks(face: InjectedProps): InjectedProps {
+ const sources = face['hooks']
+ if (sources === undefined) return face
+ const { hooks: _hooks, ...rest } = face
+ const bound: InjectedProps = rest
+ for (const [name, source] of Object.entries(sources as Record>)) {
+ const hookName = `use${name[0]?.toUpperCase() ?? ''}${name.slice(1)}`
+ bound[hookName] = observableHook(source)
+ }
+ return bound
}
function cachedRootInject(entry: StoredEntry, actions: object | undefined): InjectedProps {
diff --git a/packages/client/web-react/tests/scoped-slots.spec.tsx b/packages/client/web-react/tests/scoped-slots.spec.tsx
index 36b99a6ef8..971a6ad060 100644
--- a/packages/client/web-react/tests/scoped-slots.spec.tsx
+++ b/packages/client/web-react/tests/scoped-slots.spec.tsx
@@ -748,6 +748,25 @@ describe('inject: execution point, parameter derivation, cache granularity', ()
expect(inject).toHaveBeenCalledWith()
})
+ it('binds the inject hooks compartment into use selector hooks (sources never reach the component)', () => {
+ const h = makeHost()
+ h.declare('k.single', SINGLE_ROOT)
+ const badge = observable('cold')
+ const seen: Record[] = []
+ h.add('k.single', {
+ component: (props: { useBadge?: (sel: (s: string) => S) => S; hooks?: unknown; plain?: string }) => {
+ seen.push({ hooks: props.hooks, plain: props.plain, read: props.useBadge!(s => s) })
+ return null
+ },
+ inject: () => ({ plain: 'kept', hooks: { badge } }),
+ })
+ mountRoot(h, { 'k.single': SINGLE_ROOT }, renderSlot => renderSlot('k.single', {}))
+ // The raw compartment is consumed by the binding; the plain member passes through.
+ expect(seen.at(-1)).toEqual({ hooks: undefined, plain: 'kept', read: 'cold' })
+ act(() => { badge.set('hot') })
+ expect(seen.at(-1)!['read']).toBe('hot')
+ })
+
it('session inject receives sessionId and caches per (entry x session): switch-back reuses', () => {
const h = makeHost()
h.declare('k.session', SINGLE_SESSION)
From 305f185ede0a40e064a2f618007323d1b6cde318 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Tue, 28 Jul 2026 04:13:32 +0000
Subject: [PATCH 18/24] chore(deps): bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)
---
updated-dependencies:
- dependency-name: actions/upload-artifact
dependency-version: '7'
dependency-type: direct:production
update-type: version-update:semver-major
...
Signed-off-by: dependabot[bot]
---
.github/workflows/build-exe-for-python-sdk.yml | 4 ++--
.github/workflows/ci.yml | 2 +-
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml
index a112267f4d..5965707630 100644
--- a/.github/workflows/build-exe-for-python-sdk.yml
+++ b/.github/workflows/build-exe-for-python-sdk.yml
@@ -106,7 +106,7 @@ jobs:
--package sdk
--output-dir dist-python
- - uses: actions/upload-artifact@v6
+ - uses: actions/upload-artifact@v7
with:
name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl
@@ -237,7 +237,7 @@ jobs:
/tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default
'
- - uses: actions/upload-artifact@v6
+ - uses: actions/upload-artifact@v7
with:
name: ${{ steps.runtime.outputs.wheel }}
path: dist-python/${{ steps.runtime.outputs.wheel }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 6c3697f3b8..577bd92b42 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -96,7 +96,7 @@ jobs:
tar -czf "$RUNNER_TEMP/node-24-built-tree.tar.gz"
apps/*/lib packages/*/*/lib vendor/*/lib
- - uses: actions/upload-artifact@v6
+ - uses: actions/upload-artifact@v7
with:
name: node-24-built-tree
path: ${{ runner.temp }}/node-24-built-tree.tar.gz
From 2b74db670efbbbe7e84b763e263ca4f3b6a52c4e Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:06:08 +0800
Subject: [PATCH 19/24] refactor(client): rename the provide reprojection to
updateCurrentProvideInfo and privatize the id resolvers
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
provideInfo(id)/maybeProvideInfo(id) lost their last external caller
when the renderer host switched to the currentProvideInfo observable;
both become private (tests assert through the public projection). The
reprojection method's name now says what it does — re-derive and
publish on change — and matches the field family it maintains.
---
packages/client/runtime/README.md | 2 +-
.../runtime/src/client/sessions/service.ts | 23 ++++++-------
.../runtime/tests/sessions-service.spec.ts | 32 +++++++++++--------
3 files changed, 29 insertions(+), 28 deletions(-)
diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md
index 81261945cb..16c1124ec8 100644
--- a/packages/client/runtime/README.md
+++ b/packages/client/runtime/README.md
@@ -39,5 +39,5 @@ Changing the target can change or invalidate provider-side cache reuse; this pac
## Known Limitations and Deferred Work
- **`loader.unload` is a stub (throws not-implemented)** — the full chain (fiber dispose → registration cascade → style removal) lands with the HMR project.
-- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`provideInfo()`/`binding()`/`scope()`) is pure addressing, render-safe. The staged state can widen to a multi-pane list when concurrent panes land.
+- **Scope teardown is stage-driven, single-occupant today** — the staged session follows `list.current` exactly (staging is the open signal: the event window opens ⟺ the session is on stage); a removed-while-staged session's scope survives frozen until the stage moves on, not until true observer count reaches zero. Resolution (`binding()`/`scope()`) is pure addressing, render-safe; the render layer reads the current bundle through the `currentProvideInfo` observable. The staged state can widen to a multi-pane list when concurrent panes land.
- **Value imports of this package from plugin bundles must use the `/client` subpath** — the bare package name is not in the loader externals table and inlines a second module instance, whose private scope-tag Symbol never matches (the empty-state P0 postmortem).
diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts
index b5ce1905eb..759c320bde 100644
--- a/packages/client/runtime/src/client/sessions/service.ts
+++ b/packages/client/runtime/src/client/sessions/service.ts
@@ -212,7 +212,7 @@ export class SessionsService {
// The current-provide projection follows the same current writes.
this.list.subscribe(() => {
this.followCurrent()
- this.projectCurrentProvide()
+ this.updateCurrentProvideInfo()
})
// The runtime's own contribution comes first: useSession rides the same
// provide channel every plugin uses (no renderer special case).
@@ -261,16 +261,17 @@ export class SessionsService {
for (const record of this.scopes.values()) {
record.provideInfo = this.materializeProvideInfo(record.binding)
}
- this.projectCurrentProvide()
+ this.updateCurrentProvideInfo()
}
/**
- * Publish the current selection's provide bundle when it changed. Bundles
- * are identity-stable per (scope, roster) materialization, so an identity
- * compare is exact; synchronous notify — both call sites (list.subscribe,
- * provide()) already sit behind their own batching or registration edges.
+ * Re-derive the current selection's provide bundle and publish it when it
+ * changed. Bundles are identity-stable per (scope, roster)
+ * materialization, so an identity compare is exact; synchronous notify —
+ * both call sites (list.subscribe, provide()) already sit behind their own
+ * batching or registration edges.
*/
- private projectCurrentProvide(): void {
+ private updateCurrentProvideInfo(): void {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
@@ -446,20 +447,16 @@ export class SessionsService {
* {@link SessionsService.currentProvideInfo}). Pure resolution — render-safe:
* no staging, no window side effects (StrictMode double-invokes and
* concurrent discarded passes must stay free).
- * @param id - session id.
- * @returns the provide info, or undefined for a session neither listed nor already scoped.
*/
- provideInfo(id: string): SessionProvideInfo | undefined {
+ private provideInfo(id: string): SessionProvideInfo | undefined {
return this.resolve(id as SessionId)?.provideInfo
}
/**
* Resolve the current-session-optional standard kit. Unknown or absent ids
* return the static no-session projection rather than removing hook props.
- * @param id - current session id, when selected.
- * @returns a definite or no-session provide bundle.
*/
- maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
+ private maybeProvideInfo(id: string | undefined): SessionMaybeProvideInfo {
return (id === undefined ? undefined : this.provideInfo(id)) ?? this.maybeInfo
}
diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts
index b97dac3d78..45539d3b99 100644
--- a/packages/client/runtime/tests/sessions-service.spec.ts
+++ b/packages/client/runtime/tests/sessions-service.spec.ts
@@ -79,7 +79,8 @@ describe('scope tree', () => {
expect(scopeOf(scoped as Context)).toBe('s1')
expect(scopeOf(b.ctx)).toBeUndefined()
const binding = b.svc.binding(sid('s1'))
- expect(binding?.session).toBe(b.svc.provideInfo('s1')?.hooks['session'])
+ b.svc.open(sid('s1'))
+ expect(binding?.session).toBe(b.svc.currentProvideInfo.getSnapshot().hooks['session'])
expect(b.svc.binding(sid('s1'))).toBe(binding)
expect(binding?.ctx).toBe(scoped)
})
@@ -183,16 +184,17 @@ describe('current selection (migrated from ui-layout, arbitrated into the list s
})
describe('cell (render-layer session kit)', () => {
- it('resolves an identity-stable {sessionId, session} cell; unknown ids yield undefined', async () => {
+ it('resolves an identity-stable {sessionId, session} cell through the current projection', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }])
- const info = b.svc.provideInfo('s1')
- expect(info).toBeDefined()
- expect(info?.sessionId).toBe('s1')
+ b.svc.open(sid('s1'))
+ const info = b.svc.currentProvideInfo.getSnapshot()
+ expect(info.sessionId).toBe('s1')
// The bundle carries bare observables; hook binding happens in React.
- expect(info?.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
- expect(b.svc.provideInfo('s1')).toBe(info)
- expect(b.svc.provideInfo('ghost')).toBeUndefined()
+ expect(info.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
+ // Re-staging the same id republishes nothing: identity holds.
+ b.svc.open(sid('s1'))
+ expect(b.svc.currentProvideInfo.getSnapshot()).toBe(info)
})
it('currentProvideInfo follows selection: absent projection ↔ definite bundle, notified on each move', async () => {
@@ -204,10 +206,14 @@ describe('cell (render-layer session kit)', () => {
const notified = vi.fn()
b.svc.currentProvideInfo.subscribe(notified)
b.svc.open(sid('s1'))
- expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s1'))
+ const s1Bundle = b.svc.currentProvideInfo.getSnapshot()
+ expect(s1Bundle.sessionId).toBe('s1')
+ expect(s1Bundle.hooks['session']).toBe(b.svc.binding(sid('s1'))?.session)
expect(notified).toHaveBeenCalledTimes(1)
b.svc.open(sid('s2'))
- expect(b.svc.currentProvideInfo.getSnapshot()).toBe(b.svc.provideInfo('s2'))
+ const s2Bundle = b.svc.currentProvideInfo.getSnapshot()
+ expect(s2Bundle.sessionId).toBe('s2')
+ expect(s2Bundle).not.toBe(s1Bundle)
expect(notified).toHaveBeenCalledTimes(2)
b.svc.clear()
await Promise.resolve() // clearSelection projects through the manager notifier
@@ -249,12 +255,11 @@ describe('cell (render-layer session kit)', () => {
expect(notified).not.toHaveBeenCalled()
})
- it('provideInfo()/binding() are pure resolution: no staging, no deferred sweep', async () => {
+ it('binding() is pure resolution: no staging, no deferred sweep', async () => {
const b = bench()
await feedList(b, [{ id: 's1' }, { id: 's2' }])
b.svc.open(sid('s1')) // staged
- b.svc.provideInfo('s2') // resolution only — must NOT move the stage
- b.svc.binding(sid('s2'))
+ b.svc.binding(sid('s2')) // resolution only — must NOT move the stage
await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives
expect(b.svc.scope(sid('s1'))).toBeDefined()
})
@@ -265,7 +270,6 @@ describe('cell (render-layer session kit)', () => {
const historyCalls = () => b.api.calls.filter(c => c.method === 'session.history')
// Resolution is addressing, not staging: no window pull.
b.svc.scope(sid('s1'))
- b.svc.provideInfo('s1')
b.svc.binding(sid('s1'))
expect(historyCalls()).toHaveLength(0)
b.svc.open(sid('s1'))
From f331f248d88762ead77e42dae721877f123506f8 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:16:14 +0800
Subject: [PATCH 20/24] fix: static
---
packages/client/runtime/README.i18n.yaml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml
index 3fa9c934f3..4629e67d59 100644
--- a/packages/client/runtime/README.i18n.yaml
+++ b/packages/client/runtime/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
-README.md: 81261945cb2fd8b15f7c2f15cb1ae0b8e9928499
+README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
From d833be412afa0f091d9f206140fc095847681198 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:33:37 +0800
Subject: [PATCH 21/24] fix(client): contain notification-callback failures and
document the source lifecycle
Review follow-ups: the three new notify loops (currentProvideInfo
subscribers, ui-skill lexicon listeners, late-registration controller
setup) now contain per-callback failures so one faulty consumer cannot
starve the rest, abort the list projection pass, or poison the source
roster with no disposer; controller lexicon polling drops a throwing
source with a console record like the candidate path. The ui-slash
README (both languages) now states the late-registration warm and the
subscribeLexicon contract, and the scenario suite drives a typed /name
token gaining its decoration when the roll settles with no further
input.
---
.../runtime/src/client/sessions/service.ts | 11 ++++++-
.../tests/input-scenarios.spec.tsx | 29 +++++++++++++++++++
packages/client/ui-skill/src/client/index.ts | 11 ++++++-
packages/client/ui-slash/README.i18n.yaml | 6 ++--
packages/client/ui-slash/README.md | 2 +-
packages/client/ui-slash/README.zh.md | 2 +-
.../client/ui-slash/src/client/controller.ts | 11 ++++++-
.../client/ui-slash/src/client/service.ts | 11 ++++++-
8 files changed, 74 insertions(+), 9 deletions(-)
diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts
index 759c320bde..9754c87345 100644
--- a/packages/client/runtime/src/client/sessions/service.ts
+++ b/packages/client/runtime/src/client/sessions/service.ts
@@ -275,7 +275,16 @@ export class SessionsService {
const next = this.maybeProvideInfo(this.list.getSnapshot().current)
if (next === this.currentProvideInfoSnapshot) return
this.currentProvideInfoSnapshot = next
- for (const fn of [...this.currentProvideInfoListeners]) fn()
+ for (const fn of [...this.currentProvideInfoListeners]) {
+ try {
+ fn()
+ } catch (error) {
+ // Contain subscriber failures: this notify runs inside the list
+ // notification, where a throwing render-side subscriber would starve
+ // later listeners and abort the projection pass that scheduled it.
+ console.error('sessions.currentProvideInfo subscriber failed:', error)
+ }
+ }
}
/** Build the static no-session kit and reject duplicate declared names. */
diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx
index 826405f2be..807650169b 100644
--- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx
+++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx
@@ -236,6 +236,35 @@ describe('scenario H: backspace breaks the token', () => {
})
})
+describe('scenario: reference decoration lights up when the lexicon settles', () => {
+ it('a typed /name token gains the text-ref mark without further input once the roll goes hot', async () => {
+ let roll: readonly string[] | undefined
+ let notify: (() => void) | undefined
+ const b = await scopedBench((slash) => {
+ slash.registerSource({
+ trigger: '/', name: 'skill',
+ candidates: () => Promise.resolve([]),
+ onPick: () => undefined,
+ lexicon: () => roll,
+ subscribeLexicon: (_session: ClientSessionContext, listener: () => void) => {
+ notify = listener
+ return () => { notify = undefined }
+ },
+ } as never)
+ })
+ // Typed before the catalog settled: a plain token, no decoration.
+ b.type('/deploy now')
+ expect(b.view.container.querySelector('[data-decoration="text-ref"]')).toBeNull()
+ // The catalog settles (ui-skill's settle path fires the same notification).
+ act(() => {
+ roll = ['deploy']
+ notify?.()
+ })
+ const mark = b.view.container.querySelector('[data-decoration="text-ref"]')
+ expect(mark?.textContent).toBe('/deploy')
+ })
+})
+
describe('scenario I: unknown /xyz + enter', () => {
it('adjudication misses in one hop and the whole line rides the default sink', async () => {
const b = await bench()
diff --git a/packages/client/ui-skill/src/client/index.ts b/packages/client/ui-skill/src/client/index.ts
index 7226f45163..d34c6ba96d 100644
--- a/packages/client/ui-skill/src/client/index.ts
+++ b/packages/client/ui-skill/src/client/index.ts
@@ -48,7 +48,16 @@ export function apply(ctx: ClientContext): void {
const lexiconListeners = new Map void>>()
const notifyLexicon = (sessionId: SessionId): void => {
- for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) listener()
+ for (const listener of [...(lexiconListeners.get(sessionId) ?? [])]) {
+ try {
+ listener()
+ } catch (error) {
+ // Contain listener failures: settlement notifies from an ignored
+ // promise chain (a throw would surface as an unhandled rejection)
+ // and one faulty consumer must not starve the others.
+ console.error('[ui-skill] lexicon listener failed:', error)
+ }
+ }
}
const fetchCatalog = (sessionId: SessionId): Promise => {
diff --git a/packages/client/ui-slash/README.i18n.yaml b/packages/client/ui-slash/README.i18n.yaml
index c09d7f4c28..1053e205b0 100644
--- a/packages/client/ui-slash/README.i18n.yaml
+++ b/packages/client/ui-slash/README.i18n.yaml
@@ -1,6 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
-# pnpm run verify-translation-pairing --write
-README.md: d2978695d71686059bfbcbb4fc3ef896d92add4a
-README.zh.md: 6aeb078a922aaa93d50ed16b4dbe54329737d018
+# pnpm run verify-translation-pairing --write packages/client/ui-slash/README.md
+README.md: 4e363c2682bf91862ec40f3f2174831451fb9b0d
+README.zh.md: 76d39673cb853d1889ee84cb9f3595708eae2db3
diff --git a/packages/client/ui-slash/README.md b/packages/client/ui-slash/README.md
index d2978695d7..4e363c2682 100644
--- a/packages/client/ui-slash/README.md
+++ b/packages/client/ui-slash/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone and the roster is warmed once at scope birth. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
+Input trigger pipeline plugin: `/` and `@` detection under the caret (word-boundary + guard-tier rules), the grouped candidate menu, and pick routing to registered sources. `ctx.slash` owns the source roster and resolves one `SlashController` per session scope (`sessionOf`); the conversation wiring layer drives `track`/`arbitrate`/`onSpace`/`adjudicate` on the controller. Sources receive a `ClientSessionContext` projection per call — sessions are always agent-backed, so the projection is the session identity alone. A source is warmed in every session controller it can reach: the roster present at scope birth warms during controller construction, and a source registered later is warmed into every live controller by the registration itself. Sources whose `lexicon` roll changes after warm implement `subscribeLexicon(session, listener)`; the controller re-polls on each notification and publishes the aggregation through its `lexicon` snapshot store. The pipeline is command-agnostic: space/enter adjudication polls the optional `matchSpace`/`matchEnter` hooks in registration order and the first non-undefined answer wins.
Layering: `src/core/` (T2) is the pure core — `detectTrigger`, `menuReduce`/`seedGroups`/`MENU_CLOSED`, `exactMatch`, zero React/DOM/cordis; `src/client/service.ts` is the shell wiring the core to the menu snapshot store, the per-hit candidate fetch (generation-gated, `AbortSignal`-superseded, failed sources drop silently with a console record), and the three pick paths. `src/types.ts` and the two `contract.ts` files are the frozen cross-package contract (design v4 §5.1); changes require main-thread arbitration.
diff --git a/packages/client/ui-slash/README.zh.md b/packages/client/ui-slash/README.zh.md
index 6aeb078a92..76d39673cb 100644
--- a/packages/client/ui-slash/README.zh.md
+++ b/packages/client/ui-slash/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份,roster 在 scope 出生时预热一次。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
+输入触发管线插件:光标处的 `/` 与 `@` 检测(词边界 + guard tier 规则)、分组候选菜单,以及把 pick 路由到已注册 source。`ctx.slash` 拥有 source roster,并按会话 scope(`sessionOf`)各解析一个 `SlashController`;会话领域的接线层在 controller 上驱动 `track`/`arbitrate`/`onSpace`/`adjudicate`。source 每次调用收到一个 `ClientSessionContext` 投影——会话恒为 agent-backed,因此投影只含会话身份。source 在它能触达的每个会话 controller 中都会被预热:scope 出生时在场的 roster 随 controller 构造预热,晚于此注册的 source 由注册动作本身预热进每个活 controller。`lexicon` 名录在预热后仍会变化的 source 实现 `subscribeLexicon(session, listener)`;controller 每收到通知就重拉,并把聚合结果经其 `lexicon` snapshot store 发布。管线对命令零知识:空格/回车裁决按注册序轮询可选的 `matchSpace`/`matchEnter` 钩子,第一个非 undefined 的应答胜出。
分层:`src/core/`(T2)是纯内核——`detectTrigger`、`menuReduce`/`seedGroups`/`MENU_CLOSED`、`exactMatch`,零 React/DOM/cordis;`src/client/service.ts` 是壳层,把内核接到菜单快照 store、逐 hit 候选拉取(以 generation 把关、后继请求经 `AbortSignal` 取代旧请求、失败的 source 静默丢弃并留一条 console 记录)和三条 pick 路径上。`src/types.ts` 与两个 `contract.ts` 文件是冻结的跨包契约(设计 v4 §5.1);变更需经主线程仲裁。
diff --git a/packages/client/ui-slash/src/client/controller.ts b/packages/client/ui-slash/src/client/controller.ts
index 9e95dbdd41..ab0b26da54 100644
--- a/packages/client/ui-slash/src/client/controller.ts
+++ b/packages/client/ui-slash/src/client/controller.ts
@@ -290,7 +290,16 @@ export class SlashController {
const rolls = new Map()
for (const src of this.deps.roster.all()) {
if (src.lexicon === undefined) continue
- const names = src.lexicon(projection)
+ let names: readonly string[] | undefined
+ try {
+ names = src.lexicon(projection)
+ } catch (error) {
+ // A faulty source drops silently with a console record (the
+ // candidate-fetch failure policy); the refresh runs inside
+ // notification callbacks, where a throw would starve other consumers.
+ console.error(`[ui-slash] source "${src.name}" lexicon failed:`, error)
+ continue
+ }
if (names === undefined) continue
const prev = rolls.get(src.trigger)
rolls.set(src.trigger, prev === undefined ? names : [...prev, ...names])
diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts
index 0ca3b91c2a..c47d44c3d4 100644
--- a/packages/client/ui-slash/src/client/service.ts
+++ b/packages/client/ui-slash/src/client/service.ts
@@ -50,7 +50,16 @@ export class SlashService extends Service implements SlashServiceContract {
throw new Error(`slash source "${src.trigger}${src.name}" is already registered`)
}
live.sources.push(src)
- for (const controller of live.controllers.values()) controller.sourceAdded(src)
+ for (const controller of live.controllers.values()) {
+ try {
+ controller.sourceAdded(src)
+ } catch (error) {
+ // Contain faulty source callbacks (warm/subscribeLexicon): the
+ // registration must stand with a usable disposer and the remaining
+ // controllers must still be notified.
+ console.error(`[ui-slash] source "${src.trigger}${src.name}" late-registration setup failed:`, error)
+ }
+ }
return () => {
const at = live.sources.indexOf(src)
if (at < 0) return
From 2665e55e5d3437ce5013fe1e49a6698bd63c6eb3 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:36:16 +0800
Subject: [PATCH 22/24] docs(runtime): align the zh README resolution sentence
with the privatized resolvers
---
packages/client/runtime/README.i18n.yaml | 2 +-
packages/client/runtime/README.zh.md | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml
index 4629e67d59..32d4fdf417 100644
--- a/packages/client/runtime/README.i18n.yaml
+++ b/packages/client/runtime/README.i18n.yaml
@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/runtime/README.md
README.md: 16c1124ec812b9f030ce8266a16cdb8f5db0e6cc
-README.zh.md: cbbf6eded4a5375223791275f26f3bc7b6553200
+README.zh.md: a3d2a2dfdd1662afee65ec45e26b1ef1029f44b5
diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md
index cbbf6eded4..a3d2a2dfdd 100644
--- a/packages/client/runtime/README.zh.md
+++ b/packages/client/runtime/README.zh.md
@@ -39,5 +39,5 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
## 已知限制与暂缓事项
- **`loader.unload` 是 stub(抛出 not-implemented)**:完整链路(fiber 释放 → 注册级联 → 样式移除)随 HMR 项目落地。
-- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`provideInfo()`/`binding()`/`scope()`)只是纯寻址,可安全用于渲染。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
+- **scope 拆卸由阶段驱动,目前只能有一个占用者**:已 staged 的 Session 精确跟随 `list.current`(staging 就是打开信号:事件窗口打开 ⟺ Session 位于 stage);在 staged 状态下被移除的 Session,其 scope 会冻结保留,直到 stage 转向其他 Session,而非直到真实观察者数量降为零。解析(`binding()`/`scope()`)只是纯寻址,可安全用于渲染;渲染层经 `currentProvideInfo` observable 读取当前 bundle。并发 pane 落地时,staged 状态可以扩展为多 pane 列表。
- **插件组合包从该包执行值导入时必须使用 `/client` 子路径**:裸包名不在 loader external 表中,会内联第二个模块实例;其私有 scope-tag Symbol 永远无法匹配(空状态 P0 事故复盘)。
From 059ba4e0d1827b15f979e7b42c98e0d34b2c8820 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:48:46 +0800
Subject: [PATCH 23/24] docs(client): add the reactive-read and
contract-currency discipline
---
packages/client/AGENTS.md | 11 +++++++++++
1 file changed, 11 insertions(+)
diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md
index 0fd9e71f01..11be39a93d 100644
--- a/packages/client/AGENTS.md
+++ b/packages/client/AGENTS.md
@@ -16,6 +16,17 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
6. **Stores: read `props.useStore`, write `props.actions.*`** — the declared actions are the complete mutation surface. Write the store as an exported `createXXXStore()` factory (module-level handles are forbidden — de-facto singletons); share by passing one handle to several registers inside `apply`. Production code never calls the factory or `.create()` outside `apply`; tests do (that is the sanctioned zero-machinery path).
7. **inject returns plain data and callbacks** from the apply closure's own ctx — no hand-made hooks, no ReactNode producers, no whole-service objects. A registrant-private reactive fact rides the reserved `hooks` compartment (bare observables the renderer binds to `use`; components never see the sources). Its capability boundary is the plugin's declared `inject` topology; there is no wider ctx to reach for.
+## Reactive read and contract-currency discipline
+
+The three stale-UI bugs this section descends from shared one root: mutable state read during render without a subscription. The rules:
+
+1. **Everything a render reads that can change outside React arrives through a subscription**: a framework hook (rule 4 above), never a getter call, a `.getSnapshot()` in render, or a mirror copied into `useState`/a second store. Event handlers may read live snapshots (`keyboard.snapshot`); render may not.
+2. **Business components contain no subscription machinery**: no `useSyncExternalStore`, no manual `useState`+`useEffect` subscribe pattern (it has a render-to-effect gap that drops notifications). A registrant-private reactive fact goes through the inject `hooks` compartment; a cross-entry fact goes through a store; a per-session fact goes through `sessions.provide`.
+3. **Data-access ladder** — resolve needs in this order, and escalate rather than improvise: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration, never a hand-rolled subscription.
+4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is NOT a currency: do not add new ReactNode-valued owner props or inject members (existing ones — composer `accessory`/`overlay`/`leftItems`/`rightItems` — are legacy under progressive removal; route new render content through a slot instead).
+5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source; a fresh source per render re-subscribes uSES), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves — a fresh object per call is an infinite re-render).
+6. **Whoever rebuilds a published value republishes it through the same source in the same step.** Rebuild-without-notify is exactly the stale-roster bug; registration paths that can run after consumers exist must notify the live consumers (the slash late-source warm is the template).
+
## Export discipline (client plugin packages)
The `/client` surface of a UI plugin package is a contract face, not a convenience barrel. Three rules, enforced package-wide (do not restate them as per-file comments):
From 095e3944ae51aff390dfd62a6139e55f0c4bc656 Mon Sep 17 00:00:00 2001
From: imccyu <276526105+imccyu@users.noreply.github.com>
Date: Tue, 28 Jul 2026 14:52:04 +0800
Subject: [PATCH 24/24] docs(client): state the reactive-read rules positively
---
packages/client/AGENTS.md | 14 +++++++-------
1 file changed, 7 insertions(+), 7 deletions(-)
diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md
index 11be39a93d..a7d80b3232 100644
--- a/packages/client/AGENTS.md
+++ b/packages/client/AGENTS.md
@@ -18,14 +18,14 @@ The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-
## Reactive read and contract-currency discipline
-The three stale-UI bugs this section descends from shared one root: mutable state read during render without a subscription. The rules:
+How live data reaches render code, and what may cross a business boundary:
-1. **Everything a render reads that can change outside React arrives through a subscription**: a framework hook (rule 4 above), never a getter call, a `.getSnapshot()` in render, or a mirror copied into `useState`/a second store. Event handlers may read live snapshots (`keyboard.snapshot`); render may not.
-2. **Business components contain no subscription machinery**: no `useSyncExternalStore`, no manual `useState`+`useEffect` subscribe pattern (it has a render-to-effect gap that drops notifications). A registrant-private reactive fact goes through the inject `hooks` compartment; a cross-entry fact goes through a store; a per-session fact goes through `sessions.provide`.
-3. **Data-access ladder** — resolve needs in this order, and escalate rather than improvise: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration, never a hand-rolled subscription.
-4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is NOT a currency: do not add new ReactNode-valued owner props or inject members (existing ones — composer `accessory`/`overlay`/`leftItems`/`rightItems` — are legacy under progressive removal; route new render content through a slot instead).
-5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source; a fresh source per render re-subscribes uSES), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves — a fresh object per call is an infinite re-render).
-6. **Whoever rebuilds a published value republishes it through the same source in the same step.** Rebuild-without-notify is exactly the stale-roster bug; registration paths that can run after consumers exist must notify the live consumers (the slash late-source warm is the template).
+1. **Everything a render reads that can change outside React arrives through a framework hook** (rule 4 above). Event-handler code may read live snapshots (e.g. `keyboard.snapshot`); render code subscribes.
+2. **Business components contain no subscription machinery** — no `useSyncExternalStore`, no manual subscribe wiring, no mirroring an external snapshot into local state or a second store. Give each reactive fact its owning channel instead: registrant-private → the inject `hooks` compartment; cross-entry or remount-surviving → a declared store; per-session standard → `sessions.provide`.
+3. **Data-access ladder** — resolve needs in this order: framework hooks (standing seats + provide/inject-bound `use`) → a declared store (`useStore`/`actions`) → inject callbacks → anything else is a new framework seam and needs main-thread arbitration.
+4. **Contract currency is JSON-able data and callbacks.** Everything crossing a business boundary (owner props, inject faces, store state, provide contributions) is plain serializable data or a callback over such data; the inject `hooks` compartment is the one sanctioned carrier of bare observables, and components never see those either. ReactNode is not a currency: route render content through a slot; no new ReactNode-valued owner props or inject members (the composer's existing `accessory`/`overlay`/`leftItems`/`rightItems` seats are grandfathered and get migrated to slots progressively).
+5. **An observable source keeps two identities stable**: the source object itself (hook binding is cached per source), and its snapshot between changes (`getSnapshot` returns the same reference until the fact moves).
+6. **Whoever rebuilds a published value republishes it through the same source in the same step**, and a registration path that can run after consumers exist notifies the live consumers as part of registering.
## Export discipline (client plugin packages)