ef95d950
docs: M11 close — convergence harness held 10/10, campaign table recorded
a73x 2026-08-09 21:22
Commit message
docs/decisions.md
| Old | New | ||
|---|---|---|---|
| @@ -1494,6 +1494,275 @@ tick a mutation checkbox that could not fire and said so instead of | |||
| 1494 | proceeding. One review round was NOT-APPROVED; everything else landed | 1494 | proceeding. One review round was NOT-APPROVED; everything else landed |
| 1495 | without rework cycles. | 1495 | without rework cycles. |
| 1496 | 1496 | ||
| 1497 | ## 2026-08-09 (M11 — e2e hardening: convergence, pins, soak, mutation campaign) | ||
| 1498 | |||
| 1499 | **Verdict: cleared, both legs.** The e2e suite has stopped asking whether | ||
| 1500 | a marker string appeared and started asking whether the screen the client | ||
| 1501 | painted equals the screen the daemon holds — **22 convergence points | ||
| 1502 | across 10 scenario checkpoints**, both counts asserted as literals at | ||
| 1503 | runtime, every scenario ending in `assert_converged` with each declined | ||
| 1504 | exception named in a comment at its own site. `SOAK_N=10 make soak` is | ||
| 1505 | **10/10 green on the code that ships**, ~116s a run. The campaign that | ||
| 1506 | grades the harness broke the product **18 times**, one mutation at a | ||
| 1507 | time, and ran every break against both assertion sets out of the *same* | ||
| 1508 | mutated binaries: the old suite caught **7 of 18**, every one of them | ||
| 1509 | through a counter or a marker; the new suite caught **13 of 18**; and the | ||
| 1510 | six-row delta — rows 1, 2, 3 (paint path), 9, 10 (prediction overlay), 17 | ||
| 1511 | (clip bound) — is entirely the byte-blind class the harness was built | ||
| 1512 | for, a wrong *picture* drawn from right *bytes*. Zero rows ended | ||
| 1513 | undecided: the five both-miss rows closed as one new unit assertion and | ||
| 1514 | four banked entries with written reasons. Spec: | ||
| 1515 | superpowers/specs/2026-08-09-m11-e2e-hardening-design.md. | ||
| 1516 | |||
| 1517 | ### The campaign table | ||
| 1518 | |||
| 1519 | The milestone's measurement, as M9's latency table was. Mutations lived | ||
| 1520 | only in a detached worktree, one at a time, restored and diff-verified | ||
| 1521 | between rows; both suites ran sequentially against the same build. The | ||
| 1522 | old suite is `test/e2e.sh` at `37ec262` — the last commit predating every | ||
| 1523 | M11 assertion — carried untracked as `e2e-old.sh`, with one deviation | ||
| 1524 | recorded below the table. | ||
| 1525 | |||
| 1526 | | # | site | mutation | old suite | new suite | disposition | | ||
| 1527 | |---|------|----------|-----------|-----------|-------------| | ||
| 1528 | | 0 | control | none (unmutated) | PASS | PASS | baseline | | ||
| 1529 | | 1 | paint path | delta paint drops each delta's last row | survived | caught: `base attach` — render diverges | demonstrated | | ||
| 1530 | | 2 | paint path | delta rows painted one row too low (CUP row+2) | survived | caught: `base attach` — render diverges | demonstrated | | ||
| 1531 | | 3 | paint path | full repaint preamble loses `\x1b[2J` | survived | caught: `quic epoch resync` — render diverges | demonstrated | | ||
| 1532 | | 4 | paint path | delta row's leading `\x1b[0m` stripped (SGR bleed) | survived | survived | banked: no grid can hold it (unit-pinned already) | | ||
| 1533 | | 5 | paint path | delta paint drops the `?2026` synchronized-update wrapper | survived | survived | new assertion: the `paintDeltaClipped` wrapper pin | | ||
| 1534 | | 6 | replica sync | delta painted and applied, but never fed to the replica | caught: `line mode: contradicted=1, want 0` | caught: `quic delta resume` — render diverges | both caught | | ||
| 1535 | | 7 | replica sync | snapshot's cols/rows prefix ignored (replica never resized) | survived | survived | banked: needs a resizing client fixture | | ||
| 1536 | | 8 | replica sync | resume attach quotes one seq MORE than the client holds | caught: `reconnect was served a snapshot (7 -> 8), not a delta` | caught: same line | caught by counters (both) | | ||
| 1537 | | 9 | prediction overlay | a CONFIRMED prediction keeps its underline painted | survived | caught: `line-mode prediction` — render diverges (styled leg only) | demonstrated | | ||
| 1538 | | 10 | prediction overlay | a made prediction is also fed into the replica engine | survived | caught: `raw mode` — render diverges | demonstrated | | ||
| 1539 | | 11 | delta production | delta payload omits its highest-numbered dirty row | caught: `line mode: contradicted=1, want 0` | caught: `base attach` — render diverges | both caught | | ||
| 1540 | | 12 | delta production | delta rows emitted in reverse row order | survived | survived | banked: order is not a state property | | ||
| 1541 | | 13 | delta production | `update()` never marks row 0 dirty | caught: `line mode: contradicted=1, want 0` | caught: `base attach` — render diverges | both caught | | ||
| 1542 | | 14 | seq/epoch | every second update reuses its predecessor's seq | caught: `raw mode: displayed=1, want 2` | caught: same line | caught by counters (both) | | ||
| 1543 | | 15 | seq/epoch | reconnect served a delta even when a snapshot is owed | caught: `restarted daemon served no snapshot (0); the stale seq was honoured` | caught: same line | caught by counters (both) | | ||
| 1544 | | 16 | seq/epoch | every snapshot prefix carries `epoch + 1` | caught: `reconnect was served a snapshot (7 -> 8), not a delta` | caught: same line | caught by counters (both) | | ||
| 1545 | | 17 | scrollback/clipping | client clip bound one row short: the last tty row is never painted | survived | caught: `reattach after kill` — render diverges (plain, `-sh-5.3$`) | demonstrated | | ||
| 1546 | | 18 | scrollback/clipping | resync no longer leaves scroll view (`scroll_pages = 0` deleted) | survived | survived | banked: needs a pty-driving client fixture | | ||
| 1547 | | 19 | paint path | every SGR run stripped from delta rows at the client paint | not run (scaffolding retired) | caught: `styled content` — render diverges (styled leg only) | gate for the styled scenario | | ||
| 1548 | |||
| 1549 | Every "render diverges" cell is the suite's own failure line, `e2e FAIL: | ||
| 1550 | NAME: client render diverges from daemon grid`, named by the scenario it | ||
| 1551 | fired in. Row 19 is not one of the 18 product mutations: it is the gate | ||
| 1552 | that justified the styled scenario Task 12 added, and its old-suite | ||
| 1553 | column is honestly blank — the scaffolding was retired with the worktree, | ||
| 1554 | and a marker grep cannot see a colour by construction. | ||
| 1555 | |||
| 1556 | **Tally: six demonstrated** (1, 2, 3, 9, 10, 17 — new caught, old | ||
| 1557 | survived), **three both-caught** (6, 11, 13), **four caught by counters in | ||
| 1558 | both columns** (8, 14, 15, 16), **five both-miss** (4, 5, 7, 12, 18), all | ||
| 1559 | five closed below. The control ran first and green on both suites at | ||
| 1560 | main's tip, 114s each: a campaign whose baseline is red measures nothing. | ||
| 1561 | |||
| 1562 | **The deviation in the old suite, recorded because it flatters nobody.** | ||
| 1563 | `e2e-old.sh` carries one line that is not the 37ec262 original — the | ||
| 1564 | burst scenario's keystroke interval, bumped 0.2 → 0.25, the same fix | ||
| 1565 | landed on main in 2bc64e8. Left unpatched, that defect (below) would have | ||
| 1566 | reported false "caught" verdicts on the old suite's burst line and | ||
| 1567 | inflated the old column. Nothing else was touched: the old suite's | ||
| 1568 | assertions are the object of comparison, so they stand exactly as they | ||
| 1569 | were. | ||
| 1570 | |||
| 1571 | **Where the delta actually is.** Rows 1-10 mutate the client and rows | ||
| 1572 | 11-16 mutate the daemon, and the halves invert cleanly. Client-side | ||
| 1573 | breaks are mostly invisible to counters, because the counters live on the | ||
| 1574 | client and a client that lies to the screen still counts honestly. | ||
| 1575 | Daemon-side breaks are the opposite: the daemon is upstream of both the | ||
| 1576 | replica and the client's prediction bookkeeping, so five of those six | ||
| 1577 | trip a counter in *both* columns. That is not the convergence checks | ||
| 1578 | failing to earn their keep — it is the honest observation that the old | ||
| 1579 | suite's blind spot was the client, not the server, and the new checks are | ||
| 1580 | an instrument pointed at exactly that spot. | ||
| 1581 | |||
| 1582 | ### Defects found and fixed en route | ||
| 1583 | |||
| 1584 | **The burst knife-edge (2bc64e8) — a timing margin is a number you | ||
| 1585 | compute, not a sentence you write.** `delaypipe` drains one chunk per | ||
| 1586 | `DELAY_MS` serially in each direction, so burst keystrokes closer | ||
| 1587 | together than `DELAY_MS` queue, and each ages `(DELAY_MS - interval)` | ||
| 1588 | longer than the one before it: | ||
| 1589 | |||
| 1590 | ``` | ||
| 1591 | age at which keystroke k is confirmed = 2*DELAY_MS + k*(DELAY_MS - interval) | ||
| 1592 | ``` | ||
| 1593 | |||
| 1594 | At the old 200ms interval the fifth prediction reached judgment at | ||
| 1595 | `600 + 4*100 = 1000ms` — `predict.expire_after_ms` to the millisecond, a | ||
| 1596 | coin flip decided by which of two `milliTimestamp()` calls in the same | ||
| 1597 | poll iteration saw the boundary first. It failed 4 runs in 20 on an | ||
| 1598 | **idle** machine; no load required, and no prediction was ever judged | ||
| 1599 | wrong. The comment above the scenario had claimed margin on both sides, | ||
| 1600 | and that margin had never been computed. At 250ms the fifth lands at | ||
| 1601 | 800ms, measured 798-799 across 12 runs. The same formula, applied | ||
| 1602 | backwards, retroactively explains the M9-era `DELAY_MS=400` collapse | ||
| 1603 | recorded above under "the burst/expiry ceiling": it puts the SECOND | ||
| 1604 | keystroke at 1000ms, which is the `confirmed=1` that was diagnosed then | ||
| 1605 | as a queueing effect and is now a closed-form one. | ||
| 1606 | |||
| 1607 | A second rule came out of the same bug: **`expired` increments | ||
| 1608 | `contradicted` through the shared `abandonAll` tail**, so | ||
| 1609 | `contradicted >= expired` is an invariant and a burst assertion that | ||
| 1610 | checks `contradicted` first reports an age-out as "a prediction judged | ||
| 1611 | wrong" — a different defect with a different cause, and the reason this | ||
| 1612 | flake was first investigated as a reconcile bug. The burst assertions now | ||
| 1613 | check `expired` first. | ||
| 1614 | |||
| 1615 | **The scroll-suppression reconnect bug (412f38f) — found by the campaign | ||
| 1616 | on UNMUTATED code.** Reading `client.zig` in order to grade row 18 turned | ||
| 1617 | up a real defect in HEAD: the resync path clears `scroll_pages` but never | ||
| 1618 | calls `overlay.setScrollMode(false)`, and `flush()` does not clear the | ||
| 1619 | mode — it drops pending and retired predictions and nothing else. A | ||
| 1620 | reconnect taken while scrolled therefore leaves the client painting live | ||
| 1621 | rows with the overlay still suppressing, and the "any other key leaves | ||
| 1622 | scroll mode" branch can never rescue it because that branch is guarded by | ||
| 1623 | `scroll_pages > 0`, which the reconnect has just made false. Local echo | ||
| 1624 | prediction is off for the rest of the session, silently. Fixed | ||
| 1625 | client-side with one call next to the existing `scroll_pages = 0` — | ||
| 1626 | deliberately not by making `flush()` clear the mode, because every | ||
| 1627 | snapshot flushes and a snapshot is not a reason to leave history; the | ||
| 1628 | overlay would start deciding where the viewport is, which is the client's | ||
| 1629 | business. Pinned where the property can be reached: `a flush leaves | ||
| 1630 | scroll mode exactly where it found it` (src/predict.zig) holds the | ||
| 1631 | contract in both directions. The end-to-end pin — "a reconnect while | ||
| 1632 | scrolled leaves prediction working" — rides the banked pty-fixture debt | ||
| 1633 | below and is **not** claimed here. | ||
| 1634 | |||
| 1635 | **Phase 1 landed with zero convergence failures, and one of those zeroes | ||
| 1636 | is evidence.** The plan named a candidate defect in advance: the | ||
| 1637 | raw-mode scenario's expired prediction (`j`), suspected since M9 of | ||
| 1638 | leaving a phantom underlined glyph no repaint ever removed. Twenty-two | ||
| 1639 | convergence points went green on first placement, styled leg included, | ||
| 1640 | which proves the client does repaint on expiry the way it already does on | ||
| 1641 | contradiction. A suspected gap that is checked and found clean is a | ||
| 1642 | result, not a non-event — the alternative is carrying the suspicion | ||
| 1643 | forward for another milestone. | ||
| 1644 | |||
| 1645 | ### Findings that reshaped what we thought the code was | ||
| 1646 | |||
| 1647 | - **The client has no seq-continuity logic at all** (row 8). The | ||
| 1648 | specified mutation — "remove the client's gap detection so a stale | ||
| 1649 | delta is applied" — could not be written, because there is no such | ||
| 1650 | code: the delta arm sets `last_seq` and applies unconditionally, and | ||
| 1651 | every gap decision is the daemon's, in `sendResync`. Expressed instead | ||
| 1652 | from the one client-side lever that exists (quote `last_seq +| 1`), the | ||
| 1653 | daemon's range guard noticed the lie and served a snapshot — the safe | ||
| 1654 | answer — so the screen rendered perfectly, all convergence points | ||
| 1655 | agreed, and only the counter spoke. A delta silently applied across a | ||
| 1656 | gap is **unreachable from the client**; the property is defended | ||
| 1657 | entirely by the daemon and is already mutation-proven there. This is | ||
| 1658 | the exact complement of rows 1-3, and a fourth proof of the standing | ||
| 1659 | entry "counters are asserted because markers are blind to *how* a | ||
| 1660 | resume was served". | ||
| 1661 | - **ghostty's formatter closes every styled row it emits** (row 4). The | ||
| 1662 | campaign's own prescription for row 4 was a styled scenario, on the | ||
| 1663 | reading that the styled leg was the right instrument handed no | ||
| 1664 | specimen. The scenario landed, the mutation was re-run, and it still | ||
| 1665 | survived — because `dumpVtRow`'s output both opens and closes with a | ||
| 1666 | reset ("style is reset before newline to prevent background colors | ||
| 1667 | from" bleeding, in ghostty's own test suite). Verified on the wire, not | ||
| 1668 | inferred: the delta row for `styled-red` carries two resets and the | ||
| 1669 | mutation strips only ours. So our leading reset is redundancy against a | ||
| 1670 | live pen that no byte source in the client can produce, and the only | ||
| 1671 | pen it could protect against belongs to the HOST terminal — which no | ||
| 1672 | rendered-grid comparison can ever hold, since the render helper replays | ||
| 1673 | into a fresh engine at the default pen by construction. The unit test | ||
| 1674 | at `src/client.zig:1837` is the real guard and fails under the mutation | ||
| 1675 | today; the bank names the dependency guarantee it rests on. | ||
| 1676 | - **Catch parity is not diagnosis parity** (rows 11 and 13). Both suites | ||
| 1677 | catch both mutations, so the disposition column reads "both caught" and | ||
| 1678 | says nothing interesting. What the columns hide: the old suite fails | ||
| 1679 | eleven scenarios in, at `line mode: contradicted=1, want 0` — a | ||
| 1680 | prediction was contradicted because the row that would have confirmed | ||
| 1681 | it never arrived, which is a symptom three inferential steps from the | ||
| 1682 | cause — while the new suite fails at `base attach`, the first scenario, | ||
| 1683 | and **prints the missing row**. Same verdict, eight scenarios of | ||
| 1684 | runtime and the entire distance between "a counter is off" and "this | ||
| 1685 | row is missing". Diagnosis cost is part of a suite's value even where | ||
| 1686 | catch parity is exact, and a table with a disposition column cannot | ||
| 1687 | show it, which is why this paragraph exists. | ||
| 1688 | |||
| 1689 | ### Accepted gaps, and banked by M11 | ||
| 1690 | |||
| 1691 | The five both-miss rows closed as **one new assertion and four banks**, | ||
| 1692 | zero undecided. The banks fall in two classes and neither is a weak | ||
| 1693 | convergence check: rows 4, 5 and 12 are properties **no rendered grid can | ||
| 1694 | represent**, and rows 7 and 18 are branches **no fixture can reach**. | ||
| 1695 | |||
| 1696 | - **A pty-driving client fixture — now the suite's single largest | ||
| 1697 | coverage debt.** Rows 7 and 18 both wait on it. Every e2e client | ||
| 1698 | captures stdout to a file, so `ttySize` returns null and the client | ||
| 1699 | runs at the non-tty default of 80x24, which is also the daemon's: the | ||
| 1700 | snapshot-apply resize branch is dead code for the whole run, and no | ||
| 1701 | assertion of any kind could grade row 7. Scroll view is the same story | ||
| 1702 | from the other side — entering it requires stdin to deliver | ||
| 1703 | `\x1b[5;2~` in one read, which no suite contains, so `scroll_pages` is | ||
| 1704 | invariantly 0 and deleting an assignment of 0 to it is a semantic | ||
| 1705 | no-op. Neither is drivable from a unit test without a refactor this | ||
| 1706 | milestone should not make: the mutated statements sit inline in | ||
| 1707 | `session()`, which takes a live transport, two file descriptors and a | ||
| 1708 | run loop, has one caller and no test. One fixture — a client that owns | ||
| 1709 | a pty — makes both branches reachable and both mutations gradeable, and | ||
| 1710 | would also carry the end-to-end pin the 412f38f fix is missing. | ||
| 1711 | - **Row 12: row order is not a state property.** `composeDelta` emits | ||
| 1712 | `CUP(row+1);1H` + `EL(2)` + the row's bytes for each row it iterates, | ||
| 1713 | so the payload is a *set* by construction and permuting it is a no-op | ||
| 1714 | on the resulting grid. Asserting an emission order would forbid an | ||
| 1715 | equivalent implementation while pinning nothing a user can observe, | ||
| 1716 | which is the wrong trade for a wire format still moving; structural | ||
| 1717 | corruption is already rejected by the `seen != hdr.row_count` check. If | ||
| 1718 | row order ever becomes load-bearing — relative addressing, a | ||
| 1719 | scroll-region optimization — it becomes a protocol property and belongs | ||
| 1720 | to a `composeDelta` unit test on the composed byte string. | ||
| 1721 | - **The debugger's observation, unproven and owed a design pass.** The | ||
| 1722 | client calls `overlay.expire()` before reading frames that arrived in | ||
| 1723 | the same poll wakeup, so a prediction can be abandoned while its | ||
| 1724 | confirming evidence is sitting readable in the buffer. This came out of | ||
| 1725 | reading, not from a failing sample — **no spurious contradiction | ||
| 1726 | actually occurred** in this milestone, and the raw-mode expiry | ||
| 1727 | behaviour was proven correct by the styled leg. It touches the retired | ||
| 1728 | repaint list and the ordering of the poll loop, so it needs its own | ||
| 1729 | design pass rather than a patch; it is plausibly the same territory as | ||
| 1730 | M9's banked input-ack item, which remains the proper fix if spurious | ||
| 1731 | contradiction ever does appear. | ||
| 1732 | - **ASAN or valgrind over the QUIC tests** — carried from M9 unchanged, | ||
| 1733 | and unchanged is the point: this campaign was an e2e mutation campaign | ||
| 1734 | and says nothing about use-after-free. M9's two reasoned-not-pinned | ||
| 1735 | `deinit` orderings are still owed the only honest instrument for them. | ||
| 1736 | - **The unit-layer mutation sweep** — carried, and the campaign supplied | ||
| 1737 | evidence that it would pay. Two of this milestone's mutations were | ||
| 1738 | resolved by pins at the *unit* layer, not the e2e one (the `?2026` | ||
| 1739 | wrapper, which was pinned at no layer at all despite an assumption to | ||
| 1740 | the contrary; and the delta row's leading reset, where the existing | ||
| 1741 | pin turned out to be the whole guard). Both were found by aiming an | ||
| 1742 | e2e campaign at the product and discovering the unit layer's shape by | ||
| 1743 | accident. A sweep aimed at the unit layer directly would not need the | ||
| 1744 | accident. | ||
| 1745 | |||
| 1746 | ### Process note: the recording rule survived contact with a wrong premise | ||
| 1747 | |||
| 1748 | The rule was "every both-miss ends as a new assertion (mutation re-run to | ||
| 1749 | prove it fires) or a banked entry with a written reason — no third | ||
| 1750 | bucket, because 'noted' without a decision is the failure mode the | ||
| 1751 | campaign exists to prevent". It held, and the interesting part is *how*: | ||
| 1752 | one of the five rows had its prescribed disposition **disproved by | ||
| 1753 | attempting it**. Row 4 was filed as the single debt the convergence | ||
| 1754 | checks could pay, the fix being a styled scenario; the scenario landed, | ||
| 1755 | the mutation was re-run against it, and it survived — the premise was | ||
| 1756 | wrong, and the row moved to the "no grid can hold it" pile with a | ||
| 1757 | dependency guarantee named. A prescription that can be executed and found | ||
| 1758 | wrong is worth more than one that is merely plausible, and a rule that | ||
| 1759 | demands re-running the mutation is what forces the discovery instead of | ||
| 1760 | booking the fix as done. The scenario itself then had to justify its | ||
| 1761 | continued existence on its own terms and was gated by a new mutation | ||
| 1762 | (row 19) aimed at what it actually covers; before it, the byte-exact leg | ||
| 1763 | had exactly one specimen source in the entire corpus — the prediction | ||
| 1764 | overlay's own underline. | ||
| 1765 | |||
| 1497 | ## Open (owed by later milestones) | 1766 | ## Open (owed by later milestones) |
| 1498 | 1767 | ||
| 1499 | - Scrollback retention *tuning*. The policy itself was decided in M1 and | 1768 | - Scrollback retention *tuning*. The policy itself was decided in M1 and |
docs/roadmap.md
| Old | New | ||
|---|---|---|---|
| @@ -5,9 +5,12 @@ The forward view, one item per line, ranked. History and evidence live in | |||
| 5 | this file at each milestone close and whenever the queue reorders; the | 5 | this file at each milestone close and whenever the queue reorders; the |
| 6 | queue's order is set by the user, not by this file. | 6 | queue's order is set by the user, not by this file. |
| 7 | 7 | ||
| 8 | **Now:** M1–M10 complete; `v0.0.1-2` published as a Linux tarball; in | 8 | **Now:** M1–M11 complete; `v0.0.1-2` published as a Linux tarball; in |
| 9 | field trial on real VMs. Trial feedback outranks everything below — | 9 | field trial on real VMs, and the trial has started producing the queue. |
| 10 | what actually hurts in use is better data than any of this ranking. | 10 | Trial feedback outranks everything below — what actually hurts in use is |
| 11 | better data than any of this ranking, and the three items at the top of | ||
| 12 | the friction tier are there because they hurt this week, not because | ||
| 13 | they were predicted. | ||
| 11 | 14 | ||
| 12 | ## M9 — prediction (speculative local echo) — complete | 15 | ## M9 — prediction (speculative local echo) — complete |
| 13 | 16 | ||
| @@ -47,12 +50,50 @@ not fail, and a third cross-binary constant drift — all fixed, all with | |||
| 47 | the rule recorded ("assert the literal, never the constant the code | 50 | the rule recorded ("assert the literal, never the constant the code |
| 48 | under test reads"). | 51 | under test reads"). |
| 49 | 52 | ||
| 53 | ## M11 — e2e hardening — complete | ||
| 54 | |||
| 55 | **Verdict: cleared, both legs.** The suite has stopped asserting that a | ||
| 56 | marker string appeared and started asserting that the screen the client | ||
| 57 | painted equals the screen the daemon holds: **22 convergence points over | ||
| 58 | 10 scenario checkpoints**, both pinned as literals, every scenario ending | ||
| 59 | in a convergence check with the declined exceptions named where they are | ||
| 60 | declined — and `SOAK_N=10 make soak` at **10/10 on the shipping code**, | ||
| 61 | ~116s a run. Then the harness was graded the only honest way, by breaking | ||
| 62 | the product 18 times and running each break against both assertion sets | ||
| 63 | out of the same binaries: old suite **7 of 18**, all via counters and | ||
| 64 | markers; new suite **13 of 18**; the six-row delta is all paint, overlay | ||
| 65 | and clip — the byte-blind class, a wrong picture drawn from right bytes | ||
| 66 | (decisions.md, M11, which carries the table in full). Zero rows ended | ||
| 67 | undecided. En route the campaign found a real bug in unmutated code — a | ||
| 68 | reconnect taken while scrolled left prediction silently off for the rest | ||
| 69 | of the session — and a computed timing knife-edge in the burst scenario | ||
| 70 | that had been sitting on the expiry bound exactly, whose formula also | ||
| 71 | explains the M9-era `DELAY_MS=400` collapse retroactively. The rule | ||
| 72 | extracted: **a timing margin is a number you compute, not a sentence you | ||
| 73 | write**. | ||
| 74 | |||
| 50 | ## Trial friction, likely next — and still outranked by trial feedback | 75 | ## Trial friction, likely next — and still outranked by trial feedback |
| 51 | 76 | ||
| 52 | - **Attach auto-start**: `muxd proxy` and local `mux` call the spawn | 77 | The first three are field findings from this week's real usage on a |
| 53 | helper `muxd start` already uses, so `mux user@host` works on a box | 78 | jump-host box, not predictions. They are one natural bundle and the |
| 54 | where nothing is running. Sketched in the M10 spec; the helper is | 79 | likely next cut; the order within it is the user's call. |
| 55 | built and shipping. | 80 | |
| 81 | - **Attach auto-start** — now field-motivated, not merely sketched. | ||
| 82 | `mux user@host` against a box where no daemon is running fails with | ||
| 83 | `muxd proxy: cannot connect`, which is a correct message about the | ||
| 84 | wrong thing: the user asked for a session, not for a daemon. `muxd | ||
| 85 | proxy` and local `mux` call the spawn helper `muxd start` already | ||
| 86 | uses. Sketch banked in the M10 spec; the helper is built and shipping. | ||
| 87 | - **`muxd stop`** — the lifecycle verb is simply missing. `muxd start | ||
| 88 | --quic` against an already-running default daemon says to stop it | ||
| 89 | first, and there is no way to do that but find the pid yourself. Small | ||
| 90 | on its own; it stops being small the moment auto-start ships, because | ||
| 91 | auto-start makes socket-squatting common. | ||
| 92 | - **Error-message audit** — the `--via` failure hint "(is muxd installed | ||
| 93 | on the host?)" guessed wrong when ssh itself failed on a host-key | ||
| 94 | rejection, and sent the reading in the wrong direction. The rule to | ||
| 95 | apply across every failure path: say what happened, name the way out, | ||
| 96 | and do not guess a cause a lower layer has already named. | ||
| 56 | - **ssh→QUIC handoff**: `muxd endpoint` prints port+key over ssh; | 97 | - **ssh→QUIC handoff**: `muxd endpoint` prints port+key over ssh; |
| 57 | client caches both, tries QUIC first under a ~2s deadline, and the | 98 | client caches both, tries QUIC first under a ~2s deadline, and the |
| 58 | coordination ssh stays alive as the fallback proxy. Full sketch — | 99 | coordination ssh stays alive as the fallback proxy. Full sketch — |
| @@ -62,6 +103,29 @@ under test reads"). | |||
| 62 | backoff step; measured ~4x win available on fast links, ~2x on slow | 103 | backoff step; measured ~4x win available on fast links, ~2x on slow |
| 63 | (decisions.md, M7). | 104 | (decisions.md, M7). |
| 64 | 105 | ||
| 106 | ## Test debt, banked by M11 | ||
| 107 | |||
| 108 | - **A pty-driving client fixture** — the suite's single largest coverage | ||
| 109 | debt, and the campaign is what sized it. Every e2e client captures | ||
| 110 | stdout to a file, so it runs at the non-tty 80x24 default and can | ||
| 111 | never enter scroll mode; two mutations (snapshot resize prefix, scroll | ||
| 112 | view exit on resync) are therefore not merely unobserved but | ||
| 113 | *unreachable*, and no assertion of any kind could grade them. One | ||
| 114 | fixture makes both branches gradeable and carries the end-to-end pin | ||
| 115 | the scroll-suppression fix is currently missing. | ||
| 116 | - **The unit-layer mutation sweep** — still banked, now with evidence it | ||
| 117 | would pay. Two of M11's findings were resolved by pins at the unit | ||
| 118 | layer rather than the e2e one (the delta paint's synchronized-update | ||
| 119 | wrapper, which turned out to be pinned at no layer at all, and the | ||
| 120 | delta row's leading reset, where the existing unit pin turned out to | ||
| 121 | be the whole guard). Both were discovered by accident, while aiming an | ||
| 122 | e2e campaign at the product. A sweep aimed at the unit layer would not | ||
| 123 | need the accident. | ||
| 124 | - **ASAN or valgrind over the QUIC tests** — carried from M9 unchanged, | ||
| 125 | and unchanged is the finding: an e2e mutation campaign says nothing | ||
| 126 | about use-after-free. M9's two reasoned-not-pinned `deinit` orderings | ||
| 127 | are still owed the only honest instrument for them. | ||
| 128 | |||
| 65 | ## Banked, ranked by expected win | 129 | ## Banked, ranked by expected win |
| 66 | 130 | ||
| 67 | 0. **Prediction polish, as a group** — backspace prediction (the | 131 | 0. **Prediction polish, as a group** — backspace prediction (the |
docs/superpowers/plans/2026-08-09-m11-campaign.md
| Old | New | ||
|---|---|---|---|
| @@ -1,781 +0,0 @@ | |||
| 1 | # M11 Phase 2 — the mutation campaign | ||
| 2 | |||
| 3 | Phase 1 taught the e2e suite to assert render-vs-dump convergence. This | ||
| 4 | campaign measures what that bought: break the code deliberately, one | ||
| 5 | mutation at a time, and run each break against BOTH the suite as it | ||
| 6 | stood before Phase 1 (markers only) and the suite as it stands now | ||
| 7 | (markers + convergence + count pins). The catch-rate delta between the | ||
| 8 | two columns is the harness's measured value — the same way M9 measured | ||
| 9 | prediction against its own controls, rather than asserting it had | ||
| 10 | helped. | ||
| 11 | |||
| 12 | This document is the record. It lives in the MAIN checkout because | ||
| 13 | results are not mutations; the mutations themselves never touch main. | ||
| 14 | |||
| 15 | ## Method | ||
| 16 | |||
| 17 | **Where.** Every mutation is applied in the worktree at | ||
| 18 | `/home/xanderle/code/rad/mux-m11-campaign` (detached at main's tip — | ||
| 19 | `main` itself is checked out by the primary working copy, so the | ||
| 20 | worktree cannot hold the branch). The main checkout is never mutated. | ||
| 21 | |||
| 22 | **One at a time.** Apply a single edit, build, run both suites, restore | ||
| 23 | with `git checkout -- src/ test/`, and verify `git diff --stat` is | ||
| 24 | empty before the next mutation begins. A mutation that does not compile | ||
| 25 | is not a mutation — rework it into a different expression of the same | ||
| 26 | break until it builds. | ||
| 27 | |||
| 28 | **How built.** Each mutation is built in the worktree with the pinned | ||
| 29 | toolchain, `~/Downloads/zig-x86_64-linux-0.15.2/zig build`. The system | ||
| 30 | zig (0.17-dev) cannot build this repo. Note that `make test` does not | ||
| 31 | rebuild the `zig-out/` binaries; the build step is not optional. | ||
| 32 | |||
| 33 | **Binary provenance.** Both suites run the SAME mutated binaries, out | ||
| 34 | of the worktree's `zig-out/bin`. The old suite is not re-run against | ||
| 35 | some separately built tree — the only variable between the two columns | ||
| 36 | is the assertion set. The suites are run SEQUENTIALLY, never | ||
| 37 | concurrently; both rely on `$$`-isolated temp paths, which makes | ||
| 38 | sequential runs safe and concurrent ones not. | ||
| 39 | |||
| 40 | **The old suite.** `OLD_SUITE_SHA = 37ec262` ("test: render helper | ||
| 41 | replays a client capture into a dump-format grid") — the last commit | ||
| 42 | whose `test/e2e.sh` predates every M11 assertion. Task 1 landed the | ||
| 43 | render helper but touched only `build.zig` and `test/render.zig`, so | ||
| 44 | the e2e suite at that sha is the pre-convergence one; extracted to | ||
| 45 | `e2e-old.sh` in the worktree it has zero `assert_converged` calls and | ||
| 46 | takes FOUR args (no RENDER). It is scaffolding and stays uncommitted. | ||
| 47 | |||
| 48 | **One deviation from a verbatim extraction.** `e2e-old.sh` carries the | ||
| 49 | burst scenario's keystroke interval bumped from `0.2` to `0.25` — the | ||
| 50 | same one-line fix landed on main in 2bc64e8, which found that typing at | ||
| 51 | 200ms places the burst's fifth prediction at exactly the 1000ms expiry | ||
| 52 | bound (measured 4/20 flake on an idle machine). Left unpatched, that | ||
| 53 | defect would report false "caught" verdicts on the old suite's burst | ||
| 54 | line and inflate the old column. Nothing else in the file was touched: | ||
| 55 | the old suite's assertions are the object of comparison, so they remain | ||
| 56 | exactly as they were. | ||
| 57 | |||
| 58 | **Recording rule.** Every mutation gets one row, and every row carries | ||
| 59 | an old-suite verdict, a new-suite verdict, and a disposition. A | ||
| 60 | mutation that BOTH suites miss is a FINDING, not a footnote: it is | ||
| 61 | resolved either as (a) a new assertion landed in this milestone, with | ||
| 62 | the mutation re-run to prove the assertion now fires, or (b) a banked | ||
| 63 | entry with a written reason the gap is accepted. There is no third | ||
| 64 | bucket — "noted" without a decision is the failure mode this campaign | ||
| 65 | exists to prevent. | ||
| 66 | |||
| 67 | **Hygiene.** Stranded daemons from a mutated build are killed by | ||
| 68 | tracked pid only, never by name or pattern. | ||
| 69 | |||
| 70 | ## Results | ||
| 71 | |||
| 72 | | # | site | mutation | old suite | new suite | disposition | | ||
| 73 | |---|------|----------|-----------|-----------|-------------| | ||
| 74 | | 0 | control | none (unmutated) | PASS | PASS | baseline | | ||
| 75 | | 1 | paint path | delta paint drops each delta's last row | survived | caught: `e2e FAIL: base attach: client render diverges from daemon grid` | demonstrated | | ||
| 76 | | 2 | paint path | delta rows painted one row too low (CUP row+2) | survived | caught: `e2e FAIL: base attach: client render diverges from daemon grid` | demonstrated | | ||
| 77 | | 3 | paint path | full repaint preamble loses `\x1b[2J` | survived | caught: `e2e FAIL: quic epoch resync: client render diverges from daemon grid` | demonstrated | | ||
| 78 | | 4 | paint path | delta row's leading `\x1b[0m` stripped (SGR bleed) | survived | survived | banked: no grid can hold it (unit-pinned already) | | ||
| 79 | | 5 | paint path | delta paint drops the `?2026` synchronized-update wrapper | survived | survived | new assertion: the `paintDeltaClipped` wrapper pin | | ||
| 80 | | 6 | replica sync | delta painted and applied, but never fed to the replica | caught: `e2e FAIL: line mode: contradicted=1, want 0` | caught: `e2e FAIL: quic delta resume: client render diverges from daemon grid` | both caught | | ||
| 81 | | 7 | replica sync | snapshot's cols/rows prefix ignored (replica never resized) | survived | survived | banked: needs a resizing client fixture | | ||
| 82 | | 8 | replica sync | resume attach quotes one seq MORE than the client holds | caught: `e2e FAIL: reconnect was served a snapshot (7 -> 8), not a delta` | caught: `e2e FAIL: reconnect was served a snapshot (7 -> 8), not a delta` | caught by counters (both) | | ||
| 83 | | 9 | prediction overlay | a CONFIRMED prediction keeps its underline painted | survived | caught: `e2e FAIL: line-mode prediction: client render diverges from daemon grid` (styled leg only) | demonstrated | | ||
| 84 | | 10 | prediction overlay | a made prediction is also fed into the replica engine | survived | caught: `e2e FAIL: raw mode: client render diverges from daemon grid` | demonstrated | | ||
| 85 | | 11 | delta production | delta payload omits its highest-numbered dirty row | caught: `e2e FAIL: line mode: contradicted=1, want 0` | caught: `e2e FAIL: base attach: client render diverges from daemon grid` | both caught | | ||
| 86 | | 12 | delta production | delta rows emitted in reverse row order | survived | survived | banked: order is not a state property | | ||
| 87 | | 13 | delta production | `update()` never marks row 0 dirty | caught: `e2e FAIL: line mode: contradicted=1, want 0` | caught: `e2e FAIL: base attach: client render diverges from daemon grid` | both caught | | ||
| 88 | | 14 | seq/epoch | every second update reuses its predecessor's seq | caught: `e2e FAIL: raw mode: displayed=1, want 2` | caught: `e2e FAIL: raw mode: displayed=1, want 2` | caught by counters (both) | | ||
| 89 | | 15 | seq/epoch | reconnect served a delta even when a snapshot is owed | caught: `e2e FAIL: restarted daemon served no snapshot (0); the stale seq was honoured` | caught: `e2e FAIL: restarted daemon served no snapshot (0); the stale seq was honoured` | caught by counters (both) | | ||
| 90 | | 16 | seq/epoch | every snapshot prefix carries `epoch + 1` | caught: `e2e FAIL: reconnect was served a snapshot (7 -> 8), not a delta` | caught: `e2e FAIL: reconnect was served a snapshot (7 -> 8), not a delta` | caught by counters (both) | | ||
| 91 | | 17 | scrollback/clipping | client clip bound one row short: the last tty row is never painted | survived | caught: `e2e FAIL: reattach after kill: client render diverges from daemon grid` (plain, `-sh-5.3$`) | demonstrated | | ||
| 92 | | 18 | scrollback/clipping | resync no longer leaves scroll view (`scroll_pages = 0` deleted) | survived | survived | banked: needs a pty-driving client fixture | | ||
| 93 | | 19 | paint path | every SGR run stripped from delta rows at the client paint | not run (scaffolding retired) | caught: `e2e FAIL: styled content: client render diverges from daemon grid` (styled leg only) | gate for the styled scenario | | ||
| 94 | |||
| 95 | **Control run (row 0).** Both suites green on unmutated code at main's | ||
| 96 | tip (2bc64e8), first attempt, no re-runs. Old suite: `e2e OK`, 114s. | ||
| 97 | New suite: `e2e OK (9 scenarios, 21 convergence points)`, 114s. A | ||
| 98 | campaign whose baseline is red measures nothing; this one measures. | ||
| 99 | |||
| 100 | **Rows 1-5 (paint path).** All five built on the first attempt with the | ||
| 101 | pinned toolchain; no compile reworks were needed, though row 1 had to be | ||
| 102 | written as a two-pass count-then-skip over `proto.deltaRowIterator` | ||
| 103 | rather than an `rows[0..n-1]` slice, because the delta rows arrive from | ||
| 104 | a streaming iterator and there is no slice to trim. No suite run was | ||
| 105 | re-run: every verdict below is a first-attempt result, and no scenario | ||
| 106 | failed in a place unrelated to its mutation, so there are no flake | ||
| 107 | attributions to make. | ||
| 108 | |||
| 109 | **Rows 1 and 2 — the structural-blindness demonstration.** Both are | ||
| 110 | caught by the new suite at the FIRST convergence point, `base attach`, | ||
| 111 | and both are plain-diff catches (the normalized grid differs, so the | ||
| 112 | styled leg never has to speak). The two diffs are mirror images and | ||
| 113 | worth keeping: dropping the last row leaves the client's grid missing | ||
| 114 | `sh-5.3$ printf ... / e2e-works`, while painting one row low leaves it | ||
| 115 | with an extra leading `sh-5.3$` and everything shifted down. The old | ||
| 116 | suite passes both, and passes them for exactly the reason the milestone | ||
| 117 | predicted: `e2e-works` is present in the byte stream either way, so a | ||
| 118 | marker grep sees a healthy session. Same bytes, wrong picture. | ||
| 119 | |||
| 120 | **Row 3 — the missing clear survives longer than expected.** Dropping | ||
| 121 | `\x1b[2J` does NOT fire at `base attach`; a full repaint over an empty | ||
| 122 | or already-correct screen is indistinguishable from a correct one. It | ||
| 123 | fires at `quic epoch resync`, the first scenario where a repaint lands | ||
| 124 | on a screen that already holds different content: the client's grid | ||
| 125 | keeps `quic-attach-ok`, `quic-reattached`, `quic-pre-tear` and | ||
| 126 | `quic-pre-restart` under the post-restart lines the daemon's grid ends | ||
| 127 | at. Plain diff again. The lesson for the harness is that convergence | ||
| 128 | coverage is only as good as the state the scenario leaves on screen | ||
| 129 | before the repaint — the check that caught this is the one whose | ||
| 130 | fixture had something to clear. | ||
| 131 | |||
| 132 | **Row 4 — both suites missed, and the reason is fixture data, not the | ||
| 133 | check.** The mutation does fire: delta rows really are built with a | ||
| 134 | leading `\x1b[0m` (pinned by the `paintDeltaClipped` unit test, which | ||
| 135 | asserts `\x1b[4;1H\x1b[2K\x1b[0mfits`), so the client really did strip | ||
| 136 | four bytes off every row it painted. But no scenario in either suite | ||
| 137 | ever emits a styled byte — no `tput`, no colour SGR, nothing that | ||
| 138 | leaves a live attribute at end of row. With no live style upstream, a | ||
| 139 | suppressed reset resets nothing and the styled leg has no divergence to | ||
| 140 | find. This is a gap in the CORPUS, not in the assertion: the styled | ||
| 141 | (`--vt`, byte-exact) comparison is the right instrument and is never | ||
| 142 | handed a specimen. Resolution per the recording rule: land a scenario | ||
| 143 | that emits styled output spanning two delta rows and re-run this | ||
| 144 | mutation to prove the styled leg fires. Until that lands the row stays | ||
| 145 | `pending`. | ||
| 146 | |||
| 147 | **Row 5 — both suites missed, and this one is honest to accept.** The | ||
| 148 | `?2026` wrapper is a flicker property: it changes WHEN the terminal | ||
| 149 | shows the paint, never WHAT it shows. The convergence harness replays | ||
| 150 | the client's stream into a grid, and a grid has no concept of tearing, | ||
| 151 | so no diff can exist by construction. Recorded expectation confirmed. | ||
| 152 | One correction to the going assumption, though: this is NOT unit-pinned | ||
| 153 | on the delta path. `src/client.zig` pins `?2026h` in two unit tests, at | ||
| 154 | `renderClipped` and at the prediction overlay, but the only | ||
| 155 | `paintDeltaClipped` unit test asserts row clipping and cursor clamping | ||
| 156 | and says nothing about the wrapper. So the delta path's synchronized | ||
| 157 | update is currently unpinned at every layer. Cheapest resolution is a | ||
| 158 | unit assertion at `paintDeltaClipped`, not an e2e one — e2e is the | ||
| 159 | wrong instrument for a property no grid can observe. | ||
| 160 | |||
| 161 | **Rows 6-10 (replica sync and the prediction overlay).** All five built | ||
| 162 | on the first attempt. No suite run was re-run: every verdict is a | ||
| 163 | first-attempt result, and each failure landed in a scenario the | ||
| 164 | mutation plausibly explains, so there are no flake attributions. One | ||
| 165 | mutation (row 8) had to be reworked before it could be written at all; | ||
| 166 | that is recorded under its own heading below rather than as a footnote, | ||
| 167 | because the reason it could not be written as specified is itself the | ||
| 168 | finding. | ||
| 169 | |||
| 170 | **Row 6 — the two suites catch the same break at different places, by | ||
| 171 | different organs, and the new one gets there first.** Removing | ||
| 172 | `replica.feed(composed.bytes)` from the delta arm leaves the *paint* | ||
| 173 | entirely correct: the screen is drawn from the delta payload, which the | ||
| 174 | mutation does not touch, so every scenario that only ever paints looks | ||
| 175 | healthy. What rots is the replica, and it surfaces the moment something | ||
| 176 | *reads* it. The new suite fails at `quic delta resume` (test/e2e.sh:850) | ||
| 177 | — a resume repaints from the replica via `renderClipped`, and the | ||
| 178 | replica has not seen a delta since the last snapshot, so the client's | ||
| 179 | grid is missing precisely the delta-only lines: | ||
| 180 | |||
| 181 | ``` | ||
| 182 | sh-5.3$ printf "quic-%s\n" reattached | ||
| 183 | quic-reattached | ||
| 184 | -sh-5.3$ printf "quic-%s\n" pre-tear | ||
| 185 | -quic-pre-tear | ||
| 186 | ``` | ||
| 187 | |||
| 188 | Plain leg, and a clean statement of what the mutation deleted. The old | ||
| 189 | suite gets there too, but 372 lines later and by a different route: at | ||
| 190 | `line mode` (test/e2e.sh:1222) it reports `contradicted=1, want 0`, | ||
| 191 | because reconcile judges the overlay against a replica that never | ||
| 192 | advances, so a correctly predicted keystroke goes unanswered until the | ||
| 193 | expiry bound abandons the queue. Both columns say "caught", and the row | ||
| 194 | is honest about that — but the mechanisms are not interchangeable. The | ||
| 195 | counter catch depends on a prediction scenario existing downstream of | ||
| 196 | the damage; the convergence catch reads the corrupted state directly, | ||
| 197 | at the first scenario that repaints from it. | ||
| 198 | |||
| 199 | **Row 7 — pending, and the branch is not merely unobserved but | ||
| 200 | unreachable.** The mutation deletes `replica.resize(prefix.cols, | ||
| 201 | prefix.rows)` from the snapshot arm. Both suites pass, which was the | ||
| 202 | recorded expectation, and the reason is stronger than "no scenario | ||
| 203 | resizes": the mutated statement is never executed at all. It sits under | ||
| 204 | `if (prefix.cols != grid.cols or prefix.rows != grid.rows)`, and in the | ||
| 205 | suite that condition is never true. Every e2e client captures stdout to | ||
| 206 | a file, so `ttySize` returns null (`src/client.zig:917` — `isatty` | ||
| 207 | fails) and the client runs at its non-tty default of 80x24 | ||
| 208 | (`src/client.zig:478`), which is also the daemon's. `test/rawmode.zig` | ||
| 209 | does not change this: it is a shell stand-in on the daemon side, not a | ||
| 210 | pty allocated for the client. So `grid` starts at 80x24, every snapshot | ||
| 211 | prefix says 80x24, and the branch is dead code for the whole run. | ||
| 212 | Resolution per the recording rule: this is a banked gap with a written | ||
| 213 | reason — there is no path to a non-80x24 grid anywhere in the corpus, | ||
| 214 | and closing it needs a scenario that actually resizes (a client on a pty | ||
| 215 | plus a `resize` frame), not a new assertion. Until such a scenario | ||
| 216 | exists, no assertion of any kind could catch this mutation, so it is not | ||
| 217 | evidence about the convergence checks either way. | ||
| 218 | |||
| 219 | **Row 8 — reworked, because the check the mutation was supposed to | ||
| 220 | delete does not exist.** The specification was "remove the client's | ||
| 221 | gap-detection so a stale delta is applied rather than triggering | ||
| 222 | resync". There is no such code. The client has no seq-continuity check | ||
| 223 | anywhere: the delta arm sets `last_seq = composed.header.seq` and | ||
| 224 | applies the frame unconditionally, and every gap decision is made by the | ||
| 225 | DAEMON, in `sendResync` (`src/server.zig:1436`), which serves a delta | ||
| 226 | only when the client's `have_seq` names this epoch and falls inside | ||
| 227 | `[reset_seq, seq]`. Removing a check that is not there is not a | ||
| 228 | mutation, so the same break was expressed from the one client-side lever | ||
| 229 | that exists: the resume attach quotes `last_seq +| 1`, claiming one seq | ||
| 230 | more than the client actually holds. | ||
| 231 | |||
| 232 | What that produced is worth more than what was asked for. The intended | ||
| 233 | desync never happened, because the daemon's range guard noticed the lie | ||
| 234 | (`have_seq <= self.tracker.seq` fails when the daemon has not yet moved | ||
| 235 | past the claimed seq) and served a SNAPSHOT — the safe answer. The | ||
| 236 | screen therefore renders perfectly, all 21 convergence points agree, and | ||
| 237 | the only witness is the counter: both suites fail identically at | ||
| 238 | `reconnect was served a snapshot (7 -> 8), not a delta`. This is the | ||
| 239 | exact complement of rows 1-3. There, convergence saw what counters were | ||
| 240 | blind to; here, a resume served the wrong WAY renders identically | ||
| 241 | either way, and the counter is the only organ that can tell. Recorded in | ||
| 242 | `docs/decisions.md` already, under "counters are asserted because | ||
| 243 | markers are blind to *how* a resume was served" — this run is a fourth | ||
| 244 | proof of it, arrived at from the client side. | ||
| 245 | |||
| 246 | The residual finding: a delta silently applied across a gap is not | ||
| 247 | reachable from the client at all. Understating `have_seq` yields a | ||
| 248 | superset delta (correct); overstating it yields a snapshot (correct); | ||
| 249 | and the malformed-delta arm, which is the client's only resync REQUEST, | ||
| 250 | never fires because no e2e payload is ever malformed. The property is | ||
| 251 | defended entirely by the daemon, and it is already mutation-proven | ||
| 252 | there. No new assertion is owed. | ||
| 253 | |||
| 254 | **Row 9 — the styled leg fires, alone, and this is the check earning its | ||
| 255 | keep.** Confirmed predictions are retired into `overlay.retired` so the | ||
| 256 | authoritative row paint can take their underline off; the mutation | ||
| 257 | repaints `\x1b[4m` over every retired cell instead, leaving confirmation | ||
| 258 | visible forever. Counters are deliberately untouched — `markPainted` is | ||
| 259 | not called for retired cells, so `made`, `displayed` and `confirmed` all | ||
| 260 | read exactly as they do unmutated — and the old suite duly passes. | ||
| 261 | |||
| 262 | The new suite fails at `line-mode prediction`, and the attribution is as | ||
| 263 | sharp as this campaign gets. The normalized grids are byte-identical; | ||
| 264 | the styled grids are two bytes against fourteen: | ||
| 265 | |||
| 266 | ``` | ||
| 267 | daemon (.dvt): z | ||
| 268 | client (.rvt): ^[[0m^[[4mz^[[0m | ||
| 269 | ``` | ||
| 270 | |||
| 271 | Same character, same cell, same plain grid — the client is simply still | ||
| 272 | calling it a prediction. This is the live test of M9's | ||
| 273 | overlay-never-becomes-state invariant, and it is caught by exactly one | ||
| 274 | of the four legs the harness owns. Note the contrast with row 4, which | ||
| 275 | also needed the styled leg and got nothing: there the style had to come | ||
| 276 | from the fixture, and no scenario emits one. Here the client's own | ||
| 277 | overlay supplies the specimen, so the styled comparison finally has | ||
| 278 | something to compare. The lesson is not "styled diffing is unreliable" — | ||
| 279 | it is that a byte-exact check needs a source of bytes, and prediction is | ||
| 280 | the one part of the client that generates its own. | ||
| 281 | |||
| 282 | **Row 10 — the plain leg, at raw mode, and the counters cannot see it.** | ||
| 283 | Feeding the predicted byte into the replica is the violation M9's design | ||
| 284 | names outright, and it fails at `raw mode`: daemon grid `abc`, client | ||
| 285 | grid `abcj`. Plain diff, so the styled leg never ran (the harness stops | ||
| 286 | at the first divergence and leaves no `.dvt`/`.rvt` behind). | ||
| 287 | |||
| 288 | Why the old suite survives is the instructive half. Raw mode's swallow | ||
| 289 | phase is chosen precisely because the application answers nothing, so | ||
| 290 | the daemon sends no frames — which means `reconcile` is never called and | ||
| 291 | the self-confirmation the mutation would otherwise cause never gets the | ||
| 292 | chance to happen. The idle `expire` path fires instead, exactly as it | ||
| 293 | does unmutated, and every counter reads normally. The overlay's own | ||
| 294 | glyph is then rolled back by the expiry repaint, as designed. What | ||
| 295 | cannot be rolled back is the byte that went into the replica: an overlay | ||
| 296 | is erasable and state is not, which is the entire reason the invariant | ||
| 297 | exists. The counters see a textbook expiry; the grid sees a permanent | ||
| 298 | `j`. | ||
| 299 | |||
| 300 | **What rows 6-10 measured.** Two demonstrated (9, 10 — new caught, old | ||
| 301 | survived), one both-caught by different organs at different scenarios | ||
| 302 | (6), one caught by counters in both columns (8, and only because the | ||
| 303 | daemon defended the break the client tried to cause), one pending on an | ||
| 304 | unreachable branch (7). Combined with rows 1-5, the convergence checks | ||
| 305 | have now caught five mutations the old suite passes (1, 2, 3, 9, 10), | ||
| 306 | and the three both suites miss separate cleanly by cause: row 4 is the | ||
| 307 | right instrument handed no specimen and is resolvable by landing a | ||
| 308 | styled scenario; row 5 is a property no grid can represent and belongs | ||
| 309 | to a unit test; row 7 is a branch no scenario executes and needs a | ||
| 310 | resizing client before any assertion could speak. Only the first of the | ||
| 311 | three is a debt the convergence checks themselves can pay. | ||
| 312 | |||
| 313 | **Rows 11-16 (daemon side: delta production, seq/epoch).** All six built | ||
| 314 | on the first attempt with the pinned toolchain. No suite run was re-run: | ||
| 315 | every verdict below is a first-attempt result, and each failure landed | ||
| 316 | in a scenario the mutation plainly reaches, so there are no flake | ||
| 317 | attributions to make. One planned mutation had to be widened to reach | ||
| 318 | its target and is recorded under row 15. | ||
| 319 | |||
| 320 | **The shape of this half.** Rows 1-10 mutated the client; these six | ||
| 321 | mutate the daemon, and the difference shows up as a change in which | ||
| 322 | organ speaks first. Client-side breaks were mostly invisible to the | ||
| 323 | counters, because the counters live on the client and a client that | ||
| 324 | lies to the screen still counts honestly. Daemon-side breaks are the | ||
| 325 | opposite: the daemon is upstream of both the client's replica AND the | ||
| 326 | client's prediction bookkeeping, so a broken daemon usually trips a | ||
| 327 | counter somewhere even when nothing was built to watch for it. Five of | ||
| 328 | these six are caught by the old suite. That is not the convergence | ||
| 329 | checks failing to earn their keep — it is the honest observation that | ||
| 330 | the old suite's blind spot was the client, not the server. | ||
| 331 | |||
| 332 | **Rows 11 and 13 — the same catch from both suites, but only one of | ||
| 333 | them says what is wrong.** Both mutations withhold a row that really | ||
| 334 | changed: row 11 drops the highest-numbered dirty row from every payload | ||
| 335 | (from the header count and the row list together, so the payload stays | ||
| 336 | valid and `composeDelta`'s count check has nothing to object to), row 13 | ||
| 337 | swallows every change to row 0 by never stamping it in `update()`. | ||
| 338 | |||
| 339 | Both suites fail on both. But the old suite fails at `line mode: | ||
| 340 | contradicted=1, want 0` — the eleventh scenario in, and by a prediction | ||
| 341 | counter — while the new suite fails at `base attach`, the very first | ||
| 342 | scenario, and prints the missing content: | ||
| 343 | |||
| 344 | ``` | ||
| 345 | row 11: -sh-5.3$ printf "e2e-%s\n" works | ||
| 346 | -e2e-works | ||
| 347 | sh-5.3$ | ||
| 348 | +e2e-works | ||
| 349 | |||
| 350 | row 13: -sh-5.3$ printf "e2e-%s\n" works | ||
| 351 | +sh-5.3$ | ||
| 352 | ``` | ||
| 353 | |||
| 354 | Row 13's diff is the whole diagnosis in two lines: one row, the first | ||
| 355 | one, holding the prompt and the echoed command on the daemon and only | ||
| 356 | the bare prompt on the client. The old suite's verdict is true but | ||
| 357 | oblique — a prediction was contradicted because the row that would have | ||
| 358 | confirmed it never arrived, which is a symptom three inferential steps | ||
| 359 | from the cause. Same disposition, very different debugging cost, and | ||
| 360 | the gap is eight scenarios of runtime plus the entire distance between | ||
| 361 | "a counter is off" and "this row is missing". | ||
| 362 | |||
| 363 | Worth naming why the prediction counter is the old suite's organ here | ||
| 364 | at all: the shell prompt is where typing happens, so the row a | ||
| 365 | prediction is judged against is exactly the row these mutations | ||
| 366 | withhold. Row 11 withholds it because the prompt is the highest dirty | ||
| 367 | row; row 13 because a fresh shell's prompt is row 0. The counter is not | ||
| 368 | watching for lost rows. It is watching for lost predictions, and lost | ||
| 369 | rows happen to eat predictions. | ||
| 370 | |||
| 371 | **Row 12 — pending, and the reason is that row order is not a state | ||
| 372 | property.** Reversing the row order inside each delta payload changes | ||
| 373 | nothing either suite can see, including all 21 convergence points. | ||
| 374 | This was the honest expectation written before the run, and it is | ||
| 375 | confirmed twice over — once by both suites passing, and once by | ||
| 376 | reading the code that would have to object. | ||
| 377 | |||
| 378 | Nothing objects. `composeDelta` emits `CUP(row+1);1H` + `EL(2)` + the | ||
| 379 | row's bytes for every row it iterates, and its only validation is | ||
| 380 | `seen != hdr.row_count`. Every row therefore paints at its own absolute | ||
| 381 | address, independent of every other row, so the payload is a set and | ||
| 382 | not a sequence: permuting it is a no-op on the resulting grid by | ||
| 383 | construction. The final CUP to the header's cursor position is emitted | ||
| 384 | after the loop and is unaffected by the loop's order. | ||
| 385 | |||
| 386 | This is a genuine gap in the harness, but it is not a gap worth | ||
| 387 | closing with a grid check, because no grid can hold the evidence. If | ||
| 388 | row order ever becomes load-bearing — a relative-addressing delta | ||
| 389 | format, a scroll-region optimization — it becomes a protocol property | ||
| 390 | and belongs to a `composeDelta` unit test asserting on the composed | ||
| 391 | byte string, not to an e2e convergence point. Filed alongside row 5 as | ||
| 392 | "correctly unmeasurable here", as distinct from row 4's "measurable but | ||
| 393 | unmeasured". | ||
| 394 | |||
| 395 | **Row 14 — the grid converges perfectly and the seq is still wrong.** | ||
| 396 | Making every second update reuse its predecessor's seq (a `reuse_seq` | ||
| 397 | toggle flipped on each advance) leaves the delta CONTENT correct: with | ||
| 398 | the seq unadvanced, `buildDeltaSince(seq - 1)` selects the rows stamped | ||
| 399 | at the current seq, which is this update's rows plus the previous | ||
| 400 | update's, so every client receives a superset of what it needed. All 21 | ||
| 401 | convergence points pass. Both suites then fail at exactly the same | ||
| 402 | place, `raw mode: displayed=1, want 2`, with the full counter line: | ||
| 403 | |||
| 404 | ``` | ||
| 405 | predict made=4 displayed=1 confirmed=3 contradicted=1 expired=1 abandoned=1 suppressed=1 | ||
| 406 | ``` | ||
| 407 | |||
| 408 | The mechanism runs through `reconcile`'s first guard, `if (p.made_seq | ||
| 409 | >= applied_seq) continue` — "too new to be evidence about". A | ||
| 410 | prediction made while the client holds seq N is stamped `made_seq = N`; | ||
| 411 | the next frame legitimately carries N+1 and judges it. Under the | ||
| 412 | mutation every second frame carries N again, so the guard defers a | ||
| 413 | prediction the frame could in fact have judged. Deferred judgement | ||
| 414 | means a deferred `confirmed`, which means a streak that lengthens a | ||
| 415 | frame late, which means `.adaptive` promotion to `confident` arrives | ||
| 416 | late — and `displayed` only increments for predictions painted while | ||
| 417 | confident. The lost display is not a lost prediction; it is a | ||
| 418 | prediction that was still invisible when its moment passed. | ||
| 419 | |||
| 420 | This is the cleanest instance in the campaign of a break the | ||
| 421 | convergence checks structurally cannot see. A seq is not a cell. The | ||
| 422 | replica ends in exactly the right state and the screen shows exactly | ||
| 423 | the right bytes; what is wrong is the numbering that tells the client | ||
| 424 | WHEN it is allowed to believe things. Row 12 and row 14 are the same | ||
| 425 | lesson from opposite ends: row 12 is a wrong wire ordering with no | ||
| 426 | consequence, row 14 is a wrong wire numbering with a consequence no | ||
| 427 | grid can express. | ||
| 428 | |||
| 429 | **Row 15 — the control, and the old suite catches precisely what it was | ||
| 430 | built to catch.** Both suites fail identically, at the M7b restart | ||
| 431 | scenario, with the message the campaign brief predicted verbatim: | ||
| 432 | `restarted daemon served no snapshot (0); the stale seq was honoured`. | ||
| 433 | |||
| 434 | The mutation had to be widened, and the discrepancy is worth recording. | ||
| 435 | The plan asked for a delta served "when the client's quoted have_seq is | ||
| 436 | below the tracker's floor (or from a dead epoch)", which implies | ||
| 437 | dropping two of `sendResync`'s four guards — `have_epoch == self.epoch` | ||
| 438 | and `have_seq >= self.tracker.reset_seq`. Dropping only those does not | ||
| 439 | reach the restart scenario at all, because a RESTARTED daemon's tracker | ||
| 440 | sits at a very low seq while the client quotes a high one from the dead | ||
| 441 | instance, so the surviving upper bound `have_seq <= self.tracker.seq` | ||
| 442 | sends it to a snapshot anyway and the mutation is inert. Expressing the | ||
| 443 | intended break therefore meant reducing the guard to `have_seq != 0 and | ||
| 444 | self.tracker.rows != 0` — "serve a delta to anyone quoting any nonzero | ||
| 445 | seq". The daemon then answers the reconnect with a delta whose | ||
| 446 | `since` exceeds every row seq it holds: an empty, structurally valid, | ||
| 447 | completely useless payload. | ||
| 448 | |||
| 449 | What the two suites see is identical, and the scenario's own comment | ||
| 450 | explains why the markers stay silent: | ||
| 451 | |||
| 452 | > Markers are blind to the kind of resume — a broken epoch fence handing | ||
| 453 | > back a garbage delta would render "m7b-post-restart" just as well. | ||
| 454 | > Only the counter sees it. | ||
| 455 | |||
| 456 | The grid-content assertion in the same scenario greps `muxd dump`, i.e. | ||
| 457 | the DAEMON's grid, which never held the pre-restart marker no matter how | ||
| 458 | the resume was served. It cannot fire. The snapshot counter is the sole | ||
| 459 | witness, it was written to be the sole witness, and it is. This row is | ||
| 460 | the campaign's evidence that the old suite is not being graded on a | ||
| 461 | curve: where it has teeth, they close on the first attempt. | ||
| 462 | |||
| 463 | **Row 16 — the same fence from the other side, and the same verdict.** | ||
| 464 | Stamping `epoch + 1` into every snapshot prefix (the daemon's real | ||
| 465 | `self.epoch` untouched) poisons the identity the client echoes back: | ||
| 466 | the client stores `prefix.epoch`, quotes it on reattach, and | ||
| 467 | `sendResync` compares it against the true `self.epoch`, which now never | ||
| 468 | matches. Every resume that should have been a delta becomes a snapshot. | ||
| 469 | Both suites fail identically at the M7 delta-resume scenario: | ||
| 470 | `reconnect was served a snapshot (7 -> 8), not a delta`. | ||
| 471 | |||
| 472 | Rows 15 and 16 are exact mirror images and it is worth stating the pair | ||
| 473 | together, because between them they pin the fence from both directions. | ||
| 474 | Row 15 removes the fence, so a resync that is owed a snapshot gets a | ||
| 475 | delta; row 16 corrupts the identity the fence checks, so a resync that | ||
| 476 | is owed a delta gets a snapshot. Neither disturbs a single pixel — row | ||
| 477 | 16's snapshots are perfectly correct snapshots, merely unearned — which | ||
| 478 | is why all 21 convergence points pass under both and the counters are | ||
| 479 | the only organ that speaks. It also explains the identical failure | ||
| 480 | string shared with row 8: row 8 broke the same fence from the client's | ||
| 481 | side by quoting one seq too many. Three different mutations, one | ||
| 482 | mechanism, one assertion, and the assertion holds each time. | ||
| 483 | |||
| 484 | **What rows 11-16 measured.** Zero demonstrated, two both-caught (11, | ||
| 485 | 13), three caught by counters in both columns (14, 15, 16), one pending | ||
| 486 | (12). Read against rows 1-10 this is a clean inversion and the reason is | ||
| 487 | structural, not accidental: the convergence checks compare a client's | ||
| 488 | rendering against a daemon's grid, so they are an instrument pointed at | ||
| 489 | the client. Aim a mutation at the daemon and the daemon's own | ||
| 490 | counters — snapshots, deltas, and the M9 prediction stats the client | ||
| 491 | derives from the daemon's seq stream — are already watching, in both | ||
| 492 | suites equally. The new suite still wins rows 11 and 13 decisively on | ||
| 493 | diagnosis rather than detection: same disposition, eight scenarios | ||
| 494 | earlier, with the offending row printed instead of inferred. | ||
| 495 | |||
| 496 | **Campaign totals across all 16 rows.** Five demonstrated (1, 2, 3, 9, | ||
| 497 | 10 — new caught, old survived), three both-caught (6, 11, 13), four | ||
| 498 | caught by counters in both columns (8, 14, 15, 16), four pending (4, 5, | ||
| 499 | 7, 12). The four pending separate cleanly by cause, and only one is a | ||
| 500 | debt the convergence checks themselves can pay: row 4 is the right | ||
| 501 | instrument handed no specimen and is fixed by landing a styled | ||
| 502 | scenario. Row 5 (synchronized-update wrapper), row 7 (a resize branch | ||
| 503 | no scenario executes) and row 12 (row order under absolute addressing) | ||
| 504 | are not convergence-check debts at all — they are properties a rendered | ||
| 505 | grid cannot represent, and each belongs to a unit test on the byte | ||
| 506 | string or to a scenario that does not yet exist. | ||
| 507 | |||
| 508 | **Row 17 — the clip bound, one row short.** The client paints through | ||
| 509 | two bounded loops: `renderClipped`'s `limit = @min(grid_rows, | ||
| 510 | tty.rows)` for a full repaint, and `paintDeltaClipped`'s `if (row.row | ||
| 511 | >= tty.rows) continue` for a delta. Both were clamped one row short | ||
| 512 | (`tty.rows -| 1`), so on the suites' 80x24 clients row 24 is structurally | ||
| 513 | unpaintable while the replica and the daemon both still hold it. Two | ||
| 514 | lines, one file, one idea — a single-line edit would have been half a | ||
| 515 | mutation, because either loop alone leaves the other painting the row. | ||
| 516 | |||
| 517 | The old suite survives it and the new suite fails at the FIRST scenario | ||
| 518 | after the control, `reattach after kill`, on the plain arm of the | ||
| 519 | convergence check, with the missing row printed: | ||
| 520 | |||
| 521 | ``` | ||
| 522 | --- .../re.dump.n | ||
| 523 | +++ .../re.render.n | ||
| 524 | @@ -21,4 +21,3 @@ | ||
| 525 | 58 | ||
| 526 | 59 | ||
| 527 | 60 | ||
| 528 | -sh-5.3$ | ||
| 529 | ``` | ||
| 530 | |||
| 531 | That diff is the whole argument for the new suite in four lines. The | ||
| 532 | scenario is the same one in both suites — `seq 1 60` into a scrolled | ||
| 533 | shell, kill the client, reattach — and the old suite runs it too. What | ||
| 534 | the old suite asks is whether the marker text came back, and the markers | ||
| 535 | are the numbers, which live in rows 1..23 and came back perfectly. The | ||
| 536 | one thing that ends up on row 24 of a screen that has just scrolled is | ||
| 537 | the prompt, and no marker grep was ever written against a prompt. So the | ||
| 538 | old suite watched the exact byte stream that carried the bug and had | ||
| 539 | nothing to say about it. Worth recording as the general shape: a marker | ||
| 540 | grep is a test of the rows a fixture happens to write text into, and the | ||
| 541 | bottom row is precisely where a scrolled session puts the thing the | ||
| 542 | fixture did not write. | ||
| 543 | |||
| 544 | The unit layer is a near miss, and instructively so. The clip tests at | ||
| 545 | `src/client.zig` "renderClipped paints only rows that fit and clamps the | ||
| 546 | cursor" assert the two NEGATIVE facts — no `\x1b[25;`, no "bottom row" | ||
| 547 | from a 30-row grid — and one cursor fact, `\x1b[24;80H`, which survives | ||
| 548 | untouched because `clampCursor` was not mutated. Nothing there asserts | ||
| 549 | that row 24 IS painted. The tests pin the ceiling and leave the floor | ||
| 550 | open, so this mutation walks under them. Both of the campaign's | ||
| 551 | instruments for this bug therefore agree on the same lesson from | ||
| 552 | opposite directions: the new suite catches it only because a convergence | ||
| 553 | check compares whole grids rather than chosen rows. | ||
| 554 | |||
| 555 | **Row 18 — the resync scroll reset, unreachable and unpinned.** The | ||
| 556 | mutation deletes `scroll_pages = 0` from the reconnect path in | ||
| 557 | `fn session` (`src/client.zig` ~609), so a client that reconnects while | ||
| 558 | paged into history stays in scroll view instead of returning to live. | ||
| 559 | Both suites survive, and the reason is not that the assertions are weak | ||
| 560 | but that no e2e client can enter the state at all. | ||
| 561 | |||
| 562 | The evidence is exhaustive rather than argued. `scroll_pages` is a local | ||
| 563 | in `session()`, initialised to 0, and the only site that raises it is the | ||
| 564 | Shift+PageUp branch, which requires stdin to deliver exactly `\x1b[5;2~` | ||
| 565 | in one read AND `history_rows > 0`. Neither suite contains that byte | ||
| 566 | sequence anywhere: `grep -c $'5;2~' test/e2e.sh e2e-old.sh` returns 0 and | ||
| 567 | 0. So at the mutation site `scroll_pages` is invariantly 0 in every e2e | ||
| 568 | run ever executed, and deleting an assignment of 0 to a variable already | ||
| 569 | 0 is a semantic no-op. This is not "the suites missed it"; there is no | ||
| 570 | behavioural difference to miss. Disposition `pending`, cause: unreachable | ||
| 571 | fixture, not absent assertion. | ||
| 572 | |||
| 573 | The unit layer does not cover it either, which is the part worth acting | ||
| 574 | on. `session()` has exactly one caller and no test drives it — the run | ||
| 575 | loop is exercised only end-to-end — so the reset is pinned nowhere in the | ||
| 576 | tree. The nearest pins are adjacent but distinct: `src/predict.zig` | ||
| 577 | (~1128-1140) pins that `setScrollMode` flushes the overlay and that | ||
| 578 | `predictAt` suppresses while scrolled, and `src/client.zig` pins | ||
| 579 | `renderScrollback`'s paint and `scrollPageRange`'s arithmetic. All three | ||
| 580 | are about scroll mode; none is about leaving it. Closing this needs a | ||
| 581 | scenario that pages back and then forces a reconnect, which in turn needs | ||
| 582 | a pty-driving client fixture (`rawmode` is the existing precedent) — the | ||
| 583 | same missing-fixture debt as row 5, not a convergence-check debt. | ||
| 584 | |||
| 585 | One genuine finding fell out of reading for this row, and it is in | ||
| 586 | unmutated HEAD, not in the mutant. The resync path clears `scroll_pages` | ||
| 587 | but never calls `overlay.setScrollMode(false)`, and `overlay.flush()` | ||
| 588 | does not clear `scroll_mode` — it only drops pending and retired | ||
| 589 | predictions. So after a reconnect taken while scrolled, the client is | ||
| 590 | back on live rows with `scroll_mode` still true, and since the "any other | ||
| 591 | key exits scroll mode" branch is guarded by `scroll_pages > 0` it can | ||
| 592 | never run again, `predictAt` suppresses unconditionally, and local echo | ||
| 593 | prediction is dead for the remainder of the session. It is invisible to | ||
| 594 | both suites for exactly the reason row 18 is: no e2e client can page | ||
| 595 | back. Recorded here, deliberately unfixed — this campaign commits no code | ||
| 596 | — and it belongs with the fixture debt above, since the same pty fixture | ||
| 597 | that would pin the reset would expose this. | ||
| 598 | |||
| 599 | **Campaign totals across all 18 rows.** Six demonstrated (1, 2, 3, 9, | ||
| 600 | 10, 17 — new caught, old survived), three both-caught (6, 11, 13), four | ||
| 601 | caught by counters in both columns (8, 14, 15, 16), five pending (4, 5, | ||
| 602 | 7, 12, 18). The last row of the table is also the campaign's cleanest | ||
| 603 | single result: row 17 is a real client-side rendering bug that the old | ||
| 604 | suite runs the fixture for and cannot see, and the new suite reports at | ||
| 605 | the first scenario with the offending line printed. The five pending | ||
| 606 | rows now separate into three causes, none of which is a weak convergence | ||
| 607 | check: one specimen the checks want and do not have (row 4, styled | ||
| 608 | scenario), two properties a rendered grid cannot represent (rows 5 and | ||
| 609 | 12), and two branches no fixture can reach (rows 7 and 18), both of which | ||
| 610 | want a pty-driving client fixture rather than a new assertion. | ||
| 611 | |||
| 612 | **Worktree retired.** The campaign worktree | ||
| 613 | `/home/xanderle/code/rad/mux-m11-campaign` (detached at 2bc64e8, tracked | ||
| 614 | diff empty at teardown) was removed after row 18. Its one piece of | ||
| 615 | scaffolding, `e2e-old.sh` — the pre-M11 suite, carried untracked so that | ||
| 616 | each row could be graded against both — was deleted with it; sha256 | ||
| 617 | `aa04ecbebfa2f2dbc425cb651b543ebb995fe30dc45393fc5b797bd004ea1c27`, for | ||
| 618 | anyone reconstructing an "old suite" column later. | ||
| 619 | |||
| 620 | ## Dispositions | ||
| 621 | |||
| 622 | The recording rule allows two endings and no third: a NEW assertion | ||
| 623 | landed now, with the mutation re-run to prove it fires, or a banked | ||
| 624 | entry with a written reason. All five pending rows are closed below. | ||
| 625 | The mutations were re-applied in the MAIN checkout, one at a time, | ||
| 626 | apply-run-revert, with `git diff` verified between each — the campaign | ||
| 627 | worktree is gone, and an in-checkout gate is the repo's established | ||
| 628 | pattern for proving a new assertion fires. | ||
| 629 | |||
| 630 | **Row 4 — banked, and the campaign's own prescription was wrong.** The | ||
| 631 | prescription was a styled scenario, on the reading that the styled leg | ||
| 632 | was the right instrument handed no specimen. The scenario landed | ||
| 633 | (`styled content`, test/e2e.sh, a session printing two bold-red rows | ||
| 634 | followed by a plain prompt row) and the mutation was re-run against it. | ||
| 635 | It still survives. The reading was wrong, and the reason is a contract | ||
| 636 | nobody had checked: ghostty's VT formatter closes every styled row it | ||
| 637 | emits. `dumpVtRow` returns `\x1b[0m` (the prefix `formatSelection` adds) | ||
| 638 | plus the formatter's own output, and for a styled row that output both | ||
| 639 | opens with a reset and ends with one — ghostty's test suite spells out | ||
| 640 | why, "style is reset before newline to prevent background colors from" | ||
| 641 | bleeding. Verified on the wire, not inferred: the delta row for | ||
| 642 | `styled-red` paints as `\x1b[2K\x1b[0m` `\x1b[0m\x1b[1m\x1b[38;5;1m` | ||
| 643 | `styled-red\x1b[0m`, two resets, and the mutation strips only ours. A | ||
| 644 | full-width background fill and a styled line wrapped across two rows | ||
| 645 | were both tried; both close their pen the same way. | ||
| 646 | |||
| 647 | So the leading reset is redundancy against a live pen, and no live pen | ||
| 648 | exists. Every byte source the client paints from closes itself: the | ||
| 649 | formatter's rows, `paintOverlay`'s per-cell `\x1b[4m{c}\x1b[0m`, | ||
| 650 | `bannerText`'s `\x1b[7m{s}\x1b[0m`. The only pen the reset could | ||
| 651 | protect against is one the HOST terminal had already set before mux | ||
| 652 | started, which is exactly the thing no rendered-grid comparison can | ||
| 653 | ever hold — the render helper replays into a fresh engine at the | ||
| 654 | default pen, by construction. This is not a corpus gap; it is a | ||
| 655 | property that is invisible to the harness for the same structural | ||
| 656 | reason as rows 5 and 12, and it moves to that pile. | ||
| 657 | |||
| 658 | It is also already pinned where it lives. Re-running mutation 4 against | ||
| 659 | `make test` fails on the assertion that has been there since the paint | ||
| 660 | landed: | ||
| 661 | |||
| 662 | ``` | ||
| 663 | error: 'client.test.paintDeltaClipped skips rows beyond the tty and clamps the cursor' failed | ||
| 664 | src/client.zig:1837: try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[4;1H\x1b[2K\x1b[0mfits") != null); | ||
| 665 | Build Summary: 196/197 tests passed; 1 failed | ||
| 666 | ``` | ||
| 667 | |||
| 668 | No new assertion is owed, and adding one that says what line 1837 | ||
| 669 | already says would be theatre. | ||
| 670 | |||
| 671 | **Row 19 — the styled scenario stays, and here is what it is worth.** A | ||
| 672 | scenario landed on a disproved hypothesis has to justify itself on its | ||
| 673 | own terms or come back out, so it was gated by a mutation aimed at what | ||
| 674 | it actually covers: strip EVERY SGR run from delta rows at the client | ||
| 675 | paint, not merely the leading reset. Glyphs unchanged, colours gone. | ||
| 676 | The new scenario is the first convergence point to fail, on the styled | ||
| 677 | leg alone: | ||
| 678 | |||
| 679 | ``` | ||
| 680 | e2e FAIL: styled content: client render diverges from daemon grid (-daemon +client): | ||
| 681 | (plain diff empty — 0 bytes) | ||
| 682 | daemon (.dvt): ^[[0m^[[1m^[[38;5;1mstyled-red^[[0m | ||
| 683 | client (.rvt): styled-red | ||
| 684 | ``` | ||
| 685 | |||
| 686 | Before this, the byte-exact leg had exactly one source of specimens in | ||
| 687 | the whole corpus — the prediction overlay's own underline (row 9). Now | ||
| 688 | the fixture supplies one too, which is what puts daemon-side style | ||
| 689 | emission and the delta path's style transport under the check at all. | ||
| 690 | The old-suite column is honestly blank: `e2e-old.sh` went with the | ||
| 691 | worktree, and a marker grep cannot see a colour by construction — the | ||
| 692 | text is identical under this mutation. | ||
| 693 | |||
| 694 | **Row 5 — new assertion, at the unit layer, gated.** `paintDeltaClipped | ||
| 695 | brackets the whole paint in one synchronized update` (src/client.zig) | ||
| 696 | asserts the delta paint STARTS with `\x1b[?2026h\x1b[?25l` and ENDS with | ||
| 697 | `\x1b[?25h\x1b[?2026l` — on the ends rather than by substring search, | ||
| 698 | because the property is that every row lands inside the brackets, which | ||
| 699 | only position can show. e2e is the wrong instrument here and always was: | ||
| 700 | a synchronized update changes when the terminal shows a paint, never | ||
| 701 | what it shows. Re-applying mutation 5 (wrapper dropped from the delta | ||
| 702 | path) fails exactly this test and nothing else: | ||
| 703 | |||
| 704 | ``` | ||
| 705 | error: 'client.test.paintDeltaClipped brackets the whole paint in one synchronized update' failed | ||
| 706 | src/client.zig:1868 | ||
| 707 | Build Summary: 197/198 tests passed; 1 failed | ||
| 708 | ``` | ||
| 709 | |||
| 710 | **Row 7 — banked: needs a resizing client fixture.** The branch is | ||
| 711 | unreachable, as row 7's note established, and the question left open was | ||
| 712 | whether the snapshot-apply resize is drivable from a unit test instead. | ||
| 713 | It is not, without a refactor this milestone should not make. The | ||
| 714 | mutated statement sits inline in `session()`'s frame switch — read the | ||
| 715 | prefix, compare against `grid`, `replica.resize`, restamp `grid` — and | ||
| 716 | `session()` takes a live transport, two file descriptors and a run loop. | ||
| 717 | There is no `applySnapshot` seam to call, `session()` has one caller and | ||
| 718 | no test drives it, and `Engine.resize` is already tested on its own, so | ||
| 719 | a test of the callee could not catch a deleted call. Banked with row 18: | ||
| 720 | same debt class, a fixture that can drive a client at a size other than | ||
| 721 | 80x24, which today means a pty. Note the asymmetry with row 4 — that one | ||
| 722 | is invisible to the instrument; this one is invisible to the corpus. | ||
| 723 | |||
| 724 | **Row 12 — banked: order is not a state property.** Rows are absolutely | ||
| 725 | addressed — `composeDelta` emits `CUP(row+1);1H` + `EL(2)` + the row's | ||
| 726 | bytes for each row it iterates — so the payload is a set by | ||
| 727 | construction and permuting it is a no-op on the resulting grid. | ||
| 728 | Asserting an emission order would forbid an equivalent implementation | ||
| 729 | while pinning nothing a user can observe, which is the wrong trade for | ||
| 730 | a wire format that is still moving. Structural corruption is already | ||
| 731 | rejected: `composeDelta` validates `seen != hdr.row_count`. If row order | ||
| 732 | ever becomes load-bearing — relative addressing, a scroll-region | ||
| 733 | optimization — it becomes a protocol property and belongs to a | ||
| 734 | `composeDelta` unit test on the composed byte string. | ||
| 735 | |||
| 736 | **Row 18 — banked: needs a pty-driving client fixture.** No e2e client | ||
| 737 | can enter scroll mode, because entering it requires stdin to deliver | ||
| 738 | `\x1b[5;2~` in one read and no suite contains those bytes; with | ||
| 739 | `scroll_pages` invariantly 0 the mutation is a semantic no-op. Closing | ||
| 740 | it needs a scenario that pages back and then forces a reconnect, which | ||
| 741 | needs a client on a pty (`rawmode` is the precedent, but it stands in | ||
| 742 | for the SHELL, on the daemon side). Banked together with row 7 as one | ||
| 743 | debt: a client fixture that owns a pty, which would make both branches | ||
| 744 | reachable and both mutations gradeable. | ||
| 745 | |||
| 746 | **The bug the campaign found, now fixed.** Row 18's note recorded a | ||
| 747 | genuine defect in unmutated HEAD and deliberately left it alone, since a | ||
| 748 | campaign commits no code. It is fixed here. The reconnect path in | ||
| 749 | `session()` cleared `scroll_pages` but never told the overlay, and | ||
| 750 | `flush()` does not clear `scroll_mode` — it drops pending and retired | ||
| 751 | predictions and nothing else. A reconnect taken while scrolled therefore | ||
| 752 | left the client painting live rows with the overlay still suppressing, | ||
| 753 | and the "any other key leaves scroll mode" branch could never rescue it | ||
| 754 | because that branch is guarded by `scroll_pages > 0`, which the | ||
| 755 | reconnect had just made false. Local echo prediction was off for the | ||
| 756 | rest of the session, silently. The fix is one call, | ||
| 757 | `overlay.setScrollMode(false)`, next to the existing `scroll_pages = 0`. | ||
| 758 | |||
| 759 | Client-side deliberately, rather than making `flush()` clear the mode | ||
| 760 | itself: every snapshot flushes, and a snapshot is not a reason to leave | ||
| 761 | history — the overlay would start deciding where the viewport is, which | ||
| 762 | is the client's business. The counters do not move either way, because | ||
| 763 | `setScrollMode` is a no-op when the bit is already false and its flush | ||
| 764 | would otherwise abandon exactly the predictions the flush ten lines | ||
| 765 | later abandons. | ||
| 766 | |||
| 767 | Tested where the property can be reached: `a flush leaves scroll mode | ||
| 768 | exactly where it found it` (src/predict.zig) pins the contract the fix | ||
| 769 | depends on in both directions — a flush taken while scrolled still | ||
| 770 | suppresses, a flush taken live still predicts. The end-to-end pin, "a | ||
| 771 | reconnect while scrolled leaves prediction working", rides the banked | ||
| 772 | pty-fixture debt above and is not claimed here. | ||
| 773 | |||
| 774 | **Where the five rows ended.** One new assertion (row 5, unit), four | ||
| 775 | banked (4, 7, 12, 18), zero undecided. The banks fall in two classes and | ||
| 776 | neither is a weak convergence check: rows 4, 5 and 12 are properties no | ||
| 777 | rendered grid can represent, and rows 7 and 18 are branches no fixture | ||
| 778 | can reach. Row 4 changed class during resolution — it was filed as the | ||
| 779 | one debt the convergence checks could pay, and paying it disproved the | ||
| 780 | premise. One product bug fixed, one scenario added, pins moved to 10 | ||
| 781 | scenario checkpoints and 22 convergence points. | ||
docs/superpowers/plans/2026-08-09-m11-e2e-hardening.md
| Old | New | ||
|---|---|---|---|
| @@ -1,5 +1,7 @@ | |||
| 1 | # M11: e2e Hardening Implementation Plan | 1 | # M11: e2e Hardening Implementation Plan |
| 2 | 2 | ||
| 3 | **Executed in full; verdicts and deviations recorded in decisions.md M11.** | ||
| 4 | |||
| 3 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | 5 | > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| 4 | 6 | ||
| 5 | **Goal:** the e2e suite asserts "the screen the client painted equals the screen the daemon holds" (render-vs-dump convergence), pins its own scenario count, gains a soak mode — then a mutation campaign breaks the code deliberately and measures what the new suite catches that the old one could not. | 7 | **Goal:** the e2e suite asserts "the screen the client painted equals the screen the daemon holds" (render-vs-dump convergence), pins its own scenario count, gains a soak mode — then a mutation campaign breaks the code deliberately and measures what the new suite catches that the old one could not. |
| @@ -42,7 +44,7 @@ | |||
| 42 | - Create: `test/render.zig` | 44 | - Create: `test/render.zig` |
| 43 | - Modify: `build.zig` (module + exe + test loop + e2e arg) | 45 | - Modify: `build.zig` (module + exe + test loop + e2e arg) |
| 44 | 46 | ||
| 45 | - [ ] **Step 1: Write the file — core function, main, and failing tests together** (the tests fail because the file is new; TDD's red step here is the whole file compiling and its tests being exercised by the build) | 47 | - [x] **Step 1: Write the file — core function, main, and failing tests together** (the tests fail because the file is new; TDD's red step here is the whole file compiling and its tests being exercised by the build) |
| 46 | 48 | ||
| 47 | ```zig | 49 | ```zig |
| 48 | //! e2e render helper: replays a captured mux-client stdout stream through | 50 | //! e2e render helper: replays a captured mux-client stdout stream through |
| @@ -190,7 +192,7 @@ test "render: styled state survives replay identically to a direct feed" { | |||
| 190 | } | 192 | } |
| 191 | ``` | 193 | ``` |
| 192 | 194 | ||
| 193 | - [ ] **Step 2: Wire into build.zig.** Four edits: | 195 | - [x] **Step 2: Wire into build.zig.** Four edits: |
| 194 | 196 | ||
| 195 | After the `delaypipe_mod` block (~line 208): | 197 | After the `delaypipe_mod` block (~line 208): |
| 196 | 198 | ||
| @@ -224,14 +226,14 @@ Add the 5th e2e artifact arg after `e2e.addArtifactArg(delaypipe_exe);` (~line 2 | |||
| 224 | e2e.addArtifactArg(render_exe); | 226 | e2e.addArtifactArg(render_exe); |
| 225 | ``` | 227 | ``` |
| 226 | 228 | ||
| 227 | - [ ] **Step 3: Run the tests.** `make test` — expect the four render tests to pass among the total (193 → 197). If ghostty-vt import fails, compare against how `engine_mod` gets its import — render_mod itself must NOT import ghostty-vt directly; it reaches it through `engine`. | 229 | - [x] **Step 3: Run the tests.** `make test` — expect the four render tests to pass among the total (193 → 197). If ghostty-vt import fails, compare against how `engine_mod` gets its import — render_mod itself must NOT import ghostty-vt directly; it reaches it through `engine`. |
| 228 | 230 | ||
| 229 | - [ ] **Step 4: Fire the mutations (M9 rule — a test whose mutation doesn't fire doesn't count).** Two, one at a time, in the main checkout, reverted immediately after each: | 231 | - [x] **Step 4: Fire the mutations (M9 rule — a test whose mutation doesn't fire doesn't count).** Two, one at a time, in the main checkout, reverted immediately after each: |
| 230 | 1. In `feedForFinalGrid`, replace the body with `eng.feed(stream);` (drop the boundary). Run `make test`: tests 2 and 3 MUST fail ("primary junk"/"trailing" pollute the grid). Revert. | 232 | 1. In `feedForFinalGrid`, replace the body with `eng.feed(stream);` (drop the boundary). Run `make test`: tests 2 and 3 MUST fail ("primary junk"/"trailing" pollute the grid). Revert. |
| 231 | 2. Replace `lastIndexOf` with `indexOf`. Run `make test`: test 3 MUST fail (grid says "first"). Revert. | 233 | 2. Replace `lastIndexOf` with `indexOf`. Run `make test`: test 3 MUST fail (grid says "first"). Revert. |
| 232 | Verify `git diff` is empty after the reverts. | 234 | Verify `git diff` is empty after the reverts. |
| 233 | 235 | ||
| 234 | - [ ] **Step 5: Commit.** | 236 | - [x] **Step 5: Commit.** |
| 235 | 237 | ||
| 236 | ```bash | 238 | ```bash |
| 237 | git add test/render.zig build.zig | 239 | git add test/render.zig build.zig |
| @@ -243,14 +245,14 @@ git commit -m "test: render helper replays a client capture into a dump-format g | |||
| 243 | **Files:** | 245 | **Files:** |
| 244 | - Modify: `test/e2e.sh` (header, helpers, base-flow placement) | 246 | - Modify: `test/e2e.sh` (header, helpers, base-flow placement) |
| 245 | 247 | ||
| 246 | - [ ] **Step 1: Take the 5th argument.** After `DELAYPIPE="$4"` (line ~10): | 248 | - [x] **Step 1: Take the 5th argument.** After `DELAYPIPE="$4"` (line ~10): |
| 247 | 249 | ||
| 248 | ```sh | 250 | ```sh |
| 249 | # M11 convergence: replays a client capture into a grid (test/render.zig). | 251 | # M11 convergence: replays a client capture into a grid (test/render.zig). |
| 250 | RENDER="$5" | 252 | RENDER="$5" |
| 251 | ``` | 253 | ``` |
| 252 | 254 | ||
| 253 | - [ ] **Step 2: Add the helpers**, after the `proxy_pid()` function and before `cleanup()`: | 255 | - [x] **Step 2: Add the helpers**, after the `proxy_pid()` function and before `cleanup()`: |
| 254 | 256 | ||
| 255 | ```sh | 257 | ```sh |
| 256 | # --- M11: render-vs-dump convergence ----------------------------------- | 258 | # --- M11: render-vs-dump convergence ----------------------------------- |
| @@ -297,7 +299,7 @@ assert_converged() { | |||
| 297 | } | 299 | } |
| 298 | ``` | 300 | ``` |
| 299 | 301 | ||
| 300 | - [ ] **Step 3: First placement + the control.** After the base scenario's three checks (immediately after the `kill -0 "$DPID"` detach check, line ~216): | 302 | - [x] **Step 3: First placement + the control.** After the base scenario's three checks (immediately after the `kill -0 "$DPID"` detach check, line ~216): |
| 301 | 303 | ||
| 302 | ```sh | 304 | ```sh |
| 303 | # 4. The M11 claim itself: the screen the client painted equals the | 305 | # 4. The M11 claim itself: the screen the client painted equals the |
| @@ -319,9 +321,9 @@ echo "e2e OK: convergence control fires on a doctored stream" | |||
| 319 | 321 | ||
| 320 | Add `"$OUT.doctored"` and its derived names to the `cleanup()` rm list (a run failing between cp and rm must not leak them). Derived convergence files from FAILING assert_converged calls are deliberately left behind as evidence — cleanup does not chase `*.render` etc. for every capture. | 322 | Add `"$OUT.doctored"` and its derived names to the `cleanup()` rm list (a run failing between cp and rm must not leak them). Derived convergence files from FAILING assert_converged calls are deliberately left behind as evidence — cleanup does not chase `*.render` etc. for every capture. |
| 321 | 323 | ||
| 322 | - [ ] **Step 4: Run it.** `make e2e` — expect pass, with the new OK line. If the base convergence itself fails, STOP and diagnose from `$OUT.render`/`$OUT.dump` (see the context note: a landing failure is a candidate real defect; likely causes are a format mismatch in render.zig's trailing newline, or a genuine paint bug). Do not weaken the diff. | 324 | - [x] **Step 4: Run it.** `make e2e` — expect pass, with the new OK line. If the base convergence itself fails, STOP and diagnose from `$OUT.render`/`$OUT.dump` (see the context note: a landing failure is a candidate real defect; likely causes are a format mismatch in render.zig's trailing newline, or a genuine paint bug). Do not weaken the diff. |
| 323 | 325 | ||
| 324 | - [ ] **Step 5: Commit.** | 326 | - [x] **Step 5: Commit.** |
| 325 | 327 | ||
| 326 | ```bash | 328 | ```bash |
| 327 | git add test/e2e.sh | 329 | git add test/e2e.sh |
| @@ -335,7 +337,7 @@ git commit -m "test: e2e asserts render-vs-dump convergence, with a control that | |||
| 335 | 337 | ||
| 336 | Every capture that today mixes stderr into the client stream gets split (`2>&1` → `2> "FILE.err"`), because the replay engine would otherwise paint exit messages and predict-stats lines into the grid. The stats-reading helpers (`want_stat`, `want_stat_ge`, `predict_stat` call sites) then read the `.err` sibling. Marker greps stay on the stdout file — session output is stdout. Failure diagnostics that `cat` a capture should cat both files. | 338 | Every capture that today mixes stderr into the client stream gets split (`2>&1` → `2> "FILE.err"`), because the replay engine would otherwise paint exit messages and predict-stats lines into the grid. The stats-reading helpers (`want_stat`, `want_stat_ge`, `predict_stat` call sites) then read the `.err` sibling. Marker greps stay on the stdout file — session output is stdout. Failure diagnostics that `cat` a capture should cat both files. |
| 337 | 339 | ||
| 338 | - [ ] **Step 1: Transport/M10 scenarios — split and place.** Work top-to-bottom; line numbers are pre-edit anchors, verify each against content: | 340 | - [x] **Step 1: Transport/M10 scenarios — split and place.** Work top-to-bottom; line numbers are pre-edit anchors, verify each against content: |
| 339 | 341 | ||
| 340 | | # | Scenario (anchor) | Split | Placement (after) | | 342 | | # | Scenario (anchor) | Split | Placement (after) | |
| 341 | |---|---|---|---| | 343 | |---|---|---|---| |
| @@ -358,16 +360,16 @@ Every capture that today mixes stderr into the client stream gets split (`2>&1` | |||
| 358 | 360 | ||
| 359 | Declined, each with a one-line comment AT the site (the spec requires exceptions named in place): `--version`/keygen (no daemon), the `refuse` block (no daemon survives), `--via` death + dead-first-transport (no session), abort-during-reconnect + abort-during-handshake (never attached / reconnecting), wrong-key (refusal), doomed-child (no daemon), `$OUT.kill` (stream truncated by `kill -9` mid-paint — possibly mid-escape-sequence), `$OUT.a` (capture ends before B's marker landed; B's convergence covers this grid). | 361 | Declined, each with a one-line comment AT the site (the spec requires exceptions named in place): `--version`/keygen (no daemon), the `refuse` block (no daemon survives), `--via` death + dead-first-transport (no session), abort-during-reconnect + abort-during-handshake (never attached / reconnecting), wrong-key (refusal), doomed-child (no daemon), `$OUT.kill` (stream truncated by `kill -9` mid-paint — possibly mid-escape-sequence), `$OUT.a` (capture ends before B's marker landed; B's convergence covers this grid). |
| 360 | 362 | ||
| 361 | - [ ] **Step 2: Run.** `make e2e` — green before touching the prediction scenarios. Any divergence: diagnose from the evidence files; a real paint/replica defect gets its own fix commit before proceeding. | 363 | - [x] **Step 2: Run.** `make e2e` — green before touching the prediction scenarios. Any divergence: diagnose from the evidence files; a real paint/replica defect gets its own fix commit before proceeding. |
| 362 | 364 | ||
| 363 | - [ ] **Step 3: Commit.** | 365 | - [x] **Step 3: Commit.** |
| 364 | 366 | ||
| 365 | ```bash | 367 | ```bash |
| 366 | git add test/e2e.sh | 368 | git add test/e2e.sh |
| 367 | git commit -m "test: convergence on every transport scenario that ends with a live daemon" | 369 | git commit -m "test: convergence on every transport scenario that ends with a live daemon" |
| 368 | ``` | 370 | ``` |
| 369 | 371 | ||
| 370 | - [ ] **Step 4: Prediction scenarios — split, retarget stats, place.** | 372 | - [x] **Step 4: Prediction scenarios — split, retarget stats, place.** |
| 371 | 373 | ||
| 372 | | # | Scenario | Split | Stats reads move to | Placement (after) | | 374 | | # | Scenario | Split | Stats reads move to | Placement (after) | |
| 373 | |---|---|---|---|---| | 375 | |---|---|---|---|---| |
| @@ -385,9 +387,9 @@ Details that must not be missed: | |||
| 385 | - Add every new `.err` file and `$OUT.race` to the `cleanup()` rm list. | 387 | - Add every new `.err` file and `$OUT.race` to the `cleanup()` rm list. |
| 386 | - Raw mode (#20) is the scenario most likely to FAIL on landing — the expired `j` phantom-glyph candidate from the context notes. If it does: that is a finding, handled as a client fix (its own commit, with the convergence check as the regression test), not a declined scenario. | 388 | - Raw mode (#20) is the scenario most likely to FAIL on landing — the expired `j` phantom-glyph candidate from the context notes. If it does: that is a finding, handled as a client fix (its own commit, with the convergence check as the regression test), not a declined scenario. |
| 387 | 389 | ||
| 388 | - [ ] **Step 5: Run.** `make e2e` green. | 390 | - [x] **Step 5: Run.** `make e2e` green. |
| 389 | 391 | ||
| 390 | - [ ] **Step 6: Commit.** | 392 | - [x] **Step 6: Commit.** |
| 391 | 393 | ||
| 392 | ```bash | 394 | ```bash |
| 393 | git add test/e2e.sh | 395 | git add test/e2e.sh |
| @@ -399,7 +401,7 @@ git commit -m "test: convergence on the prediction scenarios; stats reads move t | |||
| 399 | **Files:** | 401 | **Files:** |
| 400 | - Modify: `test/e2e.sh` | 402 | - Modify: `test/e2e.sh` |
| 401 | 403 | ||
| 402 | - [ ] **Step 1: Add `ok` next to the convergence helpers:** | 404 | - [x] **Step 1: Add `ok` next to the convergence helpers:** |
| 403 | 405 | ||
| 404 | ```sh | 406 | ```sh |
| 405 | # Scenario checkpoints. The suite's final line asserts the COUNT of these | 407 | # Scenario checkpoints. The suite's final line asserts the COUNT of these |
| @@ -413,9 +415,9 @@ ok() { | |||
| 413 | } | 415 | } |
| 414 | ``` | 416 | ``` |
| 415 | 417 | ||
| 416 | - [ ] **Step 2: Convert every `echo "e2e OK: ..."` line to `ok "..."`.** There are 9 after Task 2 (the original 8 plus the convergence control). `grep -n 'e2e OK' test/e2e.sh` to find them; none may remain as bare echoes except the final summary line (next step). | 418 | - [x] **Step 2: Convert every `echo "e2e OK: ..."` line to `ok "..."`.** There are 9 after Task 2 (the original 8 plus the convergence control). `grep -n 'e2e OK' test/e2e.sh` to find them; none may remain as bare echoes except the final summary line (next step). |
| 417 | 419 | ||
| 418 | - [ ] **Step 3: Pin both counts at the end.** Replace the final `echo "e2e OK"` with: | 420 | - [x] **Step 3: Pin both counts at the end.** Replace the final `echo "e2e OK"` with: |
| 419 | 421 | ||
| 420 | ```sh | 422 | ```sh |
| 421 | # The pins. Literals, not variables set from counting something else — | 423 | # The pins. Literals, not variables set from counting something else — |
| @@ -436,9 +438,9 @@ echo "e2e OK (9 scenarios, 21 convergence points)" | |||
| 436 | 438 | ||
| 437 | The literals 9 and 21 are the expected values from Tasks 2–3; if the sweep landed a different count, set the literal to what actually runs (count the call sites) — the pin's job is to freeze reality, not this plan's guess. Record the final numbers in the commit message. | 439 | The literals 9 and 21 are the expected values from Tasks 2–3; if the sweep landed a different count, set the literal to what actually runs (count the call sites) — the pin's job is to freeze reality, not this plan's guess. Record the final numbers in the commit message. |
| 438 | 440 | ||
| 439 | - [ ] **Step 4: Fire the mutation.** Comment out one `ok` call (the keygen one), run `make e2e`: MUST fail with the checkpoint-pin message. Restore, comment out one `assert_converged` call, run: MUST fail with the convergence-pin message. Restore, `git diff` shows only the intended Task 4 edits. | 441 | - [x] **Step 4: Fire the mutation.** Comment out one `ok` call (the keygen one), run `make e2e`: MUST fail with the checkpoint-pin message. Restore, comment out one `assert_converged` call, run: MUST fail with the convergence-pin message. Restore, `git diff` shows only the intended Task 4 edits. |
| 440 | 442 | ||
| 441 | - [ ] **Step 5: Run + commit.** `make e2e` green. | 443 | - [x] **Step 5: Run + commit.** `make e2e` green. |
| 442 | 444 | ||
| 443 | ```bash | 445 | ```bash |
| 444 | git add test/e2e.sh | 446 | git add test/e2e.sh |
| @@ -451,7 +453,7 @@ git commit -m "test: pin scenario and convergence counts to literals" | |||
| 451 | - Create: `test/soak.sh` (mode 755) | 453 | - Create: `test/soak.sh` (mode 755) |
| 452 | - Modify: `build.zig`, `Makefile` | 454 | - Modify: `build.zig`, `Makefile` |
| 453 | 455 | ||
| 454 | - [ ] **Step 1: Write `test/soak.sh`:** | 456 | - [x] **Step 1: Write `test/soak.sh`:** |
| 455 | 457 | ||
| 456 | ```sh | 458 | ```sh |
| 457 | #!/bin/sh | 459 | #!/bin/sh |
| @@ -514,7 +516,7 @@ cat "$SUMMARY" | |||
| 514 | exit 1 | 516 | exit 1 |
| 515 | ``` | 517 | ``` |
| 516 | 518 | ||
| 517 | - [ ] **Step 2: Wire the build.** In `build.zig`, after the e2e step: | 519 | - [x] **Step 2: Wire the build.** In `build.zig`, after the e2e step: |
| 518 | 520 | ||
| 519 | ```zig | 521 | ```zig |
| 520 | const soak = b.addSystemCommand(&.{"test/soak.sh"}); | 522 | const soak = b.addSystemCommand(&.{"test/soak.sh"}); |
| @@ -534,9 +536,9 @@ soak: | |||
| 534 | $(ZIG) build soak | 536 | $(ZIG) build soak |
| 535 | ``` | 537 | ``` |
| 536 | 538 | ||
| 537 | - [ ] **Step 3: Smoke it.** `chmod +x test/soak.sh`, then `SOAK_N=1 make soak` — one green run, "soak OK: 1/1". Then verify the failure path fires: `SOAK_N=1` with a deliberately broken pin (temporarily set the OK literal to 99), expect "soak FAIL: 1 of 1" plus the table naming the pin line. Restore the pin, `git diff` clean apart from Task 5 files. | 539 | - [x] **Step 3: Smoke it.** `chmod +x test/soak.sh`, then `SOAK_N=1 make soak` — one green run, "soak OK: 1/1". Then verify the failure path fires: `SOAK_N=1` with a deliberately broken pin (temporarily set the OK literal to 99), expect "soak FAIL: 1 of 1" plus the table naming the pin line. Restore the pin, `git diff` clean apart from Task 5 files. |
| 538 | 540 | ||
| 539 | - [ ] **Step 4: Commit.** | 541 | - [x] **Step 4: Commit.** |
| 540 | 542 | ||
| 541 | ```bash | 543 | ```bash |
| 542 | git add test/soak.sh build.zig Makefile | 544 | git add test/soak.sh build.zig Makefile |
| @@ -545,10 +547,10 @@ git commit -m "test: make soak repeats the suite and attributes failures to runs | |||
| 545 | 547 | ||
| 546 | ### Task 6: kill-criterion leg 1 — the soak run | 548 | ### Task 6: kill-criterion leg 1 — the soak run |
| 547 | 549 | ||
| 548 | - [ ] **Step 1:** `make test && make e2e` green (full, fresh). | 550 | - [x] **Step 1:** `make test && make e2e` green (full, fresh). |
| 549 | - [ ] **Step 2:** `SOAK_N=10 make soak` (~25 min — run it in the background and wait; do not shorten N: the criterion says NOT EXERCISED below 10). | 551 | - [x] **Step 2:** `SOAK_N=10 make soak` (~25 min — run it in the background and wait; do not shorten N: the criterion says NOT EXERCISED below 10). |
| 550 | - [ ] **Step 3:** If any run fails: diagnose per the quiesce rule (lengthen that scenario's pre-detach settle; never loosen a diff), fix, commit the fix, and RESTART the count at run 1 — 10/10 means ten consecutive greens on the final code. | 552 | - [x] **Step 3:** If any run fails: diagnose per the quiesce rule (lengthen that scenario's pre-detach settle; never loosen a diff), fix, commit the fix, and RESTART the count at run 1 — 10/10 means ten consecutive greens on the final code. |
| 551 | - [ ] **Step 4:** Record the result (N/N, wall clock per run, any fixes made) in the commit message of a docs touch or in the final close notes — this is kill-criterion evidence. | 553 | - [x] **Step 4:** Record the result (N/N, wall clock per run, any fixes made) in the commit message of a docs touch or in the final close notes — this is kill-criterion evidence. |
| 552 | 554 | ||
| 553 | --- | 555 | --- |
| 554 | 556 | ||
| @@ -558,11 +560,11 @@ Not code: a recorded experiment. Read the spec's Phase 2 section before starting | |||
| 558 | 560 | ||
| 559 | ### Task 7: campaign infrastructure + the control run | 561 | ### Task 7: campaign infrastructure + the control run |
| 560 | 562 | ||
| 561 | - [ ] **Step 1: Worktree.** `git worktree add ../mux-m11-campaign main` (or the repo's worktree convention). Copy the QUIC dep outputs so the first build doesn't refetch: `cp -r deps/quic/out deps/quic/work ../mux-m11-campaign/deps/quic/`. | 563 | - [x] **Step 1: Worktree.** `git worktree add ../mux-m11-campaign main` (or the repo's worktree convention). Copy the QUIC dep outputs so the first build doesn't refetch: `cp -r deps/quic/out deps/quic/work ../mux-m11-campaign/deps/quic/`. |
| 562 | - [ ] **Step 2: The old suite.** Record OLD_SUITE_SHA = the commit BEFORE Task 2's first e2e edit (find it: `git log --oneline -- test/e2e.sh`, the commit before "render-vs-dump convergence"). Extract: `git show OLD_SUITE_SHA:test/e2e.sh > ../mux-m11-campaign/e2e-old.sh && chmod +x ../mux-m11-campaign/e2e-old.sh`. It takes FOUR args (no RENDER). | 564 | - [x] **Step 2: The old suite.** Record OLD_SUITE_SHA = the commit BEFORE Task 2's first e2e edit (find it: `git log --oneline -- test/e2e.sh`, the commit before "render-vs-dump convergence"). Extract: `git show OLD_SUITE_SHA:test/e2e.sh > ../mux-m11-campaign/e2e-old.sh && chmod +x ../mux-m11-campaign/e2e-old.sh`. It takes FOUR args (no RENDER). |
| 563 | - [ ] **Step 3: The campaign doc.** Create `docs/superpowers/plans/2026-08-09-m11-campaign.md` (in the MAIN checkout — results are not mutations) with the methodology header (old-suite sha, binary provenance, the per-mutation procedure below) and an empty table: `| # | site | mutation | old suite | new suite | disposition |`. | 565 | - [x] **Step 3: The campaign doc.** Create `docs/superpowers/plans/2026-08-09-m11-campaign.md` (in the MAIN checkout — results are not mutations) with the methodology header (old-suite sha, binary provenance, the per-mutation procedure below) and an empty table: `| # | site | mutation | old suite | new suite | disposition |`. |
| 564 | - [ ] **Step 4: The control run — both suites green on UNMUTATED code.** In the worktree: `~/Downloads/zig-x86_64-linux-0.15.2/zig build`, then `./e2e-old.sh zig-out/bin/muxd zig-out/bin/mux zig-out/bin/rawmode zig-out/bin/delaypipe` and `test/e2e.sh <same four> zig-out/bin/render`. Both must pass — a campaign whose baseline is red measures nothing. Record the control row in the table. | 566 | - [x] **Step 4: The control run — both suites green on UNMUTATED code.** In the worktree: `~/Downloads/zig-x86_64-linux-0.15.2/zig build`, then `./e2e-old.sh zig-out/bin/muxd zig-out/bin/mux zig-out/bin/rawmode zig-out/bin/delaypipe` and `test/e2e.sh <same four> zig-out/bin/render`. Both must pass — a campaign whose baseline is red measures nothing. Record the control row in the table. |
| 565 | - [ ] **Step 5: Commit the campaign doc.** `git add docs/superpowers/plans/2026-08-09-m11-campaign.md && git commit -m "docs: M11 campaign methodology and control run"` | 567 | - [x] **Step 5: Commit the campaign doc.** `git add docs/superpowers/plans/2026-08-09-m11-campaign.md && git commit -m "docs: M11 campaign methodology and control run"` |
| 566 | 568 | ||
| 567 | **Per-mutation procedure (referenced by Tasks 8–11):** in the worktree — (1) apply the single edit; (2) rebuild with the pinned zig (`zig build` — remember `make test` does not rebuild binaries); (3) run the OLD suite, record caught (which FAIL line) or survived; (4) run the NEW suite, record the same; (5) `git checkout -- src/ test/` and verify `git diff --stat` empty; (6) append the row to the campaign doc in the main checkout and commit it (`docs: campaign row N`, batched per task is fine). A mutation that fails to COMPILE is not a mutation — rework it until it builds (the M10 lesson: inferred error sets can make an edit uncompilable; pick a different expression of the same break). | 569 | **Per-mutation procedure (referenced by Tasks 8–11):** in the worktree — (1) apply the single edit; (2) rebuild with the pinned zig (`zig build` — remember `make test` does not rebuild binaries); (3) run the OLD suite, record caught (which FAIL line) or survived; (4) run the NEW suite, record the same; (5) `git checkout -- src/ test/` and verify `git diff --stat` empty; (6) append the row to the campaign doc in the main checkout and commit it (`docs: campaign row N`, batched per task is fine). A mutation that fails to COMPILE is not a mutation — rework it until it builds (the M10 lesson: inferred error sets can make an edit uncompilable; pick a different expression of the same break). |
| 568 | 570 | ||
| @@ -570,53 +572,53 @@ Not code: a recorded experiment. Read the spec's Phase 2 section before starting | |||
| 570 | 572 | ||
| 571 | These are the mutations the old suite is structurally blind to (markers grep the byte stream; a wrong paint of a right byte passes). The kill criterion requires caught-new/survived-old to be demonstrated here. | 573 | These are the mutations the old suite is structurally blind to (markers grep the byte stream; a wrong paint of a right byte passes). The kill criterion requires caught-new/survived-old to be demonstrated here. |
| 572 | 574 | ||
| 573 | - [ ] **Mutation 1 — drop the last row of a delta paint.** In `client.zig`'s delta-paint loop (anchor: the `\x1b[{d};1H\x1b[2K` row paint, ~line 973): skip the final row of each delta's row set. | 575 | - [x] **Mutation 1 — drop the last row of a delta paint.** In `client.zig`'s delta-paint loop (anchor: the `\x1b[{d};1H\x1b[2K` row paint, ~line 973): skip the final row of each delta's row set. |
| 574 | - [ ] **Mutation 2 — paint one row off.** Same loop: paint at `row.row + 2` instead of `row.row + 1` (CUP is 1-based; +2 shifts every delta row down one). | 576 | - [x] **Mutation 2 — paint one row off.** Same loop: paint at `row.row + 2` instead of `row.row + 1` (CUP is 1-based; +2 shifts every delta row down one). |
| 575 | - [ ] **Mutation 3 — skip the clear on a full repaint.** In `paintFull` (anchor: `\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J`, ~line 941): drop the `\x1b[2J`. Stale glyphs survive under new content. | 577 | - [x] **Mutation 3 — skip the clear on a full repaint.** In `paintFull` (anchor: `\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J`, ~line 941): drop the `\x1b[2J`. Stale glyphs survive under new content. |
| 576 | - [ ] **Mutation 4 — SGR bleed.** In the delta row paint: if the row's bytes start with `\x1b[0m`, strip those 4 bytes before writing, so the previous row's style bleeds in. Expect: plain diff may pass, STYLED diff catches — record which. | 578 | - [x] **Mutation 4 — SGR bleed.** In the delta row paint: if the row's bytes start with `\x1b[0m`, strip those 4 bytes before writing, so the previous row's style bleeds in. Expect: plain diff may pass, STYLED diff catches — record which. |
| 577 | - [ ] **Mutation 5 — omit the synchronized-update wrapper.** Drop `\x1b[?2026h`/`\x1b[?2026l` from the delta paint. Expected honestly: BOTH suites may miss (2026 is a flicker property, not a state property, and it is pinned at the unit layer — client tests assert its presence). If both miss, that is a finding for Task 12: the likely disposition is banked-with-reason, but write it down, don't presume it. | 579 | - [x] **Mutation 5 — omit the synchronized-update wrapper.** Drop `\x1b[?2026h`/`\x1b[?2026l` from the delta paint. Expected honestly: BOTH suites may miss (2026 is a flicker property, not a state property, and it is pinned at the unit layer — client tests assert its presence). If both miss, that is a finding for Task 12: the likely disposition is banked-with-reason, but write it down, don't presume it. |
| 578 | - [ ] Run the per-mutation procedure for each; commit the rows. | 580 | - [x] Run the per-mutation procedure for each; commit the rows. |
| 579 | 581 | ||
| 580 | ### Task 9: replica-sync + prediction-overlay mutations (client) | 582 | ### Task 9: replica-sync + prediction-overlay mutations (client) |
| 581 | 583 | ||
| 582 | - [ ] **Mutation 6 — apply a delta but skip feeding the replica engine.** Screen paints from the payload; the replica goes stale; reconcile and any replica-sourced repaint diverge. | 584 | - [x] **Mutation 6 — apply a delta but skip feeding the replica engine.** Screen paints from the payload; the replica goes stale; reconcile and any replica-sourced repaint diverge. |
| 583 | - [ ] **Mutation 7 — ignore a snapshot's cols/rows prefix.** Skip the resize-to-prefix on snapshot apply. Expected honestly: likely BOTH miss (every e2e grid is 80×24 and there is no path to another size in the suite). A finding either way — the disposition question for Task 12 is whether a non-default-size scenario is buildable or the gap is banked with that reason. | 585 | - [x] **Mutation 7 — ignore a snapshot's cols/rows prefix.** Skip the resize-to-prefix on snapshot apply. Expected honestly: likely BOTH miss (every e2e grid is 80×24 and there is no path to another size in the suite). A finding either way — the disposition question for Task 12 is whether a non-default-size scenario is buildable or the gap is banked with that reason. |
| 584 | - [ ] **Mutation 8 — accept a delta for a stale seq without requesting resync.** In the client's delta arm (anchor: `last_seq = composed.header.seq`, ~line 772): remove the gap-detection/resync request so an out-of-sequence delta is applied anyway. | 586 | - [x] **Mutation 8 — accept a delta for a stale seq without requesting resync.** In the client's delta arm (anchor: `last_seq = composed.header.seq`, ~line 772): remove the gap-detection/resync request so an out-of-sequence delta is applied anyway. |
| 585 | - [ ] **Mutation 9 — leave a confirmed prediction's underline on.** In the overlay's confirm path: keep the confirmed cell in the painted set. The styled diff is the only e2e eye that can see this; counters stay correct. | 587 | - [x] **Mutation 9 — leave a confirmed prediction's underline on.** In the overlay's confirm path: keep the confirmed cell in the painted set. The styled diff is the only e2e eye that can see this; counters stay correct. |
| 586 | - [ ] **Mutation 10 — feed a prediction into the replica engine (the M9 invariant).** Where the overlay records a prediction, also `replica.feed(...)` the predicted byte. | 588 | - [x] **Mutation 10 — feed a prediction into the replica engine (the M9 invariant).** Where the overlay records a prediction, also `replica.feed(...)` the predicted byte. |
| 587 | - [ ] Run the procedure; commit the rows. | 589 | - [x] Run the procedure; commit the rows. |
| 588 | 590 | ||
| 589 | ### Task 10: daemon-side mutations — delta production and seq/epoch (server) | 591 | ### Task 10: daemon-side mutations — delta production and seq/epoch (server) |
| 590 | 592 | ||
| 591 | - [ ] **Mutation 11 — skip one dirty row.** In `DeltaTracker.buildDeltaSince` (~line 196) or `update` (~149): omit the highest-numbered dirty row from the payload (the prompt row, typically — marker greps live higher). | 593 | - [x] **Mutation 11 — skip one dirty row.** In `DeltaTracker.buildDeltaSince` (~line 196) or `update` (~149): omit the highest-numbered dirty row from the payload (the prompt row, typically — marker greps live higher). |
| 592 | - [ ] **Mutation 12 — emit rows in the wrong order.** Reverse the row order in the delta payload. Expected honestly: rows are absolutely addressed by CUP, so the final grid may be identical and BOTH suites may miss — a disposition-by-reason candidate. | 594 | - [x] **Mutation 12 — emit rows in the wrong order.** Reverse the row order in the delta payload. Expected honestly: rows are absolutely addressed by CUP, so the final grid may be identical and BOTH suites may miss — a disposition-by-reason candidate. |
| 593 | - [ ] **Mutation 13 — mark a changed row clean.** In the tracker's change detection: never mark row 0 dirty. | 595 | - [x] **Mutation 13 — mark a changed row clean.** In the tracker's change detection: never mark row 0 dirty. |
| 594 | - [ ] **Mutation 14 — reuse a seq.** Don't increment the seq for one update in each pair (every second delta repeats its predecessor's seq). | 596 | - [x] **Mutation 14 — reuse a seq.** Don't increment the seq for one update in each pair (every second delta repeats its predecessor's seq). |
| 595 | - [ ] **Mutation 15 — answer a reconnect with a delta when a snapshot is owed.** In the attach/reconnect handler (~line 1093 area): serve `buildDeltaSince` even when the client's `have_seq` is below the tracker's floor. Note: the old suite has real teeth here (the restart scenarios assert snapshot counters) — this row is the control showing the old suite catching what it was built to catch. | 597 | - [x] **Mutation 15 — answer a reconnect with a delta when a snapshot is owed.** In the attach/reconnect handler (~line 1093 area): serve `buildDeltaSince` even when the client's `have_seq` is below the tracker's floor. Note: the old suite has real teeth here (the restart scenarios assert snapshot counters) — this row is the control showing the old suite catching what it was built to catch. |
| 596 | - [ ] **Mutation 16 — wrong epoch in the snapshot prefix.** Send `epoch + 1` in the snapshot prefix the daemon writes. | 598 | - [x] **Mutation 16 — wrong epoch in the snapshot prefix.** Send `epoch + 1` in the snapshot prefix the daemon writes. |
| 597 | - [ ] Run the procedure; commit the rows. | 599 | - [x] Run the procedure; commit the rows. |
| 598 | 600 | ||
| 599 | ### Task 11: scrollback/clipping mutations (client) | 601 | ### Task 11: scrollback/clipping mutations (client) |
| 600 | 602 | ||
| 601 | - [ ] **Mutation 17 — clip bound off-by-one.** In the client's paint clipping (anchor: the unit test asserting `\x1b[24;80H` reachable and `\x1b[25;` never emitted, ~line 1780): clamp one row short, so the last grid row is never painted. | 603 | - [x] **Mutation 17 — clip bound off-by-one.** In the client's paint clipping (anchor: the unit test asserting `\x1b[24;80H` reachable and `\x1b[25;` never emitted, ~line 1780): clamp one row short, so the last grid row is never painted. |
| 602 | - [ ] **Mutation 18 — scroll position not reset on resync.** In the resync path (anchor: `repaint_after_resync`, ~line 585–632): skip leaving the scroll view on resync. Expected honestly: scroll view is tty-only (Shift+PageUp), so e2e likely can't see it under EITHER suite — unit tests at ~line 1653 pin it. Record and disposition. | 604 | - [x] **Mutation 18 — scroll position not reset on resync.** In the resync path (anchor: `repaint_after_resync`, ~line 585–632): skip leaving the scroll view on resync. Expected honestly: scroll view is tty-only (Shift+PageUp), so e2e likely can't see it under EITHER suite — unit tests at ~line 1653 pin it. Record and disposition. |
| 603 | - [ ] Run the procedure; commit the rows. Then remove the campaign worktree: `git worktree remove ../mux-m11-campaign` (after confirming `git -C ../mux-m11-campaign diff --stat` is empty). | 605 | - [x] Run the procedure; commit the rows. Then remove the campaign worktree: `git worktree remove ../mux-m11-campaign` (after confirming `git -C ../mux-m11-campaign diff --stat` is empty). |
| 604 | 606 | ||
| 605 | ### Task 12: findings resolution — the recording rule | 607 | ### Task 12: findings resolution — the recording rule |
| 606 | 608 | ||
| 607 | - [ ] **Step 1:** For every row where BOTH suites missed, decide per the recording rule — no third bucket: | 609 | - [x] **Step 1:** For every row where BOTH suites missed, decide per the recording rule — no third bucket: |
| 608 | - (a) **new assertion**, landed now in `test/e2e.sh` (or the unit layer if that is where the property lives), with the mutation RE-RUN in a fresh worktree to prove the new assertion fires — the checkbox for each such fix is that demonstrated failure; or | 610 | - (a) **new assertion**, landed now in `test/e2e.sh` (or the unit layer if that is where the property lives), with the mutation RE-RUN in a fresh worktree to prove the new assertion fires — the checkbox for each such fix is that demonstrated failure; or |
| 609 | - (b) **banked**, as a written entry (goes into decisions.md at close) stating exactly why the gap is accepted and what would reopen it. | 611 | - (b) **banked**, as a written entry (goes into decisions.md at close) stating exactly why the gap is accepted and what would reopen it. |
| 610 | - [ ] **Step 2:** Update the campaign table's disposition column for every row; no row may read "noted". | 612 | - [x] **Step 2:** Update the campaign table's disposition column for every row; no row may read "noted". |
| 611 | - [ ] **Step 3:** If new assertions changed the convergence or checkpoint counts, update the Task 4 literals and say so in the commit. | 613 | - [x] **Step 3:** If new assertions changed the convergence or checkpoint counts, update the Task 4 literals and say so in the commit. |
| 612 | - [ ] **Step 4:** Re-run `make test && make e2e` green; if e2e assertions were added, re-run `SOAK_N=10 make soak` for the final code (leg 1 must hold on what ships). | 614 | - [x] **Step 4:** Re-run `make test && make e2e` green; if e2e assertions were added, re-run `SOAK_N=10 make soak` for the final code (leg 1 must hold on what ships). |
| 613 | - [ ] **Step 5:** Commit: `git commit -m "test: assertions landed from campaign findings; campaign table complete"` | 615 | - [x] **Step 5:** Commit: `git commit -m "test: assertions landed from campaign findings; campaign table complete"` |
| 614 | 616 | ||
| 615 | ### Task 13: milestone close | 617 | ### Task 13: milestone close |
| 616 | 618 | ||
| 617 | - [ ] **Step 1:** `docs/decisions.md` — new M11 section: the campaign table IN FULL (it is the milestone's measurement, as M9's latency table was), the kill-criterion evidence for both legs (soak 10/10 with wall clocks; paint-path caught-new/survived-old rows called out), any defects found en route (the raw-mode phantom-glyph candidate, if it fired), and the banked entries from Task 12 with their reasons. | 619 | - [x] **Step 1:** `docs/decisions.md` — new M11 section: the campaign table IN FULL (it is the milestone's measurement, as M9's latency table was), the kill-criterion evidence for both legs (soak 10/10 with wall clocks; paint-path caught-new/survived-old rows called out), any defects found en route (the raw-mode phantom-glyph candidate, if it fired), and the banked entries from Task 12 with their reasons. |
| 618 | - [ ] **Step 2:** `docs/roadmap.md` — M11 section marked complete with a verdict paragraph; ASAN/valgrind and the unit-layer mutation sweep stay banked, each annotated with what the campaign taught about them; delete the campaign scratch doc (`git rm docs/superpowers/plans/2026-08-09-m11-campaign.md`) once its table lives in decisions.md. | 620 | - [x] **Step 2:** `docs/roadmap.md` — M11 section marked complete with a verdict paragraph; ASAN/valgrind and the unit-layer mutation sweep stay banked, each annotated with what the campaign taught about them; delete the campaign scratch doc (`git rm docs/superpowers/plans/2026-08-09-m11-campaign.md`) once its table lives in decisions.md. |
| 619 | - [ ] **Step 3:** Tick this plan's checkboxes; commit: `git commit -m "docs: M11 close — convergence harness, pins, soak, campaign table"` | 621 | - [x] **Step 3:** Tick this plan's checkboxes; commit: `git commit -m "docs: M11 close — convergence harness, pins, soak, campaign table"` |
| 620 | 622 | ||
| 621 | --- | 623 | --- |
| 622 | 624 | ||