docs(journey): framing revision 2 — close review round 1

Two blocking, three major, one minor. All six accepted and verified in
the code before the doc changed.

The affinity key was misstated. `ensure_server` sets `key_uri` only
when the root came from config or a marker walk, so a markerless file
keys on nil and loose files across unrelated directories deliberately
share one server per language. Revision 1's "(root, language)" would
have split what the runtime shares and re-reported one failure once per
directory. The memo now uses the real key and the stage changes no
reuse behaviour.

Dedupe and current-failure state were conflated into one record, and
revision 1 said nothing about recovery. One record cannot do both jobs:
keep it and `*lsp*` shows a failure the user has already fixed; clear
it and the message returns on the next file open. They are now two
records with different lifetimes, the reported identity includes the
command so repointing config at a different missing executable reports
again, and recovery is pinned in both surfaces.

The modeline provider is a pure per-buffer projection by design — it
runs for every window on every paint. Revision 1's "read the failure
table" would have made it derive an affinity key, invoking user root
resolvers and project detection inside painting. The failure is now
projected per buffer at attach time and the segment stays one map
lookup, with a preservation pin that counts resolver invocations.

"Adopt listview's idiom" is replaced by requiring `pmacs.listview.open`
and naming what it guarantees, including `on_refresh` — without which
`listview.refresh` early-returns and `g` is a bound key that silently
does nothing.

The acceptance had no journey ratchet row despite the stage making step
6 real; `tests/journey_acceptance.rs` states that steps 6-12 join as
later stages make them real, and 1b-1 added step 9 the same way. The
end-to-end row is now acceptance 1 and the M4 pins stay beneath it.

Also refreshes the ledger's canonical-base anchor and recovery floor
from `7586905` to `fbcf235`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lv428Fth9LRtffwJSsqH7T
This commit is contained in:
Levi Neuwirth 2026-07-30 21:34:02 -04:00
parent 638b108774
commit 931d949c6b
2 changed files with 271 additions and 64 deletions

View File

