Skip to content

[Spec 83] v2 client shell: apps/v2 renders the live hierarchy - #99

Merged
pseudoseed merged 12 commits into
mainfrom
spec/83-v2-client-shell
Aug 24, 2026
Merged

[Spec 83] v2 client shell: apps/v2 renders the live hierarchy#99
pseudoseed merged 12 commits into
mainfrom
spec/83-v2-client-shell

Conversation

@pseudoseed

Copy link
Copy Markdown
Owner

Approved spec for #83, the second buildable unit of the v2 UI program (#37) after spec 52 shipped the server events.

Problem

GET /v2/events shipped and nothing had ever consumed it. Its tests drove fake in-process clients; no browser had opened it. apps/v2 did not exist.

Root cause of the 11 revisions

Reading the server code was not enough. I opened the stream with curl against the running Tower and got seven facts no code reading had produced, each of which changed the spec:

Observed Consequence
No machine node is ever emitted — V2NodeKind is workspace/architect/builder Criterion 1 was unimplementable as written
Scope of 1 workspace → 16 nodes, but counts said 22 workspaces / 58 builders counts is machine-wide, off by 45 in the observed case
encodeURIComponent(paths.join(','))HTTP 200, empty tree, dark frame parseScope splits the raw value before decoding. Silent, and renders as a plausible empty machine
All 13 builders returned parentId: "workspace:<path>" FR-3 is unsatisfiable. Filed as #97
A dark workspace is in a dark frame and not in nodes No node to mark; darkPaths is a separate store
Two connections returned different streamIds, both at seq: 0 The cursor is per-stream. A global rule kills the page on every Tower restart
darkPaths computed once at connect; inScopeSet excludes it Dark can never clear, in either direction. Filed as #98

Fixes that came from the review rather than the wire

  • /v2/ could not be served at all under the original constraints. handleV2Route 404s every path but GET /v2/events and tower-routes.ts was frozen, so no file was left that could serve the page. isPublicRoute also does not list /v2/, so a browser navigation would 401 before key injection ran.
  • listWorkspaces() returns [] on 401, 403, 500 and a dead socket (tower-client.ts:400-403), which made "unreachable must not look empty" unimplementable through it.
  • A new builder had no sparkline. v2-sampler.ts:196 strips buckets from every node delta, so the builder criterion 2 spawns arrives with no trace and fails criterion 5 on the same row.
  • A malformed frame was dropped without advancing the cursor, so frame N+1 applied onto a tree that missed N, and a resume replayed the bad frame forever.
  • seq validated as "is a number" admitted NaN, -1, 1.5 and 2**60.
  • /v2/ would 404 from the installed CLI. packages/codev publishes dashboard-dist from apps/web/dist with no equivalent for apps/v2.

What ships

14 locked decisions. One reducer over frames as the whole state model. Reconnect resumes rather than reloads. Four statuses on shipped tokens with rust reserved for gates. Containment, not an outline. 41 test scenarios, including transport framing across arbitrary ReadableStream chunks — the reader is hand-written, so the reducer tests prove nothing about it.

FR-15 is marked deferred, not satisfied: it says one environment degrades its subtree only, an environment is a connected Tower, and this unit has exactly one — so there is no sibling left running to prove the requirement.

Testing

  • Live stream consumed by hand at each stage; every server claim in the spec carries the file and line it was read from.
  • Every reviewer finding verified against the source before acting. Two were pushed back on in part.
  • 11 review rounds on the codex lane. Frontmatter records only codex; no reviewer ever issued APPROVE, and it says so rather than implying a clean sweep.

Spec only. No source changes.

🤖 Generated with Claude Code

pseudoseed and others added 12 commits August 23, 2026 23:20
Second buildable unit of the v2 program, after spec 52 shipped the server
events. Scope is deliberately one thing: render the hierarchy, live, and
nothing else.

The reason for that scope is that spec 52's stream has never been consumed.
Its tests drove fake in-process clients; no browser has opened it. The first
client is therefore the first real test of the contract, and if tiling,
terminals and gates land in the same unit, a wrong tree could be caused by
any of them.

Locked: one reducer over frames as the whole state model; tick is the only
writer of buckets and absence in tick means zero; reconnect resumes rather
than reloads; the four statuses map to the shipped design tokens with rust
reserved for gates; containment rather than an outline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ix dark

Three defects found by the opencode review, each verified against the source
before acting.

C1+C2 froze both files that own /v2/. handleV2Route 404s every path but
GET /v2/events (v2-routes.ts:250), and tower-routes.ts is the only other
router, so no file was left that could serve the page. C2 now names the frozen
contract files and permits v2-static.ts plus a bounded dispatch prologue.

