---
title: "Day two: a map, four surveys, and a spike that said no"
date: 2026-08-02
summary: >-
  Nine hours spent deciding what to build, and on purpose no framework code at
  all: a wayfinder map, four research surveys of roughly 4,800 lines, one
  throwaway spike, and nineteen tickets. The spike settled the riskiest decision
  by building rather than arguing — the realm boundary is a filename suffix,
  refused at resolution time, and per-export granularity is out. The only change
  outside the planning directory was one line of formatter configuration.
---

# Day two: a map, four surveys, and a spike that said no

Day one built plumbing. Day two spent nine hours deciding what to put through
it, and on purpose wrote no framework code at all. The output is a map, four
research surveys totalling roughly 4,800 lines, one throwaway spike, and
nineteen tickets — six of them already resolved. The only change outside the
planning directory was one line of formatter configuration.

That is the whole point of the posture the map states in two words: plan, don't
build.

## The map

Everything lives under `.scratch/ursprung-v0/`, charted as a wayfinder map with
a written destination: a v0 architecture spec that locks Ursprung's module
boundaries, build pipeline stages, resumability format, module-resolution
policy, and public API surface — complete enough to implement without reopening
architecture.

The map was restructured once during the day, and the change was substantive:
**spikes are part of the destination**, not preparation for it. The reasoning
comes straight out of the research. Ursprung's combination — signals,
resumability, no virtual DOM, a hand-written toolchain, Workers-only — has no
prior art, and the nearest reference points changed their minds only after
building. Qwik shipped without a virtual DOM and then added one; it has
rewritten its serialization format twice in three years. So questions marked
`prototype` must be answered by a disposable artifact before they are written
down, rather than reasoned about and locked. No framework code survives the map.

## Three axioms, and the one that has teeth

**No legacy, no backwards compatibility.** ES modules only. No JS lowering for
browsers, no CJS interop. Exploit the newest Cloudflare, workerd and web-platform
APIs rather than the intersection of what everything supports. Where a decision
trades reach against modernity, modernity wins, and the discarded reach goes in
Out of scope rather than getting re-litigated.

**The dependency budget.** Ursprung depends on close to nothing, and every
exception is named: oxc (four build-time packages — `oxc-parser`,
`oxc-transform`, `oxc-resolver`, `oxc-minify`), the TC39 signals polyfill,
`typescript` for type checking only, `tailwindcss` for styling, and — added late
in the day — `capnweb` for browser↔server communication. Nothing else. In
particular, no esbuild and no rolldown: if Ursprung bundles at all, it authors
the bundler.

**Agents are the audience.** This is the one that actually changes answers.
Ursprung is built to be used by coding agents writing apps, not by humans
writing apps, and that inverts several conventional trade-offs. Conventions that
exist to spare humans typing are worth little — an agent will happily maintain
an explicit, verbose, fully-typed declaration. Rules an author must obey are
cheap, provided they are stated precisely and the violation produces a
diagnostic that names the fix. Magic and inference are liabilities, because they
produce failure modes an agent cannot reason its way out of.

The consequence is that the bar on error messages goes _up_, not down. Several
decisions below are "refuse it and print a good diagnostic" precisely because
that trade is cheap under this principle and expensive under the usual one.

## What the surveys found

Four research tickets ran to completion, and three of them came back with news.

**oxc gives us far less than assumed.** No hosting for custom transforms, no
AST→source printing reachable from JavaScript, no semantic analysis. Three
architectural consequences follow directly: transforms must be span-directed
source splicing rather than AST rewriting, Ursprung must write its own scope
analyser, and the build cannot run inside workerd.

**The signals justification does not hold.** The TC39 polyfill was permitted on
the explicit ground that signals will one day be native. The survey found Stage
1 rather than Stage 2, no specification text, no plenary appearance since June
2024, a polyfill dormant for eighteen months whose README says not to use it in
production, and zero framework adoption — Angular, Vue, Preact and Solid all
ship their own. Request isolation under workerd is safe, with three named
hazards. Serialization support is nil, so resumability owes the proposal
nothing.

**Resumability and code splitting are separable.** Marko 6 shipped resumability
_without_ per-handler code splitting, and Qwik's QRL machinery accounts for
roughly 80% of its accidental complexity. Also: Qwik v2 turns out to have a
virtual DOM. Effects must be serialized as data records, not closures. The
budget anchor from the survey is 12.9% of page weight.

**workerd behaves as hoped, with rules.** Server code-splitting works
mid-request, but chunks must have pure module bodies and chunk count costs
TTFB. Request state must never live in module scope. Startup is a one-second
deploy-time gate that is invisible during local development. Miniflare is
embeddable, which means Ursprung can run its own dev server.

## The spike that said no

Ticket 5 — the client delivery model — was the day's largest decision, and it
was settled by building a throwaway rather than by arguing. The spike is about
350 lines over a fixture shaped to hit every case the ticket enumerated: a
client entry, a three-hop transitive contamination chain, a value export of a
server module imported from the client, a type-only import, and credentials in
two server modules that must never reach the browser. Thirteen assertions, all
passing.

