Repository navigation
App-wide layer isolation: ingest → decide → render as one-directional, grep-able folders #493
Description
Problem
The renderer already has three real architectural layers — ingest (channels → clean object), decide (clean object → ordered rows), render (rows → JSX) — but they are not isolated. They're smeared across directories and reach into each other's internals, so the codebase reads as "everything mixed into everything," which is the exact thing that makes it un-maintainable as it grows.
Goal: each layer becomes one grep-able folder with a one-directional dependency (ingest → decide → render), so a service can be understood, changed, or replaced without touching the others.
The layers today (evidence)
INGEST — the normalization layer. The single translator from the raw, per-provider, multi-channel event firehose into one provider-neutral per-session object (SessionRuntime). It does six jobs:
| Job | Where it lives now |
|---|---|
1. provider decode (Claude JSONL / Codex rollout / opencode → neutral Entry) |
providers/{claude,codex,opencode}/renderer/transcript/ |
| 2. channel boundary (neutral 9-channel contract) | shared/sessionFeed/ |
3. ingress + commit (dispatch events → runtime.entries, uuid dedup, totalEntries) |
workspace/hook/ipc/useIpcSubscriptions.ts |
4. semantic fold (one-session-one-reducer: ~24 events → SemanticLiveTurn) |
workspace/semantic/foldEvent.ts (+ helpers, summarize) |
| 5. phase derivation | workspace/semantic/streamPhaseMachine.ts |
| 6. ghost bridge (mint provisional rows while JSONL lags the semantic stream) | workspace/ghosts.ts, workspace/mergedEntries.ts |
Its output is SessionRuntime, and its discipline is already sound: "one session ⇒ one reducer; every surface selects from runtime.*, never opens its own transport." The isolation exists conceptually — it just isn't isolated physically.
DECIDE — the ledger. src/renderer/src/rendering/ (adapter → observations → model → view). Mostly its own dir, produces neutral RenderRow[].
RENDER — the feed. src/renderer/src/features/feed/ (Feed + rows → JSX). Consumes the ledger.
What's actually wrong (the isolation violations)
- Ingest has no home. Stages 3–6 live in
workspace/next to the UI (tile-tree/,dispatch/, ~40 UI files). There is noingest/folder. - The clean object is fat.
SessionRuntime(workspaceState.ts:285) conflates render-input (entries,semantic,ghosts,streamPhase,conditions,screen,totalEntries) with per-pane UI state (draftInput,draftImages,paneToast,unreadSince,picker,promptSuggestion,pendingRewindUndo). The ledger only wants the first half — andcollectLedgerInput'sRuntimeLedgerSlicesalready picks exactly that, but as an ad-hoc pick, not a declared contract. - DECIDE reaches both ways.
rendering/imports ingest internals (workspace/semantic/foldEvent,workspace/ghosts,workspace/semantic/streamPhaseMachine) and render internals (features/feed/model/renderModel×4,features/feed/ui/semantic/renderUnits,features/feed/lib/agentSpawnTools). A clean decide layer imports one input contract and one output contract, nothing else. - The output seam is inverted.
FeedRenderItem(the render contract) lives infeatures/feed/but the ledger's bridge (rendering/view/ledgerFeedItems.ts) imports it — sorendering/ → features/feed, the wrong direction.
Proposed structural moves (each a separate, behavior-preserving PR)
PR-1 — extract the ingest layer. New src/renderer/src/session-runtime/ holding stages 3–6 (foldEvent, streamPhaseMachine, helpers, summarize, ghosts, mergedEntries, the runtime-mutating ingress action) + the SessionRuntime type (split out of workspaceState.ts; WorkspaceState/tile/layout stays in workspace/). Stages 1–2 stay under providers/+shared/ (provider/boundary-owned) but are documented as ingest's front-end. Path aliases mean few import fixes (like the testing/ colocation).
PR-2 — declare the input contract. Split the output type: RuntimeRenderInput (the 7 render fields) vs PaneUiState (draft/toast/unread/picker/…). Make RuntimeRenderInput the type the ledger consumes, and cut the rendering/ → foldEvent/ghosts/streamPhaseMachine back-edges. Move groupSemanticActivity (currently in features/feed/renderUnits, imported by the bridge since #492) into rendering/view/ — it's a decide-layer view concern.
PR-3 — flip the output seam. Move the RenderRow → FeedRenderItem bridge + the FeedRenderItem type into features/feed/, so rendering/ emits only neutral RenderRow[] and never imports features/feed. Dependency becomes strictly feed → rendering.
Acceptance
- Three folders:
session-runtime/(ingest),rendering/(decide),features/feed/(render). - Dependency direction is one-way —
rendering/imports neitherfeatures/feednorworkspace/internals;session-runtime/imports no UI. (Verified by a one-off grep, not a CI lock.) - The ledger consumes exactly
RuntimeRenderInputand emits exactlyRenderRow[]. - No behavior change — pure moves + contract typing; full gates + corpus green.
Related: the headless-package channel triplication (Semantic*/Screen*/Committed* ×3) is the SEPARATE provider-plug-and-play / neutral-hub refactor (#394) and is out of scope here. The rendering rewrite (#442/#483/#492) delivered the DECIDE layer; this issue isolates its neighbors.
Activity
refactor(layers): declare RuntimeRenderInput — the ledger's licensed …
Juliusolsson05 commentedon Jul 9, 2026
Execution stack is up, built from a full four-way read of the three layers (plan with all evidence + deviations:
docs/superpowers/plans/2026-07-09-layer-isolation.mdon the PR-1 branch):session-runtime/(ingest gets one grep-able home;SessionRuntimesplits out ofworkspaceState.ts)RuntimeRenderInput(the ledger's licensed seven-field input; bothas unknown as SessionRuntimecasts die;isAgentSpawnToolName→ provider registry; deadpolicy/foldPolicy.tsdeleted)rendering/view/→features/feed/ledger/; replay bridges by injection;rendering/is framework-free)Notable deviations from this issue's sketch, each evidence-backed in the plan: the ingress hook stays in
workspace/hook(it's IO wiring interleaved with pane-UI policy — the reducers it calls are the layer, and they all moved; the acceptance criterion still holds), andgroupSemanticActivityends up feed-side because this issue's PR-2 and PR-3 instructions contradicted each other once the whole view stage relocates.Acceptance greps (from PR-3, both empty):
Gates on every PR: tsc node+web clean · vitest 467/467 · remote-client vite build (it deep-imports the moved reducers). Stack is linear on main.
fix(replay): address 4-agent review of the #493 stack — require the p…
Merge pull request #501 from Juliusolsson05/refactor/layer-isolation
Merge pull request #502 from Juliusolsson05/refactor/render-input-con…
Merge pull request #503 from Juliusolsson05/refactor/flip-view-seam
Juliusolsson05 commentedon Jul 9, 2026
Closed via the merged stack #501 → #502 → #503. Pre-merge review: 4-agent panel (2 Claude + 2 Codex — move fidelity, architecture/identity, replay/test net, remote client). Zero blockers; both actionable findings fixed in
7e13284(requiredprojectItemsguard so an un-projected replay can't vacuously pass the #239 drop invariant; moved-path comment refresh).Acceptance verified on the merged head:
Three folders, one-way deps:
session-runtime/(ingest) ·rendering/(decide, framework-free) ·features/feed/(render, owns the bridge). The ledger consumes exactlyRuntimeRenderInput, emits exactlyRenderLedger/RenderRow[]. Documented deviations + follow-ups (ingress split, phone committed-tail plumbing, docs index refresh) are indocs/superpowers/plans/2026-07-09-layer-isolation.md§7 and the PR bodies.Merge origin/main into feat/remote-mobile-feed-rewrite (#493 layer st…
docs(rendering): refresh for post-#493 layer layout — address codex r…