@ -62,17 +62,19 @@ lesson, §1 for the two framings).
machine-local: `origin` may name this canonical URL, a release mirror,
or something else, and therefore has no authority by name alone.
- Canonical base at this snapshot:
`githubsucks/main` @ `7586905` (the docs-only coherence listview
correction #189, atop the docs-only landed-state refresh #185, the M4
config-sink race fix #174, bottom-panel Stage 2B-1 #184, the
Journey/GPU directory-target ratchet #183, Journey Stage 1a #182 and
the previously recorded landed work).
`githubsucks/main` @ `fbcf235` (the reap-ledger diagnostic #202, atop
the test ambient-root isolation framing #201, the reap-ledger framing
#200, the ledger absorption #199, the M5.5 protocol-version pin #198,
the docs-only coherence listview correction #189, the docs-only
landed-state refresh #185, the M4 config-sink race fix #174,
bottom-panel Stage 2B-1 #184, the Journey/GPU directory-target ratchet
#183, Journey Stage 1a #182 and the previously recorded landed work).
**Protocol schema support is
`v6..=v21`; the production server-first `Hello` still advertises
v20** — two different facts, and #184 landed only the first. The
previous snapshot named `0442d78`, and **the
previous snapshot named `7586905`, and **the
recovery floor advances with it**: the check below now requires
`7586905` or newer, so a tree at `0442d78` no longer passes. That is
`fbcf235` or newer, so a tree at `7586905` no longer passes. That is
deliberate — the floor moves with the base, because a check that
accepts an older commit than the declared base passes on a tree the
rest of this file does not describe.
@ -111,7 +113,7 @@ git worktree list
git status --short --branch
```
The `git log` command must expose `7586905` — the base named above — or a
The `git log` command must expose `fbcf235` — the base named above — or a
newer intentional main. Keep this threshold and the canonical-base line in
step: a recovery check that accepts an older commit than the base it
declares canonical will pass on a tree the rest of this file does not
@ -258,8 +260,9 @@ If it does not, stop and repair the remote/fetch configuration.
- **Branch `journey-stage1b2-lsp-guidance`**, worktree
`../pmacs-journey-1b2`, based on `githubsucks/main` @ `fbcf235`.
**Framing only; no code, no PR yet.**
`docs/journey-stage1b2-lsp-guidance-framing.md` revision 1, no review
rounds yet. Sibling of Stage 1b-1 (PR #203, step 9); this is step 6.
`docs/journey-stage1b2-lsp-guidance-framing.md` revision 2, one review
round closed (two blocking, three major, one minor; all accepted).
Sibling of Stage 1b-1 (PR #203, step 9); this is step 6.
- **What it is.** `COHERENCE.md` §1.2's canonical silence: a
preconfigured-but-missing language server fails with no status
message, no record, and no modeline marker, while tree-sitter
@ -288,6 +291,23 @@ If it does not, stop and repair the remote/fetch configuration.
rate is **once per file open**. Hence the rule: **memoize the report,
not the failure** — the spawn is still retried, so installing the
binary mid-session recovers with nothing to invalidate.
- **The affinity key is `(language, key_uri)`, and `key_uri` is nil for
markerless files** — `ensure_server` sets it only when the root came
from config or a marker walk (`lsp.lua:644-648`), so loose files in
unrelated directories deliberately share one server per language.
Round 1 caught revision 1 keying the memo on the resolved *root*,
which would have split what the runtime shares and re-reported one
failure per directory.
- **Dedupe and current-failure state are two records, not one.**
`reported` is keyed by `(language, key_uri, command)` and never
cleared — the command is in the key so repointing config at a
different missing executable reports again. `failures` is keyed by
`(language, key_uri)` and **cleared when a spawn for that key
succeeds**, so `*lsp*` stops showing a failure the user has fixed.
A third, buffer-keyed projection feeds the modeline, because the
statusline provider is a **pure per-buffer lookup** and deriving an
affinity key inside it would run root resolvers and project detection
every frame, for every window.
- **The durable surface cannot show the failure today.**
`status_buffer_text` renders from `self.clients`, which a failed spawn
never enters. The stage keeps its failure record in Lua and renders it

View File

@ -1,11 +1,50 @@
# Journey Stage 1b-2 — say when language intelligence did not start
**Status: framing, rev 1 — awaiting approval.**
**Status: framing, rev 2 — awaiting review round 2.**
**Serves `COHERENCE.md` §1.2 (the silence asymmetry), §2 (the golden
journey, step 6), §19, §20 Priority 1.**
## 0. Revision history
- rev 2 (2026-07-30) — review round 1. Two blocking, three major, one
minor; all six accepted, all six verified in the code first.
- **The affinity key was misstated** (§2.2). The runtime's reuse key
is `(language, key_uri)`, and `key_uri` is deliberately **nil** for a
markerless file — `ensure_server` sets it only when the root came
from config or a marker walk (`lsp.lua:644-648`), so loose files
across unrelated directories share **one** server per language, on
purpose. Rev 1 said "(root, language)", which would have given every
directory of loose files its own memo entry and re-reported the same
shared failure once per directory. The stage now keys on the real
affinity key and does not change that behaviour.
- **Dedupe and current-failure state were conflated** (§2.2, §2.4).
Rev 1 had one record and said nothing about recovery. One record
cannot do both jobs: keep it and `*lsp*` shows a failure that has
since been fixed; clear it and the per-session dedupe is lost, so
the message returns on the next file open. They are now two records
with different lifetimes, and **the reported identity includes the
command**, so repointing config from one missing executable to
another reports again. Recovery is pinned in both surfaces.
- **The modeline's pure per-buffer projection had to be preserved**
(§2.5). The provider reads `attachments[ctx.buffer]` specifically so
a passive split reports its own buffer, and does no work while
painting. Rev 1's "read the failure table" would have made the
segment recompute an affinity key — invoking `project_root_for`,
user root resolvers, and `pmacs.project.detect` **every frame, for
every window**. The failure is now projected per buffer at attach
time, and the segment stays a single map lookup.
- **"Adopt listview's idiom" was too weak** (§2.3). It now requires
`pmacs.listview.open` and names the guarantees that come with it,
including `on_refresh` — without which `listview.refresh` early
returns (`listview.lua:259`) and `g` is a **dead key** in the new
panel.
- **§19's journey ratchet was missing from acceptance** (§4). This
stage makes step 6 real, and `tests/journey_acceptance.rs`'s stated
rule is that steps 612 join as later stages make them real. The M4
pins stay; an end-to-end journey row is added.
- **The ledger's canonical-base anchor was stale** — it named
`7586905` while `main` is `fbcf235`. Updated with the recovery
floor, which moves with it.
- rev 1 (2026-07-30) — first framing. Scouted against `githubsucks/main`
@ `fbcf235` (reap-ledger #202).
@ -204,28 +243,90 @@ Three parts, each load-bearing:
3. **The two things a user can do** — install it, or repoint the config
— plus the durable surface.
### 2.2 When to say it — once per (root, language), per session
### 2.2 When to say it — and two records, not one
Per §1.4 the failure recurs on every file open, so the report is
memoized on the **same affinity key `ensure_server` already computes**
(`key_uri` plus `language`).
memoized. Two things have to be got right, and rev 1 got both wrong.
**Memoize the report, not the failure.** The spawn is still attempted
every time, so a user who installs the binary mid-session gets a working
server on the next file open with no cache to invalidate. Only the
*message* is suppressed after the first.
**The key is `ensure_server`'s own affinity key, which is not the
root.** `ensure_server` computes:
That asymmetry is the whole rule, and it is the one thing here easy to
get backwards: memoizing the failure would be a behaviour change, would
need invalidation, and would make recovery require a restart.
```lua
-- builtin/runtime/lsp.lua:644-648
local root, source = project_root_for(language, path)
local key_uri = nil
if source == "config" or source == "detected" then
key_uri = file_uri_for(root)
end
```
`key_uri` is **nil** whenever the root came from the fallback (the
file's own directory), and the reuse loop matches `info.root_uri ==
key_uri`, nil matching nil. That is deliberate and documented in place:
loose files in unrelated directories share **one** server per language,
because keying them on their own directories "would give every directory
of loose scratch files its own server, for every language".
So the memo key is `(language, key_uri)` — the same pair, nil included —
and **not** the resolved root. Keying on the root would split what the
runtime deliberately shares and re-report one shared failure once per
directory. This stage does not change that behaviour; it matches it.
**Two records, because one cannot do both jobs.**
| Record | Key | Lifetime | Read by |
|---|---|---|---|
| `reported` | `(language, key_uri, command)` | never cleared, session-scoped | the status-line report only |
| `failures` | `(language, key_uri)` | **cleared when a spawn for that key succeeds** | `*lsp*`, and the per-buffer projection |
Rev 1 had one record and said nothing about recovery. One record forces
a bad choice: keep it and `*lsp*` reports a failure the user has since
fixed; clear it and the dedupe is gone, so the message returns on the
next file open.
**`reported` includes the command.** A user who repoints
`pmacs.lsp.config.rust.command` from one missing executable to another
has a genuinely new failure and must hear about it; a key without the
command would swallow it as a duplicate.
**Memoize the report, not the failure.** The spawn is still attempted on
every file open, so a user who installs the binary mid-session gets a
working server with no cache to invalidate — and that success is what
clears `failures`. This asymmetry is the whole rule and the easiest
thing here to get backwards: memoizing the *failure* would be a
behaviour change, would need invalidation, and would make recovery
require a restart.
### 2.3 Where — status line now, `*lsp*` for later
The status line is transient and can be overwritten before it is read;
COHERENCE's rule is that an automatic failure must leave a **trace**,
not a flash. So the same event also lands in a durable record that
not a flash. So the same event also lands in `failures`, which
`M-x lsp.status` renders.
**`*lsp*` is opened with `pmacs.listview.open`, not merely "in its
idiom".** Naming the primitive is what buys the guarantees, and a
hand-rolled panel would have to re-derive every one of them:
- **Owned-handle identity.** Found-by-name is *not* adoption
(`listview.lua:157-167`): a foreign buffer already called `*lsp*` is
never claimed, clobbered, or given an erroring intercept.
- **Collision behaviour.** A taken name disambiguates `*lsp*<2>`…`<99>`
and **raises** when exhausted rather than adopting.
- **Immutable generated contents** — a read-only intercept with a named
error, plus round-trip input so a semantic frontend cannot swallow the
panel's single-key bindings.
- **`q`** quits to the previous buffer.
- **`on_refresh`, which is not optional here.** `listview.refresh`
early-returns unless the panel has one (`listview.lua:259`), so
omitting it makes **`g` a dead key** — a bound chord that silently
does nothing. The panel's `on_refresh` recomputes *both* sections, so
`g` after installing a server shows the recovery.
`status_buffer_text()` returns one string; the panel splits it into
inert rows (no `on_visit`) beneath the failure section. Rows without a
visit action are an ordinary listview shape, not a special case.
### 2.4 The durable record lives in Lua, and that is a deliberate limit
§1.6 established that `status_buffer_text` renders from `self.clients`,
@ -249,19 +350,54 @@ promoting it as follow-on work rather than pretending it is done.
§1.5 is the sharpest half of §1.2: highlighting masks the failure, so a
user has no reason to *go looking* for a command. The segment therefore
gains one branch — when the buffer's (root, language) has a recorded
failure and no attachment, render a distinct label:
gains one branch — when the buffer has a recorded failure and no
attachment, render a distinct label:
```
LSP:! (vs LSP:ok / LSP:… / nothing at all)
```
**The segment must stay a pure per-buffer projection.** Its comment says
so — *"pure modeline projection … reads the private per-buffer
attachment map directly so passive split windows report their own buffer
… never attaches, flushes didChange, or issues a request"*
(`lsp.lua:969-983`) — and the reason is structural, not stylistic: it
runs for **every window, every paint**.
Rev 1 said the segment should "read the failure table", which is keyed
by `(language, key_uri)`. Deriving that key from a buffer means calling
`project_root_for`, which invokes **user-supplied root resolvers** and
`pmacs.project.detect`. Per frame, per window. A resolver with a side
effect, or one that raises, would then run inside painting.
So the failure is **projected per buffer at attach time**, beside the
existing map:
```lua
failed_attachments[tostring(buf)] = { language = …, command = … }
```
written where `attach_buffer` observes `ensure_server` returning nil, and
**cleared on the same path when an attach succeeds**. The segment
becomes one more map lookup and computes nothing:
```lua
local rec = attachments[key]
if rec then return "LSP:" .. pmacs.lsp.modeline_label(rec.server) end
if failed_attachments[key] then return "LSP:!" end
return nil
```
**It must not fabricate an attachment record.** `attachments` is read by
`attachment_for_request`, the completion driver, and the request paths,
`attachment_for_request`, the completion driver, and every request path,
all of which treat a record as naming a live server; inventing one would
route requests at a server that does not exist. The segment reads the
failure table directly and returns early, exactly as it does today for
"no record".
route requests at a server that does not exist. The failure projection is
a **separate** table for exactly that reason.
Two tables with two lifetimes: `failures` (affinity-keyed, for `*lsp*`)
and `failed_attachments` (buffer-keyed, for the modeline). They are
written and cleared at the same moment, but neither can serve the
other's reader without doing work in the wrong place.
### 2.6 What this stage does not do
@ -301,38 +437,83 @@ failure table directly and returns early, exactly as it does today for
Labels per Stage 1a §6.0: **N** new behaviour, must fail on full
revert; **P** preservation, falsified by a named mutation.
1. **N — the failure is reported, through the real path.** Open a file
whose configured server command does not exist; the status line names
the command, the language, and the underlying error. Driven through
`buffer.after-load`, not by calling `ensure_server` directly — the
hook's `pcall` is part of what is being tested.
2. **N — it is reported once per (root, language).** Open a second file
in the same project: the spawn is attempted again (observable), the
message is not repeated. Falsified by dropping the memo.
3. **N — the memo is on the report, not the failure.** After a failed
**The journey row comes first**, because it is the one that says the
stage worked.
1. **N — journey step 6, end to end** (`tests/journey_acceptance.rs`).
That file's rule is that steps 612 join as later stages make them
real, and 1b-1 added step 9 the same way. Launch on a project whose
configured server command does not exist, open a source file through
the real dired `RET`, and assert the user is **told**: the status
line names the command and the modeline reads `LSP:!` rather than
nothing. This is the ratchet row; the M4-level pins below stay and
cover the mechanism.
2. **N — the failure is reported, through the real path.** The status
line names the command, the language, and the underlying error.
Driven through `buffer.after-load`, not by calling `ensure_server`
directly — the hook's `pcall` is part of what is being tested.
3. **N — reported once per `(language, key_uri, command)`.** Open a
second file **in the same project** (same detected root, so the same
non-nil `key_uri`): the spawn is attempted again — observable, and
asserted — and the message is not repeated. Falsified by dropping the
memo.
4. **N — the markerless case shares one memo, as it shares one server.**
Two loose files in *different* directories, neither under a project
marker, both resolve `key_uri = nil` and report **once** between them.
Falsified by keying the memo on the resolved root, which is rev 1's
design: that reports twice. This pin exists because the root and the
affinity key differ exactly here.
5. **N — a different language, or a genuinely different root, reports
again.** Falsified by keying on the language alone, and by keying on
the language plus a constant.
6. **N — a changed command reports again.** Repoint the config from one
missing executable to another and open a file: a second message,
naming the new command. Falsified by dropping `command` from the
reported identity.
7. **N — the memo is on the report, not the failure.** After a failed
attach, make the command resolvable and open another file: a server
attaches. Falsified by memoizing the failure instead — this is §2.2's
whole claim and needs its own pin.
4. **N — a different language, or a different root, reports again.**
Falsified by keying the memo on the language alone.
5. **N — `M-x lsp.status` renders a buffer** containing both the failure
section and `status_buffer_text()`'s output. Falsified by removing
either half. Must assert **content produced**, not that a buffer
exists.
6. **N — the modeline distinguishes failed from not-applicable.** A
`.rs` file with a failed spawn renders `LSP:!`; a `.txt` file renders
nothing. Both halves asserted — a pin that only checks the `.rs` case
passes if the segment renders `!` unconditionally.
7. **P — a working server is unaffected.** Attach, modeline label,
requests: unchanged. Targeted mutation: making the new branch fire
whenever an attachment is absent, which would mark every plain-text
buffer failed.
8. **P — the two existing report sites still report.** Root-resolver and
subscriber failures keep their messages. Targeted mutation:
refactoring the three sites onto a shared helper that drops one.
9. **P — a fabricated attachment is never created.** After a failed
spawn, `attachment_for_request` returns nil and no request is issued.
Targeted mutation: §2.5's forbidden implementation.
attaches. Falsified by memoizing the failure instead — §2.2's whole
claim, and it needs its own pin.
8. **N — recovery clears both surfaces.** Continuing from 7 in the same
session: the modeline for the recovered buffer reads its live label,
and `M-x lsp.status` no longer lists that failure. Falsified by never
clearing `failures` / `failed_attachments`, which is what one record
would have forced. **Asserted in both surfaces**, because they read
different tables (§2.5).
9. **N — `M-x lsp.status` renders a buffer** containing both the failure
section and `status_buffer_text()`'s output. Asserts **content
produced**, not that a buffer exists.
10. **N — `g` refreshes the panel.** With the panel open, resolve the
failure and press `g`: the failure section is gone. Falsified by
omitting `on_refresh`, which makes `listview.refresh` early-return
(`listview.lua:259`) and leaves the panel stale while `g` appears
bound.
11. **N — a foreign `*lsp*` buffer is not adopted.** Create a buffer
named `*lsp*` with user content, then run `lsp.status`: the user's
bytes are untouched and the panel opens as `*lsp*<2>`. This is
`listview.open`'s guarantee, and it is pinned here rather than
assumed because "found by name is not adoption" is precisely the
rule a hand-rolled panel loses.
12. **N — the modeline distinguishes failed from not-applicable.** A
source file with a failed spawn renders `LSP:!`; a plain-text buffer
renders nothing. **Both halves asserted** — a pin that checks only
the failing case passes if the segment renders `!` unconditionally.
13. **P — the segment does no work.** With a failure recorded, painting
the modeline invokes neither a root resolver nor
`pmacs.project.detect`. Pinned with a counting resolver installed
through the real config; assert the count is unchanged across
repeated renders. Targeted mutation: rev 1's design, which derives
the affinity key inside the provider.
14. **P — a working server is unaffected.** Attach, modeline label,
requests: unchanged. Targeted mutation: making the new branch fire
whenever an attachment is absent, which would mark every plain-text
buffer failed.
15. **P — the two existing report sites still report.** Root-resolver
and subscriber failures keep their messages. Targeted mutation:
refactoring the three sites onto a shared helper that drops one.
16. **P — a fabricated attachment is never created.** After a failed
spawn, `attachment_for_request` returns nil and no request is
issued. Targeted mutation: §2.5's forbidden implementation.
**Fixture note.** The natural fixture points a config at a path that
does not exist, which is reliable and hermetic. It must assert its own
@ -362,12 +543,18 @@ relying on rust-analyzer's absence.
- **Journey steps touched:** 6 (Partial → Works for the failure case;
the success case is already fine). Indirectly 10, which is gated on 6
or 9 succeeding. 9 was closed by 1b-1.
or 9 succeeding. 9 was closed by 1b-1. **The ratchet gains a step-6
row** (§4 acceptance 1) — `tests/journey_acceptance.rs` states that
steps 612 join as later stages make them real, so a stage that makes
one real and adds no row leaves the ratchet describing a journey the
editor has outgrown.
- **Interaction islands: none added.** The message uses the existing
status line; `*lsp*` is a generated read-only buffer of the kind
`COHERENCE.md` §14 already calls the proven "output channel" pattern,
and it should adopt `listview`'s idiom rather than inventing a third
read-only buffer shape.
status line; `*lsp*` is opened through **`pmacs.listview.open`**
(§2.3), the primitive `COHERENCE.md` §14 records as the proven
listview/panel shape — not a third hand-rolled read-only buffer. §14's
complaint is precisely that each new panel re-invented ownership,
read-only enforcement, and quit behaviour; naming the primitive is how
this stage avoids being the next example.
- **Config registry:** not adopted. Nothing here is a user-tunable
scalar; the memo is session state, not configuration.
- **Background-work attribution:** this *is* the attribution fix for one