Commit Graph

39 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 2cf3303437 improvise-wpxr: a cell write naming a view the document lacks resolves nothing
Answers the ChatGPT Codex P2 on ba3d2875. Grids looked the view up through
resolve_view, whose read-side fallback to the default accepted SetCell
{ view: "Nope" } wherever Default drew the record. The view is now looked
up strictly, and a stored record is gated too, so a missing view is
RecordGone at the basis check for every record.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 18:58:33 -07:00
Edward Langley 58cd5d3858 improvise-wpxr: SetCell/ClearCell name the view that drew them
An empty record exists only as a layout position, so resolve it against the
layout of the view the command names -- the one grid_in projects for that
session -- rather than the host's own layout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-23 17:55:01 -07:00
Edward Langley 3c6001ac7b Merge remote-tracking branch 'gh/main' into fix/6oy4-float-roundtrip
# Conflicts:
#	context/test-evidence/command.md
#	context/test-evidence/protocol.md
2026-09-22 21:41:49 -07:00
Edward Langley c831e5ea4a WIP: float_roundtrip on frame parsers; failing NaN quiescence test (improvise-6oy4, improvise-4w09) 2026-09-22 21:32:00 -07:00
Edward Langley a77e3a9146 A read-only tab refuses edits instead of painting what it cannot save (improvise-j4qt)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:12:24 -07:00
Edward Langley 699f0da05e WIP: a read-only worker refuses edits before they are applied (improvise-j4qt)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VdfEGXA7aJ6ftMsCc9PJze
2026-09-22 21:06:47 -07: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 d00cea2477 fix: show decimals by default, and let a formula name its own format
improvise-e0ex. `Margin = Profit / Revenue` drew as `1`: `View::new` seeded
`,.0` — zero decimals — so a 0.66 ratio rounded half-away-from-zero. The cause
is the format, not the arithmetic; exact decimal math would not change the
rendering by one character, so nothing here touches the evaluator and gotcha 8
is unchanged.

Two halves, per the user's ruling:

* `View::new` seeds `,.2`. Revenue draws `18,400.00` and Margin `0.66`. The
  noise on whole numbers is the cheaper error — a currency column with two
  redundant zeroes is legible, a ratio with none is wrong — and a format cannot
  be guessed from a value.
* A formula may carry its own format, which wins for its target; a raw measure
  keeps the view's. `formula.pest` gains
  `format_clause = ${ ^"FORMAT" ~ sp+ ~ format_spec }`, with `sp+` for `WHERE`'s
  reason (a predicate is invisible to the grammar-walking generator) and
  `FORMAT` added to `quote::KEYWORDS`.

`improv.pest` is deliberately unchanged: `formula_raw` already carries the
`## Formulas` line verbatim, so the format rides in the formula text with
nothing to add — and the generator, which reads `improv.pest`, sees no new
spelling to violate.

Resolution has one home. `FormatPlan::of(model, view)` replaces the
`(comma, decimals)` pair every render path threaded — `GridLayout::display_text`,
`viewport::grid_body`/`compute_col_widths`, `project::grid_in` and
`persistence/export.rs::render_csv` — rather than being added beside it, which
would be two answers to "what format is this cell". It is asked of the cell's
`CellKey`, so a cell aggregating over `_Measure` keeps the view's format by
construction. `GridBody::col_formats` carries the answer to the totals strip,
which is formatted on its own path.

Persistence is untouched except for metadata: `format_f64` is display,
`persistence/format.rs::format_number` writes round-trip precision, and the two
have no call between them. Newly created views write `format: ,.2`; existing
documents are unaffected, because `format_md` has always written the line
explicitly. `web/demo.improv` moves to `,.2` on both views.

The trap this sets is gotcha 34's: `format_md` writes `Formula::raw`, so a
`FORMAT` suffix round-trips whether or not the walker parsed it. The round-trip
test therefore asserts the parsed field — injecting a walker that drops the
clause fails that one test and nothing else in `improvise-io`, 242 tests
including the whole characterization suite and the grammar properties.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 23:33:52 -07:00
Edward Langley 12714c7527 feat(browser): a boot failure is a state with a reason, not silence
improvise-1wft and the five beads it blocked were one failure: nothing
carried a boot failure's REASON from the worker to the page. The channel
is one rule -- a protocol frame crosses the port as a STRING, a shell
notice as an OBJECT -- so it needs no protocol change and the
discriminator is total.

A boot failure is a STATE, not a rejected promise. A document another
context holds the exclusive handle to now OPENS read-only: measured first
(harness probe, Chromium + Firefox), getFile() reads the bytes while the
handle is held, with the second-handle refusal asserted so the probe
cannot pass vacuously.

- improvise-1wft  the channel: Notice/NoticeKind, boot_notice, take_notice
- improvise-i0v9  read-only boot over vfs::ReadOnly + OpfsFs::read_unattached
- improvise-86nw  start() rejecting for any reason now posts BootFailed
- improvise-9cma  boot::document_name -- and a/b/c.improv was ACCEPTED as
                  c.improv, not rejected, which is worse than the report
- improvise-iobc  vfs::Origin: a seed error is a notice only when the
                  document really had to be created without it
- improvise-vep8  the watchdog arms BEFORE transport.ready; its message
                  stopped claiming the connection was accepted
- improvise-7gip  DedicatedWorkerGlobalScope (needs a web-sys feature)
- improvise-zre1  a persist that fails raises a notice; the trigger is the
                  dirty flag surviving, observed rather than predicted
- improvise-7gf8  the render breaker trips once instead of retrying forever
- improvise-ociy  non-OPEN sends are loud; a viewport announcement is not
- improvise-y01k  the three ws-server drops are logged
- improvise-r4nf  serde failures throw instead of reading as "nothing to send"