Scope is required (v2-routes.ts:260) and rev. 1 never said where the client
gets it. D7: one GET /api/workspaces at startup, never polled, never re-derived
on reconnect. Zero workspaces renders the empty site and does not open the
stream.

dark.id is workspace:<path> (v2-routes.ts:395), not a machine. D5 replaces the
claim with three states that must not look alike: empty, dark workspace,
unreachable Tower. The last has no wire signal, so it is the one that defaults
to looking empty.

Also: D6 documents why the key injection is duplicated rather than exported
(tower-routes.ts imports v2-routes.ts, so exporting cycles) and makes its three
security properties testable. C3 rules out subscribeEvents/parseSseText, which
target /api/events and a {type,body} envelope. D8 names the 01-site.html cut.
Both open questions resolved: Tower proxy in dev, light default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…an fail

Two defects from the codex review, both verified against the source.

isRequestAllowed runs before dispatch and isPublicRoute (server-utils.ts:142)
does not list /v2/. A browser navigating there sends no codev-tower-key header,
and neither do the script and link tags the shell emits, so rev. 2's page 401s
before v2-static.ts is reached and the key injection can never run. D9 adds two
GET clauses, with the annotator shell as the precedent already in that function
(server-utils.ts:156-165). /v2/events stays keyed.

D7 told the client to bootstrap scope through listWorkspaces(). That method
returns [] when result.ok is false (tower-client.ts:400-403), so 401, 403, 500
and a dead socket all arrive as an empty list, which makes D5's requirement
that unreachable and empty be distinguishable unimplementable. The client does
its own fetch and branches on status. I could not tell must not be spelled the
same way as no.

Also restores the template's Open Questions heading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six defects from the codex review plus five findings from actually opening the
stream. Every claim below was measured against the running Tower on 4100, not
read off the code.

The stream emits workspace, architect and builder. There is no machine node and
there never will be from this contract, so criterion 1 was unimplementable as
written. D10 makes the local machine the page's own frame.

counts is machine-wide, not the scope's rollup. A scope of one workspace
returned 16 nodes alongside counts of 22 workspaces and 58 builders, off by 45.
D11 puts it in the footer, labelled as the machine's totals.

encodeURIComponent(paths.join(',')) is wrong and silent. parseScope splits the
raw query value on comma before decoding, so the encoded separators survive as
%2C, the split finds one part, and the server resolves one nonsense path. The
answer is HTTP 200 with nodes: [] and a dark frame. D12 states the encoding and
scenario 18 asserts it directly rather than trusting a round-trip.

All 13 builders in a real workspace returned parentId of workspace:<path>. Zero
architect parents, because discoverBuilders hardcodes spawnedByArchitect: null
(overview.ts:602,662,697). FR-3 is not satisfiable here. D13 renders the flat
truth and forbids inferring a parent by name-matching, because a guessed
hierarchy that is usually right is worse than an honest flat one.

A dark workspace appears in a dark frame and not in nodes, so there is no node
to mark. D1 adds darkPaths as a separate store and D5 says the plot is built
from the encoded id.

From the review: C3 endorsed listWorkspaces() while D7 forbade it, and scenario
14 still called it. C4 said setInterval only in backoff, which contradicts
itself since backoff is a one-shot setTimeout; now zero setInterval and exactly
one named setTimeout. D1 gains cursor-advances-on-every-frame plus defined
answers for invalid JSON, unknown type, EOF and non-2xx. D14 adds the packaging
gap: packages/codev publishes dashboard-dist from apps/web/dist and has no
equivalent for apps/v2, so /v2/ would work in-repo and 404 from the installed
CLI; an npm pack check is required because in-repo passing is not evidence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six internal contradictions from the codex review, plus one more measured frame.

D6 asked both key-injection implementations to comment-reference each other
while C1 freezes tower-routes.ts byte-for-byte. Only v2-static.ts carries the
reference now.

Criterion 21's allowlist omitted v2-static.ts, the file D6 requires.

D7 retried a failed bootstrap on backoff while criterion 11 demanded exactly
one request per page load. The rule is once successfully: after a 200 it is
never requested again including across reconnects, after a failure it retries.

D1 stored streamId, seq and buckets from a snapshot but not counts, and no
counts delta is guaranteed to follow, so the machine-total footer could stay
empty forever with the contract looking unexercised.

Scenario 9 still allowed setInterval in backoff, against C4 and criterion 11.

D10 required the machine hostname with no source. It is
window.location.hostname, chosen because it is what the person typed to get
here and there is no second machine until FR-16.

Measured, from a 40-second read of the live stream: a tick arrives as
{seq:1,type:tick,at:...,buckets:{}}. buckets is number[] on a node
(v2-events.ts:13) and Record<builderId, number> on a tick (:54) — one name, two
shapes, and a reducer that treats them alike breaks on the first tick, which is
also the first frame after the snapshot. The empty object is the common case,
not an edge one.

D13 now cites #97, filed against discoverBuilders.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…uess

Five findings from the codex review. The first is a seventh contract limitation,
verified in the source and filed as #98.

Rev. 5 said a recovering dark workspace stops appearing in the next snapshot
and the client clears it. It cannot. handleV2Route computes darkPaths and
inScope in one pass at connect (v2-routes.ts:302-314), inScopeSet excludes the
dark paths, and deps.isReadable is never called again on that connection. Both
directions are broken: a workspace that becomes readable stays dark forever,
and one that becomes unreadable never goes dark, so it reads as live-but-quiet.
FR-15 is true at connect and decays after. The client's only honest options are
to replace darkPaths on a snapshot and to refuse to poll for freshness, since
reconnecting to force a snapshot is polling with extra steps.

A 200 from /api/workspaces whose body cannot be read — invalid JSON, no
workspaces field, workspaces not an array — must surface as unreachable and
retry. Never the empty state. The status code is not permission to guess.

Approach A still said it reuses packages/sdk, left over from rev. 1, while C3
and D7 say no SDK behaviour is imported.

The risks table still asked for cross-reference comments both ways, which C1's
freeze on tower-routes.ts forbids, and pointed at criterion 18 and scenario 7
for claims that had moved to criterion 15 and scenarios 4 and 5.

Scenario 10 was one Playwright case against thirteen browser-facing criteria.
It now carries one scenario each, including that rust appears nowhere on the
page except a gate, and that the performance budgets are measured in the
browser rather than asserted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing tested

Five findings from the codex review.

FR-15 says one environment being down degrades that subtree only, and an
environment is a connected Tower (FR-14, FR-16), not a workspace. This unit has
exactly one environment, so when it is unreachable the whole page is the
subtree and there is no sibling left running to prove anything. Goal 4 claimed
FR-15 satisfied through dark workspaces; rev. 7 marks it deferred. D5's
handling is FR-15's foundation, not FR-15.

Rev. 6 said to drop a malformed or unknown frame without advancing the cursor.
That is unsafe twice over: frame N+1 then applies on top of a tree that
silently missed N, and a reconnect resumes from before N so the server replays
the same bad frame forever. A bad frame is now terminal for the connection,
with exactly one recovery attempt that drops since and stream to force a fresh
snapshot, and a hard stop after that. The mismatch state names the seq and type
that broke it.

Criterion 21's whitelist forbade the test files the spec itself demands, plus
the pnpm-workspace.yaml and pnpm-lock.yaml changes a new workspace necessarily
makes. It now separates production code from tests and workspace metadata.

The SSE reader is hand-written and had no transport tests, so it could pass
every reducer test and still fail on normal network chunking. Scenario 27 covers
frames split across chunks, several frames in one chunk, a partial trailing
frame at EOF, CRLF, and a chunk that splits a multi-byte UTF-8 character.

D6 said everything but the shell, assets and /v2/events returns 404, while the
frozen handler returns 405 for a non-GET /v2/events (v2-routes.ts:256-259).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three findings from the codex review.

Rev. 7 required the mismatch state to name the seq and type of a frame that
failed to parse. Neither is knowable from invalid JSON. The state now reports
what it actually got: invalid JSON after cursor N plus an escaped 120-byte
preview; a type verbatim when the frame parsed but the type is unknown; type,
seq and the failing field when the payload is bad.

TypeScript describes the frames and validates nothing. A decoded object with a
known type and a bad payload reached the reducer unchecked. Every frame is now
validated at runtime, with the failing conditions listed per type, and a known
type carrying a bad payload is terminal like an unknown type.

One deliberate exception, spelled out because the new terminal rule invites
getting it wrong: a structurally valid node carrying a status outside the four
still renders visibly wrong per D3 and does not enter the mismatch state. An
unexpected value in a well-formed frame is a contract drift the page should
show and survive; a malformed frame is a contract it cannot read.

D7 validated that workspaces is an array and not that each entry carries a
non-empty string path. Measured shape is {path, name, active, proxyUrl,
terminals}. Without per-entry validation, {workspaces: [{}]} becomes a scope
path of undefined, the server answers with a dark frame and reason unknown, and
a client bug arrives disguised as a server fact. A failing entry is a mismatch
for the whole bootstrap, not a workspace to skip, because a partial scope
silently drops machines the person expects to see.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… from afx cleanup

Four findings from the codex review, plus the spec/plan boundary stated.

Rev. 8's field validation was partial, and the reviewer offered the right
choice: enumerate the whole schema or narrow the guarantee explicitly. Neither
half-measure is safe. The rule is now validate every field the reducer reads
and no others, with the read-set listed per frame type as a closed table. A
second full schema here would duplicate packages/types and drift from it, while
ignoring a field the reducer does read is how a number lands in parentId and
the tree quietly reparents. Scenario 35 asserts the narrowness holds, so a
harmless added server field cannot kill the page.

seq validated as is not a number admitted NaN, Infinity, 1.5, -1 and 2**60. It
must be a finite non-negative safe integer.

Ordering is non-decreasing, not strictly increasing. Measured: a snapshot and
the dark frame beside it both arrived with seq 0, so frames legitimately share
a sequence and the intuitive invariant would reject a correct stream. A seq
below the cursor stays terminal.

The Playwright scenario for criterion 6 called afx cleanup, which would put an
irreversible worktree destruction in a test runner instead of with a human. It
uses a gone fixture; the real path is verified once by hand during UX
verification.

Eight rounds pushed file names and line numbers into this document, so the
spec/plan split is now stated rather than left to the plan to re-litigate. The
line-number citations stay as evidence: they are the proof behind two claims
this spec got wrong before measuring, and a spec that asserts server behaviour
without saying where it read it gets revised again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two findings from the codex review. The first would have killed the page on
every Tower restart.

Rev. 9 made any frame whose seq is below the cursor terminal, while D2 requires
accepting a replacement snapshot with resumed: false. Those contradict.
Measured: two consecutive connections returned streamId 73da9f829b466efd then
0b3e88cce99fe0b1, both starting at seq 0. So every refused resume arrives at or
below the old cursor, and rev. 9 declared all of them corrupt. Ordering is now
non-decreasing within one streamId and uncompared across streamIds, where a new
snapshot establishes a fresh baseline at its own seq however low.

Mismatch was described as terminal without saying what ends it, which reads as
a latch: one bad frame would poison a tab until someone reloaded it, and a page
reload is what D2 exists to avoid. A valid fresh snapshot now clears the state
and resets the one-attempt budget.

Rev. 9 called a malformed bootstrap 200 both unreachable and a contract
mismatch, while D5 requires those two to look different. The split follows what
is actually retryable: a non-200 or a thrown fetch is transient and retries on
backoff indefinitely, while a 200 whose body cannot be read retries once and
stops, because a server returning garbage returns garbage again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… retry can fix

Four findings from the codex review. The first was a contradiction between two
of this spec's own success criteria.

v2-sampler.ts:196 emits every node delta as stripBuckets(node), and only the
snapshot paths call withBuckets (v2-routes.ts:233, 361, 376). So the builder
that appears when you spawn one arrives via a node frame with buckets absent,
and rev. 10's do not touch buckets left it with no trace at all. Criterion 2
spawns that builder and criterion 5 requires it to have a sparkline. A node
upsert now leaves an existing trace untouched and creates 20 zeros when the id
is new and the kind is builder. That does not break tick is the only writer: a
zero-filled trace is the empty state, not data.

Rev. 10 sent every non-2xx stream response to unreachable plus backoff while
also calling a 400 a client-built bad scope. Those contradict, and the result
retries a request guaranteed to fail identically, forever. Responses are now
classified by whether a retry can fix them: 400 and 404 and 405 are contract
mismatches with no retry, 401 and 403 are auth-labelled unreachable with no
retry, and 5xx or a thrown fetch retries on backoff indefinitely. A 400 is
almost always D12's encodeURIComponent trap, and a page that retries it forever
shows a spinner instead of the answer.

Scenario 26 said a malformed bootstrap 200 produces the unreachable state,
contradicting D7 and scenario 38 in the same document.

D1 said a snapshot stores buckets, but V2SnapshotFrame has no top-level
buckets — they ride on each node. It also needed to say darkPaths is cleared
before that snapshot's own dark frames are applied, or a recovered workspace
stays dark.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
11 review rounds on the codex lane, each one catching real defects, with the
sharpest findings coming from consuming the live stream rather than reading the
code. Stopping here: the remaining findings are shrinking and a builder will
surface real problems faster than a twelfth round will.

Frontmatter records only the codex lane. No reviewer ever issued APPROVE, and
this says so rather than implying a clean sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@pseudoseed
pseudoseed merged commit ad82d53 into main Aug 24, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant