docs: revise generated-buffer immutability framing

Close review round four by preserving applied edits through the
borrow-free fan-out, making the transaction criteria discriminating,
removing the unproven unlock capability, requiring the CRDT divergence
fault seam, and aligning Stage 2 with owner-local identity routing.

Update the active-work lane to carry revision 5 and its cross-lane
facts.
This commit is contained in:
Levi Neuwirth 2026-07-28 21:41:19 -04:00
parent d98d0b3994
commit 238fd043bf
2 changed files with 391 additions and 234 deletions

View File

@ -465,10 +465,10 @@ has **no branch and no framing yet**.
`../pmacs-generated-immutability`. **PR #188**, base `main`, forked from `../pmacs-generated-immutability`. **PR #188**, base `main`, forked from
`githubsucks/main` @ `ad41cf1`, **integrated to `7586905`** (#189, `githubsucks/main` @ `ad41cf1`, **integrated to `7586905`** (#189,
`COHERENCE.md` only; clean merge, no conflict). Framing only — `COHERENCE.md` only; clean merge, no conflict). Framing only —
`docs/generated-buffer-immutability-framing.md`, revision 4, plus this `docs/generated-buffer-immutability-framing.md`, revision 5, plus this
lane. **No runtime code, no protocol change.** lane. **No runtime code, no protocol change.**
- **PROPOSED — three review rounds closed (fifteen findings, nine P1, six - **PROPOSED — four review rounds closed (twenty findings, thirteen P1,
P2). Not approved. Do not implement, do not merge.** seven P2). Not approved. Do not implement, do not merge.**
- **What it frames.** The class-wide half of the `set_generated_contents` - **What it frames.** The class-wide half of the `set_generated_contents`
invariant that `docs/agent-handoff.md` §4 and `COHERENCE.md` §14 both invariant that `docs/agent-handoff.md` §4 and `COHERENCE.md` §14 both
record as unfinished: `Buffer::undo` gates on `ensure_writable()` record as unfinished: `Buffer::undo` gates on `ensure_writable()`
@ -495,15 +495,19 @@ has **no branch and no framing yet**.
(`:1140-1163`), so it was neither cleaned nor detected; and the (`:1140-1163`), so it was neither cleaned nor detected; and the
unconditional relock **locked a fresh buffer that was never unconditional relock **locked a fresh buffer that was never
successfully written**. `NoOp` clears, `Rejected` restores the entry successfully written**. `NoOp` clears, `Rejected` restores the entry
lock state, `Diverged` clears nothing and surfaces. lock state, `Diverged` clears nothing and surfaces. **Revision 5 keeps
the five outcomes but preserves the `Edit` in
`AppliedThenFailed { edit, error }`: the borrow-free Lua finisher fans
it out to window caches and replica mirrors before returning the
error.** Collapsing to `Result` inside `Buffer` was too early.
- **Two stages, two PRs.** Stage 1 — listview ownership fix **plus its - **Two stages, two PRs.** Stage 1 — listview ownership fix **plus its
identity-routing fix in the same PR**, dired and listview adopting the identity-routing fix in the same PR**, dired and listview adopting the
shipped primitive, the window-coordinate clamp, and the fold decision. shipped primitive, the window-coordinate clamp, and the fold decision.
Stage 2 — the new primitive, compile's nine write sites, the search Stage 2 — the new primitive, compile's nine write sites, the search
panel's four, compile/search ownership + routing, the path-backed panel's four, compile/search ownership + routing, the path-backed
refusal plus `mark_clean`, the `identity_protected` field, and refusal plus `mark_clean`, and the terminal-only
the bounded `unlock_generated`. `identity_protected` guard. **No Lua unlock ships.**
- **Six facts from this lane that other lanes need before it merges:** - **Nine facts from this lane that other lanes need before it merges:**
- **`bypass_intercept` is the wrong inventory key.** It misses - **`bypass_intercept` is the wrong inventory key.** It misses
`*buffer-list*`, `*help*` and `*workers*`, which are generated with `*buffer-list*`, `*help*` and `*workers*`, which are generated with
plain writes and no intercept at all. `docs/agent-handoff.md` §4's plain writes and no intercept at all. `docs/agent-handoff.md` §4's
@ -545,10 +549,11 @@ has **no branch and no framing yet**.
identity buffer.** It does `self.read_only = false` unconditionally identity buffer.** It does `self.read_only = false` unconditionally
(`src/buffer.rs:546`), so it lifts a lock it did not install, writes, (`src/buffer.rs:546`), so it lifts a lock it did not install, writes,
and re-locks. Present on `main`, untested, unframed anywhere before and re-locks. Present on `main`, untested, unframed anywhere before
this revision. Bounded in Stage 2 by the `identity_protected` field — revision 4. Refused in Stage 2 by the `identity_protected` field —
an **intrinsic** flag set once by `TerminalSession::open`, never an **intrinsic** flag marked once by a crate-private monotonic
written by `set_read_only`. Revision 3 tried to infer this from the `mark_identity_protected()` in `TerminalSession::open`, never written
lock's provenance instead; that broke the lift-and-restore idiom at by `set_read_only`. Revision 3 tried to infer this from the lock's
provenance instead; that broke the lift-and-restore idiom at
`tests/terminal_copy_mode_acceptance.rs:578-584`, and the general `tests/terminal_copy_mode_acceptance.rs:578-584`, and the general
lesson is that a **derived** fact must be maintained by every lesson is that a **derived** fact must be maintained by every
mutation of what it derives from — and `set_read_only` is `pub`. mutation of what it derives from — and `set_read_only` is `pub`.
@ -558,6 +563,13 @@ has **no branch and no framing yet**.
compiles it, so a green run of that suite proves nothing about the compiles it, so a green run of that suite proves nothing about the
seam. Any lane touching `read_only` semantics must run it with the seam. Any lane touching `read_only` semantics must run it with the
feature and confirm `acc16e` is in the count. feature and confirm `acc16e` is in the count.
- **`identity_protected` is not generated-lock provenance.** Revision
4 tried to use “not a terminal identity buffer” as proof that the
generated primitive installed the lock; it is not. Revision 5
therefore removes `pmacs.buffer.unlock_generated` from the arc
entirely. Wdired's future generated→editable transition remains
dired Stage 3 work and must be owner-specific or use the eventual
lock-policy enum.
- **The CRDT `Replace` mid-transaction divergence is real and - **The CRDT `Replace` mid-transaction divergence is real and
unowned.** `crdt.delete` then `crdt.insert` (`src/buffer.rs:1140-1163`); unowned.** `crdt.delete` then `crdt.insert` (`src/buffer.rs:1140-1163`);
if the first succeeds and the second fails, the code's own comment if the first succeeds and the second fails, the code's own comment
@ -565,7 +577,11 @@ has **no branch and no framing yet**.
violation." It reaches `apply_edit` and `apply_edit_skip_intercepts` violation." It reaches `apply_edit` and `apply_edit_skip_intercepts`
today and is reported as an ordinary `CrdtRejected`, so nothing today and is reported as an ordinary `CrdtRejected`, so nothing
distinguishes it. This lane names and contains it; **repair is distinguishes it. This lane names and contains it; **repair is
deferred and unowned.** deferred and unowned.** Revision 5 makes the classifier mandatory:
a private delete→insert helper is fault-injected under
`cargo test --lib --features crdt`; there is no four-variant fallback
that maps divergence to `Rejected` and leaves a fresh buffer
writable.
- **Overlap warning.** Stage 2 touches `src/lua_bindings/mod.rs`'s buffer - **Overlap warning.** Stage 2 touches `src/lua_bindings/mod.rs`'s buffer
mutator bindings and `src/buffer.rs`. Do not run it concurrently with mutator bindings and `src/buffer.rs`. Do not run it concurrently with
the `apply_resource_op` lane or the bottom-panel 2B work without the `apply_resource_op` lane or the bottom-panel 2B work without

View File

