Commit Graph

978 Commits

Author SHA1 Message Date
Edward Langley 625f4acb80 docs(worker-server): move the browser-host argument to context/code/clients.md
The 121-line module doc on worker.rs, vfs.rs's seeding and flat-root rules,
bindings.rs's async-seam argument and the crate lib.rs coverage statement now
live in context/code/clients.md; the four bug narratives in the tests move to
context/test-evidence/clients.md, keyed by test name.

No behaviour change: comments only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:49:04 -07:00
Edward Langley 2c30ef0138 docs(gp): move the gp-tree / improvise-gp reasoning into context/
The arguments in these two crates' comments, written out where a comment run
cap cannot reach them: context/code/gp.md for the design arguments and
context/test-evidence/gp.md for the bug narratives, keyed by test name.

Nothing in crates/ changes yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:49:00 -07:00
Edward Langley b394c9377f docs(protocol): land the crate's reasoning as context/ prose
The arguments in improvise-protocol's comments, relocated before the
comments are capped: the fold rules, the basis tiers, the one-envelope-pair
derivation, the TextEntry spine, and the four accessors that must not enter
a draw loop. Test narratives go to context/test-evidence/protocol.md,
keyed by test name per design-principles §7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:47:13 -07:00
Ed Langley 1c42731bef Merge pull request #34 from fiddlerwoaroof/chore/backlog-grooming
chore(beads): label the backlog by area, wire the real ordering, validate on create
2026-09-20 09:44:27 -07:00
Edward Langley 6c0fa34c93 chore(merge): gh/main -> cleanup-ci 2026-09-20 09:37:40 -07:00
Edward Langley 461c22fce3 chore(beads): label the backlog by area, wire the real ordering, validate on create
Grooming after wave 1. The tracker had 62 open beads, one label between them
and 36 dependency edges that nearly all pointed at closed work — so 'bd ready'
was empty, 'bd blocked' saw one issue, and the next wave's partition existed
only in the orchestrator's head.

AREA LABELS on every open bead (area:protocol, :command, :projection,
:shared-frontend, :browser, :active-view, :tui, :core-io, :gp, :release,
:epic, :docs), so a wave partitions with 'bd list --label' instead of from
memory. needs:user marks the three nobody else can move.

ORDERING that was real but unrecorded:
  k69y <- jytm   the draw_status fix is a no-op until the split lands
  bck  <- fdt6   'add a formula' is the milestone's one unmet criterion
  1ey  <- plcx   criterion (5) needs a document big enough to scroll
  jq8l <- plcx   same reason
  rw3a <- 2ud    needs CmdContext::file_path, and should not add a 21st
                 copied field ahead of that refactor
  gd28 <- 7b8v   the addressed drill form is ADR-001 layer 2
Ready 48, blocked 16, no cycles.

Note for the next person: 'bd dep add' REFUSES to overwrite an existing
relates-to edge with a blocks edge — it errors rather than converting. Several
of these had been filed as relations when the relationship was directional, so
they were silently blocking nothing. 'bd dep unrelate' then 'bd dep add'.

validation.on-create = warn, because this session filed 19 beads with no
acceptance criteria and only found out from 'bd lint' afterwards. --validate
refuses outright and names the missing sections, and it works on --dry-run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:37:02 -07:00
Ed Langley 1568524009 Merge pull request #33 from fiddlerwoaroof/chore/comment-cap-checker
Groundwork for improvise-9c8c: a runnable definition of done for the comment cleanup.

scripts/check-comment-runs.py reports every comment run over two lines (1321 today, harness/ included after review), exempts doctest fences because a fence is a running test rather than prose, and --check-evidence requires every context/test-evidence heading to name a test that exists.

design-principles §7 said to record a bug in the test's doc-comment, which the cap contradicts; that evidence moves to context/test-evidence/ keyed by test name, with the orphaning cost now guarded rather than merely stated.
2026-09-20 09:36:38 -07:00
Edward Langley 67949a0881 chore: update ci, use nix 2026-09-20 09:36:26 -07:00
Edward Langley 3fcf05208d chore: remove useless claude code review workflow 2026-09-20 09:25:02 -07:00
Edward Langley f4b89fa5e3 fix: scan harness/, and make the orphan check real rather than claimed
Both findings from the review on #33, and the second is the sharper one.

The checker skipped harness/opfs-contract/src/lib.rs — a shipped Rust source
tree with 14 runs over the limit, including a 22-line module doc — so it could
have reported success while the convention was violated. harness/ is now in the
default roots.

And design-principles claimed a renamed test was made detectable because 'the
checker looks for both halves'. It did not: it counted comment lines and listed
doctest fences, and nothing read context/test-evidence/ at all. So the cost the
convention says is controlled was uncontrolled, in the same commit that
introduced the convention — a reviewed paragraph is not evidence, and this one
was mine.

