Skip to content

App-wide layer isolation: ingest → decide → render as one-directional, grep-able folders #493

Closed

Description

@Juliusolsson05

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)

  1. Ingest has no home. Stages 3–6 live in workspace/ next to the UI (tile-tree/, dispatch/, ~40 UI files). There is no ingest/ folder.
  2. 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 — and collectLedgerInput's RuntimeLedgerSlices already picks exactly that, but as an ad-hoc pick, not a declared contract.
  3. 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.
  4. The output seam is inverted. FeedRenderItem (the render contract) lives in features/feed/ but the ledger's bridge (rendering/view/ledgerFeedItems.ts) imports it — so rendering/ → 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 neither features/feed nor workspace/ internals; session-runtime/ imports no UI. (Verified by a one-off grep, not a CI lock.)
  • The ledger consumes exactly RuntimeRenderInput and emits exactly RenderRow[].
  • 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

  1. Juliusolsson05 commented on Jul 9, 2026

    @Juliusolsson05
    OwnerAuthor

    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.md on the PR-1 branch):

    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), and groupSemanticActivity ends 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):

    grep -rn "@renderer/features\|@renderer/workspace\|from 'react'" src/renderer/src/rendering/ --include='*.ts' | grep -v '\.test\.'
    grep -rn "@renderer/\(workspace\|features\|app-state\)\|from 'react'" src/renderer/src/session-runtime/
    

    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.

  2. added 4 commits that reference this issue on Jul 9, 2026
  3. Juliusolsson05 commented on Jul 9, 2026

    @Juliusolsson05
    OwnerAuthor

    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 (required projectItems guard so an un-projected replay can't vacuously pass the #239 drop invariant; moved-path comment refresh).

    Acceptance verified on the merged head:

    $ grep -rn "@renderer/features\|@renderer/workspace\|from 'react'" src/renderer/src/rendering/ --include='*.ts' | grep -v '\.test\.'
    (empty)
    $ grep -rn "@renderer/\(workspace\|features\|app-state\)\|from 'react'" src/renderer/src/session-runtime/
    (empty)
    

    Three folders, one-way deps: session-runtime/ (ingest) · rendering/ (decide, framework-free) · features/feed/ (render, owns the bridge). The ledger consumes exactly RuntimeRenderInput, emits exactly RenderLedger/RenderRow[]. Documented deviations + follow-ups (ingress split, phone committed-tail plumbing, docs index refresh) are in docs/superpowers/plans/2026-07-09-layer-isolation.md §7 and the PR bodies.

  4. added 2 commits that reference this issue on Jul 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions