Workbench — the review-first surface
The workbench is the review-first canvas at /workbench (apps/web) over the
live platform spec, plus interaction telemetry flowing. The spec and the telemetry
log are disk-backed (MAXSTACK_DATA_DIR, defaulting to .maxstack/ in
dev — data-dir.server.ts; stores in @maxstack/mcp createFileSpecStore +
the JSONL telemetry host), so the workbench, the MCP tools, and a headless
script share one durable project. Accept/reject lands through the
provenance.review spec-op, so every review is an op-log audit entry.
maxstack demo drives the whole loop through it reproducibly.
The agent is the primary interface, so the workbench stays a review surface — the place a human grounds truth — not a product of its own. That constraint holds. What did not hold was the sentence that used to open this document: "three panes, and this is the workbench's final scope". Panes were added one at a time, each reasonable on its own, until there were eleven and the result was a set of panels rather than a place — see what needs you, which is now the top of the surface and the answer to "where do I start".
Why it is deliberately small #
Every pane is a pure fold of the spec or a thin wrapper over an already-shipped platform primitive (generators, the ledger). That discipline was adopted while it was still an open question whether the workbench should be a product in its own right or only the human end of an agent-driven loop — so the surface could shrink to the review floor without stranding bespoke machinery. That question is settled, and the discipline stands: the workbench renders and reviews; it owns no machinery. It is the platform's stated thesis (§1: "review is a first-class activity, not just generation") rendered visible. The one the design corespec-style conversational scoping.
What needs you #
Above every pane, and the only thing on the surface that is ordered.
attentionReport (packages/mcp/src/attention.ts) answers "what should I look at
first?" as data: one list, worst first, every item naming what it is, why it
outranks the next one, and where to go. The ranking is not a display preference —
it encodes which mistakes are unrecoverable:
| Rank | Category | Why it is there |
|---|---|---|
| 1 | public exposure that would change | the only damage done the instant it lands; data that reached the internet has reached it |
| 2 | removals | a dropped column is dropped data |
| 3 | proposals that cannot be batched | the ones a reviewer would otherwise clear without reading |
| 4 | latent exposure | declared-but-not-live public surfaces — one op from rank 1, with no review in between |
| 5 | drift on owned files | the platform has moved underneath code it may not touch |
| 6 | everything else | routine proposals, available upgrades |
Three rules it holds to:
- Nothing here is a count. "17 pending" is a number, not attention; nobody can act on a badge. The report names specific rows, and the count sits underneath as context.
- The routine majority is one line. Those are the rows bulk review exists to make cheap; listing them individually would put them back in the way.
- What could not be checked is on screen. An empty report from a host that
could not look is indistinguishable from a real all-clear, and the second is the
dangerous mistake.
unavailablenames every unevaluated category, and the headline refuses to say "nothing needs you" when anything went unchecked.
Blast radius — what a spec diff does not say #
A reviewer deciding on data.addField is not deciding about a line in a JSON
document. They are deciding whether a column appears, whether a form grows an
input, whether a REST endpoint accepts a new key, and whether a value becomes
readable by the public internet. blastRadius
(packages/mcp/src/blast-radius.ts) derives the surfaces a spec produces and
diffs that inventory between the current spec and a hypothetical one with the
pending ops applied in memory. One line of spec becomes named consequences:
$ maxstack review --section blast-radius
If you accept everything pending: adds 2; changes 2
adds (2):
`order.sku` string
`order.viewerRole` string
changes (2):
the `order` table 1 column → 3 columns
`/api/order` (list, create, update, delete) total → sku, total, viewerRoleIt deliberately does not run the generators: emitting code for two specs and diffing it would be slower, would answer at file granularity ("routes.ts changed"), and would make the reviewer read a diff to find the fact.
The accepted-or-all trap. Grounding runs over getAcceptedOrAll, and so does
this, because the question is "what will the runtime build" rather than "what would
a reasonable runtime build". While nothing in a collection is accepted, every
suggested row in it is already being built — so accepting one changes no derived
surface, and the honest answer is "no change to what gets built". groundingNote
says so and names the collections in that state. It is per collection, not per
spec: an accepted entity whose fields are all suggested has its fields in
fallback while its entity list is not. The first version checked the whole spec, so
the explanation vanished exactly when it was most needed — found by running
maxstack review against a real project, not by reasoning about it.
Exposure is built on portalExposureReport, not beside it: that function's
docblock states the rule ("two implementations of a security boundary is one more
than is safe") and it is the one pinned by the agreement test asserting a portal
response's keys equal exactly the fields it reports as readable. What the blast
radius adds is the question that report deliberately does not answer — liveness.
It covers every declared portal, paused included, because it answers "what could
be exposed"; a diff needs "what is exposed right now", so rows are filtered to
activePortals. That filter is what makes the transition visible: a paused portal
publishes nothing until somebody un-pauses it, and then every field it names shows
up as newly public. Everything not live is reported as latent exposure with its
reason, never omitted.
Three surfaces, one ordering #
| Entry point | |
|---|---|
| Workbench | AttentionPane, above every other pane |
| Terminal | maxstack review [--section exposure|blast-radius] [--json] |
| Agent (MCP) | the workbench tool, same three sections |
The workbench is the best surface, never the only one, and that rule is load-bearing rather than ceremonial: the exposure view — the single most important thing a human should review — once had no CLI verb and no MCP tool, while a route comment claimed both existed. A public-boundary report you can only see by opening Chrome is one that does not get seen.
The ordering is computed once and rendered three times. A "most important thing"
that differs by surface is not a most important thing. That happened once,
when two hosts each computed their own answer to one question. Disk facts (drift,
upgrades, ownership) arrive through PlatformContext.attention, because the shared
layer has a spec and nothing else; a host wiring less gets a narrower report that
says it is narrower.
Hydration is a standing hazard on this surface #
A getServerSnapshot mismatch once shipped green here, because a client-only
render() never calls getServerSnapshot at all. Server-render tests are
therefore mandatory on this surface, and
apps/web/app/workbench/panes.hydration.test.tsx drives a real renderToString
followed by a real hydrateRoot.
The assertion is on onRecoverableError — not console.error, not the final
DOM. React 19 recovers from a mismatch by client-rendering the subtree and still
lands on correct-looking markup, so a DOM assertion passes either way, which is
precisely how this class hides. Every pane is server-rendered with no state and no
effects; on the attention pane that is a requirement rather than restraint, since a
stranded subtree in the first thing on the page is a maintainer reading a stale list
of what needs them. The tests earned their place on the first run, catching the pane
rendering literal ** in its headline.
Loading must never mutate #
Easy to violate by accident: the public-exposure
and removal categories are computed by applying every pending accept to a
projection of the spec. Had that projection ever reached spec.save, opening the
page would settle every review in the queue — the worst bug this surface could
have, and one that would look like the feature working. The projection is
in-memory, its ops are stamped actor.path: 'attention-hypothetical' so a leak
would be findable by name, and two tests in review-cost-wiring.test.ts assert the
spec and the op log are untouched after a load.
Extending it #
- New attention category → add it to
ATTENTION_KINDSin severity position; the rank is derived from that array, so adding one means deciding what it outranks. Do not append by default. - New host fact → optional on
AttentionInputs, with a matchingunavailableline. A category that can be absent has to say when it is. - New derived surface → add to
SURFACE_KINDS, again in severity position, and givederiveSurfacesa case. If the runtime owns the naming rule, pin it with an agreement test rather than duplicating it quietly (apps/web/app/blast-radius.agreement.test.ts). - New pane → server-render it and add it to the hydration test. Client state needs a reason, not a default.
What the rest of it does #
The original three panes, all a pure fold of the live platform SpecSystem —
the same singleton the MCP tools mutate, so the workbench and an agent conversation
see one source of truth:
- Spec zoom (left) — the three layers as one tree (product → data → pages →
pricing), every row badged with its derived provenance state, with per-layer
counts. Each top-level node is a zoom link (
?focus=<id>): clicking it swaps the center pane to that node's detail. The altitude ladder from goals down to fields — and, for a page, down to the emitted code. - Review queue / detail (center) — with no focus, the review queue:
every undecided AI suggestion across the data / page / pricing layers
(high-priority first), each row with Accept / Reject. This is the
suggest→accept queue the provenance flags (
isSuggested/isAccepted) already carry. Accept makes a suggestion grounding truth; reject is a soft-reject (isAccepted = false), never a delete. With a node focused, the pane shows its detail: an entity's fields + the pages derived from it (the spec→UI link); a page's blocks, the entity it renders, its acceptance criteria (e2eTests), and a live preview of the page the ownershippagegenerator emits for it — the emitted modules evaluated through the real<Slot>runtime (workbench/preview.server.ts), not just their source. - Decisions + activity (right) — the append-only decision ledger split into
open and resolved. Open decisions are an alternatives browser: the
options with their pros/cons, a radio to pick one (recommended pre-selected),
a rationale box, and Resolve — which appends a
resolvedentry via the settledresolveDecisionledger primitive (append-only; the pending entry is never rewritten). Below it, the interaction-telemetry feed: a running count by kind and the newest-first activity stream.
Shape (so a later phase can extend or delete it cleanly) #
app/workbench/view-model.ts— pure, unit-tested.buildWorkbench(spec)→{ queue, tree, decisions, counts }.applyReviewAction(spec, ref, action)transitions one provenanced row immutably (via the settledaccept/rejecttransitions in@maxstack/spec, not a new spec-op — provenance decisions are distinct from structural spec-ops). Locates nested rows by{kind, id, parentId}and throws on a stale ref.app/workbench/workbench.server.ts— the seam to the platform context:loadWorkbench(focusId)(view + detail + live preview via thepagegenerator),submitReview(load → applyReviewAction → save),submitResolve(resolveDecisionon the ledger). Each read/write appends a telemetry event.app/workbench/telemetry.ts+telemetry.server.ts— the append-only interaction-event log (§5 "events flowing"). Pure JSONL serialize/parse + summarize (mirrors the harness metrics DB shape); the server host appends to<data-dir>/telemetry.jsonlwhen the app has a data dir (the dev default), falling back to an in-memoryglobalThislog under unit tests.app/workbench/drift.server.ts+drift-pane.tsx— the Ownership pane: what the maintainer owns, what it was derived from, and how far it has drifted, over the sameownershipDrift()foldmaxstack driftand theownership_driftMCP tool render. Read from the project's own app directory rather than the running bundle's owned-code manifest — the opposite of the slots pane, deliberately: "does this fill render?" is a question only the bundle can answer, and "how far has the file on disk fallen behind?" is a question only the file can. The loader'sFsthrows onwrite, so "informational, never applied" is structural.app/routes/workbench.tsx— loader +action+ the three-pane render. Plain<Form method="post">per row/decision, no client JS beyond React Router's.
Decision recorded here (not re-litigating a settled one) #
Accept/reject is modeled as a provenance transition, not a structural
spec-op. The structural op vocabulary is deliberately additive (§3-L1);
accepting a suggestion changes its provenance, not the spec's structure. The
provenance module ships accept/reject for exactly this. The system-level
provenance.review op is a thin, validated wrapper that applies the same
settled transition and records it in the spec's op log (change: 'review',
origin human from the workbench, ai when an agent drives it via MCP) — it
exists so the decision trail (who accepted what, when) is as durable and
diffable as every other mutation. Reject remains a soft-reject, never a
delete.