Commit Graph

23 Commits

Author SHA1 Message Date
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 664b4a7780 fix: the --allow-origin help was a three-line comment run
CI's `check-comment-runs.py` runs with no flags and the local gate in my brief
runs it with `--check-evidence`, which reports the evidence audit and not the
run cap — so a three-line clap doc comment passed locally and failed in CI.
Run both spellings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:27:25 -07:00
Edward Langley cead3210e5 fix: two Origin headers state none a policy can answer for
Review of the previous commit. `HeaderMap::get` is first-wins, and the
deployment README recommends puts a reverse proxy in front of the socket — one
that APPENDS `Origin` rather than setting it leaves two, and answering from the
first lets the second be anything at all. No browser sends two, so two is not a
state any policy can answer for and the handshake is refused, exactly as an
`Origin` that is not text already was.

Guarded by two_origins_state_none_this_policy_can_answer_for, proved by
restoring the first-wins read, which fails that test alone across the
workspace. The test also asserts the allowed origin BY ITSELF is still allowed,
so the refusal is demonstrably the second header rather than the first.

Second review finding: a_handshake_from_a_foreign_origin_is_refused matched
"403" in a Display string, which cannot tell a refused upgrade from a dropped
connection. It now matches Error::Http and asserts the status. Proved by
changing forbidden() to UNAUTHORIZED, which fails it and
a_refusal_is_a_forbidden_response.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:21:49 -07:00
Edward Langley d100639348 fix: check Origin at the handshake, so a page cannot drive someone's session
improvise-kc7n. A websocket handshake is exempt from the same-origin policy —
there is no CORS preflight — so any page a user visited could open a socket to
improvise-ws-server from that user's own allowed address and read and edit every
document served. An address check cannot see it: the request genuinely comes
from an allowed client, and only Origin says what is driving it. Loopback is the
least protected case, having no proxy in front of it at all.

`src/origin.rs` is the decision and `transport::admitted` its one caller, which
refuses the upgrade with a 403 rather than accepting and ignoring. The default
allows pages served from loopback — which is the shipped local recipe, page on
`http://localhost:8080` and socket on another loopback port — and refuses
everything else; `--allow-origin` names the deployed page, which reaches the
server through a TLS proxy and is not loopback. An absent Origin is allowed and
decided rather than defaulted: no browser omits it, so absent is a non-browser
client, and refusing those would break this crate's own tests while buying
nothing that --bind does not already buy.

Five edits, five guards, each reverted alone against the named test:

- origin.rs permits() present arm  -> a_foreign_origin_is_refused
- origin.rs permits() None arm     -> a_handshake_with_no_origin_is_accepted
- origin.rs is_loopback()          -> a_handshake_from_a_loopback_page_is_accepted
- transport.rs handshake callback  -> a_handshake_from_a_foreign_origin_is_refused
- main.rs --allow-origin wiring    -> allow_origin_widens_the_policy_and_nothing_else_does

`transport::serve` takes the policy rather than defaulting one, so a host cannot
forget to pass it. The argument is context/code/clients.md §Origin and the
evidence context/test-evidence/clients.md; README no longer says the server
never looks, and the proxy's Origin allow-list is now defence in depth.

This is not authentication — it answers what page is driving a client, never who
is connecting. improvise-kpnr is untouched and still open.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
2026-09-20 21:20:18 -07:00
Ed Langley c59df9f406 Merge pull request #48 from fiddlerwoaroof/wave/e-active-view
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
Split active_view: the document's default vs each client's (jytm, 6c6q, k69y)
2026-09-20 11:49:41 -07:00
Edward Langley 53ffe9f193 fix: close three demonstrated bypasses of the comment cap, and self-test it
Adversarial review of PR #44 constructed all three, each compiling clean with
no warnings, each passing the checker.

Block comments were invisible: the scanner only ever tested startswith("//"),
so a six-line /* */ passed — and /** */ is a DOC comment that rustdoc renders
exactly as /// does, making `/**` the cheapest way to write the long argument
the rule exists to forbid. There are zero block comments in the tree today,
which is the only reason this was unexploited; #44 is the change that gives
anyone a reason to look.

Fence state was per-FILE, so one unclosed ``` exempted every later comment in
the file. A dangling doctest would fail the build in a lib target, but doctests
do not run for bins, tests/, examples/ or harness/ — all default scan roots.
Fence state now belongs to the comment block: any non-comment line clears it.

Attributes terminated a run, so `#[inline]` between two pairs of doc lines
split one argument of four into two legal runs of two. The module docstring
had always promised attributes are "ignored"; the code disagreed with it, and
disagreed in the direction that passes.

