Commit Graph

14 Commits

Author SHA1 Message Date
Cursor Agent b872923331 fix(improvise-2mqz): export the requesting session view
Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
2026-09-27 16:51:50 +00:00
Cursor Agent e0f04ee334 feat(improvise-2mqz): add wire forms for SaveAs and ExportCsv downloads
This commit implements the download flow for SaveAs and ExportCsv per the
USER RULING on improvise-2mqz. The correct shape is:
- A request frame asks the host for BYTES
- ServerFrame returns the bytes (or Rejected)
- Browser triggers downloads via Blob + <a download>

Key changes:

Protocol layer (improvise-protocol):
- Add Request::DownloadDocument and Request::DownloadCsv variants
- Add ClientFrame::DownloadDocument and ClientFrame::DownloadCsv
- Add ServerFrame::DocumentBytes and ServerFrame::CsvBytes
- Add KeyOutcome::LocalThenRequest variant for operations that need
  both local fold (mode change, buffer clear) AND a server request
- Update stranding() to unstrand SavePrompt and ExportPrompt modes
- Update keymap to use rebind() instead of commit() for commit-save
  and commit-export (because LocalThenRequest can't be in a Sequence)

Host layer (improvise-command):
- Handle new ClientFrames in host.rs returning bytes or Rejected
- Add Rejection::ExportFailed variant for CSV export failures
- Update CommitSave and CommitExport to clear their buffers
- Make render_csv() public in improvise-io for host use

WASM client layer (improvise-wasm-client):
- Add PendingDownload type with filename, bytes, and kind
- Store pending downloads for JavaScript retrieval via take_pending_download()

Browser layer (web/app.js):
- Add triggerDownload() function using Blob + <a download>
- Poll for pending downloads after frame handling

This unstrands SavePrompt and ExportPrompt which were previously refused
by stranding() due to missing wire forms.

Refs: improvise-2mqz

Co-authored-by: Ed Langley <fiddlerwoaroof@users.noreply.github.com>
2026-09-25 19:15:40 +00:00
Edward Langley 253b84b2da fix(protocol): a refused frame is put back by the fold that made it (improvise-nxah)
`Client::key` folds a locally-resolved batch before sending it, so the page
redraws on the keystroke. A refusal left those folds in place: measured against
a real `Host`, a refused commit left the client one row down with its edit
buffer cleared while the host's mirror of that same client was still at the
origin holding the user's typing.

What rolls back is `ViewState` — the thing the fold produces — never a cache. A
`Command::Model` is a no-op in `reduce_view`, so the optimistic fold never
touched the caches, and the bug report's own evidence (`page_coords`) cannot
diverge this way: it moves only under `Projection::Grid`. What diverges is the
mode, cursor, status and buffers that ride along with the model command.

The rollback needed a positive acknowledgement first, which the protocol did not
have: with only `Rejected` on the wire, a client holding two frames in flight
cannot tell which one an answer addresses. `ServerFrame::Accepted` closes that —
emitted once per accepted mutating frame, last, after everything the frame
produced — the same shape as the basis-publication gap ADR 001 originally left.

Two existing facts make the rule usable rather than a convention: only
`Command::Model` can be refused, so a frame carrying none is answered by neither
and needs no entry; and `Rejection::refused_an_edit` already separates the two
`Save` refusals, so a failed write cannot consume the answer owed to an edit.
A refusal drops one frame and replays the rest, because a single saved snapshot
is unsound under pipelining — it would discard a later frame the host applied.

This does not do improvise-3al1's force, and it does not contradict it: the
2026-09-21 wave recorded the two tickets as contradictory on the ground that a
rollback would wipe the staged typing. It does the opposite — the typing frames
carry no model command, are never refused, and have already settled, so rolling
back the commit restores the buffer to the typing. Asserted, not inferred.

Reasoning: context/code/clients.md, context/code/protocol.md §4.
Evidence: context/test-evidence/clients.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-22 15:50:29 -07:00
Edward Langley 8e24e4011e chore(protocol): cap comments in basis.rs, frame.rs, reduce.rs, keymap.rs
The three basis tiers, the one-envelope-pair derivation, the ModelCommand
count and the TextEntry spine move to context/code/protocol.md §§2,4-6.

Three comments were wrong about the code and are corrected rather than
relocated (recorded in §9):

- ClientFrame::Key claimed "the host runs it through the keymap against the
  authoritative App". Host::handle returns an empty vec for that arm --
  improvise-bpq2 moved resolution to the client and updated one copy of the
  argument, not all of them. Fourth sibling of gotcha 48.
- every_command_round_trips_through_json named CellKey and CellValue as
  payload types "the view half never carried". ModelCommand has carried no
  CellKey since ADR 002, and CellValue is in the view half as SetYanked.
- buffer_of said "the seven modes that carry a MinibufferConfig". Eight do,
  since SavePrompt; seven is the TextEntry::new call-site count, which is
  the exact confusion repo-map gotcha 11 warns about.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:49:57 -07:00
Ed Langley 22d43b2b30 Merge pull request #23 from fiddlerwoaroof/wave/a-grid-frame
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Browser bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
WU-A: the grid frame on the wire (improvise-anr9, improvise-s1xw absorbed, improvise-1125).

row_labels/col_labels become one AxisFrame per axis carrying the five facts gotcha 44 enumerated, so the browser draws multi-level headers, repeat suppression, group spans and collapse indicators instead of Food/Alice.

Includes the user's P1: a cell's identity is cats zipped with its tuple, and invalidate_relabelled compared tuples alone — so an axis swap left every CellDisplay and column total standing under a coordinate that no longer named them, and because grid_updates diffs against the framed mirror, the producer then sent nothing at all. A client wrong forever.
2026-09-20 00:40:47 -07:00
Edward Langley 7b04752c4c docs(protocol): the record-tier fix left its own stale sibling
The second review pass cleared the code and found that **243b9a9 landed its
own instance of the rule this PR enforced in three other files**: it gave
`CheckedBatch` a new "two of the three tiers hoist, and the third does not"
section and left the sibling paragraph in `frame.rs` — the **protocol-facing**
doc, the one a client author reads instead of `improvise-command` — saying the
basis is answered once and later commands are simply applied. A client author
reading only that concludes their later mutations are unchecked, which is the
exact over-generalisation the fix commit was correcting one crate over. Three
files grepped, the fourth written fresh and not grepped.

It now says what is true: the two document tiers are answered once, the record
tier is asked of every mutation, and the exemption is narrower than "later
commands are not checked" — a record that *neither exists nor is claimed* is
one the frame is creating, and only that pair is exempt. A record that exists
is answered for however the claim is written.

Also fixed, flagged by both reviewers and still live: `frame.rs` claimed "a
frame is applied whole or not at all" fifteen lines above "all or nothing is
the contract and is not yet delivered". The first is true of a **refusal** and
false of an apply-time failure, which is the documented hole. The qualifier is
now on the first, and the last paragraph names itself as the other way a frame
can stop half-way.

And one line on the `frame_basis` test helper: it always includes a record
where `BasisCache::claim` omits an unknown one, so pointed at a record that
does not exist it would fabricate `(hash, 0)` — a claim no real client could
compose, and exactly the input that flips the create exemption. No current use
is wrong; the doc now says to name only records that exist.

Doc-only. 1291 passing; clippy, fmt and the wasm gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:22:36 -07:00
Edward Langley 90ea26e7fa feat(command): one basis per frame, then the chain runs in order
The user has settled the contract, and it rejects the "at most one document
mutation per frame" rule this branch shipped. Verbatim: *"a sequence of
commands sent to the server should assume that it's played in order from
start to end and that each command sees the state after the previous command
runs"*, sent as *"a single transaction from a given base"*. Four clauses:

1. A frame states **one basis**, describing the base the client composed
   against.
2. That basis is checked **once**, against that base, before anything runs.
3. Commands are applied **in order**, and command N sees the state command
   N-1 left. Data dependence between them is expected, not an error.
4. **All or nothing.**

This commit lands clauses 1–3 and removes the rejected rule. **Clause 4 is
not implemented** and is documented as an open hole rather than a closed one;
the design note on PR #26 covers what rollback costs.

**Per-command basis checking is not merely awkward, it is incapable**, which
is the finding that makes clause 3 necessary rather than preferable. A client
cannot claim a counter for a record its own chain is about to create, because
`BasisCache::claim` filters to records the cache has been told about — so
`[AddItem("East"), SetCell(East)]`, the archetypal chain, is refused
`RecordGone`/`RecordUnstated` by a per-command check *in any order*. The base
describes what the client **saw**; the chain's internal coherence is the
transaction's job, not the basis check's.

So `CheckedBatch::against` answers the basis on the first command that
carries one and admits the rest through a private
`Checked::within_an_answered_frame`. Private is what stops it being a bypass:
from outside the module the only doors are still `Checked::against` and
`CheckedBatch::against`.

**Two things the rejected rule got wrong, kept as the lesson.** *Arity is not
a property of a variant* — a future `Command::RunScript` is one variant
performing unbounded mutations, so no count over the vocabulary can ever be
right. And *the transaction boundary was never the frame*: because one
command may already be a chain, clause 4 is needed inside a command as much
as across a frame of them, which is why the design note puts the mechanism at
`App::apply_effects`.

`mutates_the_document` is **deleted rather than kept**, which is a deliberate
deviation from the instruction to keep it: with the rule gone it has no
caller, and dead production code behind an `#[allow]` is worse than none. The
reason it was made exhaustive — a `matches!` answers `false` for a future
variant in silence — is preserved in the `CheckedBatch` doc, where clause 4
will read it when the classifier returns.

`Rejection::FrameNotAtomic` goes with the rule; nothing can produce it. The
taxonomies on `Rejection` and `ServerFrame::Rejected` return to two classes
and name the third that arrives with rollback.

Evidence, both faults injected:

- `a_data_dependent_chain_lands_in_order` — `[AddItem("East"), SetCell(East)]`
  through a real `Host`. Restoring the per-command check fails it, which is
  the whole point. It asserts **both** commands landed, because asserting
  only the first would pass against a host that dropped the second.
- `a_frame_whose_later_mutation_fails_is_not_yet_undone` characterizes the
  open hole with `[RemoveItem("North"), SetCell(North)]`: the removal lands,
  the write resolves to nothing and `model_effects` returns an **empty**
  batch, and the frame is reported accepted having applied half of itself.
  Both the partial application and the silence are open. **This test inverts
  when clause 4 lands**; until then its job is to fail loudly if anyone
  believes the hole is closed.
- The worker's `a_partly_refused_batch_…` fixture moved its injected
  staleness to the **first** command, since a later one no longer carries an
  answer of its own. Removing the injection still fails it.

The hole is unreachable from any client in this workspace — the only producer
of a multi-command frame is `keys::commit`, one mutation among view commands
— which is what makes it safe to document rather than rush.

Merged `main` (3915994). One semantic conflict: PR #27 added a
`mark_dirty()` call in `commit.rs::add_category`, which no longer exists —
dropped, since `AddCategory` is a `Model` effect and `apply_effects` raises
the flag.

1245 passing; clippy, fmt and the wasm gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:46:52 -07:00
Edward Langley cded59acb7 docs: the rule widened and its public statement did not follow
`ClientFrame::Commands` — the doc a third transport reads first — still said
"at most one `Command::Model`" after the rule became "at most one document
mutation", which understates it by exactly the case the previous commit was
fixing, and would have led a new client straight into it.

Found by grepping the rule's own wording rather than by a test, which is
gotcha 48's third paragraph for the third time on this branch: when a fact is
stated in more than one place, changing it is a grep, not an edit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:24:54 -07:00
Edward Langley 120286a44b fix(command): a frame carries at most one document mutation
Copilot's second review of PR #26 found that the previous commit fixed the
**example** and not the **rule**, and it is right. Refusing only the
document-*replacing* case left the general one open, and the general one is
worse because it is silent:

    [RemoveItem("North"), SetCell(the North record)]

Both commands pass the pre-frame check. `RemoveItem` applies and deletes the
record. `model_effects` then resolves the already-answered `SetCell` against
the mutated document, finds nothing, and returns an **empty effect batch**.
The frame is reported as accepted having applied only its first command, and
the client believes its edit landed.

Worse, that is a **regression the batch check introduced**: under the old
per-command check the `SetCell` would have been refused with
`Rejection::RecordGone`, which is the correct answer.

So the rule is now the count rather than a list of dangerous variants: **a
frame may carry at most one `Command::Model`**, refused with
`Rejection::FrameNotAtomic` (which replaces `DocumentReplacedMidFrame`, and
`replaces_the_document` goes with it).

**This costs nothing real, and that is the argument for it rather than an
excuse.** Two model mutations in one frame could never both be honestly
based: `BasisCache::claim` hands back what the server last *published*, and
the server publishes between batches and never during one, so the second
command's claim necessarily describes a document that stops existing when
the first lands. Refusing the pair is that incoherence said out loud. The
batch shape that actually exists — `SetSelected` plus the offsets
`viewport_effects` returns, optionally around one edit — is untouched,
because a view command carries no basis and cannot invalidate one.

`Command::SwitchView` is deliberately **not** counted although it moves the
document: it carries no basis at all (ADR 001 names that hole and leaves it
open) and a record surrogate is view-independent, so a switch cannot
invalidate an edit beside it. Counting it would reject useful frames for
staleness their own siblings caused — the mistake in the other direction,
and the one that made per-command re-checking wrong to begin with.

With the count in place, atomicity stops being an argument: at most one
command in a frame can be refused, and it is refused before anything runs.

Also from the same review, all correct:

- `App::batch_aborted` named `Sessions::apply_save`, a function that no
  longer exists; the consumer is `Host::save`. That matters more than a
  stale name, because the singleton-batch identity that makes the flag
  readable at all is only true at *that* call site.
- The "two classes" taxonomy on `Rejection` and the "two causes" one on
  `ServerFrame::Rejected` each sat immediately above an enum that could
  report a third. Both now enumerate three — staleness, a request the host
  cannot honour, and a frame that cannot be answered as one unit — and say
  that the variants rather than the sentence are the list to match.

Evidence: `a_frame_carrying_two_document_mutations_is_refused_whole` drives
**both** shapes — the structural one Copilot named and the
`[LoadModel, TransposeAxes]` one the previous commit handled — because a
guard written for one shape is exactly what got this wrong twice
(`doctrine(one_arm_of_a_symmetric_rule_is_evidence_about_that_arm_only)`).
Two faults injected and watched to fail: disabling the count fails the
refusal arm, and widening it to `> 0` fails the **accepting** arm, which
drives one mutation surrounded by the view commands a resolved keypress
really batches with it.

1206 passing (the baseline moved to 1198 when `main` merged in); clippy, fmt
and the wasm gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:12:45 -07:00
Edward Langley 373965c596 fix(protocol): saving is a frame, and a frame may not replace its own basis
Copilot's review of PR #26 found two real defects, both of which this branch
introduced, and both of which are the same mistake in different clothes: an
argument that held for the case I tested and not for the case I did not.

**1. `Command::Save` put a runtime-fallible operation inside a transactional
batch.** `Host::commands` guarantees a frame is applied whole or not at all,
but `apply_save` could still answer `Applied::Rejected { SaveFailed }` from
*inside* that loop — so `[Save, SetCell]` reported a failed write and then
applied the edit anyway, and `[SetCell, Save]` reported "refused" for a
frame that had fully applied. A client reading `ServerFrame::Rejected` could
not tell which. My own doc on `apply_checked` said it "cannot return
`Applied::Rejected`", and that was simply false.

**Saving is now `ClientFrame::Save`, not `Command::Save`.** That is the
honest home for it and I would argue it even without the bug: a `Command` is
something the shared state folds, and saving mutates no shared state and no
client's slice — `reduce_view` had nothing to fold, and `Checked` had no
basis to answer for, so both arms were shims. As a frame it sits beside
`ClientFrame::Viewport`, which is the other session input no `Command`
carries, and **it cannot be composed with a mutation at all**: the batching
question stops being a rule and becomes unconstructible
(`doctrine(an_invariant_is_made_unrepresentable_not_validated)`). The user's
ruling was "add Save to the wire"; it is on the wire, one layer out.

`Host::save` answers both refusals directly, so `Checked` goes back to being
about ADR 001 staleness and nothing else. The behaviour a client sees is
unchanged: `NoDocumentPath` decided before anything runs, `SaveFailed` read
off `App::last_batch_aborted` after a batch that is exactly `[Save]`, both
addressed to the asker alone, success silent.

**2. The documented "residue" was under-stated, and the argument only covered
the command I had tested.** A frame whose earlier command replaces the
document leaves its later ones checked against the outgoing one. I argued
that was harmless because a `SetCell` names its cell by surrogate (ADR 002)
and an outgoing surrogate resolves to nothing in the incoming document. True
— and it covers exactly one command. `TransposeAxes` names no surrogate, so
`[LoadModel, TransposeAxes]` passed every check against the outgoing `App`
and then reshaped a freshly loaded workbook under a basis describing the
previous one: precisely the cross-document mutation the generation tier
exists to reject.

Re-checking the remainder is not available as a fix — it rejects *after* the
load has landed, which is the partial application the batch check exists to
prevent. So `CheckedBatch::against` **refuses the frame whole**, with
`Rejection::DocumentReplacedMidFrame`. `replaces_the_document` is an
exhaustive match beside `records_named`, so a new `WholeWorkbook` variant
fails the build until it classifies itself.

This is the one place here where the *variant* is the right question rather
than "did the thing move" (gotchas 24, 39): a client composing a frame that
**may** invalidate its own basis half way through has composed an incoherent
claim whether or not the load succeeds.

**3. Copilot also caught three stale documents**, two already fixed in
7cf6dd7 and one not: `worker.rs`'s persistence section still opened with
"there is no `Command::Save` … consequently no wire client can ask its host
to save", contradicting its own next paragraph. Rewritten. The third
reference, `context/repo-map.md` gotcha 45, is orchestrator-owned and is
reported as text in the hand-back.

Evidence, faults injected and watched to fail:

- `a_frame_that_replaces_the_document_and_then_edits_it_is_refused_whole` —
  disabling the new guard fails it. Its fixture is **`TransposeAxes` and not
  `SetCell`**, deliberately: the surrogate argument would have hidden the
  bug. It carries the accepting arm too (a `LoadModel` alone must still
  land), or "refused" would be true for the wrong reason.
- `a_write_that_fails_is_refused_rather_than_reported_as_saved` and
  `a_client_can_ask_its_host_to_save` carry over to the frame unchanged,
  and the save-failure injection still fails the first.

1197 passing; clippy, fmt and the wasm gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:56:15 -07:00
Edward Langley 7cf6dd78eb docs: two comments this branch made false, found by grep rather than by a test
`doctrine(a_fact_stated_twice_is_changed_by_grep)` — gotcha 48's third
paragraph, which this branch has now paid for twice. Both of these are
arguments that lived beside the code they described and stayed behind when
the code moved.

`ClientFrame::Commands` still told every future host that the batch is
applied in order and abandoned at the first rejection, and named the hole
that leaves in ADR 001 as filed rather than closed. That is the *variant's
own doc* — the first thing somebody implementing a third transport reads —
and it now says the opposite, because the frame is the unit of atomicity.
`ServerFrame::Rejected` said a refusal means a moved basis, which is now one
of its two causes.

`keys.rs` explained that `transpose` deliberately does not mirror the
`mark_dirty` its `Cmd` emits, because dirtiness is host-local bookkeeping
with no wire variant. There is no `mark_dirty` any more, so the comment
described a gap that has stopped existing — the most expensive kind of stale
comment, because it reads as a known limitation rather than as history.

No behaviour change. 1197 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:48:50 -07:00
Edward Langley 301a74821a feat(protocol): the grid's frame on the wire, as AxisFrame
`GridCache::row_labels`/`col_labels` were `Vec<String>` built by
`GridLayout::row_label`, which slash-joins a multi-level tuple — the exact
rendering `two_row_categories_produce_cross_product_labels` asserts must not
appear. So the protocol carried a form of the labels the TUI itself
considered wrong, and `improvise-wasm-client/src/render.rs` drew precisely
that form: the browser showed `Food/Alice` where the TUI showed two header
columns, with no groups and no collapse indicators (gotcha 44).

Both fields become one `AxisFrame` per axis, carrying the five facts gotcha
44 enumerates: the categories (hence the header levels), the label *tuples*
and the inline group headers, the group a position sits in, and each group's
collapse state. `record_col` stays off — session-local, keys pending drill
edits, correct to keep off.

It is one type and not a `row_levels` beside `row_labels`, because two
answers to "what are the labels" is the disease (improvise-edp). The rules
that used to be written twice are methods on it and are now called from both
front ends: `shows_level` (repeat suppression, improvise-jjx's fix),
`group_of`, `body_from`, and `header_widths` — the row-header width rule,
which existed twice already, inline in `render_grid` and again in
`viewport::compute_row_header_width` (improvise-1125).

`GridWidget` loses its `&View`: nothing in it reads the view any more.

Evidence. `the_whole_frame_is_unchanged` — the exact-buffer characterization
over a grid with row groups, column groups, a page axis, multi-level headers
and a total row — passes byte for byte, so the TUI is unchanged. The new
`tests/grid_frame_agreement.rs` drives the widget into a ratatui buffer and
the browser client through a `Snapshot` of the *same* cache, and compares
what each draws; re-introducing the slash-join on either side was injected
and watched to fail before the test was kept.

`tests/web_demo_model.rs::the_web_demo_has_one_category_per_visible_axis` is
deleted: it guarded the gap this closes, and a consolidation that keeps its
compatibility test is unfinished.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:43:47 -07:00
Edward Langley c848d93255 refactor(protocol): ClientFrame batches, because one keypress is several commands
A cursor key resolved on a client is `SetSelected` plus the scroll
offsets that follow it (`viewport_effects`), and a host that folded the
selection but not the offset projects that client the wrong window. They
must arrive whole, so `ClientFrame::Command(Command)` becomes
`Commands(Vec<Command>)` — the same argument `ServerFrame::Commands`
already carries, and the same conclusion: `Commands(vec![c])` expresses
everything a singleton variant could, so there is one variant, not two.
`ClientFrame::command(c)` is the constructor every existing call site now
spells.

`Host::commands` applies a batch in order and abandons it at the first
rejection: the commands after a refused one were composed against a
picture the document has just contradicted, so applying them invents a
basis nobody claimed. That does leave a hole ADR 001's "never applied,
not even partially" does not have — a refused batch keeps its earlier
commands — which no client in this workspace can reach and which is
stated on the variant rather than hidden.

`the_retired_spellings_do_not_parse` grows the client half. It is the
sharper case of the three: the singleton was this crate's own spelling
until this commit, and `{"Command":{...}}` differs from
`{"Commands":[{...}]}` by a pair of brackets — exactly what a permissive
alias would paper over. Both new host tests had their fault injected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:28:43 -07:00
Edward Langley e77176364c refactor(protocol): one home for the client/server frame envelopes
Three envelope vocabularies for two deployments become one pair in
`improvise-protocol::frame`: `ClientFrame` / `ServerFrame`.

The question the ticket said to answer first was whether the WORKER and
the SOCKET want the same vocabulary. They do, and each of the three
differences is a date rather than a decision:

- `Viewport` is deployment-independent. `improvise-ws-server::wire`'s own
  argument for it never mentions a socket: the extent is not in
  `ViewState`, no effect produces it, so no derivation reaches it, and a
  worker hosting the same `Sessions` owes `set_extent` the same answer.
  It is the handshake for the same reason on both — a `Snapshot` seeds
  the VISIBLE cells.
- `Key` is deployment-independent too. A binding names an intent resolved
  against a `CmdContext` a thin client does not hold (gotcha 38); that
  says nothing about what the bytes travel over. The socket lacked the
  variant because key forwarding was not built on it (improvise-bpq2).
- `Commands(Vec<_>)` batches and a singleton `Command(_)` cannot. The
  fan-out must arrive whole and in sequence because a `GridUpdate::Cells`
  that overtakes its labels is DROPPED (gotcha 33) — a property of the
  fold, not of the transport. An in-process `postMessage` stream can
  interleave exactly as a socket can.

The worker pair was also incoherent by omission: written before ADR 001,
it can say `Command::Model { basis, .. }` but has no `Rejected`, so a
worker speaking it would let a client compose a claim it could never be
answered on.

The two mirror tests are deleted, which is the measure of success: with
one definition `improvise_ws_server::ClientFrame` and
`improvise_wasm_client`'s are the SAME TYPE, so a rename or an added
variant is a compile error on both sides by construction rather than a
round trip somebody has to remember to extend by hand. What they were
blind to (a variant added on the server and never mirrored) cannot be
expressed. `tests/browser_round_trip.rs` remains and is now the stronger
statement: the wasm client's serialized frame is handed to a real
`Host::handle`.

`a_frame_named_the_other_envelopes_way_does_not_parse` moves to
`frame.rs` as `the_retired_spellings_do_not_parse`, keeping its
injected-fault discipline: the retired `Bootstrap`/`Command` spellings
must not come back as aliases.

`Host::handle` gains an explicit `ClientFrame::Key => Vec::new()` arm.
Behaviour is unchanged — the frame used to fail to parse and be skipped
by `next_client_msg` — but the gap is now a line of code with
improvise-bpq2 on it instead of a variant that does not exist.

The wasm client's duplicated fold surface goes with the envelopes:
`on_message`/`handle_message`/`apply` were `on_frame`/`handle_frame`/
`fold_frame` without the `Rejected` arm. `web/app.js` calls neither.

1132 passing, 0 failed (1134 before: 5 tests deleted, 3 added).
wasm32-unknown-unknown clean for all six bundle crates.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 18:50:47 -07:00