The Web Client is a separate browser Cordis application assembled independently from the Host; ctx.provide('botharness', core) is visible only to Host-side plugins, so the roster cannot inject the core service. Core therefore exposes its read model as explicit unary RPC methods on the generic Connection RPC channel: the client calls ctx.connection.rpc.call('/api', 'botharness/<method>', payload, signal); the Host registers endpoints with ctx.connection.rpc.intercept('/api', …) or precise routes via ctx.connection.fetch.register(...). Responses use one envelope, { ok: true, value } or { ok: false, error }, plus a change cursor carried by list/get. Status push is not direct states.on in the browser: the upstream remote event whitelist (API_REMOTE_FORWARDED_EVENTS) is a first-party static list that third-party packages cannot append to, so M3 refreshes after user actions and low-frequency polls, and may later add a private SSE route. The client ships as an independent @botharness/client package (dsh.client.platform: 'web', ./client export) with a hand-built lazy-CJS bundle; the shell module baseline stays external and everything else is inlined. Typert @Remote remains a later candidate once its build-time generator is shown to be reproducible outside the DSH workspace.
Considered Options
- Typert
@Remotefirst — deferred: it needs build-time codegen plus first-partyapi-remotesassembly; reproducibility outside the DSH workspace is unverified and the read model is still moving. - Expose the core service via
injectto the client half — impossible: the browser half is a separate Cordis app; there is no cross-process service injection, anddsh.client.injectis informational only. - Direct
states.onpush to the browser — impossible today: forwarded events are a static first-party whitelist; a third party cannot register its own. - Direct SSE now — deferred: M3 needs refresh-on-action, not real-time; a private route on
fetch.register(requestBody: 'streaming') can be added behind the same method surface once six-state liveness is proven necessary. - Fold the client into
@botharness/core(DSH’s default two-halves-per-package) — rejected for now: the halves have different build targets and dependency laws, and the independent package keeps the Host half free of React; the cost is a deliberate deviation from the DSH packaging convention, recorded in the client spec.
Consequences
- Core must own a wire contract — method names, payloads, envelope, cursor/version — with exact names draft until M3 lands;
docs/client-bridge.mdholds the surface. - M3 client is refresh/polling only, no live push; “six-state realtime” may need a private stream later.
- Architecture docs are corrected: no
provide('botharness') → clientarrow; the communication table splits Host-internal Cordis / browser-internal Cordis / cross-process RPC. @botharness/clientneeds its own Loader entry anddsh.clientmanifest; DSH’s shared client build preset is unpublished, so the lazy-CJS bundle (window.__ModuleLoader__.load) is hand-rolled — the M3 top engineering risk.- The browser half treats every RPC result as a plain value and renders loading/error states; no Host object graph leaks into React props.
- Evidence:
docs/research/2026-09-18-dsh-client-ui-and-docs-ia.md(§1.4, §1.5, §2).
Update (2026-09-19) — registration goes through the Typert gateway
ctx.connection.rpc.intercept('/api', …) squats the single interceptor slot owned by @deepseek-ai/dsh-api-gateway, so every native API 404s while botharness/* keeps working (#50). The Host half now registers BotharnessBridgeService — a TypertRemoteService (Cordis key botharnessBridge, wire namespace botharness) with SRC remote-method markers — and the gateway claims botharness/* from the typert registry; no build-time codegen. The wire contract keeps its shape with a Typert argument envelope: the client calls ctx.connection.rpc.call('/api', 'botharness/<method>', { args: { …named arguments } }, signal), and failures ride RemoteError back into the same { ok: false, error } branch. The rest of this decision is unchanged.