Every notice routes through shell.fail, so gotcha 48's disarm half is not
re-broken. Two comments in shell.spec.js stated the opposite of the new
arming order and were corrected rather than left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 14:51:34 -07:00
Edward Langley 1dd6950ed3 A view-scoped ModelCommand names its view (improvise-lltt)
Nine ModelCommand variants configure a view and named it implicitly: the
effect read App::view_mut(), the HOST's slice. Since improvise-jytm made
ViewState::active_view authoritative per client, a client on another view
moved the host's view instead of its own -- its grid did not change and
every other client's did.

Each of the nine now carries `view: String`; model_effects maps it to
effect::ViewTarget::Named, and the nine effects carry a ViewTarget rather
than reading an ambient one. There is no operandless form left:
App::view_mut() is deleted and replaced by view_mut_of(&ViewTarget), so a
future geometry effect has to say which view it writes. The host's key
path passes ViewTarget::Active, which is right -- the TUI is the host and
has no session to ask. A Named view the document has not got is a no-op
rather than resolve_view's fallback onto the default.

Second half, which the ticket did not predict: naming the view made a
reframe of a view the host is NOT on reachable, and adopt_cursor_epoch
could not see one -- cursor_epoch is bumped only by the host's own
reset_cursor, so those clients were reset by nobody and told nothing
(gotcha 24's strictly-worse-than-nothing state). App::last_reframed_elsewhere
is the second trigger; its opening frames are taken lazily inside
view_mut_of, the one door such a write goes through, so the held-key path
still hashes one view and improvise-mcq4's declined cost is not paid.

Evidence and the three injected faults: context/test-evidence/command.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-21 10:23:27 -07:00
Edward Langley 3f8f7cb765 refactor(core): evaluate_aggregated takes Aggregation, not a list it cannot read
The parameter was none_cats: &[String] and the body consulted it exactly once,
for emptiness -- a boolean wearing a list's type, since the aggregation runs
over key.0 through matching_values. git log -S back to the commit that
introduced Axis::None shows it never examined an element, so the name promised a
filter the function has never performed.

Aggregation::{Exact, OverHidden} admits only the two states the body
distinguishes, and Aggregation::of reduces the list once at the boundary that
holds it. The regression guard is the signature: two lists with the same
emptiness and different contents is no longer a constructible call.

recompute_formulas and aggregate_raw keep &[String] -- they walk the elements.

improvise-5jm5

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:13:35 -07:00
Edward Langley 7d25762725 test: the worker's dirty-marker test could not see the widget vanishing
Re-review finding, low severity and correct. The test asserted only
`!document.dirty`, and `DocumentCache::default()` is already clean — so it
could not tell "told it cleared" from "never told anything", and passed under
an injection that made `document_update` always return `None`. It is the test
that names the browser deployment, which is the one place nothing ever sends
ClientFrame::Save, so it is the only guard on that path.

It now records every marker the client is told and asserts `[true, false]` for
the edit. The bootstrap seeds its own clean marker first, so the sequence is
cleared after the handshake and the assertion is about the edit rather than
about the connect. Verified failing under both injections: dropping the
worker's re-projection gives `[true]`, and stopping the widget being projected
at all gives `[]`.

Declined, with the reason: a test for `GridLayout::record_cell_key` returning a
key for a `RecordCol::Category` column. The injection the reviewer describes
would make a coordinate column project `Derived` where a formula owns that
coordinate — and the reviewer's own harder case established that a formula
targeting a regular category SHOULD project `Derived` there, in both modes. So
the injected behaviour is not clearly a fault, the exhaustive match already
forces a decision on any new `RecordCol` variant, and a test would be pinning
an answer nobody has argued for. Recorded as a known gap rather than guarded.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:59:55 -07:00
Edward Langley 24d9f61e35 fix: improvise-ir7l was not closed in records mode, and a save told no client
Two findings from the adversarial review of #51, both demonstrated before being
fixed and both re-proved by injection afterwards.

FINDING 1 — a formula-owned cell was still a writable Record in records mode,
and my own guard test's written premise was false. "A formula target lives only
in measure_item_names" does not hold: an item can be BOTH an explicit _Measure
item and a formula target, and improvise-poia in this same PR made the `+`
column that builds one a single keystroke away. Type `Profit` into `+` on a
document that already has `Profit = Revenue - Cost`, and the column projects
CellId::Record; `999` commits, is stored, and is then shadowed forever by
formula_cache — the exact defect ir7l exists to close, in both front ends.

The question is now asked of the record's REAL coordinate.
GridLayout::record_cell_key is the one mapping from a records position to a
model cell; cell_id and the new CmdContext::owning_formula both go through it.
The synthetic (_Index, _Dim) key names no measure, which is why EditOrDrill's
guard could not fire there.

typing_into_a_records_formula_column_is_refused_on_the_key_path drives the
reviewer's repro through App::handle_key with no test API. It exists because the
projection half and the EditOrDrill half are separately breakable — injecting
each alone fails a different assertion.

FINDING 2 — DocumentCache::dirty never cleared on a client. Both save paths
cleared model_state.dirty OUTSIDE a projection: Host::save returned only a
possible Rejected, and Worker::on_frame built its frames and persisted after. So
after the user's last keystroke a browser showed [+] on a saved document,
indefinitely — which is the failure mode protocol.md's own z754 section names as
the thing the design avoids.

The z754 test that should have caught it called sessions.project_all by hand,
which is exactly the call both production paths omit. Not a test comparing a
thing with itself; a test exercising a call the deployment never makes. The new
ones go through Host::handle and through the worker's real message loop.

Also filed improvise-8ec3: the reviewer measured improvise-io's test binary at
3.7-5.2s where this machine reads 1.4-1.7s, so the 2s per-binary rule is
breached on some machines — on main too — and the figure design-principles §7
pins is not reproducible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:53:29 -07:00
Edward Langley 0f14b2a39e fix(protocol): a formula-owned cell projects CellId::Derived, not Record
improvise-ir7l. project::cell_id decided Record vs Aggregate purely on whether
a regular category sat on Axis::None; it never asked whether the coordinate
named a formula target. So a formula cell was a Record, render.rs::editable
said true, and a write to it landed, was stored, and was then shadowed forever
by formula_cache — not a revert, a permanent silent shadow, on the demo's
front page.

Rather than a second "but not really" flag beside CellId (the two-answers
shape ADR 002 exists to forbid), a formula-owned cell now projects a different
kind of identity. Derived carries the formula's target and no surrogate, so
the write is unconstructible rather than refused.

The predicate is Model::formula_for(&CellKey), one home, which already existed
three times open-coded inside model/types.rs. The TUI lands in the same change:
EditOrDrill calls the same predicate and refuses, or the two front ends
diverge.

Two orderings are load-bearing and tested: Derived is asked before the record
branch (the coordinate is stored), and after the aggregate branch (gotcha 10 —
an aggregating formula cell is still drillable).

Two tests were passing while their names were false for the Profit column and
are renamed and two-sided now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:59:59 -07:00
Edward Langley 625f4acb80 docs(worker-server): move the browser-host argument to context/code/clients.md
The 121-line module doc on worker.rs, vfs.rs's seeding and flat-root rules,
bindings.rs's async-seam argument and the crate lib.rs coverage statement now
live in context/code/clients.md; the four bug narratives in the tests move to
context/test-evidence/clients.md, keyed by test name.

No behaviour change: comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:49:04 -07:00
Edward Langley f12a2d3517 docs: reconcile the docs with the code after wave 1
An independent review checked every factual claim in the docs against the
code at 22d43b2, after eight PRs landed in one wave. This applies its
replacement text.

context/repo-map.md
  - gotcha 44 rewritten: the cache DOES frame the grid now (improvise-anr9,
    AxisFrame/FrameEntry/header_level_widths). The surviving half — the TUI
    builds at offsets zero, so a cache position is absolute — is kept,
    because four code comments cite gotcha 44 for exactly that.
  - gotcha 45: ClientFrame::Save exists (improvise-368h); MarkDirty is gone
    and dirtiness is read off EffectKind::Model(_); Rc<T>: Storage lives in
    improvise-io (improvise-hmws); OpfsFs::list joins dir (improvise-vhcz);
    OpfsFs is run by harness/ and bindings.rs is now the dark corner.
  - gotcha 47: Host::commands answers the frame whole (improvise-955n); the
    open hole is clause 4, not a per-command Checked. set_viewport "must"
    -> "does".
  - gotcha 43: the frame, not the command, is what is answered; two of three
    tiers hoist.
  - gotcha 21 widened: three effects abort, and tidy-up goes at the head.
  - gotcha 48: web/app.js is executed by harness/tests/shell.spec.js;
    improvise-m18f -> improvise-taxx (fixed); the open half is improvise-xps8.
  - gotcha 42: the never-rendered / improvise-hwpi sentence pair deleted.
  - gotcha 18: live_records and analyze_records are both decided now.
  - gotcha 1: the records columns are [coords…, +cat, measures…, +].
  - AppMode is 17 variants, ModeKey is 17, eight text-entry modes; the keymap
    table moved to improvise-protocol/src/keymap.rs (six references).
  - the Wasm section: the mirror is gone; the wasm gate covers eight crates.
  - the File Inventory regenerated rather than patched (57/79 rows were
    stale); the duplicate ws-server/host.rs collapsed to a pointer at the
    23-line shim; harness/, protocol keymap.rs/viewmodel.rs, improvise-gp
    fit.rs/dataset.rs, cmd/fit.rs, model/record.rs and the integration tests
    added.
  - the suite speed re-measured rather than re-asserted.

README.md — the four "no browser here" sites narrowed to what is still true;
17 modes; the browser section names the Playwright harness.

AGENTS.md — the dead-ID audit recipe was itself defective: two paths, a
character class blind to a commit SHA, and a two-name exclusion list. Widened,
made runnable under the worktree shell guard, and its own prose no longer
spells any id.

prolog-sessions/kb.pl — seven invariants replaced in place (they were false),
five location/3 facts re-sited plus three new, six doctrines and seven
invariants added. doctrine 48->54, invariant 44->51, decision 47 unchanged,
location 31->34. kb_consistent passes.

Comment-only Rust changes: a duplicated sentence in worker.rs, records_columns'
doc omitting NewCategory, improvise-709f2df -> commit 709f2df, and three more
copies of "OpfsFs has never been run".

cargo test --workspace: 1309 passed, 0 failed, 2 ignored — unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:56:33 -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 22360d46e5 Merge main into wave/a-grid-frame
Two conflicts, both expected.

**`invalidate_relabelled` (improvise-hkt6, WU-K).** Both sides kept: the
signature and the item-tuple comparison are this branch's — `old` is an
`AxisFrame` now, and a group header appearing or collapsing must not blank
cells it did not rename — and the totals blanking and its whole argument are
main's. `grid_updates` still diffs the totals against the *framed* mirror,
which is what makes a renamed column whose total did not change send one;
`a_relabelled_column_drops_its_total`,
`a_relabelled_row_leaves_the_totals_alone` and
`a_renamed_column_whose_total_did_not_change_still_gets_its_total_back` all
pass against the new frame.

**`lib.rs`'s composition test.** Main's deletion taken: 18189c1 moved
`client_showing`, `published_basis` and
`a_client_composes_a_write_from_the_id_it_was_given` into `socket.rs` when
it consolidated `set_cell_at` and `typed`.

Main's new `GridUpdate::RowLabels`/`ColLabels` call sites are converted, and
five doc comments naming the retired spellings with them — a fact stated in
several places is a grep, not an edit.

1327 passing, 0 failing. clippy/fmt clean, wasm gate clean, both bundles
build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:10:15 -07:00
Edward Langley 819a165802 Merge remote-tracking branch 'gh/main' into wave/c-host-and-save 2026-09-20 00:03:31 -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 affb9a1812 fix(harness): make the two expected-failures deterministic, and unstale 4 docs
The browser job **failed in CI** on the previous head while passing locally,
with both `test.fail` tests reporting "expected to fail, but passed". That is
the worst possible outcome for an expected-failure: it is a check that is
green when the bug is present and red when it is absent, so a flaky one
inverts CI's meaning rather than merely being noisy. Copilot independently
flagged the same test from a different direction on the same head.

Two timing assumptions, both wrong off my machine:

**A closed port is not a deterministic "refused before open".** Port 9's
`close` timing is a property of the host's network stack, and on the GitHub
runner it did not arrive inside the window, so `#status` still read "cannot
reach" and the assertion passed. Replaced with the harness's **own http
server**: it accepts the TCP connection and then fails the WebSocket upgrade,
so `error` and `close` both fire promptly, everywhere. The page cannot tell
the two apart and neither can `web/app.js` — in both cases the socket never
opened.

**A timer does not order a close after an open.** The watchdog test's mock
closed 300ms in; on a slow host that can land before the page sees `open`, in
which case `armWatchdog` never runs and the test passes having exercised
nothing. Both mocks now close on the page's **first frame**, which the page
sends only after `transport.ready` resolves — so "opened, then dropped" is
true by construction rather than by luck.

And the setup assertions now assert the **precondition of the bug** rather
than that something happened: the m18f test polls the recorded `#status`
history for "cannot reach" before enabling `test.fail`, because a bare
non-empty check is already satisfied by "connecting to …" (Copilot's exact
point). Verified three ways: `--repeat-each=3` green; breaking `paintStatus`
gives 2 real failures, not expected ones; simulating PR #21's fix still
flips the m18f guard to "expected to fail, but passed".

**Four checked-in explanations this branch made false**, also Copilot's, and
gotcha 48's third rule for the second time in this PR — when a fact is stated
in more than one place, changing it is a grep, not an edit:

- `worker-server/src/lib.rs` said `storage_contract` "has still never been
  run" against `OpfsFs` and that browser verification is deferred.
- `bindings.rs` said it is "the only part of this crate that no test in the
  repository can reach". Still true of the shim, for a narrower reason now
  worth stating: the harness drives `OpfsFs` directly and never instantiates
  it.
- `vfs.rs::holds_a_document` and its test both said `attach` indexes the
  zero-byte entry it creates. It no longer does, which is the whole of the
  fix three commits ago — so the check is now belt and braces rather than the
  mechanism, and says so.

`web/app.js` and `web/worker.js` carry the same stale "untested JavaScript"
claim and are **not this unit's files**; reported for the orchestrator rather
than edited across the boundary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:32:21 -07:00
Edward Langley 436faf8d1c fix: SwitchView is a document mutation, and a save frame is not retried
Copilot's third round of PR #26, and the first finding is the **third** time
the same mistake has been caught here: an argument that holds for the command
that came to mind.

**1. `SwitchView` counts.** I excluded it on the grounds that it carries no
basis and a record surrogate is view-independent, so a switch cannot
invalidate an edit beside it. That is true of `SetCell` and `ClearCell` — and
false of *every other* model command, because `TransposeAxes`, `SetAxis`,
`HideItem`, `SetNumberFormat` and their siblings all act on the **active
view**. So `[SwitchView("Second"), TransposeAxes]` passed both checks and
then transposed `Second` rather than the view the transpose was composed
against.

The rule is now "at most one **document mutation** per frame", where
`mutates_the_document` is the two `Command` variants `Sessions::apply` routes
away from the mirror. Deliberately the *document* question rather than "does
it carry a basis", which is exactly the distinction I got wrong. No client
here batches a `SwitchView` with anything, so it costs nothing.

**2. An explicit `ClientFrame::Save` is not also auto-persisted.** A failed
write leaves `dirty` set — deliberately, so the next frame retries — and the
worker's automatic trigger runs after *every* frame. A save frame therefore
attempted the same write twice. On a permanent failure that is duplicate I/O;
on a transient one it is worse, because the answer was composed from the
first attempt, so the client was told `SaveFailed` about a document that was
now on disk. A per-frame question is the right shape here precisely because
the automatic write exists for edits **nobody asked** to save.

**3.** `Rejection`'s summary line now says it answers a *frame*, since two of
its three classes are not refused commands at all.

Evidence, both faults injected:

- `a_frame_carrying_two_document_mutations_is_refused_whole` gains a third
  shape. Dropping `SwitchView` from `mutates_the_document` fails it.
- `an_explicit_save_is_not_retried_by_the_automatic_write` asserts the
  **number of write attempts**, which took a counting, always-failing
  `Storage`. Both properties are load-bearing: "it was refused" is true
  whether the write happened once or twice, and a *successful* save clears
  `dirty` so the redundant attempt never happens — the bug is invisible from
  both the refusal and the happy path. Restoring the retry gives 2 where the
  test requires 1.

`a_refused_batch_leaves_the_commands_before_it_unapplied` now leads with
`SetSelected` rather than `SwitchView`, which the new rule would refuse
first. That is not a concession: a view command is the shape a real frame
has, and it is still observable in the asker's mirror.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:23:47 -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 748db12f9f review: close the /code-review backlog on this branch
Four findings from running the repo's own review skill over the branch, all
of which are gaps in evidence or in stated argument rather than wrong lines.

**A doctrine was cited by the wrong name.** The two-part batch test cited
`doctrine(an_order_test_needs_two_histories)`, which is about telling "sorted
correctly" from "hashed that way today". The doctrine it actually rests on is
`a_helper_that_builds_the_world_the_code_wants_tests_nothing`: *pair a refusal
test with an accepting one, or the refusal can pass by refusing everything*.
Corrected, because a doctrine name in a doc comment is a pointer somebody
will follow.

**The worker's persist trigger had no guard on the fact it now depends on.**
`touches_document` used to name `SwitchView` explicitly; it now asks the
document, which only works because `effect::SwitchView` declares
`Model(LayoutGeometry)`. Reclassifying it `ViewOnly` would make a browser
silently stop writing view switches back — `Workbook::active_view` persists
as the `Initial View` line — and nothing would have failed.
`a_wire_view_switch_marks_the_document_dirty` pins it; injecting that
reclassification fails it, and fails nothing else on the worker side, which
is exactly the silence it exists to break.

**The dirty raise over-approximates and did not say so.** An effect that
declares a document change and turns out to do nothing — a `LoadModel` that
cannot read its path — leaves the flag raised. That is the same direction
and the same reason as `layout_version`, which bumps for a `LayoutGeometry`
effect that no-ops, and as `Dirty::of`. The cost is one redundant write; the
other direction costs an edit. Now written next to the raise.

**Two arguments existed only in a commit message.** `Sessions::apply`'s doc
enumerates which commands do not stay on the mirror and did not mention
`Command::Save` — a third kind again: no document half, but a storage half.
And the worker's retry-on-failed-write behaviour is new (a permanently
pathless worker now retries every frame) and is harmless for a reason worth
stating: the only thing a failed `Save` produces is a status line, and
nothing in a worker renders one.

