docs: gotcha 49 for the origin check, and the inventory is a dated snapshot
CI / Build all bundles (push) Has been cancelled
CI / Test and lint (host) (push) Has been cancelled
CI / wasm32 check (browser bundle crates) (push) Has been cancelled
CI / Browser JS unit tests (node --test) (push) Has been cancelled
CI / Knowledge base consistency (push) Has been cancelled
CI / Lint the harness crate (push) Has been cancelled
CI / Browser harness (OPFS contract + shell) (push) Has been cancelled
CI / Deploy to GitHub Pages (push) Has been cancelled

Gotcha 49 records improvise-kc7n: a websocket handshake is exempt from the
same-origin policy, so no network control can see cross-site hijacking — an
IP allow-list, a private network and loopback binding all answer "may this
client reach the endpoint" and the honest answer is yes. Loopback is the
least protected case, not the safest. Records why all three arms are decided
rather than defaulted, and that none of it is authentication.

The File Inventory line counts were audited: 26 of 61 resolvable entries had
drifted past 10%, several by a third. Refreshed, and relabelled as a dated
snapshot with the method, because false precision in a carefully-checked
document is worse than an obvious estimate. Two traps found while doing it,
both recorded: a path-walking refresh resolves a bare name like lib.rs
against the first matching crate and rewrites the wrong number, and the
ws-server/ entries use a shorthand prefix no such walk resolves at all.

