Commit Graph

1481 Commits

Author SHA1 Message Date
Levi Neuwirth 26a26006bb
docs: worker identity framing revision 4 --- scope rule 1, take Q#W-7
Two changes, both from review, both about claiming exactly what is
enforced and no more.

Rule 1 said it covered "all yield points". It covers the two supported
pmacs yield APIs. Raw coroutine.yield remains reachable: R46 is a
convention ("package code uses :await() rather than coroutine.yield",
async.lua:26-27), not an enforcement, and the scheduler diagnoses a
non-Handle yield only after the fact --- step() resumes at :197 and
inspects what came back at :212, by which point the coroutine has
already suspended and the enclosing dispatch never returns to run its
pop. No refusal sited in a yield helper can intercept that.

So the property is stated as what it is: the supported ways to yield
are refused inside the scope, and an R46 violation can still leak the
name --- loudly, through pmacs.error into *errors*, but unrestored.
Section 6 says explicitly that this is NOT asserted, because a test
implying coverage the design lacks is worse than the recorded gap.

Q#W-7 is approved into this lane rather than split out. Same supported
helper, same invariant, same async.lua edit family; splitting would
preserve a known hole without reducing integration risk. So
yield_to_next_tick gains both refusals --- the new
_in_dispatch_name_scope and the missing _in_commit_scope --- and the
commit_to gap closes in the same commit as rule 1. Its witnesses are
the same pair as rule 1: the refusal fires AND the scope restores, on
the reasoning that a guard which raises while leaving the scope pushed
trades a silent fault for a loud one and fixes neither.

Reachability by a real caller stays UNPROVEN and the framing says so in
three places, including here. The defect was found by reading; the
tests pin the guard rather than reproducing a user-visible bug. Nobody
should later cite this as evidence the bug was observed.

The causal-extent paragraph and the Q#W-5 comparison were both
re-scoped to match, since both leaned on "non-yieldable" as an
unqualified property.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:51:54 +02:00
Levi Neuwirth 7730f87bba
docs: worker identity framing revision 3 --- the ambient must not yield
Review found that revision 2 asserted the property its whole design
rested on. It called the dispatch-name extent "synchronous" and never
checked. A registered handler is arbitrary Lua running inside
pmacs.async and may call Handle:await(), which parks the coroutine with
the name still pushed --- so every tick callback and every other
coroutine allocating a job in the meantime inherits it. The existing
tests already await inside pcall, so this is the ordinary shape of an
awaiting handler, not a corner case.

Rule 1 now enforces non-yieldability instead of assuming it, and the
enforcement was already in this file: Handle:await refuses to run
inside pmacs.window.commit_to (async.lua:87-90), with a comment giving
exactly this reasoning --- yielding "would restore the scope while this
coroutine is still parked". _in_dispatch_name_scope joins
_in_commit_scope in the same place.

Three details decide whether the guard holds, and all three are pinned:
it rejects before the park, not after; it rejects unconditionally
rather than only when the handle is incomplete, because a guard keyed
on whether the job happened to finish first passes under test and fails
intermittently in production; and it covers both yield points.

That last one is a finding. pmacs.async.yield_to_next_tick
(async.lua:243-245) also yields and is public. Guarding only await
would have left the hole open through a second door.

Which exposes Q#W-7: the existing commit_to guard has exactly that gap
today. yield_to_next_tick carries no _in_commit_scope refusal, so
Journey Stage 1a Q#JR14b invariant has a second entrance. Reported
rather than patched, and reachability by a real caller is explicitly
UNPROVEN --- it is a code reading, not a repro. My vote is to fix it in
this lane since the lane already edits that function family, but it is
another lane invariant so it is a question.

Q#W-5 justification is rewritten rather than left standing. It argued
parent was deferrable because its ambient would span asynchronous
lifetimes while this one did not --- an argument revision 2 was not
entitled to make, since its own ambient could be parked by any awaiting
handler. With rule 1 the distinction is real: this extent cannot be
suspended, and parent cannot be rescued by refusing to yield because
yielding is the mechanism it needs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:51:54 +02:00
Levi Neuwirth e1ca382ea5
docs: worker identity framing revision 2 --- drop owner, specify the name path
Two review blockers, both correct, both about the same failure: a field
or a claim that looks like attribution without being it.

BLOCKER 1 --- owner is not honest before P3. Revision 1 proposed
owner = package-or-builtin while populating it from static
per-subsystem constants at each dispatcher. Those disagree. A generic
dispatcher has no trustworthy knowledge of who invoked it, and
pmacs.process.spawn is callable by any package, so a static "lsp" label
is an origin or category and would confidently misattribute
third-party work to a builtin at exactly the point section 9 wants
attribution.

owner is removed rather than renamed. origin or subsystem would be
honest wording, but a second string field beside purpose, used to group
the view, gets adopted as ownership by the next reader regardless of
its name --- and it would squat on the slot P3 has to fill. Stage 2
needs a grouping key and should get a real one. No P3 alignment is
claimed any more.

BLOCKER 2 --- the handler name needs a mechanism, not a parameter.
Revision 1 said the name was "in hand at the one place that throws it
away". That was wrong about the call chain, and re-reading it is what
showed why: dispatch(name) calls an arbitrary handler, which calls a
Lua wrapper, which calls the Rust binding, and name is a parameter of
none of them. Worse, async.lua:337-345 documents the wrapper layer as
bypassable --- other runtime files are told to call their own raw
_dispatch_* primitives --- so capturing in the wrappers would miss
exactly the callers attribution exists for.

Q#W-2 is rewritten as a contract: a dispatch-name stack owned by the
async runtime and read at allocate, the same single funnel Q#W-1
collapses. Seven rules, including the two that decide whether it is
better than nothing --- unwind-safe popping, because one erroring
handler would otherwise poison every later dispatch with a stale name,
and composition rather than replacement of a caller-supplied purpose,
because replacing recreates blocker 1 in a new place.

It also answers the objection it invites: why is this ambient allowed
when Q#W-5 defers parent for needing one. Because they are different
mechanisms --- this is a synchronous single-threaded extent with a
deterministic pop, and parent needs a lifetime model spanning ticks and
post-settlement callbacks.

Verification takes the reviewer wording fix: presence is a type
obligation now that purpose is non-optional in a private JobSpec, so
the compiler proves it and the tests prove semantics at representative
entry paths. The handler witness must be a registered handler calling a
real dispatcher, not a synthetic funnel test.

The title changed too: "who asked for it" overclaimed once owner left.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:51:54 +02:00
Levi Neuwirth dda90a2c37
docs: frame worker identity Stage 1 (revision 1)
COHERENCE.md section 0 names background-work ownership as one of two
remaining thin ends of the golden journey, and section 20 puts it
outside Priority 1 while conceding it is the last of that priority own
work. Section 9 grades the worker model "mechanism without identity".

The felt gap is narrower than the arc and is checkable: grep -c for
spinner/progress/busy in src/statusline.rs returns 0, so section 3
promise of "visible asynchronous work" is false today unless the user
knows to run M-x editor.list-workers. The git Stage 1 lane in flight
right now records a deliberate negative section 9 impact for exactly
this reason; this lane is the one that repays it.

Two scouting findings shaped the staging rather than confirmed it.

PendingJob carries eight fields, not the seven the audit lists, and the
eighth doc comment cites section 9 by name as the reason identity
belongs on the job and not in a side map. So this extends a merged
decision instead of introducing one.

pmacs.process.list filters to LineOriented, dating to the vterm Stage 1
commit, and three acceptance suites use #pmacs.process.list() as a leak
detector. Widening that accessor to show terminal PTYs would inflate
all three baselines. Making PTYs visible therefore moves to Stage 2
behind a separate accessor, which is a better answer than editing tests
that are correctly detecting a semantic change.

NO WIRE CHANGE, and that is load-bearing for scheduling: discovery
Stage 2 holds the v22-to-v23 bump slot and git Stage 2 is queued behind
it. The activity indicator is a fourth pmacs.statusline.register
provider on the existing StatuslineSegments vector.

The framing also flags a deliberate deviation from the audit rather
than quietly taking it: section 9 names owner/purpose/parent together,
and Stage 1 takes only the first two, because an unpopulated parent
field reads as "no parent" rather than "not tracked".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:51:54 +02:00
Levi Neuwirth 0857bf4349
Merge pull request #228 from levineuwirth/discovery-stage2
feat(discovery): M-x rows carry descriptions — protocol v22 → v23
2026-08-10 12:50:27 +00:00
Levi Neuwirth 4654b940ff
docs: carry the four-plus-one dedication count into the framing
fb3974b corrected the ledger --- "eight writes exist; five are
reachable" listed four, the fifth being quit_window's
QuitAction::Restore, proved unreachable and guarded anyway --- but the
framing kept the old count, and the framing is the artifact that
outlives the ledger.

Swept rather than patched at the two known lines. Every count claim
about dedication routes, sites and writes now agrees with the §3 table,
in the ledger's phrasing: four are reachable, a fifth is guarded
defensively, and ALL FIVE ARE GUARDED --- the last being the count the
safety argument actually runs on.

* The section heading said "FIVE WRITES REACH DEDICATION". It now says
  four reach it and a fifth is guarded defensively, and the "found
  three more" arithmetic is spelled out (two further apply_placement
  arms plus the unreachable quit_window site) so the total is legible
  as five GUARDED rather than five reachable.