Closing the last of those found two real violations the old checker could not
see. crates/improvise-command/src/command/cmd/text_buffer.rs carried TWO doc
comments straddling #[derive(Debug)] — both attach to the struct, so rustdoc
rendered both; a leftover from the comment wave. transport.rs had a three-line
run split by #[allow].

A checker with ten silent successes and no test of its own is the thing it
warns about, so --self-test runs every bypass this file has been fixed for
plus three shapes that must stay legal. Proved discriminating by reverting the
attribute fix: it fails and names that case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:05:22 -07:00
Edward Langley 5b7b53466f feat(active-view): split the document's default from each client's view
improvise-jytm, improvise-6c6q, improvise-k69y.

`Workbook::active_view` becomes the document's *default* — what a freshly
connected client opens and what `.improv` writes as `Initial View` — and
`ViewState::active_view` becomes authoritative per client. Two clients may sit
on different views of one document: the model is shared, the UI is not.

- `App::switch_to_view` writes only the client's slice; `App::view()`/
  `view_mut()`/`view_name()` resolve it against the workbook.
- `effect::SwitchView`/`ViewBack`/`ViewForward` are `ViewOnly`, which takes
  `ViewBack`/`ViewForward` off `ModelCommand` — their operand is one client's
  history stack (the `ApplyAndClearDrill` argument).
- `grid_in`/`category_tree_in`/`tile_bar_in` project against the session's view;
  `ClientSession` observes its own move through `projected_view`.
- `apply_switch` takes a `SessionId`, moves only the asker's mirror, and relays
  `Broadcast::ToAsker` only when the document refused. `adopt_active_view` and
  `Broadcast::ToOthers` are deleted: nothing produces them any more.
- `draw_status` reads the client's copy (improvise-k69y).

`the_two_active_view_names_never_diverge` is replaced, not deleted, by
`the_documents_default_view_outlives_this_clients_switches` and
`a_fresh_session_opens_the_documents_default_view`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 11:02:59 -07:00
Edward Langley 3d3bec43e0 docs(web,harness): cap the JavaScript and the browser harness
web/app.js's transport/watchdog/frameCost arguments and web/worker.js's two
ordering rules — including the one nothing here can test, that an `async`
message listener does not serialize — are now prose in context/code/clients.md.
The harness keeps its own section: why a worker, why a report, the engine
coverage and what `wipe` deliberately cannot clear.