--check-evidence now requires every '### name' heading to name a test that
exists. One direction only: a test may have no evidence (most do not), but
evidence naming a test nobody can find is a dangling explanation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:16:41 -07:00
Edward Langley 9494c14044 chore: cap comment runs at two lines, and say where the reasoning goes
The user's instruction (2026-09-20): reasoning moves out of code comments into
context/ prose, and a comment run is at most two consecutive lines — `///` and
`//!` included, and test doc-comments too.

scripts/check-comment-runs.py makes the rule checkable rather than remembered,
which is the difference between a convention and a guard. It reports 1321 runs
over the limit today; the move itself is improvise-9c8c.

A ``` fence in a doc comment is exempt: a doctest is a running test rather than
prose, and capping around one would delete it. `--list-doctests` finds the six
blocks that need individual decisions.

design-principles §7 said to record a bug in the test's doc-comment, which the
cap contradicts — that evidence now goes to context/test-evidence/ keyed by test
name, with the orphaning cost stated rather than discovered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 09:11:18 -07:00
Ed Langley 2b68696ce7 Merge pull request #30 from fiddlerwoaroof/docs/reconcile-after-wave-1
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
Reconcile the docs with the code after wave 1.

Gotchas 1, 11, 18, 21, 42, 43, 44, 45, 46, 47, 48 corrected against the tree; the File Inventory regenerated rather than patched; kb.pl's seven now-false invariants replaced in place and the counts up (doctrine 48->55, invariant 44->51, location 31->34); the dead-ID audit recipe widened, anchored and made runnable.

Includes the repair of a false claim this campaign propagated: improvise-xps8 described a watchdog bug the merged tree does not have, because a partial simulation of PR #21's fix reproduced the original by construction. The open-then-dropped harness test now runs past the handshake timeout and asserts the false message never appears — proved discriminating by removing setShellFailure's clearTimeout.
2026-09-20 01:31:36 -07:00
Ed Langley 40c9380807 Merge pull request #32 from fiddlerwoaroof/chore/orch-beads 2026-09-20 01:24:07 -07:00
Edward Langley 4c6959da53 feat: add skills for various agents 2026-09-20 01:18:59 -07:00
Ed Langley 04fa6e851d Merge pull request #31 from fiddlerwoaroof/chore/orch-beads 2026-09-20 01:16:34 -07:00
Edward Langley 6f9efcf2e8 docs: improvise-xps8 describes a bug that does not exist — turn it into a guard
Review findings on PR #30, all three applied.

1 [P1]. The claim that the handshake watchdog overwrites a genuine mid-session
disconnect 8s later is FALSE on any tree that has PR #21. `close` routes BOTH
arms through `setShellFailure` (app.js:248-250), which clears the timer
synchronously (app.js:165-169).

It was believed because it was measured honestly — against a tree predating
PR #21 — and then reported as "surviving" that PR on the strength of a
simulation that re-created only the `opened` flag and not the other half of the
fix, the routing. A simulated fix that is not the fix you named reproduces a
bug the real code does not have, and reads exactly like a confirmation.

Rather than delete the claim, execute the property it denied: the
open-then-dropped test now runs past HANDSHAKE_TIMEOUT_MS + 2s and asserts
"sent no document" never appears. Proved discriminating by removing
`setShellFailure`'s `clearTimeout` — the assertion then fails with exactly the
message xps8 described. gotcha 48 and the spec's comment block corrected; the
ticket is cited in neither. Recorded as
doctrine(a_simulated_fix_must_be_the_fix_you_named), the sibling of
an_injected_fault_must_be_the_fault_you_named that fails the other way: a wrong
fault proves a check that cannot fire, a wrong simulated fix proves a bug that
does not exist.

2 [P1]. The previous commit created the contradiction it was meant to remove:
design-principles recorded the measured ~2.2s while kb.pl still said "under 2
seconds". The ambiguity between per-binary and wall clock is what let it drift,
so both now state two numbers — no single binary over 2s, wall clock is the
sum, measured 1309 tests / 33 binaries / ~2.2s / longest 1.42s — in kb.pl,
design-principles and repo-map alike. Also 16 -> 17 in doctrine(binding_dump_diff).

3 [P2]. The new audit exclusion was unanchored, so `improvise-gp` was a prefix
match that would silently exclude a real future ticket. Anchored `^…$`. It
immediately caught the example id the explanatory paragraph itself spelled out
— a recipe is code, twice over.

CLAUDE.md: declined, with evidence. Both copies of the duplicated tail sit
inside `<!-- BEGIN BEADS INTEGRATION v:1 profile:minimal hash:ca08a54f -->`
markers written by `bd setup claude` and `bd setup codex` and refreshed against
that hash. A process change there is one tool invocation, not two hand edits,
and a pointer makes `bd setup <recipe> --check` report it modified. Recorded as
kb_issue_found(claude_md_tail_is_generated_not_duplicated) so it is not
re-proposed.

kb.pl: doctrine 54->55, kb_issue_found 11->12; invariant 51, decision 47,
location 34 unchanged. kb_consistent passes.

cargo test --workspace: 1309 passed, 0 failed, 2 ignored.
harness (Playwright): 12 passed, 3 skipped (webkit has no OPFS).
Dead-ID audit with the anchored recipe: 157 tokens, all resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 01:11:46 -07:00
Edward Langley 34e5bd5902 docs: gotcha 33/47 still spelled the retired RowLabels/ColLabels
The fold rule is intact under the new field names (improvise-anr9 renamed
the GridUpdate arms to RowFrame/ColFrame and GridCache's fields to
row_frame/col_frame), but three sentences still named the old ones.

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:56:33 -07:00
Edward Langley 31252a1744 chore: update configs 2026-09-20 00:52:33 -07:00
Edward Langley 2eea626ab4 chore: update issues.jsonl 2026-09-20 00:46:13 -07:00
Edward Langley bfd70a08fa chore(beads): close WU-A's three beads — the wave is complete
improvise-anr9 and improvise-1125 closed by PR #23; improvise-s1xw closed as
absorbed, verified fact by fact rather than assumed.

anr9's close records the user's P1 and why it was larger than it looked: the
migration did not break a check, it OUTGREW one — the pre-anr9 code compared
slash-joined strings and never had cats to compare. And it was wrong end to end,
because grid_updates diffs against the framed mirror, so an apply that kept the
cells made the producer send nothing at all.

1125's close records that the ticket's stated home was unreachable:
improvise-wasm-client cannot depend on improvise-command, so putting the width
rule in viewport would have guaranteed the third copy the ticket exists to
prevent.

Wave 1 complete: 8 PRs merged, 1189 -> 1309 passing.

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

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

Includes the user's P1: a cell's identity is cats zipped with its tuple, and invalidate_relabelled compared tuples alone — so an axis swap left every CellDisplay and column total standing under a coordinate that no longer named them, and because grid_updates diffs against the framed mirror, the producer then sent nothing at all. A client wrong forever.
2026-09-20 00:40:47 -07:00
Edward Langley 9bc59f6a28 chore(beads): close WU-C's five beads, file clause 4 and the drill-dirty bug
improvise-368h, gv9r, 955n and 7v62 closed by PR #26; improvise-08wb closed as
a duplicate of 955n.

955n's close records the finding that mattered most, and it was a regression the
work introduced: hoisting the basis check to the frame hoisted ALL THREE TIERS.
The document and layout tiers are frame-level facts; the record tier is not and
cannot be, because which records are at stake is a property of the command. An
independent reviewer confirmed the lost update by executing a real Host.

7v62 closed won't-fix with the compiler output on the type, which is what the
ticket asked for: a proof discharged before a mutation cannot be represented as
a borrow held across it.

improvise-yqpd filed — clause 4, transactional rollback — carrying the costed
design note, the two non-obvious acceptance criteria the P1 produced, and the
trade nobody had named: interleaving the record check with the applies would
close clause 4's silence for free, but it buys the legitimate double-write and
sells the loud failure on a broken chain.

improvise-748f filed — a drill exited with no edits, and view-back, mark the
document dirty. Unlike the LoadModel case, that is a wrong answer to no
divergence at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:29:54 -07:00
Edward Langley b76fc38370 Merge gh/main into chore/orch-beads 2026-09-20 00:29:15 -07:00
Ed Langley 54017fdd6c Merge pull request #26 from fiddlerwoaroof/wave/c-host-and-save
WU-C: the host, the session door, and the wire's save (improvise-368h, gv9r, 955n, 08wb dup, 7v62 won't-fix).

Clauses 1-3 of the user's transaction contract: one basis per frame, checked once before anything is applied, commands played in order with each seeing its predecessor's state. Clause 4 (all-or-nothing) is deferred to its own bead with a costed design note.

An independent adversarial reviewer substituted for Copilot (quota exhausted) and found a P1 in the first pass: hoisting the basis check to the frame hoisted all three tiers, so every mutation after the first had its records checked against nothing — a silent lost update, confirmed by execution against a real Host. Fixed and re-reviewed clean.
2026-09-20 00:28:59 -07:00
Edward Langley aab9b0fb21 review: the surviving mutant, and four claims that were not true
From the independent review of #23. One fixture line, four comments, and a
set of doc guards it offered as optional.

**The one windowing clause with no guard.** Deleting `pending.clear()` — keep
only the nearest header *above* the window — left the whole workspace green.
`a_window_opening_inside_a_group_names_it_without_painting_it` used a fixture
with exactly one group above the window, so it could not tell "the nearest
comes" from "every one comes". Correctness is untouched either way
(`group_of` still finds the nearest, `body_from` still starts at the first
item); it is a *cost* property, O(the groups a deep scroll crossed), and
example-based correctness tests are blind to those. The test now opens inside
`app_with_consecutive_headers`, which has two headers above the window, and
is renamed `a_window_opening_inside_a_group_names_only_the_nearest`. The
mutant dies: `["[Hidden]", "[Late]", "East"]` against `["[Late]", "East"]`.

**`grouped_model()` claimed coverage it does not have.** Its doc advertised
"multi-level row headers"; `Region` is alone on Row and `Month` alone on
Column, so both axes are single-level and `the_whole_frame_is_unchanged` —
this PR's only byte-exact evidence — exercises no multi-level suppression at
all, which is the whole subject of the change. The fixture is **not** touched:
widening it would destroy the before/after comparison, the same trade as
PR #12. The doc now says what it covers and names what it does not, and
points at `tests/grid_frame_agreement.rs` for the multi-level half.

**`RenderRow::labels()` said a group header has a label of its own**; it
returns `&[]`. Corrected, with the reason the empty slice is right (the label
lives in the `Group` variant) and the consequence for a caller that means
"every label on screen".

**The corrected expectation I wrote down was arithmetically wrong**, which is
worse than writing none — whoever picks up improvise-gd83 would have believed
it. `_Measure` is the *outer* level, so the axis is two `Q1` headers, one per
measure, with the ungrouped `Sep` inside each run rather than after both.
Membership-correct spans are four runs, `[(Q1,2),(None,1),(Q1,2),(None,1)]`,
not two. Now a named constant with the frame dumped beside it, and an
`assert_ne!` that starts failing the day gd83 lands.

**"The only thing the user can click" is not true in a browser.** `web/app.js`
puts no listener on a group `<th>` and `toggle-group-under-cursor` is still in
`the_command_names_that_still_need_the_host_are_these`, so `z` is `Upstream`
into an empty vec — a black hole, not a slow path. The rule is unchanged and
still right: the user must *see* the collapsed group. Three comments now say
seen rather than clicked, and name what would have to land first.

**Taken from the optional list**, because deleting `same_prefix` for this
reason and leaving its four siblings unguarded is inconsistent: `item`,
`label`, `shows_level` and `group_of` have zero production callers and now
each carry a one-line "not for a draw loop — see `header_labels`/`groups`/
`shares_ancestry`". `shows_level` says out loud that it materializes every
item per call and is deliberately *not* made cheaper, since a fast
per-position form is an invitation to the loop `header_labels` exists to
replace.

**Test count corrected.** 1296 passing, 0 failing, 2 ignored, under
`--no-fail-fast`. My 1331 was a miscount and is fully explained: 1296 + 2
ignored + the 33 `test result:` lines my `^test [a-z]` regex also matched.
Slowest binary 1.44s here, sum of binaries 2.31s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:28:38 -07:00
Edward Langley bcdef21123 chore(beads): file the leading-group-header trap found by the PR #23 review
improvise-0o6x P2 — body_from(idx) starts at the first ITEM, so a leading
FrameEntry::Group is skipped at offset zero. Pre-existing and byte-identical to
main, so the TUI has always done it and the characterization is honest — but a
COLLAPSED group first on its axis therefore has no header anywhere on screen in
either front end, and no way back.

It sits exactly where improvise-anr9's windowing rule says it must not: that
rule carries all headers between two in-window items precisely because a
collapsed group's header is the only affordance for getting its rows back.
Position zero is the one place the rule does not reach — not because the frame
omits the header, but because body_from drops it on the way to the screen.

The author knew: the agreement fixture orders Carol before Family to dodge the
case and says so in a comment. That knowledge lived in one comment in one test
and is now a ticket, because the moment web/app.js grows a click handler for
group headers, a leading collapsed group becomes a trap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:26:40 -07:00
Edward Langley 8633ce905e docs(host): check-before-apply is a trade, and clause 4 will want to undo it
The second review pass found the thing neither reviewer nor I predicted:
**interleaving the record check with the applies would close the clause-4
silence.** `[RemoveItem(North), SetCell(North)]` under interleaving is refused
`RecordGone` instead of silently half-applying — half of what clause 4 owes.

So check-before-apply is not a free win. It buys the legitimate double-write
that arm 3 pins (two measures on one record is one record) and **sells** the
loud failure on a broken chain. Clause 4 has to pay that back some other way,
and arm 3 is now what stops someone closing the silence by moving the check
without noticing they made the trade. That is a better reason for that arm to
exist than the one it was added for, and it belongs next to the arm rather
than only in a ticket.

Measured rather than argued, which also corrects my own commit message on
a685503: injecting the interleaving fails **two** tests, not "that arm and
nothing else" — this arm and `a_frame_whose_later_mutation_fails_is_not_yet_undone`
— and the second failing *is* the trade showing itself. I read it as noise at
the time.

Doc-only. 1291 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:23:17 -07:00
Edward Langley 7b04752c4c docs(protocol): the record-tier fix left its own stale sibling
The second review pass cleared the code and found that **243b9a9 landed its
own instance of the rule this PR enforced in three other files**: it gave
`CheckedBatch` a new "two of the three tiers hoist, and the third does not"
section and left the sibling paragraph in `frame.rs` — the **protocol-facing**
doc, the one a client author reads instead of `improvise-command` — saying the
basis is answered once and later commands are simply applied. A client author
reading only that concludes their later mutations are unchecked, which is the
exact over-generalisation the fix commit was correcting one crate over. Three
files grepped, the fourth written fresh and not grepped.

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:22:36 -07:00
Edward Langley 81faf597ce fix(protocol): a category rename re-identifies every cell on its axis
[P1, raised by the user on PR #23.] A cell's coordinate is `AxisFrame::cats`
zipped with its item tuple, so `Region = [North]` becoming
`Department = [North]` names a different cell with `old_items == new_items`.
`invalidate_relabelled` compared the tuples and nothing else, so `apply` left
the old `CellDisplay` — **including its editable `CellId::Record`** — and the
column totals standing under the new coordinate.

This is a gap my own migration opened: the pre-anr9 code compared slash-joined
strings, which could not distinguish a category change either but never had
`cats` to compare. improvise-anr9 made it a first-class part of the frame and
did not extend the staleness test to cover it.

Three parts, and the third is why it is a P1 rather than a redraw bug:

- **Every surviving position is stale, not the ones whose tuples differ.** A
  `cats` change re-identifies the whole axis at once, including the positions
  whose labels are byte-identical — which is exactly the set the tuple
  comparison keeps.
- **The totals go too, whichever axis moved.** The column arm already dropped
  each total as its column went stale; the row arm did not, and a column total
  sums rows that have all been re-identified. It is `apply`'s job and not the
  producer's, because an update must be correct with nothing behind it —
  gotcha 33(b), documented in the comment immediately above this function.
- **The stale `CellId` is the sharp half.** An axis swap is the case that
  bites: two categories may legally share an item name, so the preserved id
  stays *valid* and addresses the other record. A client goes on offering an
  edit that writes to the cell the old coordinate named — silent, invisible in
  any rendering, the family of gotcha 18's drill snapshot. (A category
  *rename* in the document is milder: every `RecordHash` moves, so a stale id
  fails to resolve and is refused as `RecordGone`.)

**Tests are frame-only, which is the whole point**: a `RowFrame`/`ColFrame`
whose `cats` moved and whose `items` did not, with no `Cells` and no `Totals`
behind it, because the producer's follow-up is what would hide this. Same
shape as `a_cell_update_that_overtakes_its_labels_is_lost`. The `id` is
asserted separately from the text, and the negative arm
(`an_unchanged_category_leaves_the_cells_that_still_fit`) is load-bearing —
without it "blank on every frame update" would pass.

And it was wrong **end to end**, not only in `apply`:
`renaming_an_axis_category_resends_every_cell` drives the producer, which
diffs the cells against the *framed* mirror — so an `apply` that kept them
made `grid_updates` agree they matched and send **nothing at all**
(`left: []` under the injected fault). `project_diff` then assigns
`self.grid = desired` and the client holds re-identified cells with no update
in the stream that could ever correct them: wrong forever, the direction
`Dirty::of` is deliberately sloppy to avoid.

Faults injected, each watched to fail: `recategorized = false` (the pre-fix
behaviour) fails both frame-only tests and the producer test; dropping only
the trailing totals loop fails the row test alone, which is the arm that
needed it.

Neighbourhood re-checked as a class, as asked. `fit_to_labels` preserves by
position and is followed by this function on both frame arms; its other two
callers (`ColWidths`, `Totals`) do not change the frame. `body_from`,
`visual_of` and `group_of` are positional walks with no identity decision.
`header_labels`, `shows_level`, `shares_ancestry` and `header_level_widths`
are drawing and measurement over tuples, and a `cats` change with identical
tuples genuinely draws and measures the same. `grid_updates` compares whole
frames, so `PartialEq` already covered `cats` on the producer side — the
defect was only in what `apply` did with the update it received.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:21:12 -07:00
Edward Langley 846a76b931 chore(beads): three follow-ups from WU-A, and the 4zl7 ruling
improvise-gd83  P3 — AxisEntry emits a group header on ENTERING a group and no
                boundary on leaving one, so an ungrouped item after a grouped run
                inherits it. Copilot found it, PR #23 declined it: it is
                GridLayout's pre-existing rule, the TUI paints the same span, and
                fixing it would destroy the exact-buffer characterization that
                was that PR's only evidence the migration changed nothing. The
                test already asserts the wrong answer with the corrected
                expectation written beside it.

improvise-jq8l  P3 — web/app.js calls scrollIntoView nowhere, so a selection can
                sit outside the rendered window. Pre-existing, surfaced by the
                viewport-reservation argument, and not group-specific.

improvise-plcx  P2 — the browser harness renders web/app.js in three browsers but
                web/demo.improv has no groups and one category per axis, so the
                multi-level, group-span and collapse paths PR #23 added are the
                one part of gridTable no test sees. Would be the first test here
                able to see that code in a browser.

improvise-4zl7 retitled and re-scoped to the user's ruling: a fitted tree must
route per record and aggregate the results. The question was never about
categorical splits — aggregate-then-route is not route-then-aggregate, so every
fitted tree means one thing on a record row and another one row up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:16:35 -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 a685503902 test(host): a chain may write one record twice, and why that works
Two measures on one record is one record, so writing the same record twice in
a frame is both legitimate and likely. It works for a reason worth pinning
rather than rediscovering: **every check happens before any command is
applied**, so the second write is compared to the record's *pre-frame*
counter and not to the one its own sibling just bumped.

Were the checks interleaved with the applies — which is what the previous
commit's per-command record check would look like if it were moved into the
apply loop — the frame would refuse itself with `RecordMoved`. Proved by
injecting exactly that interleaving: this arm fails and nothing else does.

1291 passing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:09:09 -07:00
Edward Langley 243b9a9b6b fix(command): the record tier does not hoist to the frame
The independent review of PR #26 found a **P1 regression 90ea26e
introduced**, confirmed by a probe against a real `Host` rather than by
reading. Hoisting the basis check to the frame hoisted *all three tiers*, so
every mutation after the first was admitted with no record check at all:

    A composes [SetCell(North), SetCell(South)] against an honest basis
    B writes South = 99
    A's frame arrives  ->  accepted, South = 20, B's write gone, silently

At the base commit the second command was checked and the frame refused. This
is precisely the lost update ADR 001's record tier exists to prevent, and it
is invisible to the other two tiers because `SetCell` is `CellData` and moves
neither of them.

**It cannot be deferred into the clause-4 bead**, and that is the part worth
being sure of: clause 4 never fires on this frame, because *both writes
succeed*. Rollback is about partial application; this is a wrong answer,
fully applied.

**The per-command check was doing two jobs and the commit noticed one.** "Is
the client's picture of the *document* current" is a frame-level fact and
hoists correctly. "Is the client's picture of *this record* current" does
not: which records are at stake is a property of the command, which is why
`records_named` is an exhaustive match over `ModelCommand` (gotcha 43). So
every mutation now answers for its own records, against **the frame's**
basis — the one its first mutation stated, not each command's own field, so a
later command cannot claim a fresher picture than the frame was composed
under.

**Except the records the frame is creating**, or clause 3 is lost again. The
argument that motivated clause 3 is true of records a chain **creates** and
was over-generalised to records it merely **writes** — and those are the ones
the tier is for. The exemption is therefore stated over a *property*, not a
list of commands: a record that **neither resolves in the document nor is
claimed by the basis** is one the frame is about to create
(`will_be_created_by_the_frame`). Every other combination still answers,
including "does not resolve but *is* claimed", which is a client believing in
something that has been removed and stays `RecordGone`. It is asked only
inside a frame — a first command has no predecessor that could have created
anything, so a lone write to a missing record is refused as before.
`stale_record` itself is untouched: one home for what the four combinations
mean, and the frame path differs only in *which* records it asks about.

Three arms, because a fix that refuses everything passes the first alone and
a fix that checks nothing passes the other two. Two mutants injected:
dropping the per-command record check fails
`a_later_write_in_a_chain_still_answers_for_its_own_record`; dropping the
create exemption fails `a_data_dependent_chain_lands_in_order`.

**The fixture is the finding underneath the finding.** `reduce::caught_up`
builds the claim for *one command*, so a frame's records beyond the first are
simply absent from it and come back `RecordUnstated` — my first probe failed
for that reason and not the one it was testing. A real client calls
`BasisCache::claim` once with every record its frame names, which the new
`frame_basis` test helper reproduces.

Also from the review:

- **`a_frame_whose_later_mutation_fails_is_not_yet_undone` did not pin the
  hole it is named after** — the repo's signature defect sitting on the one
  test whose stated job is to fail loudly if anyone believes the hole is
  closed. It asserted North was removed and nothing was rejected, both true
  of any *wholly successful* frame; the reviewer retargeted the second
  command to a live record and it still passed. The missing assertion is the
  one that names the hole: **the second mutation did not land.** Verified by
  making the frame wholly successful, which now fails it.

- **Three surviving statements of the removed rule**, gotcha 48's grep rule
  again: `basis.rs` (whose "the other TWO classes" over "TWO classes live
  here" gave it away), `host.rs`, and `browser_round_trip.rs` — whose live
  one-mutation assertion is kept but **re-justified as a characterization of
  what `keys::commit` emits**, not a protocol constraint, so a future reader
  does not take it for one.

- `ApplyAndClearDrill`'s `dirty = true` is gone: it declares `Model(CellData)`
  and `apply_effects` raises the flag off that. Its tests drove `apply`
  directly and so measured the effect in isolation rather than the path
  production takes; routed through `apply_effects`, one of them reveals that
  **a drill exited with no edits marks the document dirty** — the same
  over-approximation a failed `LoadModel` gets, true since the flag moved and
  invisible until now. Recorded in the assertion.

- `a_loaded_document_is_dirty_because_its_path_still_names_the_old_one` pins
  the `LoadModel` behaviour change and *why* it is right: `LoadModel` does not
  move `file_path`, so the in-memory document really is a different one from
  the file the path names. It gates `autosave_if_needed` and the `:q`
  refusal, so it is worth a test.

- `ui/reduce.rs`'s module doc pointed hosts at `Sessions::apply`, which has
  had no production caller since `Host::commands` began answering the whole
  frame. Now points at `apply_checked`.

Merged `main` (a15cc73). One semantic conflict git merged cleanly: PR #25
added `effect::MarkDirty` in `cmd/fit.rs`, which no longer exists — dropped,
since `AddFormula` beside it is a `Model` effect.

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-20 00:08:02 -07:00
Edward Langley 819a165802 Merge remote-tracking branch 'gh/main' into wave/c-host-and-save 2026-09-20 00:03:31 -07:00
Ed Langley a15cc737c2 Merge pull request #25 from fiddlerwoaroof/wave/m-gp-tree
WU-M: wire gp-tree into improvise (improvise-szrt, improvise-vx3; improvise-21d's last open child).

improvise-4zl7 stays open and grew: a formula evaluated at an aggregated cell routes on the aggregate, so aggregate-then-route is not route-then-aggregate and EVERY fitted tree — numeric splits included — means one thing on a record cell and another one row up. The bead asked this for categorical splits; it is a question about the feature.
2026-09-19 23:52:22 -07:00
Ed Langley 5f90727738 Merge pull request #29 from fiddlerwoaroof/chore/orch-beads
chore(beads): closures from PRs #22 and #24, and one deletion
2026-09-19 23:52:05 -07:00
Edward Langley 58e8a43893 perf+test: borrow the width rule, and pin the consecutive-header case
Copilot's eighth pass; both correct, and the second caught a doc that had
drifted from its own code in this branch.

**`compute_row_header_width` cloned every row tuple on every keypress.** It
is reached from `App::cmd_context`, so building an owned `AxisFrame` just to
ask for widths was a full-axis copy per key — where the pre-anr9 version
only borrowed `row_items`. The rule moves out to
`improvise_protocol::header_level_widths`, which takes an **iterator of
borrowed tuples**; `AxisFrame::header_widths` is a one-line call and so is
the layout adapter. One rule, no copy.

**The consecutive-header rule had no test, and the comment describing it was
already wrong.** `axis_frame`'s doc said consecutive headers all come, but
the compaction added earlier in this branch — keep only the nearest header
*above* the window — made that false on one side. It is two rules, not one:
above the window nothing is painted so only the nearest is kept; between two
in-window rows all of them come, because only the nearest wins `group_of`
but a dropped `[Hidden]` header is the only thing on screen the user can
click to get those rows back. Doc corrected, and
`consecutive_headers_between_two_rows_all_come` pins the second arm — the
first is `a_window_opening_inside_a_group_names_it_without_painting_it`.

Injected: compacting unconditionally drops `[Hidden]` and fails it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:51:46 -07:00
Edward Langley 47e7f3403e style(command): match the flag, not a tuple of it and a wildcard
`match (answered, cmd)` with `_` in both arms said nothing the flag did not.
No behaviour change.

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

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

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

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

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

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

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

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

Evidence, both faults injected:

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:46:52 -07:00
Edward Langley d9a4790d4d fix: an empty group name is a name; and stop reserving the group rows
Copilot's seventh pass. One applied, one applied by *withdrawing* the
previous fix rather than patching it again.

**`RenderColGroup::label` is an `Option<String>`.** `""` was the
ungrouped-run sentinel, but an empty string is a legal group *name* —
`Category::add_item_in_group` accepts one and the TUI draws its indicator
like any other — so a real group serialized identically to no group at all
and the page dropped its indicator on a truthiness test. `None` is now the
run in no group, and `app.js` checks for null.

**Inline group rows are no longer reserved in the viewport, and the reason
is written down.** Copilot was right twice in a row here, about two
opposite mistakes. Counting the current group rows is a feedback loop (a
smaller window crosses fewer groups and asks for a larger one). Keeping the
maximum is a ratchet: one group header per data row stores an oversized cost
on the first oversized projection and clamps the grid to a single row
*permanently*. Both were on this branch.

The third option — invent a density heuristic — is the one this repo warns
against, in a file no test here can execute. So the reservation keeps only
what depends on the axes and not on the window: the header lines, the group
line, the row-header width. The residue is an over-request bounded by the
groups in view, and the off-screen-selection gap it leaves is neither new
nor specific to groups — nothing in this page calls `scrollIntoView` at all.
Filed rather than guessed at.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:45:08 -07:00
Edward Langley 3ba35a0ed8 chore(beads): close WU-J's three beads, narrow xf1 to Safari, file the watchdog bug
improvise-vvl3, improvise-vhcz and improvise-hmws closed by PR #24.

vvl3's close records the finding that outlives the harness: a contract nobody
can execute is prose. storage_contract had been cited in gotcha 28 for months
as the spec OpfsFs must satisfy, and the first time OpfsFs was handed to it, it
failed on its FIRST assertion — on a semantic disagreement about what list
means, sitting in a trait doc everyone had read.

hmws needed neither of its two proposed fixes: an orphan-rule refusal is a fact
about where you are standing, not about the pair of types. The impl is simply
allowed in the crate that owns the trait.

improvise-xf1 narrowed rather than closed, and raised to P2. Chromium and
Firefox now run the full battery; Playwright's WebKit provides no OPFS at all,
so Safari is untested and the ticket's own 16.4+ claim is unverified. Parked as
a runtime probe-skip so it starts running when that changes.

improvise-xps8 filed: the handshake watchdog overwrites a genuine mid-session
disconnect eight seconds later. A third bug in the same family, and it survives
improvise-taxx's fix — confirmed by simulating that fix rather than assuming.
Not a CI guard: its trigger is the host's network stack, and an expected-failure
whose trigger is platform-dependent inverts CI's meaning rather than flaking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:44:52 -07:00
Edward Langley 18ea9d6ef5 Merge gh/main into chore/orch-beads 2026-09-19 23:44:25 -07:00
Ed Langley 8528843341 Merge pull request #24 from fiddlerwoaroof/wave/j-browser-harness
WU-J: a headless browser harness, and the first execution of OpfsFs (improvise-vvl3, improvise-vhcz, improvise-hmws; improvise-xf1 stays open for Safari).

storage_contract had been cited for months as the written-down spec OpfsFs must satisfy in a browser. The first time OpfsFs was handed to it, it failed on its first assertion.
2026-09-19 23:44:15 -07:00
Edward Langley 6b7a044a01 Merge remote-tracking branch 'gh/main' into wave/c-host-and-save 2026-09-19 23:43:18 -07:00
Edward Langley 02726e002d Merge gh/main into chore/orch-beads 2026-09-19 23:42:06 -07:00
Ed Langley 391599476b Merge pull request #27 from fiddlerwoaroof/wave/g-command-layer
WU-G: the keymap and command layer (improvise-th7m, lae, ct5, 2zq; git/aaw declined with evidence; 0hb deferred with its design note).

Includes the ejection fix the 2zq ruling called for — a virtual on an axis is a configuration, not a vacancy — which also closed a live bug where adding a second regular category evicted _Measure from Column and made every measure vanish from the demo's own view.
2026-09-19 23:41:59 -07:00
Edward Langley 3bcc12609a fix(fit): the fit's own name must not already mean something
Copilot's fifth pass, one finding and it is right.

`into` was checked against `measure_item_names()`, and `Expr::Ref` does not ask
that question: `Model::find_item_category` finds a **regular category's** item
before it falls back into `_Measure` (gotcha 7). So naming the fit after an item
of some category installed cleanly, and every later formula referring to the
fitted measure then read the category coordinate instead — silently, because
both resolve.

The check now asks the document the same question `Expr::Ref` will ask, which is
both stronger than the measure-only form and simpler than it. Two regressions:
one that a category item's name is refused, and one that earns the refusal by
asserting the property it protects — a second formula `Doubled = |Score (fit)| *
2` reads twice the fit, per record, against a real document.

1232 passing (+2); clippy, fmt and the wasm gate clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:41:38 -07:00
Edward Langley 4abadb8e6d test(harness): drop two expected-failures whose trigger is platform-dependent
An expected-failure is green when the bug is present and red when it is
absent. One whose trigger depends on the host therefore does not merely
flake — it **inverts CI's meaning**. These two failed the browser job twice
with "expected to fail, but passed", and the second time was *after* the
determinism work in the previous commit, which is the evidence that the
problem is not the assertions' precision.

Neither bug reproduces on the GitHub runner:

- improvise-m18f, where the unconditional `close` listener overwrites
  "cannot reach …" with "disconnected" — on the runner `close` does not
  follow `error` inside the window, even against a server that accepts the
  TCP connection and then refuses the upgrade.
- The handshake watchdog replacing a mid-session "disconnected" with
  "accepted the connection but sent no document" — on the runner it does not
  land, though the page demonstrably opened and then dropped, since both are
  asserted as setup.

The trigger for both is the **order and timing of `error`, `close` and a
timer on the host's network stack**. That is not a property of `web/app.js`
and not something this harness can pin, so pretending otherwise was the
mistake. Both are real, both were measured on macOS/Chromium, and the
measurement is recorded in the file with the exact status sequences so the
next person does not re-derive it — including the fact that the watchdog one
**survives** the m18f fix, because that fix is about which message `close`
writes rather than about the timer still running.

What remains is the portable half, and it is the half that was doing the
work: every message the page must write ("bad frame", "cannot reach",
"connected"/"disconnected", "sent no document"), the one case where the
watchdog must stay silent, and the two standalone-deployment tests. Filed
for the orchestrator; `web/app.js` is not this unit's file.

12 passing, 3 skipped (WebKit has no OPFS), 23s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 23:38:09 -07:00