* "all five reachable sites were momentarily unguarded together"
  (revision 8's masked contract) --- true of all five GUARDED sites,
  which is what that sentence means; the four reachable ones and the
  defensive fifth are now named there.
* "Two live guards, five reachable sites" --- two live guards cover the
  four reachable sites; site 7 carries a third, defensive guard. There
  really are three call sites of panel_commit_dedication_refusal
  (editor_core.rs display_buffer and quit_window,
  lua_bindings/window_panel.rs set_params), so the old sentence
  undercounted guards while overcounting reachability.
* Two "every site in it is still guarded" claims were literally false
  of sites 4, 5 and 8 (two harmless Ordinary arms and a unit test).
  Narrowed to every site that can dedicate the slot.
* Table row 7's verdict now carries "guarded anyway, defensively", so
  the four-plus-one reads off the table itself.

The old count is preserved as history and marked as such --- "not the
current count" --- with the correcting SHA, so a reader who saw the
earlier text knows which way the correction ran.

The miscount had NOT propagated. Repo-wide grep for the phrasing finds
it only here: DEDICATION_ROUTES in
tests/destination_capture_acceptance.rs is a [_; 4] and its doc comment
already said "four and not two"; the framing's own acceptance bullet
already said "which is four and not two"; the ledger was fixed in
fb3974b. No src/ or tests/ comment claims five reachable routes. (The
suite's unrelated "five distinct refusals" of commit_to is a different
count and is correct.)

Documentation only. Gate run twice with --acceptance
destination_capture_acceptance: fmt, clippy, lib-crdt, the destination
capture suite, m4 and gpu green both times; diff-check clean. Each run
had one wall-clock RATIO test fail under load from concurrent gates in
sibling worktrees --- m8_2's 10K-entry render (457ms vs a 200ms budget)
on the first, editor's composition_overhead_under_ten_percent (1.169)
on the second --- a different test each time, and each passes in
isolation on this tree (0.19s and ok respectively). Neither is
reachable from a markdown edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 3b8e426f90
test(window): pin the cross-frontend exception, and fix two ledger counts
panel_commit_dedication_refusal matches on `fid` as well as on the
profile: a nested commit for a DIFFERENT frontend may dedicate that
frontend's own side slot, because resolve_placement consults only the
requesting frontend's panel_capable and its own one side window, so
nothing done to B can change where A's side request lands.

That promise was documented and unpinned. Both revision 9 nesting
tests drive a single frontend, so the comparison is trivially true
throughout them: deleting it, and making any outer "panel" contract
globally restrictive, passed the whole file.

a_nested_commit_for_another_frontend_may_dedicate_its_own_slot runs
two frontends. While an outer "panel" commit for A is in force, a
nested commit for B dedicates B's slot and is ALLOWED --- and B's
slot is asserted really dedicated afterwards, not merely unrefused.
The far side runs in the same test: A's slot stays undedicated and
A's result still lands in A's panel, so the row cannot pass by having
weakened the restriction generally.

This is the suite's only POSITIVE row; every other asserts a refusal,
which is the shape it was thinnest on. An exception only the doc
comment knows about is one review round from being simplified out.

Mutation-checked: deleting `&& contract.destination.frontend == fid`
fails ONLY this test. Both single-frontend nesting tests pass under
it, which is the evidence they are independent of the frontend match
rather than merely looking so. journey_acceptance (47),
dired_acceptance (31) and cargo test --lib (1920) stay green.

Two ledger corrections, both section-local:

* "Eight writes exist; five are reachable" then listed four. The
  fifth is quit_window's QuitAction::Restore --- the site proved
  unreachable and guarded anyway. It now appears in the list that
  justifies it, and the bullet counts what actually matters: all five
  are guarded.
* The revision 9 mutation paragraph had the preservation counts
  REVERSED (journey 31 / dired 47). It is journey 47 / dired 31,
  matching the bullet further up and measured per target. The same
  reversal is in 394fa43's commit message; that is left as written
  rather than rewriting a pushed commit, and the ledger now says so
  where the numbers are, so a reader following the SHA takes the
  corrected pair.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 5f3f38dfd7
fix(window): keep a panel commit's restriction across nested scopes
Revision 8 refuses, inside a "panel" commit_to, the mutations that
would make its relaxed preflight wrong. A nested commit_to REPLACED
the enclosing contract with its own and restored it afterwards, so
the outer restriction went out of force for the whole inner body:

  commit_to(outer, function()                   -- "panel", relaxed preflight
    commit_to(inner, function()                 -- "document", MASKS the outer
      set_params(panel(), { dedicated = true }) -- ...and succeeds
    end)
    display(result, { side = "bottom" })        -- ...which now FALLS BACK
  end, "panel")

Every step is legal on its own, and the outer commit then overwrote
a newer document buffer --- the P1a failure the lane exists to
remove, reached through one extra call.

What this invalidated, precisely: NOT the enumeration of dedication
write sites. Every site in it is real and still guarded. What was
wrong was the claim that the guard was in force for the whole outer
body. So the enumeration is inherited and qualified, not redone.

Contracts now COMPOSE rather than replace. The core holds a stack;
ScopedFrontendGuard pushes on entry and truncates back to its own
depth on every exit path; panel_commit_dedication_refusal consults
every contract in force rather than the innermost. The strictest
active restriction wins. Matching stays per frontend --- a nested
commit for a different frontend may dedicate its own side slot,
which cannot change where this frontend's side request lands.

Nesting itself is NOT forbidden, which was the other candidate fix.
It closes the hole by prohibiting a construction no rule objects to:
commit_to is public Lua API for saying where a continuation's result
belongs, and a body committing to a second destination (a diff
beside a status panel) is where #227's adoption is heading. Only the
restriction needed preserving. Detecting the dedication when the
outer commit resumed was not available either --- that is a late
refusal, which is what revision 7 was rejected for.

Two pins, and they are a pair rather than one test written twice:

* a_nested_commit_cannot_mask_an_outer_panel_restriction drives the
  same four write-site rows through a nested, entirely valid
  "document" commit, and asserts the attempt is refused, the slot is
  still undedicated, and the outer commit's destination is intact.
* an_ordinary_nested_commit_still_runs_and_restores_the_outer_restriction
  pins that nesting without dedication is accepted, that the
  enclosing restriction is back in force once the nested commit
  returns, and that outside every commit dedication is ordinary
  again.

Mutation-checked: restoring the guard to the innermost contract
(.last(), exactly revision 8's swapped slot) fails only the first of
those. The other 13 pins, journey_acceptance (31), dired_acceptance
(47) and cargo test --lib (1920) all stay green. The ordinary-nesting
pin deliberately survives it --- it exists to fail the other
candidate fix.

Also sweeps the comments left by revision 7, which revision 8
superseded: no fallback_commit_refusal symbol remains, but six doc
sites still described placement-boundary enforcement as the
guarantee (ViewDestination, CommitProfile::Panel, CommitContract,
capture_view_destination, commit_destination_refusal,
panel_placement_can_fall_back), plus two comment blocks in the
commit_to binding and one stale mutation note in the acceptance
suite. Net rustdoc warnings down three.

Framing to revision 9; the active-work lane entry updated in place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 2fc2985029
fix(window): refuse the mutation that would invalidate a panel commit
Revision 8 of `docs/destination-capture-framing.md`, replacing the
revision-7 design at `ca72461`, plus the invalid-UTF-8 profile hole.
The framing now carries §3's enumeration, performed.

THE BLOCKER, unchanged. The `"panel"` commit profile skips preflight
checks 2-4 on the claim that a panel result never touches a document
window. Panel placement FALLS BACK into an ordinary document window
when the frontend is not `panel_capable` or its one side slot is
dedicated elsewhere, and installs the result there --- so a `"panel"`
commit could replace a NEWER document with every stale-intent guard
skipped.

TWO REJECTED SHAPES, kept in the framing as the record of why not
those. Revision 6 predicted the fallback at preflight and argued the
body could not change it; false, because refusing `await` stops another
COROUTINE interleaving, not the body's own synchronous statements.
Revision 7 (`ca72461`) moved enforcement to the placement boundary;
that breaks the invariant `commit_to` exists for --- `docs/agent-handoff.md`
requires it to preflight BEFORE the callback, because a body creates
buffers, handles and paint long before it asks to display anything, so
"validating at display time is four mutations too late". A refusal
arriving after all of that is a partial commit with an error return.

REVISION 8 DOES NEITHER. The preflight stays exactly where it was, and
the mutations that would invalidate it are REFUSED AT THE ATTEMPT ---
the same shape as `Handle:await` being refused inside a commit scope,
for the identical reason: something that would invalidate the scope's
guarantee is rejected outright rather than predicted around or caught
late. With them refused, the fallback never comes into existence.

THE ENUMERATION, PERFORMED --- this is the load-bearing part, and it is
closed for a structural reason rather than because inspection ran out
of ideas. Full working in the framing §3.

`resolve_placement` reaches `Ordinary` from a side request through
exactly two branches, so only two pieces of state are levers at all:
`panel_capable`, and the one side window's `dedicated`.

`panel_capable` is UNREACHABLE from a body: written only where a
`FrontendView` is constructed, and nothing in `src/lua_bindings/`
constructs, registers or unregisters one --- `register_frontend_view`
has callers only in `daemon.rs` and core unit tests.

`dedicated` has eight writes. Five are reachable: `apply_placement`'s
`Side` created, replacing and non-replacing arms, and `set_params`. Two
`Ordinary` arms are harmless --- every `Ordinary` target is filtered
`!is_side`, and one only ever clears the flag. One is a unit test.

Closing the side window is NOT a route, checked rather than assumed:
with no side leaf `side_window_for` returns `None` and placement
CREATES a fresh panel instead of falling back. `panel_hidden` is not
consulted by placement, and `params.side` is unreachable.

`quit_window`'s `QuitAction::Restore { dedicated: true }` is
UNREACHABLE, and this was the surprise --- it looked like a route with
no `dedicated` argument at the call site at all. `Restore` is stored
only on a REPLACING side placement, and a dedicated slot can never be
the target of one: a side request with a different buffer falls through
to `Ordinary`, and an exact-target request is refused by
`window_accepts_buffer`. Guarded anyway, labelled defensive, because
its unreachability is emergent from two rules in another function.

GUARDS SITED WHERE THE PROPERTY CONVERGES. All three `Side` arms are
reached through `apply_placement`, which has EXACTLY ONE caller --- so
one guard in `display_buffer` covers every request-driven dedication,
including spellings that do not exist yet. `set_params` is a genuinely
separate write and is guarded separately; dedication does NOT converge
before the field itself, and that is stated rather than papered over.
`Window::params.dedicated` is a public field, so the compiler does not
enforce the funnel --- the acceptance rows are what would catch a new
direct writer.

WHAT IS DELIBERATELY NOT REFUSED. The document profile is untouched:
constraining its body would newly refuse dired's own documented panel
path, a preservation-suite stop signal. Dedicating a DOCUMENT window is
still allowed, since it cannot change which of panel-or-document a side
request resolves to. And falling back is still allowed --- a frontend
that cannot render a panel degrades gracefully exactly as today,
because this refuses the mutation that MANUFACTURES a fallback, never
the fallback itself.

THE SECOND HOLE. `commit_profile` did `name.to_str()?`, but Lua strings
are BYTE strings, so `string.char(255)` hit mlua's generic UTF-8 error
before `BAD_COMMIT_PROFILE` was constructed --- the same reachability
class as the `Option<String>` defect revision 5 fixed, one layer down.
Bytes now, with the row asserting on message content.

TESTS: 12 pins. The inside-the-body test is ONE ROW PER REACHABLE WRITE
SITE, not per call spelling, because one spelling reaches three
different writes: `set_params`, and `display{side, dedicated}` in each
of the created, replacing and non-replacing arms. Each asserts the
three things revision 8 requires --- the dedication call is refused, the
slot is still undedicated afterwards, and nothing partial was installed
(no `*result*` buffer, panel unchanged, document unchanged).

Mutation-checked per guard: deleting the `display_buffer` guard fails
all three display rows, verified INDIVIDUALLY by rotating each to the
front so the first failure cannot mask the rest; deleting the
`set_params` guard fails only that row.

THREE FRAMING CORRECTIONS ride along, all of them cases of the document
teaching something it later argues against. Section 3 stated the
disproved premise unconditionally --- "the panel case would inherit a
check about a window it never touches" --- a hundred lines before
correcting it, so a reader met the wrong claim first; it is now
qualified at the point of the claim, and section 2 carried the same
unconditional form one section earlier ("it lands in the bottom panel")
and now says it REQUESTS one. The handoff citation was written "section
748" twice when it is LINE 748, and this document's authority is that
its citations can be followed. And the "not asserted exhaustive" hedge
on the route list is retired: the enumeration is closed structurally,
because `resolve_placement` reaches `Ordinary` from a side request
through exactly two branches.

`journey_acceptance` (47) and `dired_acceptance` (31) pass UNCHANGED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth ccbed7ab55
docs: destination-capture --- a second dedication route, and the ledger head
Review found dedication is reachable by a second path. Beyond
set_params, a body can call display(buf, { side = "bottom", dedicated =
true }), which writes request.dedicated straight into the side window
at editor_core.rs:4535, then request a second panel buffer and cause
the fallback. An implementation guarding only the named set_params call
passes revision 8 test while keeping the original defect.

That is the important part, and it is worth more than the route itself:
the second route was found in review AFTER the first was specified,
which is the evidence that guarding one named call site is not a
design. The framing now requires every discovered route recorded and
given its OWN acceptance row, states that the two known routes are not
asserted exhaustive, and says finding a third is part of the work
rather than a later review job.

The ledger head still announced revision 7 as implemented and correct,
declared the blocker closed, and prescribed placement-boundary
enforcement --- the design review had just rejected. I corrected the
lower Q#DC-2 paragraph last round and left the authoritative block
alone, so recovery met the rejected design first and the correction
second. That is the same one-site correction failure this session keeps
reproducing, and this time in the file whose entire job is to be the
volatile state of record.

The head now names all three designs, which two were rejected and why,
and that the shipped code implements the rejected one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth b72df34bc6
docs: destination-capture revision 8 --- refuse the mutation, keep the preflight
Revisions 6 and 7 were both wrong, in opposite directions, and review
caught each.

Revision 6 predicted the fallback at preflight and argued the body
could not change it. False: the await refusal stops concurrent
interleaving, not the body, which is arbitrary synchronous Lua and can
dedicate the side slot itself.

Revision 7 moved enforcement to the placement boundary. That breaks the
invariant commit_to exists for. Handoff section 748 states it without
qualification --- it preflights every precondition BEFORE invoking the
callback, because dired mutates handle state, prev and paint long
before it reaches anything that could refuse, so validating at display
time is four mutations too late. A refusal that arrives after arbitrary
Lua has created buffers, handles and paint is not a refusal; it is a
partial commit with an error return.

So revision 8 does neither. It keeps the preflight where it is and
REFUSES the mutations that would invalidate it --- the same shape as
the await refusal already in this file, for the identical reason:
something that would invalidate the scope guarantee is rejected rather
than predicted around. Refusal stays mutation-free on the normal
(false, reason) path.

The mutation surface is narrow, which is what makes this tight rather
than aspirational. dedicated is writable from Lua and is one of only
two writable window fields per Q#BP2c; panel_capable has no Lua binding
at all, checked across src/lua_bindings. But the implementation must
ENUMERATE the body-reachable transitions rather than trust that list
--- closing the side window, or any other route to no usable side slot,
counts, and I have not proven those two exhaustive.

If the enumeration is open-ended, the named fallback is to collapse the
two profiles and always run all four checks. Safe, simple, honest, and
it makes the parameterization pointless --- which is why it is the
fallback and not the answer, and why choosing it needs its own
approval.

The inside-the-body test is strengthened accordingly. Revision 7 asked
it to assert that document B was not replaced, which passes on a design
that lets the body mutate freely and merely declines the final
installation. It now asserts the dedication call is refused, the slot
is still undedicated afterwards, and nothing partial was installed. The
refusal must land on the mutation, not on the outcome.

The ledger Q#DC-2 summary still repeated the disproved premise
verbatim, so a recovering reader met two incompatible answers in one
lane entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 86cd08959a
fix(window): enforce the panel profile at placement, not at preflight
Revision 7 of `docs/destination-capture-framing.md`, closing the
correctness blocker review found in `0efc8c0` and the smaller
reachability hole beside it.

THE BLOCKER. The `"panel"` commit profile skipped preflight checks 2-4
on the claim that a panel result never touches a document window. That
claim is false: panel placement FALLS BACK to an ordinary document
window when the frontend is not `panel_capable` or its one side slot is
dedicated elsewhere -- `apply_placement` says so in its own comment --
and then installs the result there. So a `"panel"` commit could replace
a NEWER document with every stale-intent guard skipped: capture A, the
user opens B, the continuation lands, B is gone. That is the exact
failure `commit_to` exists to prevent, reached through the profile
meant to be the safe one.

WHY NOT A PREFLIGHT PREDICTION. Revision 6 proposed predicting the
fallback at preflight, arguing nothing could change in between because
the body cannot `await`. Refusing `await` prevents another COROUTINE
interleaving; it places no restriction on the body itself, which is
arbitrary Lua running synchronously and can invalidate the snapshot in
two statements -- take the panel, set it `dedicated`, then request a
side display. No preflight predicate closes that, however phrased: the
measurement is taken before the thing it measures is decided.

WHAT THIS DOES INSTEAD. `EditorCore::display_buffer` refuses between
`resolve_placement` and `apply_placement` when a side request resolved
to `PlacementKind::Ordinary` under an active `"panel"` contract whose
destination fails the document preconditions. That is the first moment
the fallback is a fact rather than a guess, and refusing before
`apply_placement` means a refused fallback mutates nothing. The
contract rides on the core, installed and restored by the same
`ScopedFrontendGuard` that scopes the frontend, so a profile can never
outlive the body that declared it; the field is crate-private, so Lua
cannot claim a profile for a placement it did not commit to.

The preflight predicate SURVIVES as an early refusal and not as the
guarantee. `panel_placement_can_fall_back` still gates the relaxation
in `commit_destination_refusal`, so the statically knowable case -- a
frontend that cannot render a panel at all, and will not acquire the
capability mid-body -- refuses before the body allocates a buffer,
registers a handle and paints. That is the same reason `commit_to`
preflights at all. Both layers are pinned, and neither pin subsumes the
other.

The four document checks now live once, in
`EditorCore::document_destination_refusal`: they are evaluated from two
sites, and two hand-written copies is how a backstop ends up weaker
than the thing it backs.

THREE DELIBERATE LIMITS, each a different decision rather than a
stricter version of this one. The document profile is untouched --
re-running its checks at placement would newly refuse dired's own
documented panel path, which is a preservation-suite stop signal. Only
a fallback is guarded, not every `Ordinary` placement -- a `"panel"`
body calling `display_file` is pinned as succeeding. And the refusal is
of the PLACEMENT, not of falling back: a `"panel"` commit with an
intact destination still degrades gracefully into the document window,
because turning graceful degradation into an error would regress every
consumer that works today on a frontend without panel capability.

THE SECOND HOLE. `commit_profile` did `name.to_str()?`, but Lua strings
are BYTE strings, so a `string.char(255)` profile hit mlua's generic
UTF-8 conversion error before `BAD_COMMIT_PROFILE` was ever
constructed -- the same reachability class as the `Option<String>`
defect revision 5 fixed, one layer down. The comparison is on bytes
now, and the invalid-UTF-8 row joins the number/table/boolean rows
asserting on message content.

FOUR DOC SITES repeated the false claim (`ViewDestination`'s own doc
twice, `capture_view_destination`, `ViewDestinationLua`) and are
corrected. Nothing else relied on it: dired, the only Lua `commit_to`
consumer, takes the two-argument document profile and already had all
four checks; `compile.lua`'s `already_in_panel` queries live state; and
the terminal adopter's rollback keys off `created_side`, already false
on a fallback.

Tests: 12 pins, up from 8. Three carry the enforcement split and none
subsumes another -- the pre-established fallback (both causes, the body
must not run), the inside-the-body transition (the body runs, the
result must not land), and the graceful fallback (a valid destination
still lands). Mutation-checked four ways; the pattern of which rows
survive each mutation is in `docs/active-work.md`.

`journey_acceptance` (47) and `dired_acceptance` (31) pass UNCHANGED.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth edb84a520d
docs: destination-capture revision 7 --- enforce at placement, not preflight
Review found revision 6 fix unsound for the same reason its target was.

Revision 6 moved the panel/document decision to a preflight prediction,
arguing nothing could change before placement because commit_to refuses
await. That refusal stops CONCURRENT INTERLEAVING --- another coroutine
mutating state while this one is parked. It does nothing about the body
itself, which is arbitrary synchronous Lua and can obtain the panel,
set dedicated = true, and then request panel display. Preflight sees a
reusable panel and relaxes checks 2-4; the body causes the fallback;
the result replaces a stale document.

No preflight predicate closes this, however phrased --- the measurement
is taken before the thing it measures is decided. So enforcement moves
to the placement boundary, where resolving to Ordinary for a request
that asked for a side IS the fallback rather than a forecast of one.
The commit scope is already Rust-side app data, so the profile and the
destination can ride there.

The tempting non-fix is named so nobody reaches for it: widening the
predicate from "will it fall back" to "could it ever" is always true,
since the body can always dedicate the slot --- which collapses the two
profiles and buys nothing.

Section 7 gains the test that distinguishes the designs: the callback
dedicates the side slot MID-COMMIT. Both fallback tests revision 6
asked for establish their state before commit_to is entered, so a
preflight-snapshot design passes them. A design passing only those two
has not been shown to work.

The ledger claimed the lane implemented with eight pins covering
section 7. Those pins were written against revision 5 matrix, which
review disproved --- none exercises a fallback placement. A recovering
machine reading that entry would have prepared a PR from a lane with an
open correctness blocker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 6032ed1c2a
docs: destination-capture revision 6 --- the panel matrix was unsound
Q#DC-2 gave the panel profile only check 1, on the stated claim that a
panel result never touches a document window. That claim is false, and
the tree says so in its own comment: reaching Ordinary while a side was
REQUESTED means the request fell back --- not panel-capable, or the one
side slot is dedicated elsewhere --- and the result is then installed
into an ordinary document window.

So a "panel" commit on a non-panel-capable frontend could replace a
NEWER document while skipping every stale-intent guard, reintroducing
exactly the failure this API exists to prevent. Reproduced in review,
not theorised. That makes it a correctness defect rather than a
strictness preference, and it is my framing error: I wrote the matrix.

The relaxation is now conditional on the placement really being a
panel. Both fallback causes are readable from core state at preflight,
and nothing can change between preflight and placement because
commit_to runs its body synchronously in a scope that refuses await ---
so the prediction cannot go stale under the commit it guards.

What is deliberately NOT the fix: refusing a panel commit that would
fall back. Falling back is existing, intentional behaviour for a
frame without panel capability, and refusing would turn a graceful
degradation into an error. The panel profile relaxes checks; it does
not get to change where things land.

Also closes an invalid-UTF-8 hole in the profile diagnostic. Lua
strings are byte strings, so string.char(255) reaches to_str() and
produces mlua generic conversion error before the documented message
naming the accepted values is ever constructed. Same reachability class
as revision 5 Option<String> defect, one layer further down --- which
is worth noticing, because I fixed that one and did not look for the
next one.

And the header said "Pre-implementation. Awaiting approval" through
revisions 2 to 5 while the ledger recorded the lane approved and
implemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 3e64898c64
docs: record the destination-capture lane as implemented
Section-local edit to the lane's own block; several lanes edit this file
concurrently.

Records the two commits, the green gate line (both preservation suites
unchanged), the mutation checks that show a matrix of deliberate
omissions is not passing vacuously, and the two places the framing did
not match the tree:

- the rename was 11 references across 5 files, not 8 across 4 —
  `src/daemon.rs` also calls the capture;
- Q#DC-4's "frontend with no document window" is a DEFENSIVE branch.
  Q#BP6 asserts a layout always keeps a non-side window, with a
  `debug_assert!` in `non_side_target` that fires under `cargo test`, so
  a registered frontend in a healthy editor always has a live document
  window. The decision stands, but #227 should not expect to meet that
  refusal.

Neither changed a decision, and both are recorded rather than quietly
absorbed: the framing says "counted, not estimated", and the next reader
will check.

`ViewDestination`'s own doc comment is corrected in the same commit,
because it repeated the framing's over-claim ("a frontend showing only a
side window") in the one place a reader would trust it, and
`capture_view_destination` now says how reachable its empty pair
actually is. Code, not only ledger, since the ledger is not what someone
reads when they wonder whether that branch can fire.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 96c7e466f1
test(destination): pin the capture, the profile, and both matrix columns
`docs/destination-capture-framing.md` §7, eight tests.

The one that decides the lane is `the_preflight_matrix_holds_in_both
_profiles`. Every cell Q#DC-2 marks "not applicable" for the panel
profile is asserted as NOT refusing, not merely left untested: a check
deliberately omitted and a check someone forgot look identical from the
outside, and the next reader restores the second one. The document
column re-asserts all four refusals in the same table, so a mutation
that collapses the two profiles fails one column or the other.

`a_bad_profile_is_refused_by_one_message_that_names_the_accepted_values`
is the guard on the argument's TYPE, not only on its behaviour. It
asserts the number, table and boolean cases produce the same message as
an unrecognized string --- which stops being true the moment the
argument is retyped to `Option<String>`, because mlua then rejects the
value during argument conversion and the pointed message is never
reached.

`a_captured_destination_survives_a_frontend_switch` runs under both
profiles. The panel profile drops three of the four preflight checks,
and a plausible way to implement that is to drop the frontend scope with
them --- which would leave a panel continuation resolving its target
from ambient state, the exact defect the lane removes.

`a_two_argument_commit_takes_the_document_profile` witnesses the default
through a check the panel profile omits (a stale buffer), because
asserting merely that a legacy call does not error would pass on one
silently downgraded to the panel profile.

ONE FINDING, RECORDED IN THE TEST RATHER THAN WORKED AROUND. Q#DC-4's
"a frontend with no document window" reads as a frontend showing only a
bottom panel, and that state is asserted impossible: Q#BP6 says a layout
always retains at least one non-side window, and `non_side_target`
carries a `debug_assert!` that fires under `cargo test` if one ever
does. So with Q#BP6 held, a registered frontend in a healthy editor
always has a live document window, and the absent document pair is a
DEFENSIVE branch rather than a routine one. The decision still stands
--- capture stays total, and an adopter with nowhere to land gets a
refusal naming that rather than permission to guess --- and the two
Q#DC-4 pins drive the reachable spelling of the same condition: a layout
whose document window has gone while the view remains. The helper says
so at its definition.

`tests/journey_acceptance.rs` (47) and `tests/dired_acceptance.rs` (31)
pass UNCHANGED, which is §7's stop signal and the reason the profile
default is the document one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 9fee5618ee
feat(window): a destination any async continuation can capture
Journey Stage 1a built `pmacs.window.commit_to` for the continuation
boundary --- "the listing settles a tick or more later, and by then the
ambient frontend, selected window, and active buffer may all name
something else" --- but nothing outside the `path.open-directory`
dispatch could mint a destination to hand it. Every other async Lua
continuation therefore resolved its target from ambient state a tick
after the request, which is PR #227's P1a finding: run `git.status` in
frontend A, let B become active, and A's panel opens in B.

This is the prerequisite lane #227 blocks on
(`docs/destination-capture-framing.md`, revision 5). No adopter here:
git's adoption is #227's work, since a prerequisite that converts its
own first consumer cannot be reviewed separately from it.

Three parts.

**`pmacs.window.capture_destination()`** returns the same
nonconstructible userdata for the current frontend. No arguments, and
that is load-bearing rather than minimal (Q#DC-1): a Lua-supplied
frontend id would reintroduce exactly the fabrication hole the userdata
design closes. Profile-blind for the same kind of reason (Q#DC-4) ---
capture freezes what is true now, and what a commit depends on is
declared later, at the commit.

**`DirectoryDestination` -> `ViewDestination`**, with the Lua userdata
and the capture renamed to match. The captured triple was already
generic; only its name and its capture site were not. The document pair
is now `Option`, set and cleared together, so a frontend with no live
document window still captures rather than returning nothing and
sending the caller back to the ambient state this exists to replace.

**`commit_to(dest, body [, profile])`** (Q#DC-2/Q#DC-5), a closed set of
two. The document profile keeps all four preflight checks. The panel
profile keeps only the first --- the requesting frontend still has a
layout --- because a panel result does not occupy the captured document
window, does not replace its buffer, and does not need it to exist, so
each of the other three would refuse for a reason unrelated to what the
continuation does. Omitting the profile means `"document"`, which is
what makes the preservation promise contractual rather than careful:
every existing two-argument caller keeps all four checks by definition
of the signature.

The profile argument is typed `mlua::Value`, NOT `Option<String>`, so
its error is REACHABLE: with the narrower type mlua rejects a number or
a table during argument conversion, before the closure body runs, and
the message naming the accepted values never appears. That is the same
trap the `dest` argument documents one position to its left. `nil` and
absence are the same answer; anything else is refused by one message
that names both accepted values.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth a177d61bf3
docs: destination-capture revision 5 --- make the profile error reachable
Revision 4 API spec contradicted itself at the binding boundary. It
required profile: Option<String> AND a pointed error naming "document"
and "panel" when a non-string arrives. mlua rejects a number or table
during argument conversion, before the closure body runs, so that
message was unreachable: a caller passing 42 would have got mlua
generic conversion error instead.

This is the identical trap the existing binding already documents for
dest --- typed Value rather than AnyUserData specifically so the
message stays REACHABLE and names the rule --- and revision 4 quoted
that comment as its reasoning while repeating the mistake one argument
to the right.

The profile is now mlua::Value, validated in the body. Nil and absence
BOTH mean document, spelled out because a Lua caller threading an
optional variable produces nil rather than absence and a third
behaviour there would stay invisible until someone hit it. A
non-string is refused by the same message that names the accepted
values.

The verification bullet is now the guard on the type choice rather than
on the behaviour: the non-string refusal is asserted ON ITS CONTENT, so
retyping the argument to Option<String> later stops the assertion
matching rather than silently degrading the error a user sees.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 1b4046b624
docs: destination-capture revision 4 --- pin the commit_to call shape
Revisions 2 and 3 said "the profile is declared at commit_to" and never
said how. That is not a detail. The binding accepts exactly (dest,
body) today, so without a specified form #227 has no stable API to
adopt against, and the promise that existing callers keep their
semantics was a hope rather than a contract.

Q#DC-5: commit_to(dest, body [, profile]). An optional TRAILING string,
typed Option<String>, so there is no arity sniffing and no
table-or-function dispatch on argument 2 --- the existing binding chose
Value over AnyUserData specifically to keep its error message reachable
and naming the rule, and a polymorphic second argument would undo that.

Trailing reads badly after a long inline closure, but that is not the
call shape in use: dired defines a named local commit at dired.lua:670
and calls commit_to(opts.dest, commit) at :717. Verified, not assumed.
Against a named body the trailing profile reads fine.

The value set is CLOSED --- document and panel, exactly Q#DC-2 two
profiles. A third is a decision, not a spelling.

Omitted means document, and that is the load-bearing part: every
existing two-argument call keeps all four preflight checks by
definition of the signature, so journey_acceptance passing untouched
follows from the API shape rather than from care.

An unrecognized profile is an ERROR naming the accepted values, not a
silent fallback to document. A fallback would hand a caller stricter or
looser checks than it asked for, which is the failure the whole
parameterization exists to prevent. Its witness asserts the legacy
two-argument form through a check the panel profile OMITS --- a
stale-buffer refusal --- because asserting merely that it does not
error would pass on a call silently downgraded to panel, which is the
regression that would quietly void Journey Stage 1a guarantees.

Git mapping settled here rather than rediscovered during adoption:
*git-status* takes panel, *git-diff* takes document.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 6b8e07c730
docs: destination-capture revision 3 --- decide Q#DC-4, pin the gate line
Q#DC-4 contradicted Q#DC-2, and on the primary panel API. Q#DC-2
concluded a panel profile depends only on a live frontend, so it can
commit with no document window at all; Q#DC-4 still voted to return nil
in exactly that case and told git to fall back to ambient behaviour.
Those cannot both hold, and the fallback advice was independently
wrong: falling back to ambient IS the P1a bug this lane exists to
remove.

Decided rather than voted on, since it is the primary API. The
destination document pair is optional; capture_destination() is
profile-blind and argument-free, because making capture profile-aware
would force a caller to know at capture time what it will do at commit
time, which is the opposite of why capture exists. The profile is
declared at commit_to, where Q#DC-2 parameterization already lives, and
a document-profile commit with no document pair is refused alongside
the other four preflight refusals. Capture never returns nil while a
frame exists.

Section 4 outline and Q#DC-1 were updated to match rather than left to
disagree --- Q#DC-1 no-arguments answer is now load-bearing rather than
incidental, because no arguments is what keeps capture profile-blind.

The ledger gate line said "new suite plus dired". --acceptance is
repeatable, so it now carries the executable command including
journey_acceptance and dired_acceptance, both named as preservation
suites and a stop signal. A volatile ledger that understates required
coverage is how a recovering machine runs a weaker gate than the lane
agreed to.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 91e4e514a1
docs: destination-capture revision 2 --- full matrix, preservation suite, coherence
Three review findings.

Q#DC-2 parameterization was incomplete. Revision 1 said only "skip the
stale-buffer check for a non-replacing continuation", but a panel
result does not depend on the captured document window at all: it does
not replace that window buffer (check 3), does not occupy it (check 4),
and does not need that specific window to exist (check 2). Retaining
any of the three can reject git.status for an unrelated document-window
change; dropping them without an explicit profile risks weakening
document replacement. The question now carries a four-row matrix with
two profiles, and check 1 --- the requesting frontend still has a
layout --- is the entire panel profile.

That has a consequence the framing now states rather than leaving to be
discovered: if the panel profile needs only the frontend, a frontend
with no document window can still host a panel, so Q#DC-4 return-nil
rule is right for the document profile and possibly wrong for the panel
one. Settled as part of answering Q#DC-2, not after it.

tests/journey_acceptance.rs joins dired as a named preservation suite
and stop signal. It carries 27 commit_to references across nine named
pins --- forged destination, scope-and-restore on normal return and on
raise, await refusal, delivery to the requesting frontend, the
declining-listener redirect guard, and two already named preservation_*
--- and Journey Stage 1a own framing treats it as a required gate. A
lane that generalizes its substrate does not get to relax that. The
stop signal now covers both suites: a suite edited to accommodate the
change under test has stopped being evidence.

The coherence-impact section was missing entirely. CLAUDE.md and
COHERENCE.md section 25 both require one for coherence-affecting work,
and this lane qualifies twice over --- new Lua API surface, and a
generalization of a Journey substrate. Section 16 is the section it
serves. Journey steps: none added, one protected. Islands, config
registry: none. Section 9: neutral, and stated precisely, because
knowing which frontend a result belongs to is NOT knowing who asked for
it --- that is the worker-identity arc and the two should not be
conflated just because both concern async continuations.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 71ef951535
docs: frame a general destination capture (revision 1)
PR #227 review found that git async completions surface in whichever
frame is active when git exits, and named the right mechanism:
commit_to exists for exactly this continuation boundary, built by
Journey Stage 1a Q#JR14 because the work settles a tick or more later,
by which time the ambient frontend, window and buffer may all name
something else.

The fix is not available to git, which is why this is a lane rather
than a line in #227. commit_to takes a DirectoryDestinationLua that is
nonconstructible from Lua by deliberate design, and the only site that
mints one is inside the path.open-directory listener dispatch, from a
pub(crate) capture. Any async Lua continuation that is not a directory
open has no way to say where its result belongs.

The captured data is already generic --- frontend, window, buffer, with
nothing directory-specific in it. Only the name and the capture site
are, and the rename is 8 references across 4 files, counted rather than
estimated.

The substantive question is Q#DC-2, and scouting is what surfaced it.
Git two continuations are different in kind. *git-status* goes to the
bottom panel, because listview.open resolves display with a "panel"
default. *git-diff* replaces a document window, deliberately, so the
status panel it was invoked from stays visible beside it. The
stale-intent check that commit_to preflight runs --- the window still
shows the captured buffer --- is right for the second and wrong for the
first: the panel never touches that window buffer, so refusing because
the user switched files there is a refusal with no relationship to what
the continuation does. One shape either over-refuses the panel case or
under-checks the document case, and the framing votes for a
parameterized preflight while holding that vote loosely.

No adopter in this lane. Git adoption is #227 work after this lands; a
prerequisite that also converts its first consumer makes the two
impossible to review separately.

Verification carries a stop signal rather than a target: if any
existing dired test needs editing, the generalization changed Journey
Stage 1a semantics and that is cause to stop, not to adjust a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 14:11:43 +02:00
Levi Neuwirth 5cf1d61b92
fix(discovery): clip command descriptions at the single-row surfaces
PR #228 review found a correctness gap this lane made reachable. The
GPU dropdown derives its height, its visible window and its
selection-highlight offset from `rows.len()` — ONE logical row per
candidate — while a detail carrying a line break shapes into more
physical lines than that. One such row misaligns every row below it
and the highlight with it. The grid TUI has the same exposure from the
other side: it writes the description into a single-row suffix on the
minibuffer band.

## Why not reject CR/LF at registration

That was the obvious fix. It was implemented, measured, and abandoned
on evidence.

MCP tool registration renders a whole schema block into
`Command.description` — tool text, blank line, `Arguments:`, then one
line per argument (`tests/fixtures/pmacs-mcp-tools/init.lua:272`, a
`table.concat(lines, "\n")`, used at `:496`). And
`tests/m9_6_acceptance.rs:583-598` ASSERTS four of those lines. A
one-line guard in `CommandRegistry::define` fails 36 tests across
`m9_6` (19/25), `m9_7` (16/19) and `m9_8` (1/17), in both feature
configurations, and could only be made green by deleting a shipped
acceptance criterion.

So the one-line constraint goes where the constraint actually is: the
surfaces that have one row. `Command.description` stays free-form,
which it legitimately is.

## The change

`Command::description_first_line` clips to the first CR **or** LF — a
lone CR ends a line too, and an LF-only clip would pass a bare `\r`
straight through to the same surface. Both single-row consumers call
it: the semantic producer filling `MinibufferRow.detail`
(`src/semantic_render.rs`) and the TUI suffix (`src/editor.rs`). A
first line that is empty ships as `None` rather than `Some("")`, which
would draw trailing padding.

No ellipsis or truncation marker, matching the in-tree precedent and
the minibuffer's own width rule.

`describe-command` and `help.list-commands` are untouched and still
report every line. That is what makes this a rendering decision rather
than data loss, and it is asserted, not assumed.

## Precedent, already in this tree

The same MCP fixture clips a tool RESULT to its first line because
"a multi-line set_status would corrupt the row layout"
(`init.lua:277-285`), leaving width clipping to the frontend. Same
hazard class, same resolution.

## Verification

`src/command.rs`: a schema block registers AND clips, in all three
break forms; a single-line description is byte-identical after the
clip; an empty first line clips to empty.

`tests/discovery_stage2_acceptance.rs`: an MCP-shaped description
reaches the TUI band and the GPU row as one line, through the real
prompt path — with the full text still reachable via
`describe-command` asserted alongside, so a clip that deleted the
schema block everywhere would fail rather than pass.

`pmacs-gpu`: one physical shaped line per logical candidate row — the
geometry invariant the dropdown depends on.

Mutation-checked: neutering `first_line` to the identity fails all
four new break-handling tests
(`a_multi_line_description_registers_and_clips_to_its_first_line`,
`a_description_whose_first_line_is_empty_clips_to_empty`,
`a_multi_line_description_reaches_the_tui_band_as_one_line`,
`a_multi_line_description_reaches_the_gpu_row_as_one_physical_line`)
and leaves the two "did not tighten past purpose" tests green.

`Command.description`'s doc comment claimed "one-line", which the MCP
path openly violates. It now states the real contract and records why
a registration guard must not be re-proposed.

`m9_6`/`m9_7`/`m9_8` pass COMPLETELY UNTOUCHED, and are now named
gate suites so that stays on the record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth bf561ff296
docs: record PR #228 and its merge block
The lane heading said "no PR yet". It also needs to carry WHY the PR is
merge-blocked, because a reader who finds only "blocked" will treat it
as backlog hygiene and unblock it by rerunning the gate.

The problem is gate integrity. --protocol promises the CRDT workspace
sweep, that sweep has a documented precondition (handoff section 5),
and the script does not run it --- confirmed by reading the plan
emitter, not inferred from the failure. So a --protocol result can be
decided by whether the build directory happened to contain pmacs-gpu
rather than by the diff under test.

It was latent until #225 gave each worktree its own target directory. A
shared target dir usually already had pmacs-gpu built, which satisfied
the precondition by accident and hid the omission.

Unblocking needs both halves recorded: the scripts/gate repair as its
own framing and PR, and then a fresh-target rerun of this branch
protocol gate under the repaired script. A rerun alone would reproduce
the same accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth 70e5781420
feat(discovery): M-x rows carry descriptions — protocol v22 -> v23
`Command.description` has always been required and has always been
rendered by `help.list-commands`. It was missing at the one moment it
would change a decision: the M-x row. This carries it there.

COHERENCE.md §5's clause "M-x rows are still bare names", per
docs/discovery-stage2-framing.md revision 3.

## The wire half is additive, and the old variant is FROZEN

postcard is not self-describing: enum variants encode by index and
fields by position. Widening `MinibufferPrompt.candidates` in place
would make every v12–v22 peer MIS-DECODE the bytes rather than ignore
them — and gating the widened form at `>= 23` would not rescue them
either, because with only one variant to gate they would receive no
minibuffer message at all. Compatibility requires the old shape to
still exist AND still be sent.

So `MinibufferPrompt` is retained unchanged for `12..=22`, and
`MinibufferPromptRows { prompt, input, cursor, rows, selected, total }`
is APPENDED as the final variant, carrying `MinibufferRow { label,
detail: Option<String> }`. A new row type, not `CompletionPopupRow`,
whose `kind` is an LSP `CompletionItemKind` code with no honest value
for a command (Q#D2-1).

Exactly one of the two reaches any peer, ever. The producer selects on
the session's negotiated version, so the CLOSE necessarily uses the
same family as the OPEN — a rows session closed by a legacy clear
leaves the dropdown on screen forever. The daemon's write loop gates
both directions again, with the legacy gate written as a RANGE
(`12..MINIBUFFER_ROWS_MIN_VERSION`) rather than a floor, so a v23 peer
cannot receive both and double-render.

`ADVERTISED_PROTOCOL_VERSION` stays 20, untouched.

## The TUI half involves no wire at all

`src/editor.rs` contains zero references to `MinibufferPrompt`:
`paint_minibuffer` reads `core.minibuffer` directly. So it reads
`Command.description` from the registry in-process, which is why this
half is independent of the bump.

Clipping is three ORDERED steps (§3.4), and the guarantee is "never a
PARTIAL name", not "the name always survives" — the prompt and typed
input consume the budget first, so the remainder can be too small even
for the bare name. If the whole name does not fit, the suffix is
omitted entirely; only once it fits is a description attempted; a
description that does not fit whole is dropped, leaving today's
`[name]`. No ellipsis stub, and no prefix of a name is ever emitted.

## Verification

`src/protocol.rs` gains this repo's FIRST literal postcard byte
fixtures: `minibuffer_prompt_v12_wire_bytes_are_frozen`, open and
cleared. A round-trip freezes nothing — it encodes and decodes with
the same types, so a field addition leaves it passing while every
shipped peer breaks. Bite-verified: reordering two fields of
`MinibufferPrompt` leaves `minibuffer_prompt_round_trips_through_postcard`
green and fails the fixture.

`line_wrap_facts_encoding_is_unchanged_by_the_v23_build` pins the
PREVIOUS final variant, per the handoff §4 rule that an appended
variant's own round-trip cannot detect a discriminant shift.

`tests/discovery_stage2_acceptance.rs` runs ONE daemon serving a v22
and a v23 session simultaneously, through the real M-x key path, and
asserts each receives its own variant AND ONLY its own — open and
close alike — by collecting every minibuffer message rather than
filtering for the expected one.

No cross-version cache test, deliberately (§3.2):
`SemanticRenderState::for_peer` bakes the negotiated version in at
attach and is dropped at detach, so a cache cannot span two versions.
A test for an impossible condition passes forever while teaching the
next reader that the hazard is real.

Five version assertions updated, each read before editing:
`src/protocol.rs` (the `PROTOCOL_VERSION` tripwire, renamed; and the
v6-floor ladder's accepted/rejected ranges),
`tests/statusline_segments_acceptance.rs`,
`tests/bottom_panel_stage2b_gpu_acceptance.rs`,
`tests/vterm_stage3_acceptance.rs`. No `ADVERTISED_PROTOCOL_VERSION`
assertion fired.

Gates: `scripts/gate --protocol --acceptance discovery_stage2_acceptance`
— all ten green, including the strengthened two-configuration sweep.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth 6eecbfcca1
docs: record discovery Stage 2 framing approval
Revision 3 was approved 2026-08-09 at 6a9a25d, after the three stale
internal references were cleared. The lane heading and the framing
status line both still said "in review", which is the kind of
contradiction that makes a reader trust whichever line they hit first.

The lane entry now also records WHAT the three review rounds found,
rather than only how many there were. A count says the doc was
reviewed; the list says what class of error it was reviewed for, which
is the part a later reader can act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth 0e260e0377
docs: clear three stale references revision 3 left behind
Revision 3 corrected the cache model in section 3.2 and the clipping
rule in 3.4, then failed to propagate either. Internal contradictions in
a framing document are worse than the original errors: a later reader
resolves them by picking whichever statement they read first.

  - Q#D2-4 still summarised the design as "per-variant cache keys",
    contradicting 3.2's corrected one-per-peer model. Now names the
    `peer_knows_minibuffer_rows` producer gate and the single per-peer
    cache, with a parenthetical recording that the sentence was stale
    rather than silently rewriting history.
  - Section 6 still required the cross-version cache test that
    revision 3 removed as unfalsifiable. Replaced with what IS
    assertable --- a v22 and a v23 peer attached simultaneously each
    receive their own variant and only their own --- and the bullet
    says why the other test is absent, so it does not get "restored"
    later by someone who notices the gap.
  - The active-work lane still called the framing revision 2.

Swept for the same class of residue from the other two corrections
("the name must survive", "both frontends render"); the remaining hits
are the notes ABOUT those corrections, which are deliberate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth b3a6da2416
docs: Discovery Stage 2 framing, revision 3
Three corrections, each to something revision 2 asserted without
checking the mechanism it was reasoning about. All three verified in
the tree.

A ROUND-TRIP FREEZES NOTHING. Revision 2 proposed pinning the legacy
variant with an encode/decode round-trip. That test uses the SAME types
on both sides, so adding a field to `MinibufferPrompt` leaves it
passing while every v12-v22 peer in the field breaks. The existing
`minibuffer_prompt_round_trips_through_postcard` (src/protocol.rs:2363)
is precisely that shape, and there are NO literal byte fixtures
anywhere in the protocol tests today --- checked, not assumed. Only
`assert_eq!(encoded, LEGACY_BYTES)` against bytes captured now can fail
when the encoding changes. Two fixtures, open and clear, matching the
shapes the semantic test already covers so the corpus is not a fresh
judgement call.

THE CACHE HAZARD I DESCRIBED CANNOT HAPPEN. Revision 2 argued for a
per-variant cache key on the grounds that a peer reconnecting at a
different version could have its first message suppressed as a
duplicate. `SemanticRenderState::for_peer(frontend_id,
negotiated_protocol_version)` is created PER PEER with its version
baked in on attach (src/daemon.rs:2080) and removed on detach (:1591),
so a cache never spans two versions and reconnecting creates a fresh
state. One per-peer cache stands; the selection is a producer gate,
`peer_knows_minibuffer_rows`, alongside the four such gates that
already exist (:1410-1435). The test for the impossible condition is
REMOVED rather than written --- a test that cannot fail passes forever
and teaches the next reader that the hazard is real.

The matched open/close family requirement is independent of caching and
stands unchanged.

"THE NAME MUST SURVIVE" IS NOT ACHIEVABLE. Prompt plus typed input
consume the width budget first, so the remaining suffix space can be
too small for even the bare name --- and revision 2's rule would then
have forced a partial name, which reads as a DIFFERENT command. The
rule is now ordered: if the whole name does not fit, omit the suffix
entirely; only once it fits is a description attempted; if the
description does not fit whole, drop it. The guarantee becomes "never a
partial name", which is achievable, rather than "the name always
survives", which is not. Witnessed at three widths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth e9bfe9a14c
docs: frame Discovery Stage 2 (revision 2) — M-x rows
COHERENCE.md section 5 grades discoverability Partial after Stage 1 and
names "M-x rows are still bare names". The descriptions ALREADY EXIST
--- `Command.description` is required, and `help.list-commands` renders
them --- so this is substrate without surface in its purest form: the
information is present, surfaced elsewhere, and absent from the one
moment it would change a decision.

TWO REVISION-1 CLAIMS WERE WRONG, both checkable in the tree:

  - "Change `candidates` in place and gate at >= 23." Postcard is NOT
    self-describing: fields encode positionally, so a v22 peer decoding
    `Vec<MinibufferRow>` where it expects `Vec<String>` mis-reads the
    bytes rather than skipping them. And gating would not have rescued
    it --- with only one variant to gate, a v12-v22 peer would have
    received NO MINIBUFFER AT ALL. That variant goes to every peer
    negotiated >= 12 (src/daemon.rs:1472).

    Revision 2 is additive: `MinibufferPromptRows` APPENDED to the enum
    (indices are positional; inserting renumbers everything),
    `MinibufferPrompt` frozen for v12-v22, per-peer selection, per-
    variant cache keys, and close matching the open's family --- a
    mismatched close is how a popup stays on screen forever.

  - "Both frontends render label + detail." The grid TUI never reads
    `MinibufferPrompt`; src/editor.rs contains ZERO references to it.
    It paints from `core.minibuffer` and renders the selected candidate
    as `format!("  [{cand}]")` (src/editor.rs:5484). The rich wire
    reaches pmacs-gpu only.

    So the TUI half is a LOCAL formatting change --- it is in-process
    with the core and reads `Command.description` from the registry
    directly, with no wire involvement. The contract is pinned
    including clipping: THE NAME SURVIVES AND THE DESCRIPTION IS
    DROPPED at narrow widths, because a clipped name is strictly worse
    than today's bare one. A multi-row TUI chooser is explicitly not
    this lane.

This lane HOLDS THE BUMP SLOT. Git Stage 1 is no-wire and runs beside
it; git Stage 2 needs a bump and must wait.

Q#D2-5 records a trap that arrives with the feature: richer rows make
M-x LOOK like a closed set, inviting someone to make acceptance reject
unmatched input. Completion is assistance, not validation ---
`resolve_accepted_value` returns literal typed text by design --- so
that would be a behaviour change, not a rendering one, and it is out of
scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 13:52:32 +02:00
Levi Neuwirth 01901029d6
Merge pull request #230 from levineuwirth/lsp-latex-coverage
feat(lsp): LaTeX via texlab, with a document-root resolver
2026-08-10 11:50:41 +00:00
Levi Neuwirth 9a1e11c0c8
fix(lsp): a `/` search boundary no longer disables the LaTeX marker walk
`latex_within_boundary` answered a question about path COMPONENTS with
string arithmetic:

    dir:sub(1, #boundary + 1) == boundary .. "/"

With a `/` boundary the needle is `"//"`, which no canonical path
begins with. Every ancestor was therefore judged out of bounds, the
marker walk never examined a single directory, and each chapter of a
thesis got its own root — two texlab processes for one document tree.
The lane's headline behaviour, silently off, with all fourteen shipped
tests still green because every one of them clamps the boundary to its
own tempdir.

Fixed by comparing segments rather than characters, so the root is a
boundary with zero segments — containing everything by construction
instead of by a special case, and tolerating a trailing separator for
free. The same root-is-special trap sat at two other points on the same
path and is closed with it:

  * `latex_parent_of` returned nil for a top-level directory, making `/`
    the one directory the walk could never examine — the identical bug
    from the far end. It now yields `/`, matching `walk_for_marker`'s
    `Path::ancestors` on the Rust side, and still terminates because `/`
    has no component to strip.
  * `latex_root_for` sliced `/paper.tex` to an EMPTY directory, which
    canonicalizes to nothing and made the resolver DECLINE — and a
    decline is the one path that falls through to `pmacs.project.detect`,
    whose walk includes `.git`. A document at the filesystem root now
    roots at `/`.
  * `latex_marker_in`'s join is guarded for `dir == "/"`, which this
    change makes reachable for the first time; the naive form produces
    `//name`, the one spelling POSIX leaves implementation-defined.

Two new pins plus a strengthened one, 16 tests:

  * `two_chapters_share_one_server_under_a_root_search_boundary` — the
    defect end to end through ATTACH, not on the predicate, because the
    symptom is two servers rather than a wrong string. Restoring the old
    comparison fails exactly this test, with the two-server output.
  * `latex_root_walk_stops_at_the_search_boundary` now asserts BOTH
    directions. "Stops at the boundary" is also satisfied by a walk that
    never runs — which is precisely what a `/` boundary produced — so the
    hermeticity property (R8's shape: a stray `latexmkrc` above the
    tempdir must stay invisible) is now paired with the walk still
    climbing to, and examining, the boundary directory itself.
  * `latex_root_for_a_document_at_the_filesystem_root_is_the_root`.

Also corrects `docs/active-work.md`: §3 no longer awaits a revision 3 —
`b5eaf27` IS revision 3 — and the lane entry now records that boundary
handling has been this resolver's interesting part twice, so a reader
weighing whether to trust it knows where to look first.

Gates: ALL GREEN via `./scripts/gate --acceptance lsp_latex_acceptance`.
`/tmp` on this machine is a near-full tmpfs and three grep tests fail
there with `QuotaExceeded`; the green run used
`TMPDIR=/home/jeans/build/pmacs-gate-tmp/lsp-latex`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 10:48:49 +02:00
Levi Neuwirth 12b7ee58d8
docs: LaTeX framing revision 3 --- step zero corrected the premise
The .texlabroot caveat is discharged, and discharging it falsified the
reasoning behind it.

.texlabroot is real, and texlab marker set is wider than this document
listed --- the bare texlabroot and latexmkrc spellings count too, and
the walk takes the innermost. The implementation ships texlab own set.

But the caveat framed marker 1 as conditional on texlab honouring the
FILE, which was the wrong question. Every arm of texlab ancestor walk
searches documents already loaded, and that workspace is built from the
folders the CLIENT supplies. Observed: with rootUri at chapters/, no
ancestor marker widened texlab view and its dependency graph never
reached the parent document; with rootUri at the marker directory the
parent resolved either way.

So texlab honours the root it is handed and never corrects a too-narrow
one. config.latex.root IS the project scope, which makes the resolver
the whole value of the lane for a multi-file thesis rather than a
nicety --- the opposite of how section 2 "Slice 1 is one config entry"
reads.

Second correction, and this one was a real trap. Revision 2 said .git
is deliberately excluded from the walk and stopped there. Omitting it
is not sufficient: project_root_for falls through to
pmacs.project.detect when a resolver returns nil, and that walk lists
.git among its markers at src/project.rs:184. A resolver that politely
declined on a markerless file would hand texlab the monorepo by the
back door, with the exclusion looking correct at every line you would
think to read. The resolver never declines for a file with a directory,
and the fixture asserts the shared detector really would have answered
the repo root so the pin cannot pass vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 10:48:49 +02:00
Levi Neuwirth 22d72fa7f7
docs: record the LaTeX lane as implemented, step zero discharged
Section-local edit to the lane's own block in `docs/active-work.md`.

The heading moves from "BRANCHED, framing in review" to "IMPLEMENTED,
gates green, no PR yet", and the §3 `.texlabroot` caveat is written up
as discharged rather than merely resolved: what texlab actually does,
how it was observed, and the one place the framing now reads stale.

The substantive finding recorded here is not "the marker works". It is
that texlab's own root walk only sees markers belonging to documents
already in its workspace, and the workspace comes from the folders the
client supplies — so texlab honours the root pmacs hands it and never
corrects a too-narrow one. That makes `config.latex.root` the project
scope rather than a hint, which is worth carrying forward whether or
not anyone rereads the framing.

Also recorded: the shared-`CARGO_TARGET_DIR` trap, because a bare
`cargo test` in this worktree fails with compile errors from a sibling
lane's code and reads exactly like a broken branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 10:48:49 +02:00
Levi Neuwirth e4c6421612
feat(lsp): LaTeX via texlab, with a document-root resolver
Framing: `docs/lsp-language-coverage-framing.md` (revision 2, §3 and
§6). One `pmacs.lsp.config.latex` entry — command `texlab`, args none,
a function-valued `root`, and deliberately nothing else.

STEP ZERO: the §3 caveat, discharged by observation.

Revision 2 marked marker 1 (`.texlabroot`) UNVERIFIED and blocking:
only texlab's version and CLI had been checked, and the CLI exposes
just `run` / `inverse-search`. Driving a minimal LSP client against
`texlab run` by hand, plus reading texlab's own source at the exact
installed tag, settles it — and corrects the premise underneath it.

`.texlabroot` IS a real marker. `crates/distro/src/language.rs` at
v5.25.1 maps `.texlabroot`/`texlabroot` -> Root, `Tectonic.toml` ->
Tectonic, `.latexmkrc`/`latexmkrc` -> Latexmkrc, and
`ProjectRoot::walk_and_find` (`crates/base-db/src/deps/root.rs`) walks
ancestors testing all three, innermost wins. So the shipped marker set
is texlab's own rather than a plausible-looking guess, and marker 1
stays.

But texlab CANNOT apply that walk to rescue a root pmacs gets wrong.
Each arm of `walk_and_find` searches `workspace.iter()` — documents
already loaded — and the workspace is built from the folders the CLIENT
supplies. Live sessions confirm it: with `rootUri` at a `chapters/`
subdirectory, no marker above it (`.texlabroot` included) widened
texlab's view, and its dependency graph never reached the parent
document; with `rootUri` at the marker directory the parent resolved,
marker present or not. texlab honours the root it is handed and never
corrects a too-narrow one.

That inverts the significance of the resolver rather than weakening it:
whatever `config.latex.root` returns *is* the project scope. It is the
whole value of the lane, not a nicety.

Also observed, because the entry depends on it: bare `texlab` serves
LSP over stdio — `initialize` returns `TexLab 5.25.1` with no
subcommand — so `args = {}` is right and `run` is not needed.

WHY `.git` IS EXCLUDED, AND WHY THAT IS NOT AN OMISSION.

texlab wants the document root; a thesis inside a monorepo must not get
the monorepo. The subtlety is that leaving `.git` out of the marker
list does not achieve this on its own. `project_root_for` falls through
to `pmacs.project.detect` when a resolver returns nil, and that walk
does include `.git` — so a resolver that declined on a markerless file
would hand texlab the repository root by the back door. The resolver
therefore never declines for a file that has a directory: no marker
means the file's own directory, which is also framing marker 4. The
acceptance pins this end to end through attach, not just on the
resolver's return, and asserts in the same fixture that the shared
detector really would have answered the repository root.

NO FILETYPE MAPPINGS, per revision 2 §2 — verified, not inherited.
`src/syntax.rs` already declares `name: "latex"` with `extensions:
["tex", "latex", "sty", "cls"]`, and grammar-extension detection sits
ahead of the LSP filetype map in `detect_buffer_language`
(`syntax.lua`). A `.tex` buffer already resolves to `latex`. The suite
asserts both halves — the extensions resolve, and `pmacs.lsp.filetypes`
is empty for them — so a later "helpful" addition cannot be mistaken
for the thing that made attach work.

Q#LX1: no `settings`, no `init_options`. Build-on-save and
forward-search are both opinionated and forward-search needs a
configured viewer.

Fixtures bound detection with `pmacs.project.set_search_boundary` and
assert the boundary took — R8's hazard is exactly this fixture's shape,
and one test pins the walk stopping at the boundary directly. Attach
fixtures use `pmacs_fake_lsp`, and the missing-server fixture an
asserted-absent path: texlab is installed on this machine, so relying
on either its presence or its absence would behave differently here and
in CI.

Verification: fourteen tests, one per §6 bullet plus the boundary and
decline cases. Seven mutations each fail the suite — resolver declining
on no marker (6 tests), no marker walk (4), a redundant `filetypes.tex`
(1), boundary ignored (1), `io.open` truthiness so a directory counts
as a marker (1), marker set narrowed (4), command renamed with
opinionated settings added (1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 10:48:49 +02:00
Levi Neuwirth 75f5f97582
docs: authorize LSP LaTeX implementation, precisely
Recorded as "implementation authorized" rather than "approved", because
that is what happened: the user authorized dispatch after a summary of
revision 2 four corrections, not after returning findings on the
document the way they did for the other four lanes.

The distinction matters for one reason. Section 3 .texlabroot caveat is
unverified and blocking --- texlab 5.25.1 is installed and its version
and CLI were checked, but the CLI exposes only run and inverse-search,
so its LSP-level behaviour was never established. A framing marked
plainly "approved" invites a reader to treat that caveat as settled
prose. It is step zero of the work.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 10:48:49 +02:00
Levi Neuwirth 7992054153
docs: LSP LaTeX coverage framing revision 2, on a branch at last
Revision 1 was untracked, on main, in a single checkout. By the handoff
own rule --- work is portable only after it is committed and pushed ---
it did not travel. Committing it here is the first fix.

Three factual corrections, all from checking rather than reading.

haskell-language-server IS installed on this machine, along with its
wrapper. Revision 1 said it was not, and that claim was the entire
basis of its Slice 1 / Slice 2 split. Only OCaml still lacks a server.
The argument for leaving Haskell out survives, but on use evidence ---
the .hs files are a rarely-edited Hakyll generator --- not on
dependency cost.

Slice 1 is smaller than framed. Revision 1 proposed filetype mappings
for .tex/.latex/.sty/.cls "so highlighting and LSP agree on what a
LaTeX file is". They already agree, by construction: the grammar
carries exactly those extensions at src/syntax.rs:1111,
grammar-extension detection sits AHEAD of the LSP filetype map in the
precedence chain per the merged grammar framing at :166-171, and
lsp.lua:267-270 calls the filetype map "mainly the LSP-only fallback".
The real missing piece is one config entry.

Q#LX3 deferral argument read a stale line. COHERENCE.md:124 and :867
both record multi-root LSP affinity as merged in #161; only :1669 still
says "first slice in flight", contradicting the same document twice.
The one item revision 1 said could justify deferring therefore
dissolves. The COHERENCE.md inconsistency is real and is left for
whoever next touches section 20 rather than smuggled in here.

Q#LX2, which revision 1 called the question most likely to make the
entry wrong in practice, is now answered rather than shrugged at. An
upward marker walk through config.latex.root, which already accepts a
resolver function. .git is deliberately excluded: a repository root is
the wrong answer for LaTeX, since texlab wants the document root, and
this is the one place where copying the other fourteen entries
instinct would be actively wrong.

The .texlabroot marker is marked UNVERIFIED and blocking. texlab 5.25.1
is installed and its version and CLI were checked directly, but the CLI
exposes only run and inverse-search, so its LSP-level behaviour was not
established. Confirm against a live session before implementing. Same
discipline the gate-protocol-build lane applies to its own
precondition: the thing the design rests on gets observed, not
reasoned about.

Also renumbered Q#HS1 to Q#LX4, because revision 1 Q#LX2 collided with
a live question ID in the merged grammar framing for the same language.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 10:48:49 +02:00
Levi Neuwirth 7cf4653417
Merge pull request #229 from levineuwirth/gate-protocol-build
build(gate): run the build the CRDT sweep depends on
2026-08-10 08:47:22 +00:00
Levi Neuwirth 3b10f9d916
docs: both U4 and U5 controls are asymmetric, and said so only one way
Three corrections. The first two are the same error in two rows.

I wrote each control as if it DECIDES, when each is informative in only
one direction.

U4: extending the deadline establishes "emitted late" IF the clear
arrives. If it does not arrive, that establishes only "not observed by
the longer deadline" --- not "never emitted" --- because transport loss
produces the same absence. No deadline, however long, separates
non-emission from transport loss. That needs producer-side emission
evidence, did pmacs write the clear, cross-checked against the
collected stream. The row now states both branches and names what the
negative branch cannot conclude.

U5: one isolated run cannot decide whether the gate suite is
implicated. A matching isolated RED proves the gate suite is not
necessary for the failure. An isolated GREEN proves nothing beyond that
run, because the failure is intermittent and absence under one run is
not evidence of dependence. I had written it as though either outcome
settled the question.

This is worth naming as a class rather than two typos: a control whose
positive branch is conclusive and whose negative branch is not, written
up as though both were, is how an inconclusive result gets recorded as
an exclusion. Two rows in this file had it.

Third, minor: "three docs" was accurate at the occurrence tip and is
not now --- #229 has since added this registry file. Replaced with
"documentation" so the claim does not rot again with the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 10:26:07 +02:00
Levi Neuwirth 5029915ae3
docs: normalize U4/U5 fragments, and drop three claims the evidence does not carry
Four review findings, all mine.

Normalization. The byte count and the :LINE suffix are
occurrence-specific --- the count is the collected suffix length, which
varies per run, and the line moves with the file --- so neither can be
a required fragment. Both rows now carry stable fragments and an
explicit NOT-fragments field naming what must not be matched on.

The LuaJIT-pass argument was the rejected overreach again. I used a
passing sibling leg as a STRUCTURAL exclusion; a deterministic defect
can be Lua-flavour-specific, so it is corroboration only. The row now
says so in those words. The real grounds are stronger anyway and were
sitting there: the workflow never invokes scripts/gate, and
full_grid_resync_acceptance runs BEFORE the changed gate suite, which
closes even the leaked-state path.

Three contradictions inside U4, each removed.

"It never emitted the blank" asserts a mechanism the next field
simultaneously calls open. Now: no blank was OBSERVED after the mark
within the deadline.

The 25,362 bytes were not a capped window. suffix.len() is the ENTIRE
post-mark output; only the displayed head is truncated, to 400 bytes.
Verified in the test source. So my control --- capture the full stream
rather than the window --- was solving a gap that does not exist. The
gap is arrival TIME, and the control now instruments that instead.

The ~20s failure duration IS the fixed Duration::from_secs(20) timeout,
so the ratio against a fast pass is mechanically determined and is not
independent timing evidence. Also verified in the source.

U5 control relabelled. Running m5_8_acceptance alone decides whether
the gate suite is implicated --- cross-suite attribution --- and
nothing more. It cannot separate injected-before-raw-mode from
raw-mode-lost from a third cause, and another isolated pass cannot
either however often it is repeated. Mechanism discrimination needs
readiness and raw-mode state observed AT INJECTION, which is now a
second, separately labelled control.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-10 00:02:53 +02:00
Levi Neuwirth ae6a815435
docs: two unresolved macOS signatures from #229 CI, as separate rows
PR #229 first CI run went red on Test (macos-latest / lua54) with
a_pty_resize_blanks_the_host_before_repainting. The rerun turned that
selector green in 0.50s against 20.26s failing, and went red on a
DIFFERENT selector, ctrl_c_during_reconnect_sleep_yields_clean_exit.
Per this file matching rule that is a new incident, not the resize
signature occurring twice, so they are two rows.

U4 records the resize failure with its exact fragments. The diff is
excluded on two structural grounds that do not depend on a rerun: #229
touches no src/ at all and no test but gate_script_acceptance, and the
sibling luajit leg passed on the same commit. A deterministic platform
defect fails both flavours --- that is how #227 non-UTF-8 fixture
presented.

U5 records the Ctrl-C failure at deliberately WEAKER exclusion
strength, and says so in its own field. The changed gate suite ran
earlier in the same job and creates worktrees and directories. No
leaked child or persistent signal-state mutation was observed, but "the
diff touches no src/" is not the same argument here as for U4, because
cross-suite leaked state is a path reachability reasoning does not
close. Its control is to run m5_8_acceptance alone, without the gate
suite ahead of it, before attributing anything either way.

Neither row claims a mechanism. The exit status shows only that Ctrl-C
arrived as SIGINT rather than as the raw-mode key event the test
drives; whether injection preceded raw mode, raw mode was lost, or
something else happened is open, and the fragment does not separate
them.

Also flagged: worker-identity-stage1 independently defines its own U4
and U5. This lane merges first, so that branch must renumber on rebase
--- a conflict resolved textually without renumbering would leave two
different incidents sharing an id, which is the failure the matching
rule exists to prevent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 23:30:53 +02:00
Levi Neuwirth a9544fa959
docs: narrow the copy-detection claim to the fixture it was measured on
The lane asserted that real git emits no 2 C record even under
status.renames=copies, and a test MEASURED it. The measurement was
real; the proposition drawn from it was too broad.

git-status(1) documents C as "copied (if config option status.renames
is set to copies)", so git does emit it. What the fixture establishes
is narrower: with ITS copy source left unchanged, git reports 1 A. and
emits no 2 C record. That is a fact about the fixture.

It is still sufficient reason to craft the row through _deliver_status
--- a weaker and true justification replacing a stronger false one ---
so no behaviour changes and no test changes. The score-based runtime
fix was never in question.

Six sites narrowed, not the two that review cited: the framing bullet,
the ledger bullet, the g6_4b doc comment, the premise comment, the
assertion message, and the rename_and_copy_repo fixture doc. Each also
records the retraction, so the old claim cannot be reconstructed from a
stale copy that outlived the correction.

What is deliberately NOT claimed anywhere: WHY an unchanged source is
not offered as a copy candidate. There is a plausible mechanism and it
was never established, and replacing one overreach with a smaller one
is how this class of error survives.

The root cause is worth recording: this claim entered the lane as a
dispatch instruction stated as settled fact, and the implementing agent
did exactly what it was asked --- measured one fixture. A measurement
cannot be broader than its fixture, however carefully it is run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 22:34:19 +02:00
Levi Neuwirth e94b256cc6
fix(git): say "copied" when a 2 record is a copy, not "renamed"
Porcelain v2's `2` record covers renames AND copies --- the `<Xscore>`
field leads with `R` or `C` --- and the diff header said "renamed from"
for either. A copied file was therefore reported to the user as a
rename, which is a different fact about their tree.

The information was already retained: `parse_status` captures `score`.
Nothing new is parsed; the header reads the byte it already has.

`kind` stays `"rename"` for both, deliberately. Every BEHAVIOUR keyed on
it is identical --- including the two-path `git diff HEAD -- <orig>
<current>`, which is right for a copy as much as for a rename. Splitting
the kind would oblige every consumer present and future to spell
`kind == "rename" or kind == "copy"`, and an arm forgotten anywhere
silently drops copies back to the one-path diff: the exact regression
this fix exists to avoid. The consumers are few and all were checked ---
`diff_plan` is the tree's only `kind == "rename"` branch,
`status_line_text` keys off `row.orig`, and the two tests that name the
kind are `g6_1`'s corpus and `g6_8`'s unborn-unreachability assertion.
`score` has no other reader anywhere.

Read from `score` rather than from `row.x`: the score names
rename-vs-copy whichever side detected the change, while `X` carries the
letter only for an index-side one.

The status ROW is UNCHANGED, and that is a decision rather than an
omission. Its `XY` prefix already reads `R.` against `C.`, out of the
same byte, in the porcelain vocabulary every other row in the panel is
read in --- so the distinction is already on screen, and a second
vocabulary beside it would be a wider surface for no new fact. `g6_4b`
asserts both prefixes so the claim is checked.

Unborn `HEAD` needs nothing, confirmed rather than assumed: `diff_plan`'s
rename branch sits inside `if not unborn`, and `g6_8` already pins that
no `2` record can occur there.

`g6_4b` is a parser/presentation test and says so. Real `git` emits no
`2 C` record --- the test MEASURES that under `-c status.renames=copies`
rather than recalling it --- so the copy row is supplied through
`_deliver_status`, the seam `g6_2b`/`g6_17`/`g6_21` already use.
Everything downstream is real: repository, panel, `d` dispatch, spawned
`git diff`, rendered buffer. Both crafted rows name paths that exist in
the fixture, so each drives a real two-path diff. Both classes are
asserted, and so is the argv --- a fix to what the user is TOLD must not
reach what the module DOES.

Mutations, each caught: header always "renamed" fails only the copy
half; header always "copied" fails only the rename half; dropping
`row.orig` from the steps fails the argv equality.

Gate: `scripts/gate --acceptance git_status_stage1_acceptance
--acceptance listview_acceptance --acceptance config_registry_acceptance`
--- all eleven steps green, acceptance 34/34.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 22:12:56 +02:00
Levi Neuwirth f53cf4f0fd
docs: record the macOS legs going green on the git Stage 1 lane
Run 31330601204 at `e816812`: all 14 jobs green, including both
`Test (macos-latest / …)` legs — the two that were deterministically red
on `g6_2`. The macOS half of the fix is therefore OBSERVED, not inferred.

What stays reasoned about is only the explanation — `EILSEQ` itself and
the `lstat`-vs-`ENOENT` argument for why `g6_2c` cannot be made portable
— which a green run can neither confirm nor refute. Kept separate on
purpose: conflating "the suite passes" with "the cause is understood" is
what put an unportable fixture in the suite in the first place.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 21:19:35 +02:00
Levi Neuwirth e816812d65
docs: record the macOS non-UTF-8 filename fact on the git Stage 1 lane
PR #227's CI round, in the lane entry: both macOS legs failed `g6_2`
deterministically while Linux stayed green, the fix at `4b82d1e`, and
the durable portability fact behind it — APFS/HFS+ validate pathnames as
UTF-8 and reject an invalid one with errno 92, EILSEQ, so a non-UTF-8
filename is a Linux-only fixture, and it cannot be reached around the
filesystem either because `git status` lstats every index entry and
EILSEQ is not ENOENT.

Also records the coverage split (parse+display and gesture refusal
everywhere; provenance Linux-only and loudly gated), the new
`lua_bytes`/`z_payload_bytes` fixture mechanism and its three-digit
escape rule, what was verified locally versus reasoned about, and the
latent sibling at tests/gpu_invocation_acceptance.rs:621 — which writes
a non-UTF-8 filename but sits behind `#[cfg(feature = "crdt")]`, and the
`crdt-test` job is ubuntu-only, so it is not red today and would be the
day that job gains a macOS leg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 21:02:13 +02:00
Levi Neuwirth 4b82d1e59e
test(git): split the non-UTF-8 coverage where the platform splits it
Both macOS legs of PR #227's matrix failed `g6_2` deterministically
while Linux stayed green. The behaviour under test was correct; the
FIXTURE was unportable. `g6_2` built `bad\xFF.txt` on disk with
`std::fs::write`, and APFS/HFS+ validate pathnames as UTF-8 and reject
an invalid one at the syscall with errno 92, EILSEQ, "Illegal byte
sequence". Linux's VFS treats a filename as opaque bytes.

The claim from Q#G-8 is unchanged and had to survive whole: a non-UTF-8
path is PARSED and DISPLAYED, so the user is not lied to about what is
modified, and RET and `d` REFUSE it with a message, because
`pmacs.process.spawn` takes `args: Vec<String>` and
`pmacs.buffer.find_or_open` takes `path: String`.

So the coverage is split along the line the PLATFORM draws, rather than
`#[cfg]`-skipped — a behaviour that vanishes on one platform is exactly
how a boundary stops being tested at all:

  g6_2   parse + display, driven from the PAYLOAD BYTES directly. No
         repository, no filesystem, runs everywhere. This half never
         needed a file: git hands the module bytes and `parse_status`
         takes a string.
  g6_2b  the gestures refusing, over a REAL repository the platform can
         create, with the unrepresentable row delivered through
         `_deliver_status` — the seam `g6_17` and `g6_21` already use,
         for the same reason: a chosen completion is not otherwise
         expressible. The repository, panel, keymap and dispatch are all
         real; only the row bytes are supplied. Runs everywhere.
  g6_2c  the one thing a payload cannot witness — that real `git` emits
         these bytes at all. LINUX-ONLY, named for the limitation, with
         a comment stating exactly what is uncovered on macOS and why
         nothing there could cover it.

`g6_2b` also covers the rename ORIGIN, which the old single test never
did: `d` on a rename passes the origin to `git diff` as an argument too,
so a check written on `row.path` alone lets it through.

WHY g6_2c CANNOT BE MADE PORTABLE. The name cannot be reached around
the filesystem either. Putting it only in the index (`update-index
--index-info` plus `write-tree`, never touching the worktree) does not
help: `git status` lstats every index entry, and on macOS that lstat
fails with EILSEQ rather than ENOENT, which git reports on stderr and
SKIPS — so the row would be absent rather than unrepresentable, and the
test would assert a different thing while looking the same. There is no
macOS arrangement in which real `git status` names a non-UTF-8 path.

What that gate leaves uncovered on macOS is the PROVENANCE of the bytes
and nothing else. The remaining link — that the spawn pipe carries bytes
rather than text — is structural: `event_to_lua` in
src/lua_bindings/mod.rs builds the stdout chunk with
`lua.create_string(bytes)`, and git.lua only concatenates chunks.

New fixture mechanism, `lua_bytes` / `z_payload_bytes`: a `-z` payload
whose paths are not UTF-8 cannot be spelled as a Rust `&str` at all, so
it is assembled as raw bytes and handed to Lua as one literal, with
every non-printable byte spelled as a THREE-DIGIT decimal escape. Three
digits always — Lua's decimal escape consumes up to three, so a shorter
one swallows the digit that follows it, which is the same hazard
`z_payload`'s comment records for `{:?}`-rendered NULs.

Verified here: the full gate suite green; 33/33 under both LuaJIT and
Lua 5.4; the two portable tests still green with `g6_2c` compiled out,
leaving no dead-code warning behind; and three mutations each caught by
`g6_2b` — removing the RET check, removing the `d` check, and removing
only the origin clause. Reasoned about rather than executed: the macOS
EILSEQ behaviour itself. After this change nothing macOS runs depends on
a filesystem accepting such a name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 21:01:52 +02:00
Levi Neuwirth 7e546aae78
docs: record PR #227's third review round on the git Stage 1 lane
The lane entry now carries round 3's single P2 --- a repository root
ending in a carriage return, truncated by round 2's own `\r?\n$` --- and
the tip moves to `39ad43d`. Section-local; nothing outside the Git
integration Stage 1 block is touched, and the P1a merge block is
unchanged and still the reason this lane cannot merge.

What is worth carrying beyond the fix itself is the `-z` finding, so the
next reader does not re-derive it: `git rev-parse` has no `-z` option on
git 2.55, and asking for one makes rev-parse echo a literal `-z` line
ahead of the toplevel at exit code 0. It was checked against the
installed git rather than assumed, which is the whole reason the fix is
a correct strip rather than a different output representation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 18:42:13 +02:00
Levi Neuwirth 39ad43db5a
fix(git): keep a repository root that ends in a carriage return whole
`strip_output_terminator` stripped `\r?\n$`, and a carriage return is as
legal a byte in a POSIX directory name as a newline is. For a repository
rooted at a directory named `trailing\r`, `rev-parse --show-toplevel`
prints the path's own `0d` and then its own `0a` terminator, and a strip
tolerant of an optional preceding carriage return cannot tell those two
bytes apart --- so it took both. The root resolved as `trailing`, and
every command afterwards ran with a `-C` and a cwd naming a directory
that does not exist: the same defect the previous commit at this call
site fixed, one byte over.

Exactly one trailing `\n` is now removed, by an explicit last-byte test
rather than an anchored pattern. Both of this function's bugs lived in a
pattern, and the third answer to the same question should not be a
cleverer one.

`-z` was CHECKED against the installed git rather than assumed, and must
NOT be used. `git rev-parse` has no `-z` option at all on git 2.55: it
is absent from the manual, `--parseopt -z` errors with "unknown switch",
and in ordinary mode `rev-parse` treats `-z` as an unrecognized flag
argument and echoes a literal `-z\n` onto stdout AHEAD of the toplevel
--- exit code 0, corrupted output, silent. `--show-toplevel` applies no
C quoting either, not even under `core.quotePath=true`. There is
therefore no unambiguous output representation to prefer over removing
the one byte git appended.

`first_line` is untouched, for the reason the previous commit recorded:
its three callers all feed the single-line status band, where taking the
first line is right.

Witnessed by `g6_14d` end to end, not at the parser: the fixture really
creates directories named `trailing\r` and `nl\nand-trailing\r`, the
real `git` resolves them, and the assertion is on the cwd of the status
spawn the module actually made, plus real rows in the panel and a RET
that opens the file the row names. The second case sends both hazards in
together because a root may hold both and neither fix may mask the
other. `g6_14c` now reaches that chain through the shared
`assert_root_resolves_whole` rather than keeping a second copy of it,
and the helper binds `pmacs.project.set_search_boundary` to the fixture
through `open_panel` --- R8's lesson, and a root fixture is exactly that
hazard's shape.

Mutation-verified: restoring `\r?\n$` fails `g6_14d` at `<tmp>/trailing`
against `<tmp>/trailing\r` while `g6_14c` still passes, which is exactly
the byte that separates the two fixes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 18:41:22 +02:00
Levi Neuwirth 93d557a527
docs: record the witness as closed, and what the audit found next door
Revision 5's ledger edits were written while the witness was still the
open blocker, and landed alongside the commit that closed it --- so the
entry asserted both at once: an OPEN BLOCKER bullet saying this lane
"currently ships without the regression guard it was created to
provide", and, further down, that same gap closed at 677fd25. A
recovering machine reads the top of an entry first, so the stale half
is the half that gets acted on. Reconciled in place: the heading, the
framing bullet and the blocker bullet now say re-opened by review and
CLOSED at 677fd25, and point at the bullet that closed it.

The script header cited framing revision 4; it is revision 5.

Also recorded, from auditing whether any OTHER assertion in that suite
is detached from the thing it names: renaming every other plan step ---
fmt, clippy, lib, m4, gpu, sweep, diff-check, acceptance-<suite> ---
leaves all 20 tests green. For most that is only a log filename and a
FAILED: entry. `sweep` is not: the runner's end-of-run listing globs
*-sweep.log and *-sweep-crdt.log, so renaming that step silently
empties the "read these, do not re-run and grep" listing that is the
U2/U3 remedy, with the suite still green. Left open deliberately and
said so --- that listing exists only on the RUN path, and every test in
this file is no-gates by design, so there is no cheap witness for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 18:18:34 +02:00
Levi Neuwirth 74d735ff8d
docs: gate framing revision 5 --- pin both emitter pairs, correct print-plan
Revision 4 said it would close the rename hole for the sweep as well as
the build, and then did not: its verification bullet required only a
singular real-plan pair in the context of build-crdt. The hole is
symmetric --- renaming sweep-crdt slips through exactly the same gap
--- so section 7 now pins BOTH emitter pairs explicitly, name and exact
command, asserted from the emitter where the name still exists.

Two older bullets also still claimed named steps appear in
--print-plan. They do not; that mode prints commands only, which is the
wording that let the attribution witness drift away from the step it
names in the first place. --print-plan is now described as the command
and order witness, and nothing more.

The ledger recorded revision 4 as approved and implemented, and
presented the synthetic self-test as the attribution witness. Both were
read as done. A recovering machine or a PR preparation from that entry
would have shipped this lane without the regression guard it exists to
provide.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 18:11:14 +02:00
Levi Neuwirth 677fd25bcd
test(gate): assert the real plan's (name, command) pair, not one half
The lane exists to guarantee two things --- that the crdt sweep is
preceded by the build producing its binary, and that a build failure is
attributed to `build-crdt` rather than to `sweep-crdt`. It shipped with
neither guaranteed, because NEITHER WITNESS COULD SEE A NAME.

  --print-plan renders `emit_plan | cut -f2-`, so the ordering test
  compared commands and never saw the names beside them.

  --self-test hardcodes the string `build-crdt` inside its OWN synthetic
  plan, so it proves things about the runner and nothing about the real
  emitter.

Review demonstrated the consequence: renaming the real build step to
`sweep-crdt` left both tests passing --- a plan that would report a
build failure under the sweep's name, sitting green, which is exactly
the misattribution the separate step exists to prevent.

--print-plan-named prints emit_plan VERBATIM: the same `name<TAB>command`
text the runner reads back from PLAN_FILE. The new assertion compares
WHOLE LINES against it, so name and command are pinned together and a
rename of either step fails. The sweep's own pair is asserted too ---
asserting only the build's name leaves the identical hole open in the
other direction.

WHY A RENDERING AND NOT A SEAM. PLAN_FILE stays uninjectable: a test
that supplied the runner's plan would turn its `eval` into a general
command executor, the same class of defect this script's own review
caught in --acceptance and fixed with a parse-time refusal. Re-deriving
the plan test-side would be a second implementation of the thing under
test, which is the failure being repaired one level up. A distinct mode
rather than a --with-names modifier leaves --print-plan's contract ---
runnable lines --- exactly as it was, and defines no flag combination
that has no meaning.

--self-test STAYS. It witnesses the runner: failure naming, the FAILED:
list, log paths, non-zero exit, and continuation past a failure via the
sentinel. That is a different thing from attributing the real step, and
what it may no longer do is stand in for it.

A second test pins that the two renderings are one plan --- the stripped
one is the named one minus its names --- so a later edit giving either
mode its own text is caught rather than leaving an assertion on a name
the runner never uses. It also pins the `name<TAB>command` shape the
runner's `IFS=<tab> read` depends on.

Both new tests stay on the no-gates paths, so the suite stays cheap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016bqGA6s9tTUFzYpbeW3tai
2026-08-09 18:02:36 +02:00