Architecture review · conduit

2026-10-16 · 1348 commits since 2026-08-15
Hot spots · commits touching file
contracts/ws-rpc.ts
135
shared-types.ts
108
transport/ws-rpc-client.ts
85
server/ws-rpc.ts
78
input/InputArea.svelte
74
read-query-effect.ts
70
layout/ChatLayout.svelte
65
claude-provider-runtime.ts
64
Red: the browser RPC path. A contract commit averages 18 files.
Legend
module
seam
leakage / hand-synced restatement
deep module
Already deep · leave alone
session-inboxread-model-subscriptionsession-command (ADR-0004)runner protocolprovider-runtime-event-to-domaintranscript appliers

1 · Derive the browser RPC client from the contract

Strong in-process
  • src/lib/frontend/transport/ws-rpc-client.ts · 1778 lines
  • src/lib/contracts/ws-rpc.ts · WsRpcRequest :2616 · WsRpcGroup :3050
  • src/lib/server/ws-rpc.ts · barrel :42-183 · DaemonRpcName :367-393
  • src/lib/frontend/transport/ws-rpc.ts · src/lib/handlers/index.ts (barrels)
  • test/e2e/helpers/rpc-mock.ts · test/unit/contracts/ws-rpc-contract.test.ts
Before · ws-rpc-client is shallow
Interface: 98 wrappers · 71 hand-copied *RpcInput types · typed errors opt-in (17 of 98)
interface
callControl · runTransportEffect
implementation
Touched per new RPC
classWsRpcRequestWsRpcGrouphandlerPick listfailure labelclient wrapper*RpcInputtest stubrpc-mock encoder
After · one deep client
One client typed by WsRpcGroup
interface
Promise edge · project routing · typed-error delivery · transport lifecycle
implementation
Touched per new RPC
class + group entryhandlertest stub
Problem
Each RPC's name and payload are restated in 7–8 places, and the client module is 98 wrappers whose interface is as large as their bodies.
Solution
One client derived from WsRpcGroup owns the Promise edge, project routing and typed errors; WsRpcRequest derives from the group; the barrels go.
  • Interface shrinks: 98 wrappers → 1
  • Leverage: 122 RPCs, one call path
  • Locality: typed errors handled once
  • Delete 71 hand-copied input types
  • Contract test stays the test surface

2 · Declare each canonical event once

Strong in-process
  • src/lib/persistence/events.ts (6 sites per event) · src/lib/contracts/stored-event.ts
  • src/lib/provider/claude/event-type-guard.ts
  • src/lib/relay/domain-event-to-relay.ts · 233 lines · src/lib/relay/project-relay-publisher.ts:22
  • test/unit/provider/relay-event-sink-exhaustive.test.ts · test/unit/pipeline/__snapshots__/exhaustiveness-guards.test.ts.snap