The code-review skill's gate list now runs BOTH spellings of
check-comment-runs.py. The flag selects rather than adds, so --check-evidence
never runs the comment cap and does not say so; three separate agents hit it
in one campaign (improvise-ijql).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FC2jVn8Au7E9JPkw6bwXXH
This commit is contained in:
Edward Langley
2026-09-20 21:36:29 -07:00
parent 384f55c636
commit 95839fce43
4 changed files with 66 additions and 35 deletions
+3 -2
View File
File diff suppressed because one or more lines are too long
+10
View File
@@ -103,8 +103,18 @@ that cannot fail in the direction the defect lies.
cargo test --workspace --no-fail-fast # fail-fast hides the blast radius
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check
python3 scripts/check-comment-runs.py # the two-line comment cap
python3 scripts/check-comment-runs.py --check-evidence # a DIFFERENT question
```
**Run both spellings of `check-comment-runs.py`.** It is one script answering
two questions and the flag *selects* rather than adds, so `--check-evidence`
never runs the comment cap and says nothing about having skipped it. Three
separate agents hit this in one campaign: a local gate using only the flagged
form went green while CI, which runs both, went red on a three-line doc comment
(improvise-ijql). The flagged form is also the expensive one — it needs a
compiled workspace — which makes it the one people run alone.
The knowledge base is checked in CI as code:
```bash
+39 -33
View File
@@ -165,7 +165,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
4. **Commands never mutate.** `&CmdContext` is read-only; return `Vec<Box<dyn Effect>>`. If you want to touch `&mut App`, add or extend an Effect.
5. **`CellKey` is always sorted.** Use the constructors; equality and hashing rely on canonical form.
6. **No `Model::add_item`.** With a `Workbook` in hand use the facade: `wb.add_item("Region", "East")?` / `add_item_in_group` / `remove_item` (improvise-tvt). With only a `Model`, go through the category: `m.category_mut("Region").unwrap().add_item("East")`.
7. **Virtual categories.** `_Measure` items = explicit items ∪ formula targets. Use `measure_item_names()` / `effective_item_names("_Measure")`. `add_formula` does *not* add items to `_Measure`. Formula-target lookup also falls back into `_Measure` via `Model::find_item_category`. Cell aggregation goes through `apply_agg`, formula `SUM(…)`/`AVG(…)` through `eval_agg` — they differ on empty input (absent vs. A third rule joins them for **non-numeric** values: `aggregate_cell_values` reduces text by **unanimity** — one matching value, or several copies of the same value, is that value; differing values, or text among numbers, is `CellValue::Error("mixed")` (improvise-l4qx). The **all-numeric branch is checked first**, so `Count` of a lone number is still `1` and not the number — that ordering is the whole difference between this rule and "a single value passes through". What the rule forbids is *dropping*: `filter_map(as_f64)` used to, and since `_Index`/`_Dim` are on `Axis::None` in every default view that made **every text cell in every document render blank**, including the pipe-quoted text `.improv` carries as a first-class value. `evaluate_aggregated` and `aggregate_raw` share the helper, so a formula ref over mixed values now errors `"mixed"` rather than silently summing the numeric ones. `0` for Sum/Count, error for Avg/Min/Max).
7. **Virtual categories.** `_Measure` items = explicit items ∪ formula targets. Use `measure_item_names()` / `effective_item_names("_Measure")`. `add_formula` does *not* add items to `_Measure`. Formula-target lookup also falls back into `_Measure` via `Model::find_item_category`. Cell aggregation goes through `apply_agg`, formula `SUM(…)`/`AVG(…)` through `eval_agg` — they differ on empty input (absent vs. A third rule joins them for **non-numeric** values: `aggregate_cell_values` reduces text by **unanimity** — one matching value, or several copies of the same value, is that value; differing values, or text among numbers, is `CellValue::Error("mixed")` (improvise-l4qx). The **all-numeric branch is checked first**, so `Count` of a lone number is still `1` and not the number — that ordering is the whole difference between this rule and "a single value passes through". What the rule forbids is *dropping*: `filter_map(as_f64)` used to, and since `_Index`/`_Dim` are on `Axis::None` in every default view that made **every text cell in every document render blank**, including the pipe-quoted text `.improv` carries as a first-class value. `evaluate_aggregated` and `aggregate_raw` share the helper, so a formula ref over mixed values now errors `"mixed"` rather than silently summing the numeric ones. `0` for Sum/Count, error for Avg/Min/Max). `Model::evaluate_aggregated` takes `Aggregation::{Exact, OverHidden}` rather than `none_cats: &[String]` (improvise-5jm5) — the list was only ever tested for emptiness, and the guard against that shape returning is the **signature**. `recompute_formulas`/`aggregate_raw` keep the list because they walk it.
8. **Display rounding is view-only.** `format_f64` (half-away-from-zero) is only called in rendering. Formula eval uses full `f64`. Never feed a rounded value back into eval.
9. **Formula eval is fixed-point.** `recompute_formulas(none_cats)` iterates until stable, bounded by `MAX_EVAL_DEPTH`; circular refs converge to `CellValue::Error("circular")`. `App::new` runs it before the first frame so formula cells render on startup.
10. **Drill into formula cells** strips the `_Measure` coordinate when it names a formula target, so `matching_cells` returns the raw records that feed the formula instead of returning empty.
@@ -203,7 +203,7 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
33. **The grid's labels frame its cells, and `apply` enforces it — the fold is not commutative.** Three consequences in `improvise-protocol/src/render.rs`. (a) A `GridUpdate::Cells` that overtakes the `RowFrame`/`ColFrame` naming its positions is **dropped**, because `fit_to_labels` truncates to the labels it has; a client must not reorder the stream, and `a_cell_update_that_overtakes_its_labels_is_lost` pins it. (b) A label change that *renames* a position blanks the cell under it: `fit_to_labels` preserves by POSITION, which is only right where the label did not change, so `apply` calls `invalidate_relabelled` itself rather than trusting a producer to send `GridUpdate::Invalidated` first. `Invalidated` therefore now means only "contents stale, frame unchanged". (c) **every arm that touches labels *or* widths re-fits** — `col_widths` is re-fitted with the columns — a new column is drawn at its label's own character count until `ColWidths` catches up — because a renderer zips widths with labels and a length mismatch indexes off the end. Fitting on the label arms alone is what let a late `ColWidths` put the mismatch straight back — a short update keeps each unheard-of column at its label's width, and a long one is dropped as having overtaken the frame that names it, exactly as (a) holds `Cells`. `GridCache` also carries `totals` and `page_coords` (improvise-n10). `totals` is framed by `col_frame` on the same terms as the cells and widths and re-fits with them, with one asymmetry: an **empty** strip means "this grid has no totals" (records mode, or an empty grid) and is therefore *not* padded, where a non-empty one is — padding it would draw a blank total row under a records grid that has none. `Invalidated` blanks the totals too: they are contents, and a stale sum under fresh cells is the one number a user takes at face value. `page_coords` is framed by nothing (it names categories, not positions) and survives a reshape. Note the fold tests: asserting `fold(prefix)+fold(rest) == fold(whole)` is vacuous (it is the definition of a left fold), so the tests that earn their place are the order-sensitivity one and the compaction-equivalence one — see `doctrine(fold_tests_must_test_order_not_the_axiom)`.
34. **A formula target is unquoted, and pipe quoting has one home.** `parse_formula` strips the pipes off a target exactly as it does off an `Expr::Ref` — the target names a `_Measure` item, so a target that kept its delimiters made `add_formula` create an item literally named `|Total Revenue|`: a ghost column the user's real item never matched, and `Expr::Ref("Total Revenue")` never resolved to. Both the TUI formula minibuffer (`AddFormula`) and `.improv` load go through that one call, so both were affected. The writer emits `Formula::raw`, not `target`, so file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration. Pipe quoting has exactly **one home**: `improvise_formula::quote`, owning both halves — `quote_name`/`is_bare_name`, `pipe_quote` (unconditional, for a `.improv` text cell that must not read back as a number) and `unescape_pipe_body`. `improvise-gp` re-exports it, `improvise-io` calls it from `persistence/{format,parse}.rs`, `parser.rs::unquote_pipe` is a shim over it, and io's own copy is **deleted** (improvise-i2m5). **The keyword guard is canonical — user-confirmed, so do not revert it as over-caution**: an item literally named `SUM` is written `|SUM|`. Bare keywords do parse, but only because `agg_call` needs a `(` and PEG backtracking falls through to `ref_expr` — rule ordering, not a stated rule — and a `## Formulas` line carries formula text verbatim, so a name `format_md` writes is read back by `parse_formula`: one namespace, one spelling rule. One thing the guard does not cover, and one that used to be an exception and no longer is. **A section header takes its name as the whole line, totally** (improvise-pczv): `header_name = { pipe_quoted ~ &NEWLINE | line_name }` — the pipe-quoted arm needs the lookahead, or `|A|B` would parse as `A` and silently drop `B`, and `line_name` catches everything else, so a header name can no longer fail to parse. Four rules share it (`model_name`, `initial_view`, `category_section`, `view_section`), which is why it is a rule and not four copies. Note a header is still *written* raw where a reference is quoted — the grammar accepts both arms, so the quoting asymmetry between a header and a reference is a writer convention, not a format one. And the two former *unquoting* copies differed only on a trailing lone backslash, unreachable from the grammar, where io returned it and `parser.rs` panicked — the surviving `unescape_pipe_body` is total.
34. **A formula target is unquoted, and pipe quoting has one home.** `parse_formula` strips the pipes off a target exactly as it does off an `Expr::Ref` — the target names a `_Measure` item, so a target that kept its delimiters made `add_formula` create an item literally named `|Total Revenue|`: a ghost column the user's real item never matched, and `Expr::Ref("Total Revenue")` never resolved to. Both the TUI formula minibuffer (`AddFormula`) and `.improv` load go through that one call, so both were affected. The writer emits `Formula::raw`, not `target`, so file text is unaffected either way — which is exactly why the round-trip tests never saw it, and why the fix repairs existing files on next load with no migration. Pipe quoting has exactly **one home**: `improvise_formula::quote`, owning both halves — `quote_name`/`is_bare_name`, `pipe_quote` (unconditional, for a `.improv` text cell that must not read back as a number) and `unescape_pipe_body`. `improvise-gp` re-exports it, `improvise-io` calls it from `persistence/{format,parse}.rs`, `parser.rs::unquote_pipe` is a shim over it, and io's own copy is **deleted** (improvise-i2m5). **The keyword guard is canonical — user-confirmed, so do not revert it as over-caution**: an item literally named `SUM` is written `|SUM|`. Bare keywords do parse, but only because `agg_call` needs a `(` and PEG backtracking falls through to `ref_expr` — rule ordering, not a stated rule — and a `## Formulas` line carries formula text verbatim, so a name `format_md` writes is read back by `parse_formula`: one namespace, one spelling rule. One thing the guard does not cover, and one that used to be an exception and no longer is. **A section header takes its name as the whole line, totally** (improvise-pczv): `header_name = { pipe_quoted ~ &NEWLINE | line_name }` — the pipe-quoted arm needs the lookahead, or `|A|B` would parse as `A` and silently drop `B`, and `line_name` catches everything else, so a header name can no longer fail to parse. Four rules share it (`model_name`, `initial_view`, `category_section`, `view_section`), which is why it is a rule and not four copies. Note a header is still *written* raw where a reference is quoted — the grammar accepts both arms, so the quoting asymmetry between a header and a reference is a writer convention, not a format one. And the two former *unquoting* copies differed only on a trailing lone backslash, unreachable from the grammar, where io returned it and `parser.rs` panicked — the surviving `unescape_pipe_body` is total. Two `## Category:` sections naming one category are **one** category, concatenated by `ParsedFile::push_category` in **pass 1** (improvise-349m) — merging in pass 2 was not idempotent, because `build_category` creates its own section's item-groups before its own `>` lines while `format_md` writes every item before every `>` line. No grammar change was needed: this is the tree-walker, not the format, and duplicate headers stay legal. And **`WHERE` now carries `IS`'s word boundary** — both are compound-atomic with `sp+` (improvise-r08u), so `SUM(Rev WHEREAS x = y)` is a syntax error rather than a `WHERE` on a category `AS`. The guard is `sp+` and **not** the `!bare_char` lookahead the ticket proposed, because the grammar-walking generator reads the grammar through `pest_meta` and **skips predicates**, emitting `WHEREa=""` — a rule whose only guard is a lookahead is a rule the generator can violate. It costs one spelling: a category may no longer abut the keyword.
35. **`Dirty` over-reports on purpose, and the asymmetry is the reason.** `Dirty::of` maps an `EffectKind` to the widgets that kind can reach, and the two directions do not cost the same: over-reporting pays one redundant `Projection`, under-reporting leaves a client's cache wrong **forever**, with nothing in the stream to correct it. `LayoutGeometry` therefore includes `formulas`, because `RemoveCategory` wears it and `Model::remove_category` ends by dropping every formula targeting the category — a client's formula panel otherwise kept showing a formula the document no longer had. It now coincides with `Dirty::EVERYTHING` and is still spelled out, because the two are derived from different effects and need not stay equal. The derivation now lives in `context/code/command.md`, and the doc comment on `Dirty::of` points there rather than restating it; the reason `LayoutGeometry` is spelled out rather than written as `EVERYTHING` is a two-line comment inside the match body. It went stale once already — claiming `AddCategory` was `CellData` after it was reclassified — so reclassifying an effect means re-reading the prose, not just the comment.
@@ -256,11 +256,13 @@ Region=West, Measure=Revenue = |pending| # pipe-quoted text
**A third thing no test here can see, which is why the docs must be read as code.** Three comments in one range said "not built yet" about work that had landed *in that same range* — `README.md`'s browser section, `app.js`'s keyboard note, and `Host::handle`'s `Key` arm — each because a follow-up commit updated **one copy of an argument and not its siblings**. When a fact is stated in more than one place, changing it is a grep, not an edit.
49. **A websocket handshake is exempt from the same-origin policy, and that is a server-side problem.** There is no CORS preflight, so any page a user visits can open a socket to `improvise-ws-server` *from that user's own allowed address* and read and rewrite every document it holds — cross-site websocket hijacking (improvise-kc7n). **No network control can see it**: an IP allow-list, a private network and loopback binding all answer "may this **client** reach the endpoint", and the honest answer is yes; only `Origin` says what is **driving** the client. **Loopback is the least protected case, not the safest** — no proxy in front of it, and loopback is not a boundary a browser enforces. `src/origin.rs` is the whole decision and `transport::admitted` its one caller; it refuses the **upgrade** with a 403 rather than accepting a socket and ignoring it, and `serve` **takes** the policy so a host cannot forget to apply it. Three arms are decided rather than defaulted: **loopback allowed** (the shipped local recipe serves its page from `http://localhost:8080`, so the one case worth optimising for must need no flag), **absent allowed** (no browser omits `Origin`, so absent is a non-browser client an address check already answers — and refusing it breaks `tests/socket.rs`, the repo's own instrument), and **two refused** (`HeaderMap::get` is first-wins, so a proxy that *appends* rather than sets leaves two, and answering from the first lets the second be anything). The deployed Pages origin is deliberately **not** hard-coded: it can only reach a plain-`ws://` server through a TLS proxy anyway, so one flag is an honest cost on an already multi-step path where a baked-in default would be a permanent hole for every other deployment. The residual hole is **stated rather than guarded** — a proxy that *strips* `Origin` turns the check off, which is why the proxy's own allow-list stays in `README.md` as defence in depth. **None of this is authentication**: it answers what page is driving a client, never who is connecting, and improvise-kpnr is still open. It correctly does **not** move to `improvise-command` beside `Host` (gotcha 45) — a `MessagePort` has no handshake and no headers, and a worker and its page are the same origin by construction, so this is genuinely transport-only and not a mirror waiting to drift.
---
## File Inventory
Line counts are static; test counts are informational — run `cargo test --workspace` for live numbers. Files under 100 lines and render-only widgets omitted.
Line counts are a **dated snapshot**, not a maintained fact: measured 2026-09-21, and **26 of 61 had drifted past 10%** before that refresh — several by a third. Treat them as "is this file big", never as a figure to cite; re-derive with `wc -l`. Note the `ws-server/` and `improvise-worker-server/` entries use a shorthand prefix that no path-walking refresh resolves, so they drift silently and must be checked by hand. Test counts are informational — run `cargo test --workspace` for live numbers. Files under 100 lines and render-only widgets omitted.
### `improvise-core` (`crates/improvise-core/src/`)
```
@@ -285,7 +287,7 @@ quote.rs 192 / 7t quote_name / is_bare_name / pipe_quote / une
the one home, both halves (improvise-i2m5). improvise-gp
re-exports it; improvise-io calls it from
persistence/{format,parse}.rs
formula.pest 92 PEG grammar for formula text
formula.pest 103 PEG grammar for formula text
ast.rs 77 Expr, BinOp, AggFunc, Formula, Filter
```
@@ -369,27 +371,27 @@ improvise-gp/src/fit.rs 1426 / 23t one fit end to end: a View's records in,
### Command layer (`crates/improvise-command/src/command/`)
```
cmd/core.rs 327 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
cmd/registry.rs 749 / 3t default_registry() — all registrations
cmd/navigation.rs 534 / 12t Move, EnterAdvance, Page*
cmd/cell.rs 192 / 6t ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
cmd/commit.rs 723 / 11t CommitAndAdvance, CommitBuffer(CommitKind), CommitExport,
cmd/core.rs 333 / 2t Cmd trait, CmdContext, CmdRegistry, parse helpers
cmd/registry.rs 822 / 3t default_registry() — all registrations
cmd/navigation.rs 502 / 12t Move, EnterAdvance, Page*
cmd/cell.rs 194 / 6t ClearCell, YankCell, PasteCell, TransposeAxes, SaveCmd
cmd/commit.rs 739 / 11t CommitAndAdvance, CommitBuffer(CommitKind), CommitExport,
CommitSave
cmd/effect_cmds.rs 460 / 5t effect_cmd! macro, 25+ simple wrappers
cmd/fit.rs 382 / 7t fit-measure: evolve a tree over the active view and store it
cmd/effect_cmds.rs 459 / 5t effect_cmd! macro, 25+ simple wrappers
cmd/fit.rs 280 / 7t fit-measure: evolve a tree over the active view and store it
as an ordinary `## Formulas` line — nothing GP-specific is
left in the document afterwards
cmd/grid.rs 512 / 9t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
cmd/mode.rs 427 / 11t EnterMode, Quit, EditOrDrill, EnterTileSelect
cmd/panel.rs 713 / 17t Panel toggle/cycle/cursor, formula/category/view panels
cmd/search.rs 202 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
cmd/text_buffer.rs 434 / 14t AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
cmd/tile.rs 139 / 5t MoveTileCursor, TileAxisOp
cmd/wizard.rs 253 / 6t HandleWizardKey — per-step import-wizard key tables
keymap.rs 1117 / 35t KeymapSet, Keymap::dispatch/dispatch_transient, effects_of,
cmd/grid.rs 540 / 9t ToggleGroup, ViewNavigate, DrillIntoCell, TogglePruneEmpty
cmd/mode.rs 414 / 11t EnterMode, Quit, EditOrDrill, EnterTileSelect
cmd/panel.rs 688 / 17t Panel toggle/cycle/cursor, formula/category/view panels
cmd/search.rs 206 / 4t SearchNavigate, SearchOrCategoryAdd, ExitSearchMode
cmd/text_buffer.rs 382 / 14t AppendChar, PopChar, CommandModeBackspace, ExecuteCommand
cmd/tile.rs 138 / 5t MoveTileCursor, TileAxisOp
cmd/wizard.rs 244 / 6t HandleWizardKey — per-step import-wizard key tables
keymap.rs 917 / 35t KeymapSet, Keymap::dispatch/dispatch_transient, effects_of,
SetTransientPrefix — what a thin client cannot have. The
table itself is improvise-protocol/src/keymap.rs
parse.rs 236 / 19t Script/command-line parser (prefix syntax)
parse.rs 232 / 19t Script/command-line parser (prefix syntax)
```
### Semantic UI state (`crates/improvise-command/src/ui/`)
@@ -422,19 +424,23 @@ main.rs 393 CLI entry (clap): open, import, cmd, script
tests/grid_frame_agreement.rs
422 / 4t the two front ends draw the same grid frame (improvise-anr9)
tests/records_wide_smoke.rs 67 / 1t
tests/shipped_documents_load.rs 59 / 1t
tests/web_demo_model.rs 87 / 2t every visible cell in web/demo.improv is a Record
tests/shipped_documents_load.rs 42 / 1t
tests/web_demo_model.rs 80 / 2t every visible cell in web/demo.improv is a Record
```
### Browser host and client (`crates/improvise-ws-server/src/`, `crates/improvise-wasm-client/src/`, `web/`)
```
ws-server/registry.rs 705 / 14t Registry of Hosts keyed by DocumentId; ClientId {document,
ws-server/registry.rs 595 / 14t Registry of Hosts keyed by DocumentId; ClientId {document,
session} — a SessionId is unique only within its document.
A document is never evicted while the server runs (ADR 001)
ws-server/host.rs 23 / 0t a re-export shim only. The session loop is
improvise-command/src/ui/host.rs (gotcha 45)
ws-server/transport.rs 286 / 1t tokio + tungstenite plumbing
ws-server/main.rs 171 / 4t CLI entry: serve a file on a port
ws-server/origin.rs 132 / 6t OriginPolicy: which origins may open a socket. Loopback by
default, --allow-origin widens, absent allowed, two refused
(improvise-kc7n). The only decision transport::admitted makes
ws-server/transport.rs 324 / 4t tokio + tungstenite plumbing, plus the Origin check at the
handshake — a refused upgrade, not an ignored socket
ws-server/main.rs 190 / 5t CLI entry: serve a file on a port; --bind, --allow-origin
ws-server/tests/socket.rs 284 / 2t the one test that uses a real socket: handshake, JSON
framing, SessionId routing, which document a URL asked for
wasm-client/lib.rs 406 / 7t Client: the wasm-bindgen surface (init, on_message, on_frame,
@@ -446,16 +452,16 @@ wasm-client/socket.rs 444 / 7t the fold (fold_frame/handle_frame), the two
wasm-client/state.rs 310 / 5t ClientState: ViewState + one cache per widget, the fold
wasm-client/tests/browser_round_trip.rs 442 / 5t improvise-1ey's scope against a real Host
wasm-client/tests/lockstep.rs 606 / 13t host and client agree on a keypress
web/app.js 519 string calls only, no protocol knowledge, no framework.
web/app.js 496 string calls only, no protocol knowledge, no framework.
Run under Chromium by harness/tests/shell.spec.js — see
gotcha 48 for what that does and does not reach
web/worker.js 100 three string calls, no protocol knowledge. RUN by
web/worker.js 64 three string calls, no protocol knowledge. RUN by
shell.spec.js's no-?ws path, with bindings.rs (gotcha 45)
web/index.html, style.css the page; legibility only
web/build.sh 38 wasm-pack build → web/pkg (gitignored). Needs
wasm-opt = ["-O", "--enable-bulk-memory"] or the bundled
wasm-opt rejects LLVM's memory.copy
web/assemble.sh 72 assembles the deployable site (copies web/*.improv)
web/assemble.sh 120 assembles the deployable site (copies web/*.improv)
web/demo.improv 57 the document the site ships and seeds from. Every cell a
Record — asserted (gotcha 46)
```
@@ -477,17 +483,17 @@ improvise-worker-server/tests/post_message.rs 381 / 7t main-thread traffic,
### Browser harness (`harness/`) — not a workspace member
```
harness/playwright.config.js 77 shell.spec.js runs on CHROMIUM ONLY (engine-independent
harness/playwright.config.js 50 shell.spec.js runs on CHROMIUM ONLY (engine-independent
logic; routeWebSocket is slower and flakier across three).
opfs.spec.js runs chromium + firefox + webkit, and webkit
provides NO OPFS at all, so it skips on a runtime probe
harness/tests/shell.spec.js 199 the first execution of web/app.js. A change to web/*.js
harness/tests/shell.spec.js 165 the first execution of web/app.js. A change to web/*.js
belongs here (gotcha 48)
harness/tests/opfs.spec.js 102 drives the OPFS contract page and reads back its result
harness/opfs-contract/src/lib.rs 417 runs improvise_io::storage::contract::storage_contract
harness/tests/opfs.spec.js 79 drives the OPFS contract page and reads back its result
harness/opfs-contract/src/lib.rs 361 runs improvise_io::storage::contract::storage_contract
against a real OpfsFs inside a real Web Worker
harness/www/opfs/{index.html,worker.js} the page and worker that host it
harness/build.sh 27 wasm-pack build for the contract crate
harness/build.sh 35 wasm-pack build for the contract crate
# gated by the `browser` job in .github/workflows/ci.yml, which can fail the build
```
+14
View File
@@ -117,6 +117,9 @@ invariant(no_input_line_is_dropped,'The Markdown renderer covers exactly the sub
invariant(a_table_cell_boundary_is_an_unescaped_pipe_outside_a_code_span,'splitRow walks the row rather than split(\"|\"), because this corpus documents a file format whose own quoting syntax is |Time Period| and a cell containing one would be shredded into extra columns -- a row that still parses, so no column count could detect it. Latent when fixed, which is the point: the trigger is one ordinary sentence away in a document whose subject is pipe quoting.').
invariant(code_spans_stand_aside_for_emphasis,'Inline rendering replaces code spans with placeholders before matching emphasis, because this corpus routinely opens ** on one side of a code span and closes it on the other; matching per segment silently drops that bold with no visible symptom. Strong is matched before emphasis with a body that may contain a single * pair, because [^*]+ cannot match **a *b* c** and the leftover * then pairs across the bold -- which turned one sentence of gotcha 33 into three italic paragraphs.').
invariant(key_matching_is_variant_equality_never_unification,'scripts/kb-merge.pl compares clause keys with same_key/2 (copy_term + numbervars), never by unifying them. Unification matched too much -- a non-ground key probe(X) matched probe(a) and deleted it -- and MUTATED the incoming clause, binding X to a, so the clause written was not the clause supplied; the round-trip verify cannot see that, since it compares the file against an intended list already corrupted by the same unification (improvise-dj1l). A RULE keys on the whole clause, because one head shape with different bodies is ordinary Prolog. check_total/2 asserts the arithmetic the tool already PRINTED beside the delta size.').
invariant(duplicate_category_sections_merge_in_pass_one,'Two ## Category: sections naming one category are ONE category, concatenated by ParsedFile::push_category in pass 1 (improvise-349m). Merging in pass 2 was not idempotent: build_category creates its own section item-groups before its own > lines, while format_md writes every item before every > line, so the reparse built the other order. push_category destructures PCategory so a fourth field cannot be silently dropped on the duplicate path. No grammar change was needed -- this is the tree-walker, not the format, and duplicate headers stay legal.').
invariant(where_and_is_share_a_word_boundary,'where_clause, inline_where and is_expr are all compound-atomic with sp+ after the keyword (improvise-r08u). Non-atomic, pest auto-inserts WHITESPACE and SUM(Rev WHEREAS x = y) reads as a WHERE on a category AS. Note compound-atomicity ALONE refuses that input, since AS x cannot be one identifier, so the residue sp+ actually closes is WHEREAS=y -- a negative test needs both shapes.').
invariant(evaluate_aggregated_takes_a_named_state,'Model::evaluate_aggregated takes Aggregation::{Exact, OverHidden}, not none_cats: &[String] (improvise-5jm5). The list was consulted once, for emptiness, in every revision since Axis::None was introduced -- the aggregation runs over key.0 via matching_values. Aggregation::of reduces at the boundary and the regression guard is the SIGNATURE. recompute_formulas and aggregate_raw keep &[String] because they walk the elements.').
doctrine(bug_first,'write a failing test demonstrating the bug before fixing it').
doctrine(unit_over_e2e,'prefer small unit tests over integration/e2e').
doctrine(suite_speed,'Two numbers, because the ambiguity between them is what let this drift: NO SINGLE TEST BINARY may exceed 2 seconds, and the WORKSPACE WALL CLOCK is the sum of them. Measured at 22d43b2: 1309 tests over 33 binaries, ~2.2s wall, longest single binary 1.42s -- so the workspace is already past 2s and the rule that bites is the per-binary one. Re-MEASURE rather than re-assert: cargo test --workspace prints a \"finished in\" per binary. Excludes compilation, and excludes harness/ (Playwright, minutes, its own CI job).').
@@ -240,6 +243,13 @@ doctrine(a_careful_comment_is_weak_evidence_that_anything_checks_it,'The compani
doctrine(a_prepare_step_is_not_evidence_until_deleted,'A test-fixture prepare step is claimed load-bearing far more often than it is. Measured 2026-09-20 on every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint: of four prepare steps documented as \"the reason the test is not vacuous\", only DeleteView and ShowItem are. ToggleGroup passes without one because ToggleGroup::apply calls toggle_group_collapse UNCONDITIONALLY -- the membership check gates only reset_cursor -- and TransposeAxes step is literally a no-op, because View::on_category_added already assigns the first regular category Row and the second Column. The check is cheap and nobody runs it: delete each prepare in turn and require the case to fail. A prepare documenting realistic input is fine; a prepare DESCRIBED as carrying the test and not carrying it is an entry that will be cited. Note the orchestrator brief asserting the replacement claim was itself wrong on two of the four, so this is not a failure of the original author alone.').
doctrine(a_precondition_asserted_after_the_act_asserts_nothing,'An assertion naming a fixture precondition must run BEFORE the code under test, or the code under test establishes it. Proven 2026-09-20 on selecting_the_page_already_selected_invalidates_no_cursor: \"the fixture was not on Q1\" ran after a SetPageSelection to Q1, so it could never see a fixture that started elsewhere. The property was still guarded -- a Q2 fixture failed -- but at a DIFFERENT assertion, under a message about renumbering. That split is the failure mode: the guard survives, the explanation drifts, and the next reader trusts the sentence rather than the assertion.').
doctrine(an_evidence_entry_that_measurement_contradicts_is_worse_than_none,'context/test-evidence records WHY a test can fail, so a mechanism stated there is load-bearing prose and will be cited. Proven 2026-09-20: the entry for every_layout_geometry_effect_is_visible_to_the_narrow_fingerprint claimed a dirty=false clear stopped the preparation satisfying the assertion. Removing the clear AND blinding document_config to 0 left all THIRTEEN cases reading false -- dirty is raised only inside App::apply_effects and no prepare runs an effect, so no preparation can set it. Nothing checks an evidence claim against the code: check-comment-runs.py --check-evidence verifies only that each heading names a test that exists. Re-run an evidence entry injection when you touch the test it describes. Probing this particular test needs the assert replaced by a print, because the loop aborts at case 1 under any injection blinding document_config -- which is probably how the wrong claim survived.').
doctrine(a_grammar_guard_must_be_generatable,'A pest rule whose only guard is a negative lookahead is a rule the grammar-walking generator can violate: the generator reads the grammar through pest_meta and SKIPS PREDICATES, so a caret-WHERE followed by !bare_char emitted WHEREa=\"\" and generated_formula_parses went red. An explicit separator (sp+) is generatable where a lookahead is not. This is why WHERE took IS\'s sp+ rather than the !bare_char improvise-r08u proposed, at the cost of one accepted spelling: a category may no longer abut the keyword. Applies to improv.pest equally, and constrains every future change to either grammar in this repo.').
doctrine(a_roundtrip_equality_is_blind_to_a_loss_both_sides_share,'fmt(parse(x)) == fmt(parse(fmt(parse(x)))) tests STABILITY of the printed form and says nothing about PRESERVATION. Demonstrated on improvise-349m: injecting a merge that DROPS a duplicate section left the whole improvise-io suite green at 2000 proptest cases while an item and its group line vanished from the document, because both operands lose the same thing. Main was unstable-but-lossless and the test could not tell that from stable-and-lossy. A preservation assertion must run BEFORE the equality. The sharpest form of check_the_checker yet seen here: the fault that survives is the one that changes BOTH OPERANDS.').
doctrine(could_not_reproduce_and_does_not_reproduce_are_different_claims,'Filing the second having earned only the first is how a real defect gets closed. On improvise-gfvj an agent concluded SortData does not reorder; the mechanism was real, and the cause was that ToggleRecordsMode pushes effect::SortData as its FIRST effect, so any records-mode fixture built by pressing R has a pre-sorted store BY CONSTRUCTION. The failure to reproduce was a property of the fixture, not of the code. Before writing \"does not reproduce\", name what would have to be true for the bug to exist and check THAT -- a fixture that cannot reach the condition reports the condition absent. Sibling of a_benchmark_fixture_that_cannot_reach_the_slow_branch_measures_the_fast_one.').
doctrine(a_script_with_two_modes_has_two_gates,'scripts/check-comment-runs.py runs the comment-run cap bare and the evidence cross-check under --check-evidence, and NEITHER MODE RUNS THE OTHER. Every wave brief this campaign, and the gate list in the code-review skill, named only --check-evidence -- so the cap was effectively unrun locally and caught only by CI. It caught one: an enum inserted between Model doc comment and its derive, leaving the enum carrying the comment and Model with none. When a gate grows a flag, the unflagged invocation stops being run by everyone who copied the command, and a gate nobody invokes is indistinguishable from a gate with nothing to catch.').
doctrine(an_injecting_reviewer_needs_its_own_tree,'An adversarial reviewer given leave to inject faults RACES THE AUTHOR if they share a worktree: on wave F the reviewer subagent injections landed in the author tree mid-gate and the author own run reported a failure for a fault it had not written. Everything was restored cleanly, but the diagnosis cost real time and could have been read as a genuine regression. A reviewer that injects gets its own worktree or a scratch copy, always. Related and separately costly: the session SCRATCHPAD IS SHARED across wave agents, and one agent overwrote another pr-body.md mid-task -- unique filenames, or a per-agent subdirectory.').
doctrine(who_may_connect_is_two_questions,'Ask of any access control whether it constrains the CLIENT or what may DRIVE the client. A websocket handshake is exempt from the same-origin policy, so an address allow-list, a private network and loopback binding all answer \"may this client reach the endpoint\" and answer YES to a cross-site hijack: the request genuinely comes from an allowed client, driven by a page the user never intended to trust. Only Origin separates them. Loopback is the LEAST protected case, having no proxy in front of it at all -- the intuition that it is the safe default is exactly backwards (improvise-kc7n).').
doctrine(a_line_count_in_a_document_is_a_dated_snapshot,'repo-map File Inventory line counts were audited 2026-09-21: 26 of 61 resolvable entries had drifted past 10 percent, several by a third. They are useful as \"is this file big\" and useless as a figure to cite, which is how false precision enters a document that is otherwise carefully checked. Refreshed and labelled with the date and the method. Two traps found while doing it: a naive path-walking refresh resolves a bare name like lib.rs or render.rs against the FIRST matching crate and silently rewrites the wrong number (it reported lib.rs 561 -> 8), and the ws-server/ and improvise-worker-server/ entries use a shorthand prefix no such walk resolves at all, so they drift silently and must be checked by hand.').
work_done('improvise-8tk',bug_fix,'ZZ/:wq quit even when the save failed, silently discarding the model. Save now aborts the effect batch on failure; save_to_current_path/1 returns Result<String,String>; three tests in ui/effect.rs.').
work_done('improvise-907',bug_fix,'Import wizard step machine redesign: validation_error() gate at ReviewProposals (no-category and over-limit), history-stack previous() bound to Left on every step, and Done made a real confirmation step holding the built workbook. Superseded improvise-gfq, improvise-o1c, improvise-c6j.').
work_done('improvise-6sq',wont_do,'Merging EditOrDrill and EnterEditAtCursorCmd into one EnterEdit{mode} is not safe: they differ in WHEN the cell value is read, not in a mode parameter. Added ui/app.rs::tests::i_and_o_diverge_on_an_empty_records_grid as executable evidence.').
@@ -339,6 +349,10 @@ decision('improvise-864a',skip_a_superseded_pages_deploy,'Three merges within 20
decision('improvise-9c8c',gate_the_comment_cap_last,'The CI step enforcing the two-line comment cap and the test-evidence keys was held back until every unit of the comment wave had merged, so that the gate\'s first green run was evidence rather than vacuous.').
decision('improvise-kh8',landing_page_at_the_root,'USER RULING 2026-09-20: the landing page should be served at the root of the repository and link to the in-browser demo. Supersedes the agent\'s proposed landing_page_beside_the_app, which argued for the app at the root with the landing page at /landing/ on the grounds that the live app is the better front door; that argument is recorded as the reason the alternative was considered, and it was overruled. web/assemble.sh puts docs/index.html at the site root and moves the app bundle (index.html, app.js, style.css, worker.js, demo.improv, pkg/, worker-pkg/) under /app/, moving the two bundle dirs WITH the app so every relative path in the generated glue stays correct. The non-obvious cost: harness/tests/shell.spec.js navigates to /index.html six times, so a path move at the root silently retargets the whole shell spec at a different page. The deployed arrangement IS what the harness serves (improvise-vvl3), which is the property that makes the harness trustworthy and also the property that makes a layout change a test change.').
decision('pr82-fingerprint-guards-2026-09-20',a_test_reads_the_view_its_effect_writes,'App::view() resolves ViewState::active_view (this client) and Workbook::active_view() is the DOCUMENT DEFAULT; gotcha 29 makes them deliberately free to differ since improvise-jytm. A test asserting what a geometry effect did must read App::view(), the view the effect writes through view_mut(). They agree in a single-client fixture, so the mismatch is latent and survives review easily -- found only because a sibling guard added beside it used the other accessor, one test reading one view through two accessors.').
decision('improvise-l8rh',ambiguous_date_is_a_known_limit,'Ruled by the user 2026-09-20: leave detect_date_format first-match. A day-first default only moves which half of the world imports silently wrong; the honest fix surfaces the ambiguity and changes FieldProposal shape plus the wizard step, which is deliberate work rather than a patch. Tolerable because improvise-7yx made it deterministic. Pinned by an_ambiguous_date_is_read_american_first with a_day_first_only_value_settles_the_ambiguity as the discriminating arm.').
decision('improvise-kc7n',origin_default_is_loopback_not_the_projects_own_page,'improvise-ws-server refuses a handshake whose Origin is neither loopback nor named by --allow-origin. Loopback is the default because the shipped local recipe serves the page from http://localhost:8080 while the socket is on another loopback port, so the one case worth optimising for must need no flag. The deployed Pages origin is NOT hard-coded: it can only reach a plain-ws server through a TLS proxy, already a multi-step setup carrying an Origin allow-list, so one flag is an honest cost there where a hard-coded default would be a permanent hole for every other deployment.').
decision('improvise-kc7n',an_absent_origin_is_allowed_and_two_are_not,'Absent is DECIDED, not defaulted: every browser sends Origin on a handshake unconditionally, so absent is a non-browser client (curl, a native client, the crate own tests) that an address check already answers, and refusing it would break the repo own instrument for nothing. TWO Origin headers are refused, because HeaderMap::get is first-wins and a proxy that APPENDS rather than sets leaves two -- answering from the first lets the second be anything. An Origin that is not valid text is refused on the same ground. The residual cost is stated rather than guarded: a proxy that STRIPS Origin turns the check off, so the proxy own allow-list stays in README as defence in depth.').
decision('improvise-kc7n',an_origin_check_is_not_authentication,'It answers what page is DRIVING a client, never WHO is connecting, and anything that is not a browser page sends no Origin and is unaffected. improvise-kpnr is untouched by it and still open. Agent recommendation on the PR thread: a token in the URL, ws-server only, because new WebSocket(url) takes no headers so the URL is the one place a page can put a credential DELIBERATELY rather than ambiently; basic auth is ruled out on evidence, since a page cannot answer a challenge on a socket handshake.').
wire_carries(command,both_directions,'user intent up, projection commands down').
wire_carries(snapshot,on_connect_only,'improvise-q08: full ViewState + render cache filled from current viewport').
wire_carries(command,both_directions,'user intent client->server, projections server->client').