@ -3,9 +3,41 @@
**PROPOSED — needs explicit user approval before implementation. DO NOT **PROPOSED — needs explicit user approval before implementation. DO NOT
implement, DO NOT merge.** implement, DO NOT merge.**
**Revision 4 — scouted against canonical `githubsucks/main` @ `7586905`, **Revision 5 — scouted against canonical `githubsucks/main` @ `7586905`,
2026-07-28.** Every count in this document was re-measured at this 2026-07-28.** Every count in this document was re-measured at this
revision; the command output is in the revision-4 block below. base in revision 4; revision 5 changes only the framed design and
acceptance, not a counted tree surface.
## Revision 5
**Answers review round 4 on `d98d0b3` — four P1, one P2. All five
findings are confirmed. This revision removes one proposed public
capability, makes one transaction outcome larger, and makes the CRDT
fault seam mandatory rather than leaving a fallback for implementation
time.**
| finding | the decision |
|---|---|
| **P1-1**`AppliedThenFailed` loses the `Edit`, so the binding cannot fan out a mutation before returning `Err` | Confirmed. `AppliedThenFailed` becomes `{ edit: Edit, error: BufferError }`; `apply_generated_edit` returns the outcome rather than collapsing it to `Result` inside `Buffer`. After the registry borrow drops, the binding fans out `Applied`, `NoOp`, **and `AppliedThenFailed`**. The last arm then surfaces its error. That preserves the window-cache and replica-mirror invariant even when a `View::on_edit` failure occurs after the rope swap. The Rust return type of `set_generated_contents` changes with the internal transaction; its Lua name and call signature do not. New criterion 15a pins both the window and CRDT directions. |
| **P1-2** — criterion 15 probes the edit flag while `read_only` masks it | Confirmed. `begin_edit` calls `ensure_writable` before it checks `editing_in_progress`, while `AppliedThenFailed` deliberately relocks. The old follow-up therefore returned `ReadOnly` for both the correct and broken implementations. Criterion 15 now performs the same Rust-side lift already used by criteria 4, 16b and 17 before issuing the ordinary edit; only then can its outcome distinguish the cleared flag from `ConcurrentEdit`. |
| **P1-3** — the four-variant fallback maps divergence to `Rejected` and restores a fresh buffer to writable | Confirmed. The fallback is withdrawn. A CRDT delete-success/insert-failure must be `Diverged`, must leave the buffer locked, and must surface distinctly. Criterion 16c is reclassified as a `crdt`-only fault-injection unit test: Stage 2 extracts a private delete→insert classifier whose production closures call loro and whose test closures force delete `Ok` / insert `Err`. There is no implementation-time choice to weaken containment because staging is inconvenient. |
| **P1-4**`identity_protected` is not generated-lock provenance | Confirmed. Q#GB7 now chooses the fallback it previously named: **this arc ships no `unlock_generated` at all.** `identity_protected` remains, but only as a monotonic terminal-identity guard in the write direction; it is never described as proof that the generated primitive installed a lock. The proposed setter becomes a crate-private, one-way `mark_identity_protected()` so “set once” is enforced rather than documentary. A future wdired door stays with dired Stage 3 and must frame the provenance/ownership transition it actually needs. |
| **P2-5** — Stage 2 still instructs implementation to build revision 3's rejected shared registry | Confirmed. The staging checklist now matches Q#GB18 exactly: `compile.lua` answers from `slot_for_buffer`, `default.lua` answers from `search_panel_owns`, and each capture site consults the other through a guarded optional predicate. No owner registers buffers with the other, and no teardown work is introduced. |
**Reachability recheck for the changed criteria.** Criterion 15 now
crosses the post-failure lock only by a Rust-side lift, so the next edit
can actually reach the flag it asserts. Criterion 15a drives the same
valid `FailingView` mutation through the production Lua finisher and
observes its window and replica consumers. Criterion 16c drives the
otherwise-unconstructible second-op failure through the private
delete→insert classifier. Criterion 13 is deliberately structural
because revision 5's decision is the absence of a capability. None
passes merely by failing before the mechanism under test.
Revision 4's measurements and its answers to review round 3 remain
below as history. Where its live design said “bounded unlock” or offered
the four-variant CRDT fallback, revision 5 supersedes it explicitly
rather than silently smoothing the reversal over.
## Revision 4 ## Revision 4
@ -245,10 +277,11 @@ Two things the arc turns out NOT to be, both discovered by measurement:
says it prevents. Q#GB18. says it prevents. Q#GB18.
- **Added in revision 3:** `read_only` is **not** this arc's flag. It - **Added in revision 3:** `read_only` is **not** this arc's flag. It
carries three unrelated authorities (§2.11), so a capability defined carries three unrelated authorities (§2.11), so a capability defined
over it reaches all three — which is why the unlock needed provenance over it reaches all three — which is why revision 5 removes the unlock
(Q#GB15), why the lock silently disables fold creation (Q#GB16), and rather than pretending `identity_protected` is provenance (Q#GB7),
why the *shipped* primitive can already overwrite a live terminal's why the lock silently disables fold creation (Q#GB16), and why the
identity buffer. *shipped* primitive can already overwrite a live terminal's identity
buffer (Q#GB15).
--- ---
@ -947,7 +980,7 @@ are the two consequences.
### 3.1 Recommendation ### 3.1 Recommendation
**`Buffer::apply_generated_edit(op: EditOp) -> Result<Edit, BufferError>` **`Buffer::apply_generated_edit(op: EditOp) -> GeneratedOutcome`
— one authorized op at a time — exposed to Lua as a new option key on — one authorized op at a time — exposed to Lua as a new option key on
the mutators that already exist:** the mutators that already exist:**
@ -960,15 +993,23 @@ buf:replace(s, e, text, { generated = true })
Semantics, per call, entirely inside one `with_registry_mut` and one Semantics, per call, entirely inside one `with_registry_mut` and one
`&mut Buffer` method — the exact ordering, including every error path, is `&mut Buffer` method — the exact ordering, including every error path, is
§3.4, which revision 3 adds because review P1-1 showed revision 2 had no §3.4, which revision 3 adds because review P1-1 showed revision 2 had no
workable one. The binding then fans the `Edit` out through the workable one. The binding then fans every outcome that carries an
`notify_buffer_edit_to_windows` call it **already makes** `Edit` out through `notify_buffer_edit_to_windows` after the borrow has
(`src/lua_bindings/mod.rs:1291`, `:1302`, `:1322`), after the borrow has dropped: `Applied` and `NoOp` before returning success, and
dropped. `AppliedThenFailed` **before returning its error**. Revision 5 makes that
third arm explicit because collapsing the outcome to `Result` inside
`Buffer` discarded the only value capable of updating window caches and
replica mirrors.
`Buffer::set_generated_contents(bytes)` is reimplemented as `Buffer::set_generated_contents(bytes)` is reimplemented as
`apply_generated_edit(Replace { range: 0..len, bytes })`. **It keeps its `apply_generated_edit(Replace { range: 0..len, bytes })`. **Its Lua name
name, its signature, its doc comment and its tests** — it becomes the and call signature stay fixed; its Rust return changes from
whole-buffer spelling of one primitive rather than a second primitive. `Result<Edit, BufferError>` to `GeneratedOutcome`**, and its direct Rust
tests match on the outcome. The change is required: a wrapper that
collapses `AppliedThenFailed` to `Err` before the binding sees it loses
the applied `Edit` and cannot satisfy the fan-out contract. It becomes
the whole-buffer spelling of one primitive rather than a second
primitive.
**One sentence for why it wins: it is the only candidate in which the **One sentence for why it wins: it is the only candidate in which the
buffer is never observably unlocked, because the lift and the re-assert buffer is never observably unlocked, because the lift and the re-assert
@ -1036,12 +1077,16 @@ in Q#GB8's deferral, not adopted here.
### 3.3 The four questions the recommendation must answer ### 3.3 The four questions the recommendation must answer
**How many `Edit`s are fanned out, and when?** One per generated op, **How many `Edit`s are fanned out, and when?** One per generated op that
immediately, by the binding that already does it. `compile.lua`'s reaches an `Edit`, immediately, by the binding. That includes
`AppliedThenFailed`: the rope and CRDT have already changed, so the
binding fans its carried `Edit` out and only then returns the carried
error. `compile.lua`'s
`emit_text` fast path emits one insert for a whole output batch, so a `emit_text` fast path emits one insert for a whole output batch, so a
typical `feed_bytes` produces one to three ops; a CR-heavy progress bar typical `feed_bytes` produces one to three ops; a CR-heavy progress bar
produces more. This is exactly today's fan-out count — the conversion produces more. Successful calls retain today's fan-out cardinality; the
changes authority, not cardinality. new failure arm adds the notification that today's early `Err` loses,
because that error can follow a real mutation.
**Per-op or per-scope history clearing?** Per op, and it is cheap by **Per-op or per-scope history clearing?** Per op, and it is cheap by
construction: because `read_only` is re-asserted immediately, **at most construction: because `read_only` is re-asserted immediately, **at most
@ -1056,19 +1101,46 @@ against the existing compile-mode timings in both configurations. If it
does, the escape hatch is to suppress recording rather than clear it — does, the escape hatch is to suppress recording rather than clear it —
recorded as a named deferral rather than designed speculatively. recorded as a named deferral rather than designed speculatively.
**CRDT-mode behaviour?** Identical to `set_generated_contents` today. The **CRDT-mode behaviour?** Identical to `set_generated_contents` today on
`Edit` carries `crdt_op` when the buffer is CRDT-backed, and success, and corrected on the post-apply error path. The `Edit` carries
`crdt_op` when the buffer is CRDT-backed, and
`notify_buffer_edit_to_windows` queues it via `notify_buffer_edit_to_windows` queues it via
`queue_daemon_origin_crdt_op` (`src/lua_bindings/mod.rs:1582`) so replica `queue_daemon_origin_crdt_op` (`src/lua_bindings/mod.rs:1582`) so replica
mirrors import the owner's write. History clearing goes to loro's mirrors import the owner's write. `AppliedThenFailed` must carry that
`UndoManager`. Nothing new. same `Edit`; returning its error before notification would leave the
authoritative CRDT changed while every replica missed the op. History
clearing goes to loro's `UndoManager`.
**How do the returned edits reach the fan-out without a live registry **How do the returned edits reach the fan-out without a live registry
borrow?** By construction, unchanged since #178: `run_bypass_edit` borrow?** By construction, extending #178's shape: `run_bypass_edit`
(`src/lua_bindings/mod.rs:1445`) closes its `with_registry_mut` before (`src/lua_bindings/mod.rs:1445`) closes its `with_registry_mut` before
returning, and the mutator bindings call returning, and the mutator bindings call
`notify_buffer_edit_to_windows` afterwards. `run_generated_edit` (§3.4) `notify_buffer_edit_to_windows` afterwards. `run_generated_edit` (§3.4)
occupies the same position and closes its borrow the same way. occupies the same position and closes its borrow the same way, but
returns the whole `GeneratedOutcome`. A binding-level finisher handles
it:
```rust
match run_generated_edit(lua, id, op) {
Applied(edit) | NoOp(edit) => {
notify_buffer_edit_to_windows(lua, id, &edit);
Ok(edit)
}
AppliedThenFailed { edit, error } => {
notify_buffer_edit_to_windows(lua, id, &edit);
Err(error.into())
}
Rejected(error) => Err(error.into()),
Diverged(error) => {
surface_divergence(lua, &error);
Err(error.into())
}
}
```
The helper is shared by the three mutators and the
`set_generated_contents` binding so one of the four cannot forget the
failure fan-out.
### 3.4 The transaction, and why revision 2 had none (review P1-1) ### 3.4 The transaction, and why revision 2 had none (review P1-1)
@ -1159,7 +1231,7 @@ for the v0.1 undo stack and does not extend past it.** Three failures:
the no-op arm (`src/buffer.rs:1245-1253`), returns `Ok`, never bumps the no-op arm (`src/buffer.rs:1245-1253`), returns `Ok`, never bumps
`revision`, so revision 3 skipped **both** the clear and `revision`, so revision 3 skipped **both** the clear and
`mark_clean`: the buffer ends locked, **modified**, and carrying `mark_clean`: the buffer ends locked, **modified**, and carrying
poppable history that `unlock_generated` re-exposes. Shipped poppable history that a later Rust-side lift can re-expose. Shipped
`set_generated_contents` clears unconditionally (`:551-553`), so `set_generated_contents` clears unconditionally (`:551-553`), so
revision 3's predicate was a **regression of an existing contract**, revision 3's predicate was a **regression of an existing contract**,
not a refinement of one. And it lands on a path this document not a refinement of one. And it lands on a path this document
@ -1192,34 +1264,53 @@ signature change and not a free one — see the cost note below.
```rust ```rust
/// What a generated write did, reported by the apply rather than /// What a generated write did, reported by the apply rather than
/// inferred by its caller. The variant, not `revision`, selects cleanup. /// inferred by its caller. The variant, not `revision`, selects cleanup.
enum GeneratedOutcome { pub enum GeneratedOutcome {
/// The rope changed and an undo entry exists. /// The rope changed and an undo entry exists.
Applied(Edit), Applied(Edit),
/// A semantic no-op: rope unchanged, no undo entry pushed. The call /// A semantic no-op: rope unchanged, no undo entry pushed. The call
/// SUCCEEDED, so the buffer is now a generated buffer. /// SUCCEEDED, so the buffer is now a generated buffer.
NoOp(Edit), NoOp(Edit),
/// Refused before anything mutated: rope, CRDT and history are all /// Failed with rope, CRDT and history exactly as they were. This also
/// exactly as they were. /// includes a deliberate no-op whose `on_edit` notification failed:
/// view side effects may have run, but no buffer/CRDT mutation exists
/// to fan out.
Rejected(BufferError), Rejected(BufferError),
/// The rope changed and a later stage failed. An undo entry exists /// The rope changed and a later stage failed. The Edit must survive so
/// over contents no caller can see. /// windows and replica mirrors observe the mutation before the error.
AppliedThenFailed(BufferError), AppliedThenFailed { edit: Edit, error: BufferError },
/// CRDT only: the CRDT was partially mutated and the rope was not. /// CRDT only: the CRDT was partially mutated and the rope was not.
/// `rope ≡ CRDT projection` no longer holds. /// `rope ≡ CRDT projection` no longer holds.
Diverged(BufferError), Diverged(BufferError),
} }
``` ```
`run_rope_edit_and_broadcast` returns this richer outcome.
`apply_edit` and `apply_edit_skip_intercepts` map it back to their
existing `Result<Edit, BufferError>` API, preserving ordinary callers'
surface; the generated path retains it through cleanup and the
borrow-free binding finisher. The enum is public because the existing
public `set_generated_contents` method and the new
`apply_generated_edit` method return it; their Rust docs state that a
higher layer that owns window or replica state must fan out every
edit-bearing variant before handling its success/error.
That mapping deliberately does **not** claim to repair the same
post-apply notification loss for every ordinary Rust edit API. Their
public `Result<Edit, BufferError>` surface cannot carry both the edit and
the later view error, and changing all of those callers is broader than
generated-buffer immutability. The pre-existing ordinary path is named
in §8 rather than hidden by the helper refactor.
**The cleanup each variant triggers.** `entry` is the `read_only` value **The cleanup each variant triggers.** `entry` is the `read_only` value
observed on entry. observed on entry.
| outcome | history | `mark_clean` | `read_only` after | returns | | outcome | history | `mark_clean` | `read_only` after | binding action |
|---|---|---|---|---| |---|---|---|---|---|
| `Applied` | **cleared** | yes | `true` | `Ok(Edit)` | | `Applied` | **cleared** | yes | `true` | fan out `Edit`, then `Ok` |
| `NoOp` | **cleared** | yes | `true` | `Ok(Edit)` | | `NoOp` | **cleared** | yes | `true` | fan out `Edit`, then `Ok` |
| `Rejected` | untouched | no | **restored to `entry`** | `Err` | | `Rejected` | untouched | no | **restored to `entry`** | `Err`, no fan-out |
| `AppliedThenFailed` | **cleared** | **no** | `true` | `Err` | | `AppliedThenFailed` | **cleared** | **no** | `true` | **fan out carried `Edit`, then `Err`** |
| `Diverged` | **untouched** | no | `true` | `Err`, and it must **surface** | | `Diverged` | **untouched** | no | `true` | distinct surfaced `Err`, no rope `Edit` |
`editing_in_progress` is cleared on **all five**, unconditionally. `editing_in_progress` is cleared on **all five**, unconditionally.
@ -1247,9 +1338,11 @@ that **this arc cannot fix it**:
- The rope is intact and the CRDT is not. Nothing local reconstructs the - The rope is intact and the CRDT is not. Nothing local reconstructs the
deleted range — loro exposes no rollback at this seam, and the deleted range — loro exposes no rollback at this seam, and the
`export_updates_since` that would name the delta runs after both ops. `export_updates_since` that would name the delta runs after both ops.
- **Clearing history would destroy the last local record of the - The rope still contains the pre-edit bytes, but the CRDT UndoManager is
pre-edit rope**, which is the only material anything could later the only existing **CRDT-native inverse record** for the successful
reconcile from. So `Diverged` clears nothing. delete. Clearing it would discard the one operation a later repair
lane might use to reconcile the document. So `Diverged` clears
nothing; this arc does not invoke that undo automatically.
- Locking is the strongest available *containment*: it stops further ops - Locking is the strongest available *containment*: it stops further ops
compounding a divergence that already exists. So `read_only = true`, compounding a divergence that already exists. So `read_only = true`,
and this is the one place where locking a buffer on an error path is and this is the one place where locking a buffer on an error path is
@ -1270,18 +1363,24 @@ lane.** Splitting a CRDT `Replace` into a single transactional op, or
reconciling the two, is loro-level work with no bearing on generated reconciling the two, is loro-level work with no bearing on generated
buffers specifically. buffers specifically.
**The ordering, with every exit path named.** **The ordering, with every exit path named.** The method returns the
outcome intact; only the borrow-free binding finisher above converts it
to Lua success/error after performing any required fan-out.
```rust ```rust
pub fn apply_generated_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferError> { pub fn apply_generated_edit(&mut self, op: EditOp<'_>) -> GeneratedOutcome {
// (1) Q#GB10: path-backed refusal. Before any state change. // (1) Q#GB10: path-backed refusal. Before any state change.
if self.file_path.is_some() { return Err(GeneratedWriteOnFileBuffer { .. }); } if self.file_path.is_some() {
return Rejected(GeneratedWriteOnFileBuffer { .. });
}
// (2) Q#GB15: this buffer's read_only is an identity protection. // (2) Q#GB15: this buffer's read_only is an identity protection.
if self.identity_protected { return Err(ReadOnly { .. }); } if self.identity_protected { return Rejected(ReadOnly { .. }); }
// (3) re-entrancy gate — begin_edit's SECOND check, not its first. // (3) re-entrancy gate — begin_edit's SECOND check, not its first.
if self.editing_in_progress { return Err(ConcurrentEdit { .. }); } if self.editing_in_progress { return Rejected(ConcurrentEdit { .. }); }
// (4) bounds pre-validation, so an invalid range costs nothing. // (4) bounds pre-validation, so an invalid range costs nothing.
self.validate_op_bounds(&op)?; if let Err(error) = self.validate_op_bounds(&op) {
return Rejected(error);
}
let entry_read_only = self.read_only; let entry_read_only = self.read_only;
self.editing_in_progress = true; self.editing_in_progress = true;
@ -1293,12 +1392,15 @@ pub fn apply_generated_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferErr
self.clear_history(); self.clear_history();
self.mark_clean(); self.mark_clean();
} }
AppliedThenFailed(_) => { self.read_only = true; self.clear_history(); } AppliedThenFailed { .. } => {
self.read_only = true;
self.clear_history();
}
Diverged(_) => { self.read_only = true; } Diverged(_) => { self.read_only = true; }
Rejected(_) => { self.read_only = entry_read_only; } Rejected(_) => { self.read_only = entry_read_only; }
} }
self.editing_in_progress = false; // (6) unconditional, all paths self.editing_in_progress = false; // (6) unconditional, all paths
outcome.into_result() outcome // binding owns fan-out + conversion
} }
``` ```
@ -1309,7 +1411,7 @@ pub fn apply_generated_edit(&mut self, op: EditOp<'_>) -> Result<Edit, BufferErr
| (3) | re-entrant on the same buffer | unchanged | unchanged (`true`, the outer edit's) | **untouched** | untouched | | (3) | re-entrant on the same buffer | unchanged | unchanged (`true`, the outer edit's) | **untouched** | untouched |
| (4) | range out of bounds | unchanged | unchanged (`false`) | **untouched** | untouched | | (4) | range out of bounds | unchanged | unchanged (`false`) | **untouched** | untouched |
| `Rejected` | mid-codepoint position, CRDT | **restored to entry** | `false` | **untouched** | untouched | | `Rejected` | mid-codepoint position, CRDT | **restored to entry** | `false` | **untouched** | untouched |
| `AppliedThenFailed` | a view rejected `on_edit` | `true` | `false` | **cleared** | **mutated** | | `AppliedThenFailed` | a view rejected `on_edit` | `true` | `false` | **cleared** | **mutated; carried `Edit` must fan out** |
| `Diverged` | CRDT delete ok, insert failed | `true` | `false` | **untouched** | rope untouched, **CRDT diverged** | | `Diverged` | CRDT delete ok, insert failed | `true` | `false` | **untouched** | rope untouched, **CRDT diverged** |
| `NoOp` | empty write over an empty rope | `true` | `false` | **cleared** | unchanged | | `NoOp` | empty write over an empty rope | `true` | `false` | **cleared** | unchanged |
| `Applied` | the ordinary case | `true` | `false` | **cleared** | replaced | | `Applied` | the ordinary case | `true` | `false` | **cleared** | replaced |
@ -1342,11 +1444,19 @@ them means that function tracking whether its `delete` succeeded before
its `insert` failed. That is a real change to a shipped CRDT path, it is its `insert` failed. That is a real change to a shipped CRDT path, it is
in Stage 2's scope, and it is the reason `Diverged` is a *named* variant in Stage 2's scope, and it is the reason `Diverged` is a *named* variant
rather than a note — a variant nothing can construct is not a design. rather than a note — a variant nothing can construct is not a design.
**If the user prefers a smaller Stage 2**, the fallback is to ship four
variants, fold `Diverged` into `Rejected`, and accept that a **Revision 5 withdraws revision 4's four-variant fallback.** Folding
CRDT-mid-transaction failure is indistinguishable from a clean refusal — `Diverged` into `Rejected` would apply `Rejected`'s cleanup: on a fresh
which is the status quo, and should be a stated trade rather than a writable buffer it restores `entry_read_only = false`, leaving a buffer
silent one. whose CRDT and rope already disagree open to further writes. That
directly contradicts the containment argument above. Stage 2 therefore
extracts a private delete→insert classifier that accepts the two
operations as closures: production supplies the loro calls; a
`#[cfg(feature = "crdt")]` unit test supplies delete `Ok` followed by
insert `Err`. The test makes `Diverged` constructible without adding a
public fault-injection API, and `cargo test --lib --features crdt` is its
gate. If that extraction proves larger than expected, Stage 2 stops for
review; it does not silently weaken the approved cleanup table.
`validate_op_bounds` is a new `validate_op_bounds` is a new
private helper duplicating the bounds arithmetic `Rope::insert` / private helper duplicating the bounds arithmetic `Rope::insert` /
@ -1368,7 +1478,9 @@ numbering is chronological; the placement is topical.*
**Q#GB1 — The streaming primitive.** `Buffer::apply_generated_edit(op)`, **Q#GB1 — The streaming primitive.** `Buffer::apply_generated_edit(op)`,
exposed as `{ generated = true }` on the three Lua mutators. exposed as `{ generated = true }` on the three Lua mutators.
`set_generated_contents` becomes its whole-buffer wrapper, keeping name, `set_generated_contents` becomes its whole-buffer wrapper, keeping name,
signature and tests. Rationale and rejected alternatives: §3. **Lua** signature and behavioral tests; its Rust return becomes
`GeneratedOutcome` so a failed-but-applied edit survives to the fan-out.
Rationale and rejected alternatives: §3.
**Q#GB2 — `generated` is additive; `bypass_intercept` stays.** Seven **Q#GB2 — `generated` is additive; `bypass_intercept` stays.** Seven
call sites outside `builtin/` depend on `bypass_intercept`, including call sites outside `builtin/` depend on `bypass_intercept`, including
@ -1386,20 +1498,35 @@ Revision 2 said "a generated write goes through `run_buffer_edit`'s
bypass arm". **That is unimplementable** — the bypass arm is bypass arm". **That is unimplementable** — the bypass arm is
`run_bypass_edit`, which calls `begin_edit`, which calls `run_bypass_edit`, which calls `begin_edit`, which calls
`ensure_writable` first (§3.4). `run_buffer_edit` `ensure_writable` first (§3.4). `run_buffer_edit`
(`src/lua_bindings/mod.rs:1353-1374`) grows a third arm: (`src/lua_bindings/mod.rs:1353-1374`) grows a third arm **and becomes the
single owner of post-borrow fan-out**. The three mutator bodies remove
their separate `notify_buffer_edit_to_windows` calls, preventing the
generated success arm from notifying twice:
```rust ```rust
if generated { if generated {
unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op)); unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op));
run_generated_edit(lua, id, op) // no begin_edit; §3.4 let outcome = run_generated_edit(lua, id, op); // no begin_edit; §3.4
finish_generated_outcome(lua, id, outcome) // fan-out, then Ok/Err
} else if bypass_intercept { } else if bypass_intercept {
unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op)); unfold_before_interactive_lua_edit(lua, id, edit_start_of(&op));
run_bypass_edit(lua, id, op) let edit = run_bypass_edit(lua, id, op)?;
notify_buffer_edit_to_windows(lua, id, &edit);
Ok(edit)
} else { } else {
run_managed_edit(lua, id, op) let edit = run_managed_edit(lua, id, op)?;
notify_buffer_edit_to_windows(lua, id, &edit);
Ok(edit)
} }
``` ```
`run_generated_edit` mirrors `run_bypass_edit`'s borrow shape but returns
the single transaction's whole outcome rather than calling `begin_edit`
+ `apply_edit_skip_intercepts`. `finish_generated_outcome` is the match
in §3.3 and is also used by the `set_generated_contents` binding.
`begin_edit` stays byte-identical, so no ordinary edit's error precedence
changes.
**What revision 2 got right and revision 3 keeps: the unfold seam.** The **What revision 2 got right and revision 3 keeps: the unfold seam.** The
generated arm still calls `unfold_before_interactive_lua_edit` at the generated arm still calls `unfold_before_interactive_lua_edit` at the
same point the bypass arm does, and for the same reason — the guard same point the bypass arm does, and for the same reason — the guard
@ -1501,9 +1628,8 @@ panels constantly and because it fixes terminal copy mode retroactively.
Alternative if the user prefers a narrower Stage 1: its own lane, in Alternative if the user prefers a narrower Stage 1: its own lane, in
which case Stage 1 must say so out loud rather than inherit it silently. which case Stage 1 must say so out loud rather than inherit it silently.
**Q#GB7 — The unlock survives ONLY bounded by lock provenance, and it **Q#GB7 — No unlock ships in this arc. Revision 5 chooses the fallback
moves to Stage 2. Revision 3 withdraws revision 2's recommendation revision 4 named (review round 4, P1-4).**
(review P1-3).**
Revision 2 recommended `pmacs.buffer.unlock_generated(buf)` — a one-way Revision 2 recommended `pmacs.buffer.unlock_generated(buf)` — a one-way
clear of `read_only`, shipped in Stage 1 — on the strength of sweep B's clear of `read_only`, shipped in Stage 1 — on the strength of sweep B's
@ -1535,35 +1661,35 @@ returns writability to a buffer whose data is gone. The recovery
scenario that justified moving this from a deferral to Stage 1 work was scenario that justified moving this from a deferral to Stage 1 work was
never a recovery. never a recovery.
**The decision.** The capability survives, bounded by **lock provenance** **Revision 4's proposed bound was not provenance.** It refused only
(Q#GB15), and: when `identity_protected` was true. That proves “this is not a declared
terminal identity buffer”; it does **not** prove “the generated-write
primitive installed this lock.” As revision 4 itself admitted,
`unlock_generated` could therefore release any non-identity-protected
Rust lock, including one a future owner installed for an unrelated
reason. The live Q#GB7 text simultaneously promised to refuse exactly
those locks. Both statements cannot be the approved contract.
- **`pmacs.buffer.unlock_generated(buf)` refuses any buffer whose lock **The decision: remove the capability, not the promise.** Neither stage
this arc's primitive did not install** — terminal identity buffers, and adds `pmacs.buffer.unlock_generated`, and Q#GB15's field is used only to
anything a future Rust owner locks for its own reasons. It is not a refuse generated writes to intrinsic identity buffers. There is no Lua
clear of `read_only`; it is the *inverse of `apply_generated_edit`'s clear of `read_only`.
lock*, and it can undo only what the same public API did.
- **It moves to Stage 2**, with Q#GB15, because it is meaningless without
the provenance the same field provides and because Stage 2 is where the
lock capability actually widens.
- **Its claim narrows.** It is the **closure of the capability
`{ generated = true }` adds** — not a recovery mechanism. The honest
statement of what it buys: after a mistaken generated write, the buffer
becomes writable again; its former contents do not come back.
**On the standing asymmetry the review asks this to address directly** This leaves the standing asymmetry visible rather than pretending to
(`remove_intercept` is exposed; `set_read_only` deliberately is not, close it: `remove_intercept` is exposed while `set_read_only` is not
`src/lua_bindings/mod.rs:3072-3078` and `docs/agent-handoff.md` §4). A (`src/lua_bindings/mod.rs:3072-3078` and
provenance-bounded `unlock_generated` **does not breach that policy**, `docs/agent-handoff.md` §4). That asymmetry is deliberate today because
and the reason is precise rather than rhetorical: it can only reach a the rope lock protects more than generated buffers. A safe inverse needs
lock that a public Lua call installed, so it adds **no reachable state a lock-kind/provenance representation, or an owner-specific transition
that `{ generated = true }` did not already make reachable**. A whose preconditions prove the caller owns the state. Neither is required
`set_read_only(buf, true)` would add the "lock with no door" state the to stop undo destroying generated output, and neither should be smuggled
invariant exists to forbid; a `set_read_only(buf, false)` would reach into this arc for an escape that cannot restore the overwritten data.
locks Lua never set. This reaches neither. If that argument does not
persuade, the fallback is to ship no unlock at all and let dired Stage 3 The future concrete consumer remains wdired in dired Stage 3. That lane
frame its own door — which costs this arc nothing, because Stage 3 is must frame the transition it needs—generated listing to editable rename
not built. surface—against its own owner handle and lifecycle. It may choose the
eventual `read_only` policy enum; it does not inherit a general-purpose
unlock from here.
**What Stage 1 loses, and why that is correct.** Nothing. Stage 1 adopts **What Stage 1 loses, and why that is correct.** Nothing. Stage 1 adopts
`set_generated_contents`, which is **already public on `main`**, on two `set_generated_contents`, which is **already public on `main`**, on two
@ -1574,8 +1700,9 @@ the shipped primitive, not about Stage 1. The `*scratch*` exposure sweep
B found is real, and it is real **today**; it is recorded in §8 as a B found is real, and it is real **today**; it is recorded in §8 as a
pre-existing hazard this arc neither creates nor closes. pre-existing hazard this arc neither creates nor closes.
**Q#GB15 — `read_only` gains a provenance companion. New in revision 3 **Q#GB15 — `read_only` gains an intrinsic-identity guard, not a
(review P1-3, sweep C).** provenance companion. New in revision 3, narrowed in revision 5
(review P1-3, sweep C; review round 4 P1-4).**
**Revision 4 replaces revision 3's `generated_lock` with an intrinsic **Revision 4 replaces revision 3's `generated_lock` with an intrinsic
`identity_protected`, because review round 3's P1-3 showed provenance `identity_protected`, because review round 3's P1-3 showed provenance
@ -1609,7 +1736,7 @@ is a property of **what the buffer is**, not of who last locked it:
```rust ```rust
/// Whether this buffer's read-only state is an intrinsic identity /// Whether this buffer's read-only state is an intrinsic identity
/// protection rather than an ordinary lock. Set once, at construction, /// protection rather than an ordinary lock. Marked once during owner setup,
/// by an owner that means "the host may not edit this at all"; never /// by an owner that means "the host may not edit this at all"; never
/// derived from `read_only` and never changed by `set_read_only`. /// derived from `read_only` and never changed by `set_read_only`.
identity_protected: bool, identity_protected: bool,
@ -1617,13 +1744,13 @@ identity_protected: bool,
Two rules maintain it, and `set_read_only` is not one of them: Two rules maintain it, and `set_read_only` is not one of them:
1. `Buffer::set_identity_protected(true)` — a new `pub` method, called 1. `Buffer::mark_identity_protected()` — a new **crate-private,
**once** by `TerminalSession::open` beside its existing monotonic** method that can only set the field to `true`, called once
`set_read_only(true)` (`src/terminal/session.rs:305`). That is the by `TerminalSession::open` beside its existing `set_read_only(true)`
only production caller. (`src/terminal/session.rs:305`). There is no “false” operation and no
2. `apply_generated_edit` refuses iff `identity_protected` (§3.4 exit 2); Lua surface, so “set once” is enforced rather than a caller convention.
`unlock_generated` refuses iff `identity_protected`. Neither ever 2. `apply_generated_edit` refuses iff `identity_protected` (§3.4 exit 2).
writes the field. It never writes the field.
**The lift-and-restore seam is now unaffected**, because **The lift-and-restore seam is now unaffected**, because
`identity_protected` is `false` for a snapshot buffer and stays `false` `identity_protected` is `false` for a snapshot buffer and stays `false`
@ -1633,20 +1760,12 @@ right.
**Why declaration beats inference, stated as a rule rather than as a **Why declaration beats inference, stated as a rule rather than as a
patch.** Inference required every mutation of `read_only` to maintain a patch.** Inference required every mutation of `read_only` to maintain a
derived fact; P1-3 is the proof that it does not. Declaration puts the derived fact; P1-3 is the proof that it does not. Declaration is sound
burden on the locker: *if you lock a buffer and Lua must not unlock it, for the one question this field now answers: *may an owner-authorized
mark it identity-protected.* One flag, one meaning, one writer, and a generated write ever edit this buffer?* Terminal identity says no,
future Rust owner that wants the same protection opts in with one line independently of temporary `read_only` lifts. It is deliberately **not**
instead of relying on an inference chain staying intact. used to answer who installed an ordinary lock; Q#GB7 no longer asks it
to.
**What this costs relative to revision 3.** It is strictly narrower in
one respect and that is worth naming: `unlock_generated` can now release
**any** non-identity-protected `read_only` buffer, including one a future
Rust owner locked without declaring itself. Revision 3's version would
have refused that by inference. The trade is deliberate — an inference
that is wrong on a shipped seam is worse than a contract that has to be
opted into — and the contract is documented on `set_read_only` so the
next locker reads it at the point of use.
**Rule 1's refusal is the half revision 2 did not have, and it closes a **Rule 1's refusal is the half revision 2 did not have, and it closes a
hole in the SHIPPED primitive** (sweep C item 1). hole in the SHIPPED primitive** (sweep C item 1).
@ -1655,16 +1774,15 @@ unconditionally (`src/buffer.rs:546`), so
`pmacs.buffer.set_generated_contents(term_buf, "junk")` on a live `pmacs.buffer.set_generated_contents(term_buf, "junk")` on a live
terminal identity buffer overwrites its contents and re-locks it as terminal identity buffer overwrites its contents and re-locks it as
though the primitive owned it. Nothing in the tree refuses that, and no though the primitive owned it. Nothing in the tree refuses that, and no
test covers it. It is why the field earns its cost in **both** test covers it. That write-direction hole is the field's sole purpose.
directions rather than existing only to make Q#GB7 safe.
**The alternatives, and why not.** **The alternatives, and why not.**
- *A registry-side set of generated-locked ids, held as Lua app-data.* A - *A registry-side set of generated-locked ids, held as Lua app-data.*
second source of truth that can drift from the flag, plus a pruning This would exist only to resurrect Q#GB7: a second source of truth that
obligation on buffer removal — the shape the terminal-config lane can drift from the flag, plus a pruning obligation on buffer removal —
records as "`prune` **reacts** to buffer removal". A field on the the shape the terminal-config lane records as "`prune` **reacts** to
buffer cannot drift from the buffer. buffer removal". Rejected along with the unlock.
- *Replacing `read_only: bool` with an enum.* Cleaner in principle, - *Replacing `read_only: bool` with an enum.* Cleaner in principle,
and it would let §2.11's third policy (`document_bytes`) ask the and it would let §2.11's third policy (`document_bytes`) ask the
question it actually means. It also churns `ensure_writable`, all six question it actually means. It also churns `ensure_writable`, all six
@ -1672,11 +1790,12 @@ directions rather than existing only to make Q#GB7 safe.
`set_read_only` signature — a refactor this arc would be smuggling. `set_read_only` signature — a refactor this arc would be smuggling.
Named as the right eventual shape in §8, not adopted. Named as the right eventual shape in §8, not adopted.
**Cost, stated:** one bool per buffer; one new `pub` method with exactly **Cost, stated:** one bool per buffer; one new crate-private monotonic
one production caller; **no invariant to maintain**, because the field is method with exactly one production caller; **no invariant to maintain
never derived from `read_only`; and one new refusal that changes shipped against `set_read_only`**, because the field is never derived from it;
`set_generated_contents` behaviour, so it lands in Stage 2 with the rest and one new refusal that changes shipped `set_generated_contents`
of Q#GB10's changes to that function, not in Stage 1. behaviour, so it lands in Stage 2 with the rest of Q#GB10's changes to
that function, not in Stage 1.
**Q#GB16 — The lock silently disables fold creation on every buffer it **Q#GB16 — The lock silently disables fold creation on every buffer it
touches. New in revision 3 (sweep C item 2).** touches. New in revision 3 (sweep C item 2).**
@ -1710,8 +1829,9 @@ here.** Three options, with the recommendation being (a):
document buffer". Cheap, honest, and it converts a silent behaviour document buffer". Cheap, honest, and it converts a silent behaviour
change into a stated one. change into a stated one.
- **(b) Preserve foldability** by changing the guard to - **(b) Preserve foldability** by changing the guard to
`read_only && !identity_protected`. Available once Q#GB15 lands, but it the intrinsic `identity_protected` predicate rather than the broad
edits a pinned Q#FD11 seam for a use case nobody has asked for. `read_only` predicate. Available once Q#GB15 lands, but it edits a
pinned Q#FD11 seam for a use case nobody has asked for.
- **(c) Do nothing and say nothing.** Rejected: this is exactly the - **(c) Do nothing and say nothing.** Rejected: this is exactly the
defect class the review's findings 1 and 3 are instances of. defect class the review's findings 1 and 3 are instances of.
@ -1721,7 +1841,7 @@ families get locked.
**Q#GB17 — The transaction shape.** §3.4. One `&mut Buffer` method, its **Q#GB17 — The transaction shape.** §3.4. One `&mut Buffer` method, its
own `run_buffer_edit` arm, `begin_edit` untouched, eight named exits, and own `run_buffer_edit` arm, `begin_edit` untouched, eight named exits, and
and cleanup driven by an explicit `GeneratedOutcome`**not** by cleanup driven by an explicit `GeneratedOutcome`**not** by
inferring one from `revision`, which revision 3 did and which was wrong inferring one from `revision`, which revision 3 did and which was wrong
in three directions (§3.4). New in revision 3 (review in three directions (§3.4). New in revision 3 (review
P1-1). P1-1).
@ -2015,13 +2135,15 @@ it is not the obvious one.
`fold.rs:68` status string, and the criterion that makes the change `fold.rs:68` status string, and the criterion that makes the change
stated rather than silent. stated rather than silent.
**Revision 3 removes `unlock_generated` from Stage 1** (Q#GB7). Revision **Revision 3 removed `unlock_generated` from Stage 1; revision 5 removes
2 put it here as "the escape from a bricked buffer"; the escape does not it from the arc** (Q#GB7). Revision 2 put it here as "the escape from a
recover anything (the history is already cleared), and the brick it bricked buffer"; the escape does not recover anything (the history is
escapes is one `main` already ships, since `set_generated_contents` is already cleared), and the brick it escapes is one `main` already ships,
already public. Stage 1 adds no lock capability that does not already since `set_generated_contents` is already public. Stage 1 adds no lock
exist, so it needs no door. The capability re-appears in Stage 2, bounded capability that does not already exist, so it needs no door. Revision
by Q#GB15's provenance. 4's attempt to restore the capability in Stage 2 used
`identity_protected` as though “not terminal identity” proved “generated
lock”; it does not. No stage adds the binding.
**Revision 2 grew Stage 1 by two prerequisites and one reversal.** The **Revision 2 grew Stage 1 by two prerequisites and one reversal.** The
ownership rule is load-bearing for the lock rather than adjacent to it, ownership rule is load-bearing for the lock rather than adjacent to it,
@ -2041,7 +2163,7 @@ reachable without `M-x`.
**Why the cut is safe under every candidate primitive:** a whole-buffer **Why the cut is safe under every candidate primitive:** a whole-buffer
replace is expressible in all of AC, and under the recommendation replace is expressible in all of AC, and under the recommendation
`set_generated_contents` keeps its name and signature as `set_generated_contents` keeps its **Lua** name and signature as
`apply_generated_edit`'s wrapper. Stage 1 is therefore not rework under `apply_generated_edit`'s wrapper. Stage 1 is therefore not rework under
any Q#GB1 outcome — which is the decisive argument for cutting here any Q#GB1 outcome — which is the decisive argument for cutting here
rather than shipping one large PR. rather than shipping one large PR.
@ -2059,18 +2181,22 @@ diff at `dired.lua:371` and `listview.lua:60-61` is written once.
shape as Stage 1's listview fix. shape as Stage 1's listview fix.
- **In the SAME PR as that disambiguation (Q#GB18):** - **In the SAME PR as that disambiguation (Q#GB18):**
`pmacs.compile.is_generated_buffer` (`compile.lua:212-217`) stops `pmacs.compile.is_generated_buffer` (`compile.lua:212-217`) stops
comparing names and scans an owner-registered id list, with comparing names and answers only
registration in `ensure_slot` and `ensure_search_panel` and `slot_for_buffer(buf) ~= nil`; `default.lua` adds local
unregistration in the `on_removed` callbacks both already have. `search_panel_owns(buf)` and exposes
`pmacs.project._is_search_panel`. Each capture site ORs its own
predicate with the other module's predicate through the guarded
optional call shape Q#GB18 specifies. **No owner registers ids with
the other and no new removal callback work exists.**
- `Buffer::apply_generated_edit` (§3.4) + the `{ generated = true }` - `Buffer::apply_generated_edit` (§3.4) + the `{ generated = true }`
option + its own `run_buffer_edit` arm + `set_generated_contents` option + its own `run_buffer_edit` arm + `set_generated_contents`
reimplemented over it (Q#GB17, Q#GB3). reimplemented over it (Q#GB17, Q#GB3).
- Q#GB10's path-backed refusal **and** `mark_clean` — one rule, both - Q#GB10's path-backed refusal **and** `mark_clean` — one rule, both
halves, since the refusal is what makes the flag change safe. halves, since the refusal is what makes the flag change safe.
- **Q#GB15's `identity_protected` field**, its write-direction refusal, and - **Q#GB15's `identity_protected` field** and its write-direction
`pmacs.buffer.unlock_generated` bounded by it (Q#GB7). All three edit refusal. `mark_identity_protected()` is crate-private and monotonic;
`set_generated_contents` or the flag it sets, so they belong with the `TerminalSession::open` is its only production caller. Q#GB7 adds no
reimplementation. unlock surface.
- Conversion of all 13 remaining write sites (`compile.lua` 9, - Conversion of all 13 remaining write sites (`compile.lua` 9,
`builtin/commands/default.lua` 4). `builtin/commands/default.lua` 4).
- Q#GB5's `ensure_slot` lock, which is only placeable once ownership - Q#GB5's `ensure_slot` lock, which is only placeable once ownership
@ -2089,13 +2215,13 @@ the cut.
2. **Q#GB18 (identity routing) rides in the same PR as Q#GB13 for the 2. **Q#GB18 (identity routing) rides in the same PR as Q#GB13 for the
same writer**, per the ordering constraint under Q#GB13. New in same writer**, per the ordering constraint under Q#GB13. New in
revision 3. revision 3.
3. **Q#GB7 (unlock) moves to Stage 2, bounded by Q#GB15.** Revision 1 3. **Q#GB7 (unlock) is removed from both stages.** Revision 1 deferred
deferred it; revision 2 built it in Stage 1 on an argument review it; revision 2 built it in Stage 1; revision 3 moved it to Stage 2;
P1-3 falsified; revision 3 lands it in Stage 2 with the provenance revision 4 replaced its provenance with an identity exclusion and
that makes it a bounded capability rather than a general one. The thereby made it general again. Revision 5 chooses no unlock. The
two reversals are recorded rather than smoothed over because the reversals are recorded rather than smoothed over because the reason
*reason* moved twice and the next reader needs to know which reason moved three times and the next reader needs to know which decision is
is live. live.
4. **Q#GB10 (path refusal + `mark_clean`) lands in Stage 2**, because it 4. **Q#GB10 (path refusal + `mark_clean`) lands in Stage 2**, because it
edits `set_generated_contents` itself and therefore changes the edits `set_generated_contents` itself and therefore changes the
already-shipped terminal snapshot. **Stage 1 is not pure Lua** already-shipped terminal snapshot. **Stage 1 is not pure Lua**
@ -2309,10 +2435,11 @@ intercept refuses it either way.
not catch a misrouted consumer, which is why 11 and 12 assert through not catch a misrouted consumer, which is why 11 and 12 assert through
`dispatch_key`. `dispatch_key`.
**Moved out of Stage 1 in revision 3:** the unlock criterion. Revision **Moved out of Stage 1 in revision 3 and removed in revision 5:** the
2's Stage 1 criterion 11 pinned `unlock_generated`; Q#GB7 moves the unlock criterion. Revision 2's Stage 1 criterion 11 pinned
capability to Stage 2, so the criterion moves with it (Stage 2 criterion `unlock_generated`; revision 3 moved it to Stage 2, revision 4's
13) and grows the negative terminal-identity half review P1-3 asks for. identity exclusion failed to bound it by generated-lock provenance, and
Q#GB7 now ships no binding in either stage.
### Stage 2 ### Stage 2
@ -2410,7 +2537,7 @@ capability to Stage 2, so the criterion moves with it (Stage 2 criterion
on `main` today for the intercept half alone. on `main` today for the intercept half alone.
10. **Coverage, not a criterion: both configurations** — default and 10. **Coverage, not a criterion: both configurations** — default and
`--features crdt` — for criteria 15 and for the §3.4 transaction `--features crdt` — for criteria 15 and for the §3.4 transaction
criteria 15, 16 (first half), 16b and 18. CI never enables the criteria 15, 15a, 16 (first half), 16b and 18. CI never enables the
feature, so CRDT must not be the only home of any of them. feature, so CRDT must not be the only home of any of them.
**Three are irreducibly `crdt`-only and must say so rather than be **Three are irreducibly `crdt`-only and must say so rather than be
@ -2437,31 +2564,17 @@ capability to Stage 2, so the criterion moves with it (Stage 2 criterion
conversion that drops the intruder edit entirely leaves the desync conversion that drops the intruder edit entirely leaves the desync
machinery unpinned while the suite stays green. machinery unpinned while the suite stays green.
**New in revision 3 — the transaction, the provenance, and the bounded **New in revision 3 — the transaction and the identity guard. Revision
unlock.** 5 removes the attempted bounded unlock.**
13. **[`main`] The unlock is real, is narrow, and refuses a lock it did 13. **[structural] No Lua unlock surface is added (Q#GB7).** There is no
not install (Q#GB7 + Q#GB15; review P1-3).** Three halves, and the `pmacs.buffer.unlock_generated`, no exposed `set_read_only`, and no
third is the one revision 2 lacked. binding that clears `read_only` without performing an
- *Real:* on a plain pathless buffer with no intercept, a generated owner-authorized generated write. *Bite:* adding the revision 4
write locks it (a `bypass_intercept` write raises), binding fails this structural assertion even if it refuses terminal
`unlock_generated` releases it (a bypass write lands), and an identity buffers; `identity_protected == false` is not proof that the
ordinary edit then lands too. generated primitive installed the lock.
- *Narrow:* on a listview panel, after `unlock_generated` an ordinary 14. **[`main`] A generated write REFUSES a terminal identity buffer
edit is still refused **by the intercept**, asserted on the full
message text per Stage 1 criterion 5.
- *Negative terminal identity, the criterion review P1-3 asks for:*
open a real terminal, take its identity buffer id, and require
`pmacs.buffer.unlock_generated(term_buf)` to **error**, with
`Buffer::is_read_only()` still `true` afterwards and an ordinary
edit still refused. Assert the post-state, not the error alone.
*Bite:* a no-op unlock fails the first half; an unlock that also
tears down the intercept — "unprotect" rather than "unlock" — fails
the second; and **revision 2's `unlock_generated` as written passes
the first two and fails the third**, which is the whole finding.
Falsify the third by deleting the `identity_protected` check.
14. **[`main`] A generated write REFUSES a buffer someone else locked
(Q#GB15; sweep C item 1).** Open a real terminal; call (Q#GB15; sweep C item 1).** Open a real terminal; call
`pmacs.buffer.set_generated_contents(term_buf, "junk")` and each of `pmacs.buffer.set_generated_contents(term_buf, "junk")` and each of
the three `{ generated = true }` mutators against it. Every one must the three `{ generated = true }` mutators against it. Every one must
@ -2476,7 +2589,7 @@ unlock.**
round 3, P1-2, is right and the diagnosis is worth stating because it is round 3, P1-2, is right and the diagnosis is worth stating because it is
the second time this arc has shipped a criterion that passes with the bug the second time this arc has shipped a criterion that passes with the bug
restored.** Both used an out-of-bounds op. Bounds validation is §3.4 exit restored.** Both used an out-of-bounds op. Bounds validation is §3.4 exit
4 — **before** `editing_in_progress` is set and **before** the unlock 4 — **before** `editing_in_progress` is set and **before** the internal lift
so the operation never enters the transaction the criteria claim to test, so the operation never enters the transaction the criteria claim to test,
and an implementation that omits *both* the flag clear and the error-path and an implementation that omits *both* the flag clear and the error-path
relock passes both. Worse, revision 3 argued *in the same document* that relock passes both. Worse, revision 3 argued *in the same document* that
@ -2493,16 +2606,36 @@ test.** Every criterion below names its exit.
`pub`, so a test crate can implement one whose `on_edit` returns `pub`, so a test crate can implement one whose `on_edit` returns
`Err(BufferError::Intercepted { .. })` — then perform a **valid** `Err(BufferError::Intercepted { .. })` — then perform a **valid**
generated write. It fails at the broadcast, *after* the rope swap. generated write. It fails at the broadcast, *after* the rope swap.
Then require an **ordinary** edit on the same buffer to report the Then **lift `read_only` Rust-side** and require an ordinary edit on
intercept's message, **not** `is already being edited`. the same buffer to report the `FailingView`'s message, **not**
`is already being edited`.
*Bite:* an implementation that returns from the `match` without *Bite:* an implementation that returns from the `match` without
reaching §3.4's line (6) leaves the flag set, and `begin_edit` reaching §3.4's line (6) leaves the flag set, and `begin_edit`
(`:726-731`) and `apply_edit` (`:774-779`) then refuse **every** (`:726-731`) and `apply_edit` (`:774-779`) then refuse **every**
later edit to that buffer for the rest of the session. Falsify by later writable edit to that buffer for the rest of the session.
Without the lift, `ensure_writable` runs before the flag check and
both the correct and broken implementations report `ReadOnly`, which
was revision 4's non-discriminating form. Falsify by
moving the flag clear inside the `Applied | NoOp` arm. Assert the moving the flag clear inside the `Applied | NoOp` arm. Assert the
*next* edit's outcome, not the failing call's — the failing call *next* edit's outcome, not the failing call's — the failing call
reports the same error either way, which is the whole reason this reports the same error either way, which is the whole reason this
criterion is about the buffer's state afterwards. criterion is about the buffer's state afterwards. Restore
`read_only` after the probe so criterion 16 begins from the specified
post-failure state.
15a. **[mutation; both configurations] `AppliedThenFailed` fans out its
carried `Edit` before surfacing the error (Q#GB17; review round 4,
P1-1).** Display the test buffer in a real window, attach the same
`FailingView`, and issue a valid shrinking generated replace through
the Lua binding. The call returns the view's error, but painting the
window must show the new shorter contents with no stale-line panic.
Under `--features crdt`, attach a replica mirror before the call and
require it to import the owner's `crdt_op` despite the Lua error.
*Bite:* deleting only the `AppliedThenFailed` arm's
`notify_buffer_edit_to_windows` call leaves criteria 15 and 16 green
— buffer cleanup is correct — while the window keeps stale line
ranges and the replica never receives an operation that already
changed the authoritative CRDT. This criterion fails that mutation
in both directions.
16. **[`main`] A generated write relocks on that same failure, and does 16. **[`main`] A generated write relocks on that same failure, and does
NOT lock on a refusal (Q#GB17). Two halves, because §3.4 gives them NOT lock on a refusal (Q#GB17). Two halves, because §3.4 gives them
opposite answers and revision 3 gave them the same one.** opposite answers and revision 3 gave them the same one.**
@ -2533,24 +2666,20 @@ test.** Every criterion below names its exit.
locked, modified buffer with poppable history. Falsify by restoring locked, modified buffer with poppable history. Falsify by restoring
`if self.revision() != rev_before`. This is not a hypothetical path: `if self.revision() != rev_before`. This is not a hypothetical path:
Q#GB5 prescribes exactly this call in `ensure_slot`. Q#GB5 prescribes exactly this call in `ensure_slot`.
16c. **[`main`, `crdt`-only] A CRDT mid-transaction failure is 16c. **[fault-injection, `crdt`-only] A CRDT mid-transaction failure is
distinguishable and surfaces (Q#GB17; §3.4 `Diverged`). New in distinguishable and contained (Q#GB17; §3.4 `Diverged`). New in
revision 4 (P1-1 direction B).** Drive an `EditOp::Replace` whose revision 4, made stageable and mandatory in revision 5.** In a
CRDT `delete` succeeds and whose `insert` fails `src/buffer.rs` unit test, drive the private delete→insert classifier
(`src/buffer.rs:1140-1163`), and require: a **distinct** error with a delete closure that succeeds and an insert closure that
variant, not `CrdtRejected`; the buffer left `read_only`; and undo returns the same error shape as loro. Require `Diverged`, then drive
history **not** cleared. *Bite:* revision 3's predicate did nothing that outcome through generated cleanup and require: a **distinct**
at all here — `revision` never advanced — so it neither cleaned nor error variant, not `CrdtRejected`; the buffer left `read_only`; and
reported, in the one case the code's own comment calls "an invariant undo history **not** cleared. *Bite:* folding the variant into
violation". Falsify by folding the variant back into `Rejected`. `Rejected` restores a fresh buffer's `entry_read_only = false`; the
**Honest caveat on stageability:** unlike 15, 16 and 16b, this test must assert the lock post-state so that mutation fails, not only
criterion has **no staging recipe verified in this document** the error discriminant. `cargo test --lib --features crdt` is the
loro's `insert` is expected to succeed when the position is valid, explicit gate. No public fault-injection API is added, and there is
which it is by construction here, so provoking the failure may need a no four-variant fallback.
fault-injection seam rather than an input. If Stage 2 cannot stage
it, the correct outcome is the four-variant fallback named at the end
of §3.4 — **not** a criterion that passes by never reaching its
path, which is precisely what P1-2 caught.
17. **[`main`] An invalid-range generated write does NOT destroy undo 17. **[`main`] An invalid-range generated write does NOT destroy undo
history (Q#GB17).** On a pathless buffer with two ordinary edits history (Q#GB17).** On a pathless buffer with two ordinary edits
already on the stack, call `b:delete(0, b:len() + 1000, already on the stack, call `b:delete(0, b:len() + 1000,
@ -2628,10 +2757,11 @@ test.** Every criterion below names its exit.
prefers no duplication, the fallback is to accept the shipped prefers no duplication, the fallback is to accept the shipped
unconditional clear and **drop criterion 17**, which should be a unconditional clear and **drop criterion 17**, which should be a
stated trade rather than a silent one. stated trade rather than a silent one.
- **That one extra `bool` on `Buffer` is the right size for lock - **That one extra `bool` on `Buffer` is the right size for terminal
provenance** (Q#GB15), rather than the enum §2.11's three policies identity protection** (Q#GB15), rather than the enum §2.11's three
really want. The bet is that the enum is a separable refactor; if it policies really want. It is deliberately not lock provenance and
is not, the field becomes churn the refactor has to undo. enables no unlock. The bet is that the enum is a separable refactor;
if it is not, the field becomes churn the refactor has to undo.
## 8. Deferred (named) ## 8. Deferred (named)
@ -2659,12 +2789,18 @@ test.** Every criterion below names its exit.
measurement says the per-op clear costs anything. measurement says the per-op clear costs anything.
- **`read_only` in `describe.buffer`** (Q#GB14) — separable, no new - **`read_only` in `describe.buffer`** (Q#GB14) — separable, no new
capability, not required by this arc. capability, not required by this arc.
- **Replacing `read_only: bool` with a provenance enum** (Q#GB15's - **Replacing `read_only: bool` with a policy/provenance enum**
rejected alternative). It is the shape §2.11's three policies actually (Q#GB15's rejected alternative). It is the shape §2.11's three
want, and it would let `document_bytes` ask the question it means policies actually want, and it would let `document_bytes` ask the
instead of the question the flag happens to answer (Q#GB16). Rejected question it means instead of the question the flag happens to answer
here as a refactor this arc would be smuggling; named as the right (Q#GB16). Rejected here as a refactor this arc would be smuggling;
eventual shape. named as the right eventual shape.
- **Ordinary edit fan-out after a post-apply `View::on_edit` error.**
The richer internal outcome makes the pre-existing loss explicit, but
`apply_edit` and `apply_edit_skip_intercepts` keep their public
`Result<Edit, BufferError>` surface in this arc. Generated bindings
retain and fan out the edit; generalizing that contract across every
Rust and Lua edit caller is separate work.
- **The five `*scratch*` find-or-create copies** (§2.10 Class 5) — - **The five `*scratch*` find-or-create copies** (§2.10 Class 5) —
`default.lua:581`, `:1145`, `listview.lua:190`, `compile.lua:1052`, `default.lua:581`, `:1145`, `listview.lua:190`, `compile.lua:1052`,
`dired.lua:914`. Correct only while `*scratch*` stays unowned and `dired.lua:914`. Correct only while `*scratch*` stays unowned and
@ -2675,10 +2811,10 @@ test.** Every criterion below names its exit.
to ship an unlock. It is a **pre-existing** exposure: to ship an unlock. It is a **pre-existing** exposure:
`pmacs.buffer.set_generated_contents` is already public on `main` and `pmacs.buffer.set_generated_contents` is already public on `main` and
already locks any buffer id it is handed. This arc neither creates it already locks any buffer id it is handed. This arc neither creates it
nor closes it — Q#GB15's provenance bounds who may *unlock*, not who nor closes it, and revision 5 deliberately adds no unlock because
may *lock*. Recorded as a standing hazard rather than as this arc's `identity_protected` cannot prove who installed the lock. Recorded as
work, which is the correction revision 3 makes to sweep B's a standing hazard rather than as this arc's work, which is the
conclusion. correction revision 3 began and revision 5 completes.
- **`docs/agent-handoff.md` §4's inventory is keyed by - **`docs/agent-handoff.md` §4's inventory is keyed by
`bypass_intercept`** and therefore misses Class C, and its headline `bypass_intercept`** and therefore misses Class C, and its headline
"four writer mechanisms" is **five** once `src/help.rs:354` is counted "four writer mechanisms" is **five** once `src/help.rs:354` is counted
@ -2687,10 +2823,12 @@ test.** Every criterion below names its exit.
- **Removed from this list in revision 3: `COHERENCE.md` §14's listview - **Removed from this list in revision 3: `COHERENCE.md` §14's listview
consumer list.** PR #189 landed the correction (§1.5). A merged consumer list.** PR #189 landed the correction (§1.5). A merged
correction is removed, not relabelled. correction is removed, not relabelled.
- **Returned to this list in revision 3: wdired's unlock.** Revision 1 - **Wdired's generated→editable transition.** Revision 1 deferred it,
deferred it, revision 2 made it Stage 1 work, and revision 3 lands the revision 2 made a general unlock Stage 1 work, revision 3 moved that
*capability* in Stage 2 (Q#GB7 + Q#GB15) while the **wdired consumer** capability to Stage 2, and revision 5 removes it. The **wdired
itself stays deferred to dired Stage 3, which is not framed. consumer** stays deferred to dired Stage 3, which must frame an
owner-specific transition or the eventual policy enum rather than
inheriting a Lua clear of `read_only`.
## 9. Coherence impact (`COHERENCE.md` §20) ## 9. Coherence impact (`COHERENCE.md` §20)
@ -2835,8 +2973,11 @@ Plus, per stage:
nothing about the seam P1-3 is about. Judge it by whether the test nothing about the seam P1-3 is about. Judge it by whether the test
count includes `acc16e`, not by the verdict alone. count includes `acc16e`, not by the verdict alone.
- **Stage 2 additionally needs a `crdt` run of whatever suite hosts the - **Stage 2 additionally needs a `crdt` run of whatever suite hosts the
§3.4 transaction criteria**, for criterion 16's second half and 16c. §3.4 transaction criteria**, for criterion 15a's replica half and
Same reasoning, same failure mode. criterion 16's second half. Criterion 16c lives in
`cargo test --lib --features crdt` because its private
delete-success/insert-failure classifier is a unit-test fault seam,
not a public acceptance input. Same reasoning, same failure mode.
- **Run `scripts/bite` on every criterion expressible as a test today.** - **Run `scripts/bite` on every criterion expressible as a test today.**
Stage 1 criteria 13, 8, 9 and Stage 2 criteria 1, 7, 8, 9, 14 have Stage 1 criteria 13, 8, 9 and Stage 2 criteria 1, 7, 8, 9, 14 have
`main` pre-images and can be falsified by revert; the rest are `main` pre-images and can be falsified by revert; the rest are