harness/tests/*.spec.js's comments were the record of what each assertion
proves; they are in context/test-evidence/clients.md under the exact test
titles, including the `test.fail` measurement that could NOT become a CI guard
because its trigger is the host's network stack, and the closed-invalid
improvise-xps8 report that a simulated fix appeared to confirm.

ws-server/src/main.rs's ```text fence was prose claiming the doctest
exemption, not a runnable doctest; the synopsis is now two lines and the full
form is in the prose file. No fences remain in this unit.

Verified: node --check on every JS file touched; the non-comment diff for both
'*.rs' and '*.js' is empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 10:02:03 -07:00
Edward Langley 3970ae30b5 docs(ws-server): move the multi-document argument to context/code/clients.md
registry.rs's grain/addressing/lifecycle argument, transport.rs's two socket
rules and the MVP-limit note, lib.rs's layer split and host.rs's "why it moved"
are now prose. The five check_the_checker narratives and the socket test's
scope statement move to context/test-evidence/clients.md.

Two comment-only repairs found while reading: Registry::handle's doc comment
was attached to the #[cfg(test)] `model` helper above it, and `connection`'s
doc comment sat above a `//` note that detached it from the item. Both now
sit on the item they describe.

The ```text usage fence in main.rs is kept — it is a synopsis, not a doctest,
and the checker exempts it; the prose around it is capped instead.

No behaviour change: comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:51:09 -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 61984ec46a docs(ws-server): its usage example named the document this branch deleted
Copilot, PR #22, third pass — a dangling reference, and one my own deletion
created, so it is mine to clean up (AGENTS.md §3) even though the file is
outside this unit's stated boundary. Flagged in the hand-back.

`improvise-ws-server`'s module doc opens with a copy-pasteable command line and
a URL path derived from the file stem, all three naming `bank-info.improv`.
Pointed at `examples/demo.improv` instead — a document that exists, and one
`tests/shipped_documents_load.rs` now proves loads, so the example cannot go
stale the same way without a test saying so.

Doc comment only; no code, no behaviour, no test moved.

Still mentioning the file, deliberately: the two test doc-comments that explain
why it was deleted. Not touched, and not mine to touch: `roadmap.org` line 194
and the `improvise-1ey` / `improvise-cr3` issue bodies in `.beads/` name it as
the demo document to point the browser at. Reported rather than edited.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:08:35 -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 ae362acc78 refactor(host): the session loop is not the socket's, and a worker needs it
`Host` is `App` + `Sessions` + `handle(id, ClientFrame) -> Vec<Addressed>`.
Nothing in it was ever a socket — its own module doc said so — but it lived
in `improvise-ws-server`, which is tokio and tungstenite and native only.
improvise-9x6 is what turned that from tidy into load-bearing: a browser
worker hosts the same authoritative `App` over a `MessagePort` and cannot
depend on that crate.

So it moves to `improvise_command::ui::host`, beside the `App` and the
`Sessions` it is made of, and both deployments name the one type. The
alternative was a second copy in the worker crate, which is the mirror
improvise-xks6 had just finished deleting for the frames (gotcha 42): a
duplicated decision layer drifts, and the drift is invisible until a client
sees it. `improvise-ws-server::host` stays as a re-export so every existing
path resolves, and `registry` stays behind — a registry of documents is a
server concern, since a worker hosts exactly one.

Adds `Host::app_mut`, narrowly: for work a host initiates on nobody's behalf,
where there is no session to address and no basis to check. A client's
`Command` still has exactly one door (gotchas 36, 43).

The 13 `Host` tests move with it; no test is added or removed.

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
Edward Langley 2e794df06d docs(ws-server): the crate has three layers, and the frames are not one
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 18:51:07 -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
Edward Langley 6e8d4ac816 Merge: a geometry change invalidates every client's cursor; a batch carries its basis (improvise-gvnb, xwi6, fkfg)
ADR 001 layer 1, the correctness floor, plus the live bug in the same code.

gvnb: reset_cursor owed the session mirrors BOTH halves. Resetting the
mirrors alone is strictly worse than doing nothing -- the diff then believes
every client already holds origin-positioned content and sends nothing, so
they stay stale permanently rather than until their next scroll. The trigger
is a cursor_epoch moving, not a command variant, for the reason gotcha 39
watches document_generation. What is broadcast is an INVALIDATION, never a
position: another client's transpose may not move where I am looking.
Applied::Diff::broadcast becomes Vec<Broadcast> so each relay names its own
audience.

xwi6/fkfg: Command::Model { basis, cmd } as a struct variant so the basis
cannot be forgotten; Applied::Rejected as the third case. Basis has no
Default and no public constructor -- a zero basis is not 'no claim', it is
the claim that the document is fresh, which in a new App is true, so a
default would be a forgeable bypass. Which records are checked is the
SERVER's exhaustive read of the command; taking the list off the client's
claim would be a force by omission.

And the ADR omitted the publication: it specifies a check no client could
satisfy without forging, because nothing in the projection stream carried
any of the three tiers. Projection::Basis + BasisCache close that, emitted
AFTER the grid updates in a batch -- the mirror image of gotcha 33.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 17:53:32 -07:00
Edward Langley f07a4c2bde chore: one last round of updates
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
2026-09-19 17:52:43 -07:00
Edward Langley 019366d8a1 feat(concurrency): ADR 001 layer 1 — a batch carries its basis and a stale one is refused (improvise-xwi6, improvise-fkfg)
The server already serialises every write — one `App`, one `apply_effects` —
so a total order exists and this is not about ordering. What was missing is
noticing that a client composed an edit against a picture of the document
that has since moved: the stale-basis shape of gotcha 24 and gotcha 39, one
level up.

`Command::Model` becomes a struct variant carrying a `Basis` — the three
tiers of ADR 001's table, `document_generation`, `layout_version`, and the
write counters of the records the batch names. `Sessions::apply` checks it
before applying anything and returns `Applied::Rejected { reason }`, a third
case beside `Diff` and `Rebased` so a host that wants the `Dirty` has to name
it (gotcha 39's argument). `ServerMsg` grows the matching third frame,
addressed to the asker alone: nothing happened, so nobody else is owed
anything, and the asker already holds what is current.

The tiering is the economy. A `CellData` batch is refused when the *layout*
moved even though no record it names was written, because the coordinates its
key hangs off changed underneath it — A deleting an item while B commits an
edit keyed by it. That is corruption rather than a lost keystroke, it is the
class a cell-level lock would not have covered, and it falls out of the tiers
with no lock anywhere.

`Rejection` keeps structural and data staleness apart, which is what ADR
002's maintained `RecordHash` index in `DataStore` exists for: `RecordGone`
means the record does not resolve and there is nothing to write to, so
nothing coherent to force; `RecordMoved` means it resolves at another
version, where force is one keystroke. Collapsing them is the failure a
regenerate-from-the-layout scheme would have had.

**No bypass, by construction.** `Basis` has no `Default` and no public
constructor — `BasisCache::claim` is the only door and it withholds until the
server has published one, so a zero-valued forgery is not expressible. Which
records are checked is `records_named`, the server's own exhaustive read of
the command, not whatever the client chose to declare: a client that omitted
its record would otherwise have a force by omission, with no flag to remove.
Force stays what the ADR says it is — a re-composition against the current
basis — and `re_composing_against_the_republished_basis_is_accepted` walks
that path with nothing that skips the check.

WHERE THE ADR WAS WRONG: it says "a batch carries the basis it was composed
against" and never says how a client comes to know one. Nothing in the
projection stream carried any of the three tiers, so the check as specified
was satisfiable only by forging. `Projection::Basis` + `BasisCache` close
that: the server publishes the basis for the records a client's own grid
names, diffed against what that client was last told, seeded in the
bootstrap. The publication is not in ADR 001's implementation order and
should be.

Tests, per `doctrine(check_the_checker)` — a check nobody has watched reject
is indistinguishable from one that always accepts, and the converse: every
tier appears twice, rejecting and accepting. Fixtures hold every identifier
constant and move only the tier under test, and each asserts that the *other*
tiers did not move, so a rejection cannot be a renamed key or the wrong tier
firing (`doctrine(a_rename_can_hide_a_stale_key)`).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 17:52:11 -07:00
Edward Langley 7a6309634d fix(sessions): a geometry change invalidates every client's cursor (improvise-gvnb)
`TransposeAxes` and `CycleAxis` reset the cursor because renumbering the
grid makes the old indices name different cells (gotcha 24), but the reset
only reached the authoritative `App`'s own view slice. Every `ClientSession`
keeps its own cursor in its mirrored `ViewState`, and nothing reset those, so
a client was left indexing a grid that had changed shape under it —
`Viewport::window` clips, so what it actually saw was a blank grid until it
happened to scroll. Reachable remotely since the model half of the wire
vocabulary landed (improvise-kmgs).

Three pieces, and the first two are worthless apart. `Command::ResetCursor`
is the wire variant, derived from the `App::reset_cursor` write the two
geometry effects make on the portable slice. `Sessions::adopt_cursor_epoch`
resets every mirror *and* returns the broadcast that tells the clients:
resetting only the mirrors would leave the diff believing each client already
holds origin-positioned content, so nothing would be sent and every client
would stay stale permanently rather than until its next scroll — a partial
fix strictly worse than none. And the trigger is `App::cursor_epoch` moving,
not the command variant, for the reason gotcha 39 watches
`document_generation` instead of matching on `LoadModel`: which effects
renumber the grid is a per-effect decision, so a table of variants here would
be a second description of it, free to disagree.

The audience had to become part of the message. A `SwitchView` broadcast
corrects state the asker folded optimistically and must not go back to it; a
cursor invalidation is a consequence the asker is owed on the same terms as
everyone else, because a `Command::Model` is the one kind a client cannot
fold optimistically. So `Applied::Diff::broadcast` is now
`Vec<Broadcast>` over `Broadcast::{ToOthers, ToAll}` — a `Vec` because one
command can owe two relays with different audiences, and an `Option` would
have had to drop one silently.

What is not broadcast is a position. Another client's transpose must not move
where I am looking — that is UI and it is mine (improvise-g0f) — it may only
declare that where I was looking has stopped meaning anything, which is a
fact about the shared geometry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 17:42:32 -07:00
Edward Langley 4762d7ceab feat(ws-server): host several documents behind one registry (improvise-j31x)
A `Host` was always one authoritative `App`, one `Sessions` and the clients
attached to them, and `Sessions` never owned the `App` -- so multi-document
is a registry above `Host` and nothing below it changed. `improvise-command`
and `improvise-protocol` are untouched.

The one thing a `Host` could not supply is a server-wide address: `SessionId`
is minted per `Sessions`, so every document's first client is `SessionId(0)`.
`ClientId { document, session }` is that address, and `Registry::route` is
the single place a `SessionId` acquires the document it came out of -- which
makes cross-document delivery unrepresentable rather than checked. A
broadcast and an `Applied::Rebased` fan-out therefore stop at the document
boundary by construction (gotchas 36, 39).

Lifecycle. Documents are opened at startup from the files named on the
command line; a client cannot ask the server to open a path, because
choosing which paths a client may open is access control and that is out of
scope. A document is *not* closed when its last session leaves: eviction
would lose unsaved edits, and would rewind `document_generation` and
`record_versions` to zero so a reconnecting client's stale basis would be
accepted as current (ADR 001).

A client names its document in the URL path -- `ws://host:port/quarterly` --
so `ClientMsg` is unchanged and a single-document server still answers `/`.
A name nothing serves is closed with a websocket close frame carrying the
reason; no `ServerMsg::Error` variant was invented for it.

Tests: 14 in `registry.rs` (socket-free), 4 in `main.rs`, 1 in `transport.rs`
and a second socket test for the URL rule. Each isolation and lifecycle claim
has a fault injected beside it (`doctrine(check_the_checker)`): a same-document
bystander that does hear the edit, a second document that has the same view
name to fold, a failed load that rebases nobody, and a freshly opened document
that has lost both the edit and the basis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 17:31:47 -07:00
Edward Langley 9fe5f633e2 feat(protocol,command): CellId on CellDisplay; SetCell takes a surrogate
ADR 002's spine, end to end.

CellId::{Record(RecordCell), Aggregate(AggregateHash)} rides on
CellDisplay::id, filled in by grid_of for the same reason `numeric` is: the
server is the side that knows. The kind of identity is the permission --
SetCell/ClearCell take a RecordCell and nothing else, so a client cannot
compose a write against an aggregate, and an aggregate id is still an id, so
Drill has something to address.

The fork is not invented: `cell_id` asks the question EditOrDrill already
asks -- a regular category on the None axis -- and records mode projects
RecordRow::coords rather than the synthetic (_Index, _Dim) key, whose
positions are exactly what ADR 002 rejected _Index for.

`id: None` means "no projected cell here" (padding, Invalidated, or a
records-mode category column, which renames a coordinate across the row and
is a different intent from SetCell). It is NOT "aggregate" -- that is
Some(CellId::Aggregate(..)).

model_effects now takes the App, because resolving a surrogate is a lookup
against authoritative state. Resolution is the index first (every record
that holds data, on screen or not) and the layout second (a cell with no
value yet is named by the grid and by nothing in the store -- which is the
whole of improvise-1ey). An unresolvable surrogate applies nothing.

record_versions moves off App into the DataStore beside the index: still
shared document state, now one lookup for both questions.

The socket integration test now composes its SetCell from the id the server
projected, which is the only handle a browser has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EoW5BB87ZZdm96SvBTWawH
2026-09-19 17:13:39 -07:00
Edward Langley df746173bf feat(ws-server): websocket host for one document (improvise-q08)
A new crate `improvise-ws-server`: a testable session loop plus a thin
tokio/tungstenite shell around it.

The ticket predates the session machinery and describes building it by
hand; most of what it asks for now exists, so what this adds is the
transport and the loop (doctrine(check_the_ticket_premise)):

- `Host` is one authoritative `App`, one `Sessions`, and every client
  attached to them — the unit of authority is the *document*, not the
  connection (the ticket's premise) and not the server. A multi-document
  registry is a map of `Host`s and is deliberately not built yet; the
  seam is in `transport`, where `serve` takes the document it hosts.
- `handle(id, msg) -> Vec<Addressed>` makes every decision and is tested
  without a socket. `Applied::Rebased` sends every client a fresh
  `Snapshot`; `Applied::Diff.broadcast` is forwarded to the *other*
  clients and never back to the asker.
- The wire adds exactly one thing to the shared vocabulary: the extent a
  client can display, which no `Command` carries by construction and
  which doubles as the handshake, since the bootstrap `Snapshot` is
  built at it.

No auth, no TLS, loopback only: a stated MVP limit.

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