f2bb8a62929c9bc4a594c8683327705e947f7d13
28 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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 |
||
|
|
c831e5ea4a | WIP: float_roundtrip on frame parsers; failing NaN quiescence test (improvise-6oy4, improvise-4w09) | ||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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 |
||
|
|
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) |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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> |
||
|
|
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:
|
||
|
|
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> |
||
|
|
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> |
||
|
|
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
|
||
|
|
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 |
||
|
|
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 |
||
|
|
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
|
||
|
|
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
|
||
|
|
f07a4c2bde | chore: one last round of updates | ||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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 |