The spacetime of code · kolu

18 min read Original article ↗

Code can be wrong in two different ways, and for a long time I only checked for one of them.

The first way you can see by looking. Two ideas braided into one thing, a name that means two things, a seam that isn’t really a seam. The code, sitting there right now, is tangled. The second way you can’t see by looking, because it isn’t there yet. Two parts that will come apart later, on clocks you could name if you stopped to think — one revs when the UI changes, the other when a schema changes — bound together today as if they moved as one. The first defect is spatial. The second is temporal. They aren’t the same defect, they don’t show up at the same time, and a review that checks for one is blind to the other.

So I run two reviewers, one for each. Rich Hickey’s structural-simplicity lens catches the spatial kind. Juval Löwy’s volatility lens catches the temporal kind. And the thing that surprised me, the thing this post is about, is how often a finding from one of them is invisible to the other. A review running only Hickey would have looked at the diff and been happy. So would a review running only Löwy. Different code each time.

This matters more than it used to, because I’m not the one typing most of the code anymore. Claude Code writes it, from high-level intent, faster than I can read it line by line. So reading it line by line stopped being the useful thing for me to do during review. Reading the structure is the useful thing now. And reading the structure is exactly what two narrow, opinionated reviewers, aimed at a finished diff and run side by side, are good at. My job shrank to three steps: pick the lenses, read what they found, decide.

I think the biggest productivity boost from AI will come when we can nearly automate the software architect out of existence.

I'm refining both /hickey and /lowy toward that end — so I don't have to babysit the AI after every PR.

— Sridhar Ratnakumar (@sridca) April 16, 2026

I posted that two days ago. This is where the refinement stands now — not the finish line, just a snapshot mid-process.

I ran both reviewers on #6231 of Kolu, a redesign of the whole UI down to a single canvas. I drove the iterations; Claude Code wrote every line; the two reviewers are themselves Claude Code subagents spawned from the same session. They ran once before any code existed and then twice against the committed diff as I revised. Most of what they found sat on one axis or the other. A few times both landed on the same code — code that would have shipped otherwise. The one-axis findings are the story.

What the two lenses are

Hickey’s Simple Made Easy hands you one question: is this complected? Are two ideas braided together in one thing, so that to touch one you have to touch the other? He’s literal about the word:

Okay. So there’s this really cool word called complect. I found it. I love it. It means to interleave or entwine or braid. Okay? I want to start talking about what we do to our software that makes it bad.

— Rich Hickey, Simple Made Easy (Strange Loop, 2011)

A Hickey reviewer reads code the way a lockpicker reads a lock2: hunting for concepts sitting in the same position that shouldn’t be. The output is always the same shape — split these apart.

Löwy’s Righting Software hands you a different question: what changes at a different rate than the things around it? He’s building on David Parnas, who wrote the rule down fifty-four years ago:

We propose instead that one begins with a list of difficult design decisions or design decisions which are likely to change. Each module is then designed to hide such a decision from the others.

— David Parnas, On the Criteria To Be Used in Decomposing Systems into Modules (1972)

A Löwy reviewer reads code the way an actuary reads a portfolio3: hunting for things tied to schedules that have nothing to do with each other. The output is always the same shape too — draw a boundary around this volatility.

These sound like the same question asked twice. They aren’t. Hickey is asking about the code as it sits still: is there concept-duplication in this snapshot, or isn’t there? Löwy is asking about the code as it moves: these two things will drift apart later, on clocks you can name, and the code doesn’t know it yet. A module can be perfectly uncomplected and still be a time-bomb. A module can be safe against every future drift and still be a tangle right now. The lenses don’t overlap. They were never meant to.

The spacetime of code

Here’s the analogy I keep coming back to, and I’ll admit up front it’s the kind that can stretch too far. In physics, space and time aren’t separate things. They’re two views of one four-dimensional whole, and two observers moving differently slice it into different amounts of each. What’s pure space to me is part space and part time to you. The thing that’s actually real is the whole, sitting underneath both our views.

Code has a whole like that too. A module’s shape right now — what’s braided with what, what shares a name, what sits in the same scope — is one view of it. How it changes over time — what revs on which clock, which decisions get reopened, how fast each part drifts — is another. A defect can sit in one view and leave no trace in the other. Usually it does.