Also checked and found clean: layering (`forbidden/2` — nothing new crosses a
crate boundary), `Option`/`Result` kept at the edges (`CheckedBatch::against`
returns one `Result` at the door and the commands inside it are concrete),
exhaustive matches (`reduce_view` names `Command::Save` rather than widening
a wildcard), and no command reaching for mutable state.

Two `kb.pl` facts and one repo-map gotcha are now stale as a result of this
branch; they are orchestrator-owned and are reported as text in the hand-back
rather than edited here.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:47:03 -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 85bbe97636 fix(host): a frame is answered whole before any of it is applied (improvise-955n)
ADR 001 is emphatic that a stale batch is never applied, **not even
partially**, and improvise-ondj went to the trouble of a *type* to make the
unchecked path unconstructible. But `Checked` is **per command**, and a
`ClientFrame::Commands` is a sequence of them: `Host::commands` applied in
order and abandoned at the first rejection, so a frame whose second command
was refused had already applied its first. The guarantee was true of a
single command and false of a batch — exactly one level above the gap
improvise-ondj closed, and answered the same way, with a type rather than a
line at the top of a function. `CheckedBatch::against` answers for every
command in a frame, and there is no way to reach the commands without it.

**The question the ticket said to answer first**, and the reason it was
declined rather than patched on PR #13: *what does a batch's basis mean when
its later commands were composed against its earlier ones' effects?* A
client resolving one keypress emits `SetSelected` plus the offsets it
computed from the selection it just set.