The crux — can boundary enforcement be done with oxc analysis plus per-module
span splicing, with **no linking step**? — came back yes, with one exception.

**The boundary is a filename suffix.** `*.server.ts` and `*.client.ts`, with
everything else universal by default. The reason is not familiarity, it is
_when refusal happens_: realm is a property of the path, so the client walk
rejects a server module on the resolved path before reading a byte of it. The
spike asserts exactly this — the contaminated `db.server.ts` is never read. A
`"use server"` directive cannot do that; it has to parse a module to learn it
should not have parsed it. The crossing is also visible in the importer's own
text, so a reviewer or an agent sees it without opening the target.

**Per-export granularity is refused, and the refusal was measured rather than
argued.** Stripping a marked export by span is trivial to implement and produces
a module that parses fine and is broken, because a universal export still closes
over the deleted binding. The only check available without semantic analysis is
a name-only reference scan, which scored one true positive out of three on the
fixture — the false positives were a function parameter of the same name.
Separating them needs the scope analysis oxc does not expose. So: split the
file. That costs a stated rule and a precise diagnostic, which the agent-first
principle says is cheap.

**Per-handler code splitting is out of v0.** Handlers stay addressable —
resumption needs stable identity — but they ride the route's client code rather
than being individually chunked. What that buys the app's user is no
first-interaction round trip and no stale-chunk-after-deploy failure mode, which
matters because the prior-art survey found Qwik has no first-party chunk
retention policy. What it buys the build is larger: no symbol→chunk manifest,
which was the thing that would have coupled the server build to the client
build.

**There is no bundler, for either output.** One emitted module per input module,
content-hashed, with `modulepreload` for the route's graph. No build-time value
injection into client source at all. Retargeting a server import turned out to
be a single span edit on the module request string — the importing module's code
is untouched and call sites need no rewriting, because the generated stub keeps
the name. That is the main reason no linking step is needed.

One useful correction fell out of the spike: `oxc-parser` now ships a read-only
`Visitor` class with node spans. Ticket 1's "no traverse binding" finding stays
correct for mutating transforms, but a read-only walker is first-party now, and
the spike needed no hand-written one.

## Module resolution

The second grilling of the day settled resolution policy, and it collapsed a
surprising amount of surface area.

There is **one** resolver, and it runs at build time. Content hashing already
rewrites every specifier, so the browser is handed resolved URLs and **no import
map is emitted**. `oxc-resolver` is adopted with most of it switched off: source
names the real file (`./foo.ts`), extensions are required, no probing.

Bare specifiers are where the policy bites. `ursprung` resolves everywhere; any
other package resolves **only inside a `.server.ts` module**. Third-party code
is refused in the client graph, because a no-bundler client output cannot absorb
a 200-module library. Packages carry no realm and cannot be marked. ESM-only
conditions with an `exports` map required and no `mainFields` — which collapses
CJS out of the design rather than refusing it as a separate rule. No aliases in
app source, because aliases hide realm. JSON imports only, attribute required,
emitted as JavaScript.

Note what that means in combination with the dependency budget: an app installs
one dependency to use the framework, but the app itself is not held to that.
Third-party server packages are explicitly allowed.

## Where contradictions went

Two of the surveys contradicted axioms that had been set at charting time. The
process decision was to convert them into tickets rather than quietly editing
the prose to match.

The signals dependency justification became ticket 18 — vendor, depend, or
reconsider. The reactivity model is not in question, only the dependency and its
stated reason. And no-VDOM versus component boundaries became ticket 19: the
DOM has nothing in it to anchor a resumed component tree to, which is exactly
the wall Qwik hit. That is not an argument for adopting a virtual DOM — Marko 6
anchors with comment markers and has none — but it cannot be assumed away
either, so it gets a prototype.

The third contradiction, that "oxc" is four packages rather than one, was simply
absorbed into the dependency budget.

Late in the day capnweb was added as the delegated answer for browser↔server
communication, in the same shape as the existing Tailwind delegation: Ursprung
does not design an RPC wire protocol, a wire value serializer, or a transport.
It drives capnweb and makes the types honest across the boundary. That created
two more tickets — a survey and an integration grilling — and it reaches further
into the map than "RPC library" suggests, since capnweb's by-reference function
passing sits on top of handler addressability and its value serializer sits on
top of the resumability format.

## The one line of real code

`oxfmt` now ignores `.scratch/**/*.md`. The issue-tracker prose is hand-wrapped
at 80 columns and is not source; code under `.scratch/` is still formatted. It
is a small change, but it is the only thing that landed outside the planning
directory all day, which is a fair summary of the day.

## Where this leaves things

Nineteen live tickets, six resolved. The build's shape is now decided at its
riskiest points: transforms are span splicing, the realm boundary is a filename
suffix enforced at resolution time, there is no bundler and no per-handler
splitting, and there is one build-time resolver emitting no import map.

What is still fog is mostly downstream of one thing — the resumability
serialization format. Client-side navigation, the event capture layer, the
document head and modulepreload, and streaming SSR all wait on knowing what the
document actually carries. That is the next interesting question.