Before · one event, ~11 files
new event
→
events.ts ×6
stored-event.ts
projector handler
claude type guard
relay silent case
HANDLED_TYPES list
snapshot + count
reclassified list
Red: registration only, no behaviour
domain-event-to-relay builds
deltathinkingtoolresultdone publisher keeps only done
After · one definition, derived guards
flowchart LR
  E["event definition
payload · required fields · producer"] -.-> U["unions · payload map · schema"] E -.-> G["producer guard"] E --> P["projector handler"] T["turn-ended alert
~15 lines"] classDef deep fill:#0f172a,color:#f8fafc,stroke:#0f172a; classDef faded fill:#f1f5f9,stroke:#cbd5e1,color:#64748b; class E deep class U,G faded
Hand edits per event
definitionprojector handler
Problem
A new event touches ~11 files, 5 only to register its name, and domain-event-to-relay.ts builds messages the publisher throws away except done.
Solution
Each canonical event is declared once with its payload, required fields and producing Provider Runtime; unions and guards derive from that, and the relay translator shrinks to a turn-ended alert.
  • Locality: one event, one definition
  • Delete ~220 lines of discarded translation
  • Four hand-kept guard lists derive
  • Producer decision stays compile-checked
Fits ADR-0004: events stay the mutation path; only their registration moves.

3 · Give the active session one owner in the frontend

Strong in-process
  • src/lib/frontend/components/layout/ChatLayout.svelte · 1032 lines · ~30 $effects
  • src/lib/frontend/components/input/InputArea.svelte · 1720 lines · canSend :504 · startsTurn :852
  • src/lib/frontend/stores/session.svelte.ts · transcript.svelte.ts · chat.svelte.ts
  • src/lib/frontend/transport/session-subscription.svelte.ts · src/lib/session-busy.ts
  • layout/SessionBar.svelte · model/InstanceModelPicker.svelte
Before · rules live in the views
sessiontranscriptchatrpc
ChatLayoutwrite idattach·catalog
InputArearead·writesendstopcatalog
SessionBarread··goal
ModelPickerread··catalog
transcript storewrite id···
3 notions of current id2 meanings of busycatalog fetched in 6 files, mixed guards0 tests on canSend / stop
After · views render one module
ChatLayout
InputArea
SessionBar
ModelPicker
↓ ↓ ↓ ↓
active session
identitycatalogcan start a turnoptimistic phaserollback
feeds + transport
tested over the existing fake-wire layer
Problem
The active session's identity, catalog and send/stop rules are derived inside ChatLayout and InputArea, with six writers of the current id and catalog staleness guards that differ by call site.
Solution
One frontend module owns the active session (which one it is, its catalog, whether a turn can start, the optimistic phase and its rollback); ChatLayout and InputArea render it.
  • Locality: one writer of current id
  • Send rules testable without mounting
  • Catalog staleness guard fixed once
  • ChatLayout test drops 18 mocks
  • Test adapter already exists

4 · Assemble a project relay in one place

Worth exploring local-substitutable
  • src/lib/relay/relay-stack.ts · 1036 lines · createRelayStack :722 (test-only caller)
  • src/lib/relay/project-relay-layers.ts · 787 lines
  • src/lib/relay/project-relay-startup.ts · 484 lines
  • src/lib/domain/daemon/Layers/relay-factory-layer.ts · Promise callbacks :181-312
  • test/integration/helpers/relay-harness.ts
Before · four files, two round trips
daemon · Effect
relay-factory-layer · 13 Effects → Promise callbacks
relay-stack · Promise API · env-var test hooks · mutable wsHandler ← harness enters here
project-relay-layers · Promise → Effect re-wrap · hand-wired Layer graph
project-relay-startup · runPromise · circular import ↔ relay-stack
A new relay dependency (e.g. AccountDirectory) needed 3–4 of these edited.
After · one Layer, two callers
daemon · Effect
project relay Layer · wiring · startup recovery · runtime
integration harness · same Layer · fake Provider Runtime adapters
Problem
The relay is wired across four files with an Effect → Promise → Effect round trip, and 26 integration flows enter through test-only createRelayStack, not the daemon's path.
Solution
The project relay becomes one Layer the daemon provides directly; the integration harness builds that same Layer with fake Provider Runtime adapters.
  • New dependency: one edit, not four
  • Tests cross the daemon's seam
  • Delete the Promise bridge
  • Test hooks leave production code

5 · Make the Claude turn one module, replayable end to end

Worth exploring true external · trace adapter
  • src/lib/provider/claude/claude-provider-runtime.ts · 3390 lines (daemon :457-1161 · runner :1163-3390)
  • src/lib/provider/claude/claude-event-translator.ts · 2002 lines
  • src/lib/provider/claude/types.ts:205 · ClaudeSessionContext
  • test/unit/provider/claude/claude-sdk-trace-replay.test.ts · claude-provider-instance-send-turn.test.ts (3630 lines)
Before · one file, ten concerns, shared state
daemon side
runner side
lifecyclerewindpermissionssettingssteer/queuedecodesubagentsusage limitsgoalsturn resolution
ClaudeSessionContext · 46 mutable fields · written by runner 34 · translator 11 · usage-limit 7 · ownership 3
Turn end has two owners: translator endTurn and runner resolveTurnEffect.
Runtime Trace replay enters at the translator only.
After · deep turn module behind the runner protocol
daemon-side runtime (own file)
runner protocol · already deep
Claude turn module
SDK messages in → canonical events + turn outcome out · decode-skip · ownership · one turn-end owner
SDK query port
live SDK
Runtime Trace replay
Problem
The runner half mixes ~10 concerns over a 46-field mutable context the translator also writes, and trace replay skips the stream loop, ownership routing and turn resolution.
Solution
Split the file at the runner-protocol seam, and make turn handling one module whose only injected dependency is the SDK query, so replay drives the whole loop.
  • Real seam: live SDK, trace replay
  • Turn end gets one owner
  • Replay covers turn resolution
  • Fewer reaches into private state (46)
Extends ADR-0002 (replay pins more of the Provider Contract). ADR-0001's per-message decode-skip moves inside unchanged.

6 · Give the session-row roll-up one home

Worth exploring local-substitutable
  • src/lib/persistence/effect/read-query-effect.ts · 2421 lines · 29-method interface :440-748
  • src/lib/persistence/effect/sidebar-projection.ts · imports read-side mappers :20-23
  • src/lib/persistence/sidebar-row.ts · read-model-types.ts · src/lib/shared-types.ts
Before · three assemblies
flowchart LR
  A["read-query :1302"] --> R1["roll-up"]
  B["read-query :1941"] --> R2["roll-up"]
  SP["sidebar-projection
(write side)"] --> R3["roll-up"] SP -.imports mappers.-> RQ["ReadQueryEffect
29 methods · 21 single-caller"] classDef leak stroke:#dc2626,stroke-width:2px,color:#b91c1c,fill:#fef2f2; class R1,R2,R3,RQ leak
pending-approval SQL ×3family lookup 3 waystests stub all 29
After · one roll-up, a narrow read interface
flowchart LR
  SP["sidebar projection"] --> SR
  L["session list read"] --> SR
  SR["session-row roll-up
parents · side threads · unread
status · pending counts"] RQ["ReadQueryEffect · shared reads"] X["single-caller lookups
beside their callers"] classDef deep fill:#0f172a,color:#f8fafc,stroke:#0f172a; classDef faded fill:#f1f5f9,stroke:#cbd5e1,color:#64748b; class SR deep class X faded
tests run on in-memory SQLite
Problem
The session-row roll-up is assembled three times and the write-side projection imports read-side mappers, while 21 of ReadQueryEffect's 29 methods have one caller yet every test stubs the whole interface.
Solution
One session-row module owns the roll-up for both the sidebar projection and the list read; single-caller lookups move next to their callers.
  • Locality: roll-up computed once
  • Read interface shrinks to shared reads
  • Stubs give way to real SQLite
  • New column: fewer restatements

7 · Push provider differences behind the Provider Runtime seam

Speculative ports & adapters
  • src/lib/provider/types.ts · ProviderInstance :334-422 · SendTurnInput :197-255
  • src/lib/domain/relay/Services/provider-turn-dispatch.ts · provider-turn-service.ts
  • src/lib/provider/orchestration-side-effect-reactor.ts:382-562
  • src/lib/handlers/model.ts (12) · permissions.ts (4)
Before · callers ask "is this Claude?"
flowchart LR
  D["turn dispatch"] --> CI["Claude internals"]
  D --> OI["OpenCode internals"]
  X["side-effect reactor"] --> CI
  M["handlers/model"] --> CI
  M --> OI
  P["handlers/permissions"] --> CI
  RS["relay startup"] --> CI
  classDef leak stroke:#dc2626,stroke-width:2px,color:#b91c1c,fill:#fef2f2;
  class D,X,M,P,RS leak
      
137 lines · 43 backend filesSendTurnInput: ~14 Claude-only fields
After · branching once, at adapter choice
flowchart LR
  C["dispatch · reactor · handlers"] --> S{{"Provider Runtime seam"}}
  S --> CA["Claude adapter"]
  S --> OA["OpenCode adapter"]
  classDef deep fill:#0f172a,color:#f8fafc,stroke:#0f172a;
  class CA,OA deep
      
two adapters: the seam is real
Problem
Provider branching leaks into 43 backend files, and the shared turn input carries Claude-only fields, so the seam changes with every Claude feature.
Solution
Provider-specific turn preparation moves into each adapter; dispatch hands over a session view and never asks which Provider Runtime it has.
  • Dispatch stops branching on driver
  • Claude features stop editing shared types
  • Delete provider-turn-service pass-through
Wide reach and overlaps #5; some branching (model catalogs) may be legitimate. Do #5 first and see what is left.
Leads that need no design · found by explorers, not yet verified
Top recommendation

1 · Derive the browser RPC client from the contract

It sits on the hottest path in the repo (135 contract commits in two months, 18 files each), it is in-process and mechanical, and the contract test is already its test surface. #2 is the natural follow-on: smaller, and mostly deletion.