Hickey’s lens is the space-like observer. It reads the snapshot: what’s tangled right now? Löwy’s lens is the time-like observer. It reads the world-line: what’s going to pull apart, and when? Same code, different axis, and each is blind to whatever lives only in the other’s.

where each finding lands on the plane of codeclean · shiptemporal onlyspatial onlyboth fireborderClassLöwy: nothing to saydisplaySuffixHickey: nothing to saycanvasMaximizedbinocular agreementHickey · tangled right now — the spatial axis →Löwy · drifts on its own clock — the temporal axis →

Löwy says as much himself, in an appendix on complexity:

Functional decomposition is as diverse as the required functionality across all customers and points in time. The resulting huge diversity in the architecture leads directly to out-of-control complexity.

— Juval Löwy, Righting Software (Appendix B)

The complexity he’s warning about starts as a temporal thing — variety across customers and across time — and only later turns into tangled code. So the two do meet eventually: a mis-scoped volatility, left alone long enough, becomes a complected mess. But they meet late. The spatial defect is visible today; the temporal one stays hidden until the clock it’s riding finally ticks. Which is the whole reason a one-lens review misses half of what’s wrong. It’s reading one frame of a two-frame picture.

A spatial defect: borderClass

Kolu’s screen is one infinite canvas. Terminals are tiles you pan and zoom around, like windows on a desk with no edges. Floating over them is a row of pills, one per terminal, grouped by repo. Each pill’s border has to say two things at once: what the agent is doing (thinking, using a tool, waiting) and whether this is the terminal you’re looking at. The first version fused them into one match:

const borderClass = () =>
  match([active(), agentState()] as const)
    .with([P._, P.union("thinking", "tool_use")], ([a]) =>
      a
        ? "pill-border pill-border-spin pill-glow-inner"
        : "pill-border pill-border-spin",
    )
    .with([P._, "waiting"], ([a]) =>
      a
        ? "pill-border pill-border-waiting pill-glow-inner"
        : "pill-border pill-border-waiting",
    )
    .with([true, undefined], () => "pill-border pill-border-active")
    // ... etc
    .exhaustive();

The comment above it, in the shipped code, said so out loud: “Single border channel: encodes BOTH active-ness and agent state.” The code was honest about what it was doing. One pattern match, returning one string, with two ideas in every arm.

Hickey’s pass flagged it on sight. Two concepts — what the agent is doing, and whether this pill is focused — were sharing arms in one match, concatenated into a single class string. Want to add a new agent state, say streaming? Now you write it twice, once for the focused case and once for the unfocused one. The focus dimension had crawled into every change that had nothing to do with focus.

Commit fd6f802 split them:

const agentBorderClass = () =>
  match(agentState())
    .with(P.union("thinking", "tool_use"), () => "pill-border pill-border-spin")
    .with("waiting", () => "pill-border pill-border-waiting")
    .otherwise(() => "pill-border pill-border-active");

// at the call site:
<div
  class={agentBorderClass()}
  classList={{ "pill-glow-inner": active() }}
/>

Two composers, two concerns. Agent state picks the animation; classList lays the focus glow on top. Adding streaming now touches the agent-state match and nothing else. The new comment reads “Two orthogonal border concerns, composed via classList.” The code’s own vocabulary flipped, from BOTH to orthogonal.

And Löwy? Nothing to say here. Active-ness and agent state rev on the same clock — they’re both UI state that changes when you click things. No mismatched schedule, no boundary to draw. The defect was purely spatial: two concepts in one match, fixable by rewriting, and that’s the end of it.

Every codebase has a borderClass. It’s the code that gets merged because it works. Löwy’s lens looks straight at it and sees nothing wrong. Without Hickey in the rotation, it stays.

A temporal defect: displaySuffix

Two terminals can end up with the same name — same repoName + branch in git, or same cwd when there’s no git. The UI has to tell them apart, so Kolu tacks a short collision-suffix onto the label: main #a3f2. Cute problem, obvious fix.

The first version computed the suffix on the client, in terminalDisplay.ts. Every render, every time the pill tree redrew, every time a tile’s chrome updated, the client walked the whole terminal list, built a map of identities, counted the collisions, and emitted a suffix for any id that had a twin. It worked. Tests passed. The suffix showed up.

Hickey’s pass had nothing to say. The logic sat cleanly in one file, read by two or three consumers, with well-named helpers (identityKey, idSuffix, identityCounts). Nothing braided. The snapshot was fine.

Löwy’s pass said this:

Identity-collision is a business rule about the live terminal set, not a per-render display preference. The volatility — “which terminals collide right now” — lives on the server, where the terminal set lives. The display layer is recomputing what the server already knows.

That’s a volatility argument, not a structural one. The set of colliding terminals changes on exactly one clock: terminal lifecycle — create, kill, cwd change, git metadata update. Not on render. Not on display preferences. Not on anything else. Computing it in the display layer means every client, every tab, every render independently re-derives what is, in fact, one global property of one set owned by one service.

Commit 5ac5fe2 moved it. recomputeDisplaySuffixes() now runs in packages/server/src/terminals.ts, once, on every metadata mutation:

export function recomputeDisplaySuffixes(): TerminalId[] {
  const counts = new Map<string, number>();
  for (const entry of terminals.values()) {
    const k = identityKey(entry.info.meta);
    counts.set(k, (counts.get(k) ?? 0) + 1);
  }
  const changed: TerminalId[] = [];
  for (const [id, entry] of terminals.entries()) {
    const m = entry.info.meta;
    const next =
      (counts.get(identityKey(m)) ?? 0) > 1 ? `#${id.slice(0, 4)}` : undefined;
    if (m.displaySuffix !== next) {
      m.displaySuffix = next;
      changed.push(id);
    }
  }
  return changed;
}

One O(N) sweep, a gate so it only republishes the sibling whose collision status actually flipped, and the suffix rides along on TerminalMetadata as displaySuffix?: string. The client renders meta.displaySuffix and deletes its identity-tracking module outright.

Hickey, this time, had nothing to say. To a snapshot the before and after look about the same — a function in a file either way. What moved was where the volatility lives: next to the thing that causes it. Only Löwy’s lens could see that.

Every codebase has a displaySuffix too. Something computed in the wrong layer, because the wrong layer was the easiest place to type it. Without Löwy in the rotation, it stays — and every future change to the collision rule has to go hunting for it in the client.

When both fire

Once in #623 the two lenses landed on the same line. The state was canvasMaximized — which tile, if any, is currently filling the screen. The first version treated it like every other preference in Kolu: a Preferences field, synced through the server, saved into SavedSession, hydrated on mount behind a careful maxHydrated flag so the first paint wouldn’t flash.

Hickey saw three concepts braided into one chain: what’s maximized, whether the client has caught up to the server yet, and how to dodge a flash on first paint. Löwy, running on its own, saw three volatilities with no shared consumer: a client UI signal that revs when you click, a server field that revs on schema changes, and a SavedSession entry with its own versioning to worry about.

Different diagnoses. Same line. Same fix. Commit 99c1c44 deleted the server field, the SavedSession entry, the hydration flag, and the oRPC mutation, and moved the signal to makePersisted over localStorage. Nine files: 14 insertions, 90 deletions.

When both lenses fire on the same spot, it’s because the factoring is wrong deep enough to show up in both views at once — tangled right now, and bound to mismatched clocks for later. Call it binocular agreement. It’s a sharp signal when you get it. It’s also the minority. In #623 the binocular findings were outnumbered several-to-one by single-axis ones across three passes, and the few that did turn up came mostly from later passes — because revising the code keeps reintroducing the kind of defect both lenses catch together. Most findings, including the two I built this post around, sat on a single axis. That’s the normal shape.

Why the passes differ

The reviews that ran before any code existed, against a design sketch, caught the obvious structural risks: terminal identity smeared across PillTree, CanvasTile, and CanvasMinimap; the mobile-versus-desktop split threatening to become scattered conditionals. We designed around all of it before the first line went in.

The reviews that ran against the committed diff, and again after revisions, caught a completely different set of things — the ones that only appear after the implementation has taste-decided its way through twenty little design choices. The early review is cheap and catches categories. The late review is expensive and catches what the design iterations did to the architecture while nobody was watching. Both borderClass and displaySuffix were born during implementation. Neither was in the sketch.

So if you only run these reviewers once, run them at the end. Not the beginning.

Trusting one lens

Most findings come from one lens alone, so the real question is how to weigh one.