The answer is that there is **one** basis and it describes the document the
client was looking at when it composed the frame. That is not a choice; it
is the only claim a client can construct. `BasisCache::claim` hands back
what the server last *published*, and the server publishes after a batch and
never during one, so no client can hold a basis describing the document as
it would be halfway through its own frame. Answering every command against
the pre-batch state is therefore not a relaxation of ADR 001 — it is the
only reading under which the claims that can actually exist mean what they
say. Re-asking per command would have been the *wrong* answer in the other
direction: it rejects a later command for staleness its own siblings caused,
which is a race nobody lost.

A corollary that looks like a loophole and is not: a frame cannot smuggle in
a second write to one record. The second `SetCell`'s claim states that
record's *pre-batch* counter, which is still what is compared, so the pair
is accepted as one composed intent — last-writer-wins within a frame is what
the client asked for.

**The residue**, stated rather than hidden and filed for the orchestrator: a
batch whose earlier command *replaces the document* leaves its later
commands checked against the outgoing one. It does not corrupt today, and
the reason is not the basis check: a `SetCell` names its cell by surrogate
(ADR 002), and a surrogate minted against the outgoing document does not
resolve in the incoming one, so `model_effects` produces an empty batch.
That is a property of `resolve_cell`, which is why it gets a test of its own
rather than a comment —
`a_batch_that_replaces_the_document_leaves_its_later_commands_unresolved`
would go quiet if `resolve_cell` ever got looser.

**The second-order effect the ticket recorded is closed twice over.**
`Worker::on_frame` skipped `persist()` when any frame in the answer was
`Rejected`, so a batch that *did* apply a geometry command before being
refused left the document moved in memory and never written back — disk and
worker disagreeing, silently. Nothing applies partially any more, and the
persist trigger became the document's own dirtiness in the previous commit,
so either change alone would close it.
`a_partly_refused_batch_does_not_leave_disk_and_memory_disagreeing` fails
only when *both* old behaviours are restored, which is how it was verified.

Mechanically: `Sessions::apply_checked` is the door that takes a proof
rather than asking for one, `Sessions::apply` is now check-then-`apply_checked`
and stays as the single-command convenience its tests use, and
`Checked::command` hands back a `&'a Command` — not one borrowed from
`&self` — so a caller can dispatch on the variant and still move the proof
into what it dispatched to.

Evidence, faults injected and watched to fail:

- `a_refused_batch_leaves_the_commands_before_it_unapplied` — written first
  and failing on the old code. **Two histories**
  (`doctrine(an_order_test_needs_two_histories)`): the same leading
  `SwitchView` followed by a *current* basis must land, or a `SwitchView`
  that simply never worked would clear the first assertion and prove
  nothing. Its fixture gives the document a second view for the same
  reason. Its stale claim is built by `caught_up` and then has **one** tier
  bumped, so the refusal can only be about the tier that moved — a first
  draft that hand-built the basis was stale in two tiers at once and
  answered `RecordUnstated` where it asserted `LayoutMoved`.
- `a_batch_is_abandoned_at_the_first_rejection` is kept: the two halves
  (nothing after, nothing before) fail independently.

1196 passing, from a 1189 baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:43:38 -07:00
Edward Langley 0b298d7f25 fix(command): raise dirtiness where the effect is classified (improvise-gv9r)
A client's edit left the authoritative document reporting **clean**, on
every transport. `reduce::model_effects` maps `ModelCommand::SetCell` to
`effect::SetCell`, which writes the cell and never touched
`model_state.dirty`; dirtiness was a *separate* `MarkDirty` effect that only
the command/key path composed. So "save if dirty" was a trigger that never
fired — a host built on it loses every client edit, silently — and anything
else that read the flag about a client-edited document got the wrong answer.

The interesting part is not the missing line, it is **why** it was missing.
improvise-kmgs derived `ModelCommand` from the effects **one at a time**, and
per element that derivation is right: `SetCell` really is the effect a
`SetCell` command names. What it could not see is that the key path never
emits `SetCell` alone — it emits the *pair*. **A derivation correct per
element and wrong for a set.** The pairing existed nowhere except at 17
hand-written call sites, which is exactly the kind of obligation this
codebase makes unrepresentable rather than validating.

So dirtiness now comes from `App::apply_effects`, off the same `EffectKind`
the `Dirty` and the two lower basis tiers are already read from (gotcha 40):
one classification, three readers, no second table to drift from. There is
no longer any way to compose a document change that omits it — on the wire,
from a keypress, or from a host acting on its own behalf. `MarkDirty`,
`effect::mark_dirty()` and every call site are **deleted**; leaving them
would have been a second answer to one question, and the next reader would
have believed the call sites were the mechanism.

All three `ModelChange` levels raise it, `WholeWorkbook` included. A replaced
document is not the document `file_path` names — `LoadModel` does not move
`file_path` — so in both the wizard and the load case the bytes on disk and
the bytes in memory have parted company, which is all `dirty` claims.
`AcceptWizardModel`'s own `dirty = true` line goes for the same reason the
call sites do.

**The worker's persist trigger moves with it.** `touches_document`
enumerated command variants *because* the flag was unusable, and it said so.
With the flag trustworthy, the trigger is the observation — the same choice
the cursor reset makes by watching `cursor_epoch` instead of matching on
`CycleAxis` (gotcha 24), and the rebase by watching `document_generation`
instead of matching on `LoadModel` (gotcha 39). A table of variants is a
second description of which commands change the document, free to disagree.
It is also strictly tighter: a refused batch changes nothing and so raises
nothing, which retires the "was anything rejected" guard beside it.

Evidence:

- `a_wire_model_command_marks_the_document_dirty` (`ui/reduce.rs`) — written
  first and watched to fail on the old code. It asserts all three
  `ModelChange` levels separately, so an effect reached by only one of them
  cannot pass on a sibling's evidence.
- `a_view_only_batch_leaves_the_document_clean` (`ui/app.rs`) — the negative
  arm, without which "raise it unconditionally" would pass. It is aimed at
  `apply_effects` and **not** at a `Command`, because a first draft written
  against the session door was **vacuous**: a view-slice command is folded
  straight into `ViewState` and never reaches the loop at all, so it passed
  with the raise made unconditional. Injecting that fault is how the vacuity
  was found; against the effect batch the same injection fails it.
- The worker's `an_edit_is_persisted_without_the_client_asking_to_save`
  fails when the new trigger is disabled.

Three `cmd/cell.rs` tests counted the dropped companion effect and are
adjusted; two are renamed, since they no longer assert anything about dirty.

1193 passing, from a 1189 baseline.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:41:00 -07:00
Edward Langley 37d06fcc13 fix(opfs): list answers in the same path shape as every other backend
improvise-vhcz. `OpfsFs::list` returned bare entry names where `MemoryFs`
and `PhysicalFs` return `dir`-joined ones — paths a caller can hand
straight back to `read`. At `Path::new("")` the two shapes coincide, and
the flat root is the only `dir` the worker ever passes, so the
disagreement was invisible: a worker that organised documents under
`docs/` would have got a **full picker in every host test and an empty one
in a browser**, with nothing saying why.

The ticket asked which of the doc and the impl was wrong. It is the impl.
The trait already says "backends that are not trees treat `dir` as a name
prefix", which is exactly what this backend needs to do and was not doing
— it ignored `dir` on the way out as well as on the way in.

`dir` stays decoration rather than becoming a filter, because the backend
is flat by construction: `entry_name` reduces every path to its file name,
and an OPFS entry name may not contain a separator at all, so
`docs/a.improv` and `a.improv` are the same entry. Joining makes `list`
agree with `read`/`write`/`delete` about which names exist; filtering
would make it disagree.

`vfs.rs`'s "the root is flat" section is rewritten rather than deleted:
the flat root is still load-bearing, but for the collapse of `dir`, not
for a `list` shape that no longer differs.

No host test can demonstrate this — `OpfsFs` is `cfg(target_arch =
"wasm32")` and has never been run anywhere. The regression guard is the
browser harness in the next commit, which runs `storage_contract` against
`OpfsFs` at `docs/` as well as at the root; reverting this line fails it
with `lists the one document`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:37:35 -07:00
Edward Langley 92a3d3e179 feat(io): a shared backend is a backend, so the async seam survives boxing
improvise-hmws. `OpfsFs` keeps `open`/`attach`/`refresh`/`commit_deletes`
off the `Storage` trait so the trait can stay synchronous — gotcha 28, and
not negotiable, because `createSyncAccessHandle` really does yield a
blocking handle. But `App::storage` is a `Box<dyn Storage>`, so handing the
backend to the `App` puts all four permanently out of reach: the caller has
to hold the concrete backend *and* give the `App` a `Storage`, which means
sharing one.

The ticket offered two fixes — ship a forwarding newtype from
`improvise-io`, or have `OpfsFs` hand out an attachable handle. Neither is
needed, and the reason is worth stating: `impl Storage for Rc<OpfsFs>` is
refused in `improvise-worker-server` because *there* both types are
foreign. In `improvise-io` the trait is local, so the impl is simply
allowed. It is blanket — `impl<T: Storage + ?Sized> Storage for Rc<T>` —
because nothing about it is OPFS-shaped; it is the general statement that a
shared handle to a backend is usable as that backend. `Rc` and not `Arc`
because the one caller is a Web Worker and wasm32 has no other threads.

So `bindings::Shared` and its `_shim_is_a_storage` canary are deleted
rather than moved: there is no newtype to ship and no second copy to keep.
The seam now costs an `Rc::clone`.

Evidence: `a_shared_backend_is_a_backend_and_a_clone_is_the_same_one`. The
regression guard is the *signature* — it does not compile without the impl
— so what the body asserts is the part a hand-written newtype could still
get wrong: that the clone the `App` holds and the handle the message loop
keeps are one backend. Proved discriminating by making the forwarded
`write` a no-op and watching it fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 22:37:28 -07:00
Edward Langley 66df7056e3 fix(worker): give report() its own doc, instead of stealing start()'s
I inserted report() between start()'s fifteen-line doc comment and start
itself, so rustdoc attached the whole of it -- 'Boot the worker over OPFS',
the out-of-band naming argument, the reason the document is an argument
rather than a fourth ClientFrame -- to a two-line JsError shim, and left the
exported entry point undocumented.

Compiles either way, which is why nothing caught it: a doc comment is legal
on any item, so the only symptom is rustdoc nobody regenerated. Found by the
PR agent reviewing PR #17.

Documentation placement only, no behaviour change, so no test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:39:36 -07:00
Edward Langley 56c1c77141 fix(worker): attach creates the entry, so listing it is not proof of a document
The first browser run of the deployed page: 'Cannot open demo.improv'.

OpfsFs::attach must create the file to obtain a sync access handle for a
document that does not exist yet, and it indexes what it created. boot
attaches BEFORE it opens, so on a fresh origin documents() already contained
demo.improv as a ZERO-BYTE entry, vfs::open read that as 'already there',
skipped the seed, and handed load_from an empty buffer.

The question vfs::open needs to ask is not 'is there an entry' -- which is
what list answers -- but 'is there something to load'. holds_a_document asks
that.

Why no host test could see it: MemoryFs has no attach. It is not a Storage
method, so the seeding tests passed for a reason that cannot hold in a
browser. The new test builds the empty entry by hand to stand in for what
attach does, and fails on the old code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 21:09:17 -07:00
Edward Langley 62bc9bab51 fix(worker): report an error with its cause, not just its label
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 bundle + static site (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled
The first real browser run of the deployed page produced 'Cannot open
demo.improv' and nothing else. That string is the CONTEXT vfs::open attaches;
anyhow's Display prints only the outermost context, so mapping through
e.to_string() kept the label and discarded the Storage or parse failure
underneath.

The worst possible place for it: bindings.rs is wasm-gated and is the one
module no test here can reach, so a devtools console is the only instrument
pointed at it -- and it was the code path discarding its own diagnostics.

report() renders with {:#}, which walks the chain. It lives in lib.rs rather
than bindings.rs so it can be tested at all. The test is written against the
old rendering and pins anyhow's Display as the trap, so the helper stops
being needed if that ever changes.

This does not fix the underlying failure; it makes the next reload name it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 20:55:27 -07:00
Edward Langley 12febcf73b Merge: the client resolves its own cursors (improvise-bpq2)
Keys become commands in the browser; the server must not know the keymap.

What made it urgent is not latency. Host::handle answers ClientFrame::Key
with an explicit empty vec, so an upstream key is a BLACK HOLE, not a slow
path -- every KeyOutcome::Upstream names a binding that does nothing at all
in a browser. And a_key_resolved_on_the_host_moves_state_no_client_can_hear
still holds: bpq2 closes the divergence by AVOIDING host-side resolution,
not by contradicting the property.

Three costs. GridExtent on GridCache -- the whole grid, of which the labels
are one window -- because a viewport-indexed cache cannot tell 'the last
visible row' from 'the last row'. The window is the client's OWN fact,
recorded from what it announced, because row_labels.len() is the window
CLIPPED and so differs exactly at the bottom edge where scroll arithmetic
matters. And ClientFrame::Commands replaces the singleton, because one cursor
key is SetSelected plus the offsets viewport_effects returns.

Two corrections to the ticket's own gap list: panel cursor movement was never
a gap (improvise-edp already made flatten_cat_tree the one home both ends
call), and the unmodified-characters rule was MIS-SCOPED rather than wrong.

Each resolver mirrors its Cmd line for line, including where siblings
disagree about a no-op move. Tidying that into one rule would make the client
a second derivation, which is what lockstep testing exists to rule out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:35:31 -07:00
Edward Langley 43422d4d21 docs(worker): the Key arm's emptiness is now the answer, not the gap
`a_key_resolved_on_the_host_moves_state_no_client_can_hear` still holds
and is still the reason — nothing in the projection stream carries the
host's own `ViewState`. What changed is the conclusion drawn from it:
improvise-bpq2 did not close the divergence by deriving `Command`s from a
`ViewState` delta, it moved resolution to the client, so a key the client
can answer never arrives as a `Key` frame at all. Two doc comments and an
assertion message that read as "not built yet" now say what is settled
and what genuinely remains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:34:22 -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 a550201fae feat(web): the page spawns the worker, opens a demo, and reads a key
Three pieces of the standalone deployment, all in web/.

improvise-djm — app.js picks its peer instead of assuming a socket. The
wasm bundle is the same artifact either way (improvise-xks6), so the
difference is confined to one factory: `?ws=` selects the websocket, its
absence spawns `new Worker('./worker.js?doc=&seed=', {type:'module'})`.
The websocket path is KEPT, because improvise-ws-server is how a
TUI-hosted document is shared and nothing else can reach it; the presence
of the parameter is the whole selection mechanism, since a second knob
naming the same fact is a second thing to get wrong.

improvise-bck — web/demo.improv, built against an assertion rather than
by flipping an axis. examples/demo.improv leaves Customer/Date on None,
so every cell is an Aggregate and nothing is editable. The new demo puts
exactly one category on each visible axis (Product on row, _Measure on
column, Quarter on page) and carries Profit = Revenue - Cost, so a
visitor sees the formula move beside the cell they typed in, with no
keystroke and no scrolling. Two tests hold it: tests/web_demo_model.rs
projects the whole grid through grid_of and asserts every CellId is
Record (ADR 002 — that is what `editable` means), and post_message.rs
drives the shipped bytes through the real seed -> VFS -> open -> Snapshot
chain. The first was proved discriminating by pointing it at
examples/demo.improv, where it names the aggregate at (0,0).

One category per axis is asserted too: GridCache slash-joins a
multi-level tuple (gotcha 44), so a second category on an axis puts that
known gap on the front page.

improvise-hwpi — all four. A keydown listener on window feeds
Client::on_key_event, so the classifier is reachable at last; a frame
that will not fold now writes to #status instead of only the console; the
socket has an error listener, so "not running" no longer reads like
"still connecting"; and close() has a re-entry guard. Plus one the ticket
could not have predicted: a worker whose start() rejects is caught inside
worker.js and fires no `error` on the main thread, so the same silence
was about to be rebuilt in the new path — a first-frame watchdog covers
both deployments, because "the peer never answered" is the one condition
that means the same thing on each.

Verified: cargo test --workspace 1159 passing (1155 + 4), clippy clean,
fmt clean, actionlint clean, ./web/build.sh end to end (345 KB / 123 KB
gzipped client, 1.33 MB / 498 KB gzipped worker), every call present in
the generated .d.ts with the right arity, every relative reference in the
assembled site resolving. NOT verified: there is no browser here. No line
of JavaScript in this commit has ever run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 19:28:27 -07:00
Edward Langley bdce1636bb feat(worker-server): the whole App in a Web Worker, over postMessage + OPFS
improvise-9x6: the server half of the standalone static-web deployment
(epic improvise-tm6). `improvise-worker-server` hosts the authoritative
`App`, the command pipeline, the projection layer and VFS persistence behind
`self.onmessage`/`self.postMessage`.

It is small because everything it needs already existed: the frames are
`improvise_protocol::frame`'s (improvise-xks6), the session loop is
`Host` (previous commit), the basis check is `Sessions::apply`/`Checked`
(improvise-ondj), and persistence is `Storage` + `OpfsFs` (improvise-6mq,
i34). What is genuinely new is three things.

`vfs.rs` — how a document gets INTO a browser's empty filesystem. Loading is
unchanged: it is `persistence::load_from` on a `Storage`, the same call the
native binary makes. Seeding is the new problem, and it is a
`Storage::write`, so a fetched demo, an upload and a saved document take the
same parse path and a seed never clobbers an edit. `list` is the document
picker a browser has no OS file manager for.

`worker.rs` — the loop: one port, one client, the `Viewport` handshake
(dropped rather than closed, since there is no connection to close), and the
filter from `Addressed`. Every decision is here and every test points at it.

`bindings.rs` — `cfg(wasm32)`, decision-free, and the only part no test here
can reach: it awaits `OpfsFs::open`/`attach`/`refresh`/`commit_deletes` and
forwards strings.

Two gaps found and reported rather than papered over. No client on any
transport can ask its host to save — `effect::Save` is `ViewOnly`, so the
wire vocabulary derived from the model effects cannot name it — and a wire
`ModelCommand::SetCell` never sets `model_state.dirty`, because dirtiness is
raised by a separate `MarkDirty` effect the key path emits. So the worker
persists on its own behalf, keyed off the command variant rather than off a
flag that is never set; `touches_document` carries the argument.

`ClientFrame::Key` is still answered with nothing. A worker CAN call
`reduce_key` — it holds the `App` — but nothing in the projection stream
carries the host's own `ViewState`, so a half-wired arm diverges rather than
under-delivers. `a_key_resolved_on_the_host_moves_state_no_client_can_hear`
demonstrates it against a real `App` and `Sessions` (improvise-bpq2).

22 tests, all host-side: 16 in the crate and 6 in `tests/post_message.rs`,
which drives the worker with nothing but the JSON a `MessagePort` carries.
Bundle: 1,334,892 bytes raw, 496,965 gzipped — 485 KB against a 3 MB budget.

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