If only Hickey fires, ask yourself: is this duplication actually going to hurt, or am I about to merge two things that only look alike and will rev apart later? The repoColor helper, duplicated in PillTree.tsx and MobileChromeSheet.tsx, was a safe merge — one idea (“the canonical color for this repo”) that happened to have two call sites. Move it to pillTreeOrder.ts, done. But I’ve watched Hickey-style deduplications fuse two things that should have revved on different clocks, and the “well, now I have to parameterize the helper” spiral that follows is exactly what Löwy is there to head off.

If only Löwy fires, ask: am I drawing a boundary around a real volatility, or around something that just happens to look bounded today? displaySuffix was the real thing — collision detection genuinely revs on terminal lifecycle, not display preferences. But a Löwy split drawn for a volatility that never actually revs is a premature abstraction in a nicer hat, and that’s its own way of being wrong.

Hickey’s failure mode is over-merging: gluing together things that should be apart. Löwy’s is over-splitting: carving up things that didn’t need a boundary. Each lens has its own way of being wrong. Running the other as a counterweight helps — but only if you treat it as a second opinion, not a veto.

How to run them

Run them as two separate reviewers, not one pass. Ask a single reader to “check for structural simplicity and volatility” and you get a blended answer, and a blended answer leans toward whichever axis that reader already cares about. So split the passes. Hickey reads the diff and writes its findings. Löwy reads the same diff and writes its own. You read both. (Both ship in srid/agency as subagents your main Claude Code session can spawn in parallel.)

Don’t expect them to agree on the fix. They sometimes agree on the location — and even then they’re rarely prescribing the same edit. Hickey wants the concepts decoupled. Löwy wants the volatilities encapsulated. Sometimes that’s one edit. Sometimes Hickey says “split the function” and Löwy says “move the boundary,” and both are right in a way that only the third read — yours — can resolve. The fix you ship is rarely either agent’s literal proposal.

And when they disagree, don’t split the difference. Take the one whose reasoning survived your own pushback, and drop the other. Splitting the difference hands you the worst of both: not a clean concept, not a clean boundary, just a compromise that fails both tests six months later.

Half a review

A single-lens review is half a review. Code has a spacetime, and complexity creeps in along both axes.

Everything above is the existence proof for that one sentence: the borderClass braid Löwy couldn’t see, the displaySuffix in the wrong layer that Hickey couldn’t see, the canvasMaximized chain where they happened to land together. Run only Hickey and you’d have shipped displaySuffix recomputing on every client on every render, forever. Run only Löwy and you’d have shipped a borderClass match that taxes every future agent-state variant you ever add.

One more thing, and it’s the part I trust most. #623 shipped seven refactor commits past the point I’d have merged on taste, and every one of them made the diff smaller. That’s the tell. The fix removes code, it doesn’t add it. If your “simplification” is growing the diff, one of your lenses is broken. Probably both.

Ship when both lenses go quiet. Not before.

Further reading

Primary sources.

The reviewers, run as Claude Code subagents.

  • srid/agency — my near-autonomous workflow for coding agents, packaged as an APM package. Ships both reviewers as subagents the main session spawns in parallel.
  • hickey/SKILL.md — the structural-simplicity reviewer’s system prompt.
  • lowy/SKILL.md — the volatility-decomposition reviewer’s system prompt.

The PR reviewed in this post.

  1. #623 is an outlier in my normal workflow — a “kitchen sink” PR landing a full UX redesign in one branch. I usually ship smaller, single-purpose PRs. The scale is part of why the third review pass caught things the earlier passes missed: a big diff has room for defects that a small one doesn’t.

  2. A pin tumbler lock — the kind in most doors — has a row of spring-loaded pins at different heights. Picking it means probing each pin one at a time with a tension wrench and a pick, feeling for the one that’s binding: caught in the wrong position, blocking the cylinder from turning. The whole activity is about the current state of the mechanism. No past, no future. Just what’s bound where, right now.

  3. An actuary evaluates a portfolio — insurance policies, bonds, pensions — not by its current dollar value but by the distribution of its futures: claim rates, maturity schedules, how correlated each piece’s movements are with the others. The question isn’t what is this worth now? but how will this distribution behave over time, and what surprises are bundled together that shouldn’t be?