From c55a6815ba2aad5c166300da46d7bd225ec0aeb7 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Wed, 10 Jun 2026 21:50:52 -0700 Subject: [PATCH] add spec ./specs/coauthoring-propose-accept.md (status: graduated) --- specs/coauthoring-propose-accept.md | 472 ++++++++++++++++++++++++++++ 1 file changed, 472 insertions(+) create mode 100644 specs/coauthoring-propose-accept.md diff --git a/specs/coauthoring-propose-accept.md b/specs/coauthoring-propose-accept.md new file mode 100644 index 0000000..5492dd8 --- /dev/null +++ b/specs/coauthoring-propose-accept.md @@ -0,0 +1,472 @@ +--- +status: graduated +--- +# Solution Design: Propose/Accept Diff Flow (F4) + +| | | +| --- | --- | +| **Author(s)** | Ben Stull (with Claude) | +| **Reviewers / approvers** | Ben Stull | +| **Status** | `draft` | +| **Version** | v0.1.0 | +| **Source artifacts** | Epic `benstull/vscode-cowriting-plugin#1` · Feature `#12` (F4) · Parent specs: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md` (graduated; §6.3 reserved `proposals[]`, §9 OPEN→F3/F4) and `specs/coauthoring-attribution.md` (graduated; §9 OPEN→F4) · F3 shipped: `#6` (PR #7, session 0007) · F2 shipped: `#4` (PR #5) · Lineage: `ben.stull/rfc-app#48` | + +**Change log** + +| Date | Version | Change | By | +| --- | --- | --- | --- | +| 2026-06-10 | v0.1.0 | Initial draft — brainstorming session 0011 | Ben Stull + Claude | + +--- + +## 1. Business Context + +### 1.1 Executive Summary + +F4 inverts the trust model of machine edits: instead of Claude's edits landing +in the document and being reviewed *after the fact* via the attribution tint, +they arrive as **proposals** — visible, anchored, diff-rendered — that the human +explicitly **accepts or rejects**. Accepting applies the edit through the +established `applyAgentEdit` seam, so accepted text lands Claude-attributed with +zero new attribution machinery; rejecting leaves the document untouched. Pending +proposals are first-class git-native state in the sidecar's reserved +`proposals[]` extension point, persisting and re-anchoring exactly like threads +and attributions. This delivers Epic #1's third acceptance pillar — +*"accept / reject Claude's proposed diffs (propose-by-default)"* — and completes +the inner-loop triad: threads (F2) · attribution (F3) · propose/accept (F4). + +### 1.2 Background + +F3 (#6) shipped the seam — `applyAgentEdit`, the single machine-edit ingress +(INV-9) — and a minimal live `@cline/sdk` turn (the edit-selection command) that +drives it **directly**: the turn completes and the buffer changes in the same +gesture. Both parent specs reserved exactly this moment: the inner-loop spec +holds `proposals: []` open in the sidecar ("F4 extension point — reuses +`anchors[]` + provenance"), and the attribution spec's §9 routes "OPEN → F4: +propose/accept drives the same seam; accepted diffs render into this attribution +substrate." Issue #12 carried five design forks to this session: the +`proposals[]` shape, the rendering primitive, propose-by-default vs. a retained +direct mode, multi-range seam growth, and accept/reject granularity. All five +are resolved here (§6.7, §9). + +### 1.3 Business Actors / Roles + +- **Coauthor (human)** — the writer/engineer; now also the **ratifier**: the + only actor who can move proposed text into the document. +- **Coauthor (machine)** — Claude via `@cline/sdk`; from F4 on it **proposes** + rather than writes. + +### 1.4 Problem Statement + +The machine writes first and the human objects later. A completed live turn +mutates the buffer immediately; the human's only recourse to an unwanted machine +edit is undo. There is no ratify-before-landing gesture — no inline diff to +inspect, no keep/reject decision point — so the epic's propose-by-default trust +model is unmet. + +### 1.5 Pain Points + +- No way to see *what the machine intends to change* before it changes it. +- No explicit keep/reject gesture; review is post-hoc (attribution tint + undo). +- A machine edit and a human ratification are indistinguishable events — the + provenance story stops at "agent wrote this," with no record that a human + chose to let it stand. + +### 1.6 Targeted Business Outcomes + +A live Claude turn produces a visible, anchored proposal (current text vs. +proposed replacement) and the document is untouched; the human accepts — the +edit applies through the seam and lands Claude-attributed — or rejects — the +proposal disappears and nothing changed. Pending proposals survive reload and +re-anchor across edits. F5 inherits a complete provenance story: +proposed → ratified → attributed. + +### 1.7 Scope (business) + +**In scope:** the `proposals[]` shape on the shared `anchors`/`Provenance` +primitives; flipping the live turn propose-by-default; in-editor rendering of +pending proposals as anchored diffs; the accept/reject UX; apply-on-accept +through the unchanged single-range seam; git-native persistence + reload + +re-anchor/orphan of pending proposals; a programmatic propose ingress so tests +drive the whole flow with no LLM. + +**Out of scope (deferred, not forgotten):** hunk-level / partial acceptance +within one proposal (the model makes it additive later — §6.7); F5 cross-rung +format + Gitea round-trip (#46); multi-range growth of the seam contract +(not needed — §6.7); chat-panel UX; multi-file orchestration; turn-taking +choreography; attribution of out-of-session edits; any server. + +### 1.8 Assumptions · Constraints · Dependencies + +- **Parent:** Epic #1. **Blocked by:** F3 #6 (shipped) and F2 #4 (shipped). + **Feeds:** F5 (cross-rung provenance story). +- Builds on shipped, unchanged machinery: the shared `anchors` map + + `Fingerprint` + `Provenance` primitives (parent INV-4), the Anchorer's + resolve/orphan ladder (INV-1/INV-3), `CoauthorStore.update` (sole-writer + read-modify-write with anchor prune + counted self-writes), the seam + (`applyAgentEdit`, INV-9), the F3 attribution substrate, and the F2 + Comments-API pattern. +- The sidecar's `proposals: unknown[]` is already declared and serialized + pass-through (`src/model.ts`); `SCHEMA_VERSION` stays **1** — filling a + reserved extension point is additive, the F3 precedent. +- `CoauthorStore.update`'s anchor prune currently references only thread + + attribution anchorIds (noted F4 TODO at `src/store.ts:65`) — F4 extends it. +- No new auth surface: the live turn's `claude-code` provider arrangement (F3 + INV-8) is untouched; accept/reject and all tests are LLM-free. + +### 1.9 Business Use Cases + +- **BUC-1** A coauthor asks Claude to edit a selection and **reviews the + proposed diff before anything lands**: accept applies it (attributed), + reject discards it, the document never changes without their say-so. +- **BUC-2** A coauthor leaves proposals pending — closes the workspace, comes + back, edits other parts of the document — and the proposals are still there, + re-anchored, still decidable. + +--- + +## 2. Solution Proposal + +Fill the sidecar's reserved `proposals[]` with **pending machine edits**: each +proposal is one anchored target range + one proposed replacement + agent +provenance, born when a live turn (or the programmatic ingress) completes. +The document is untouched at propose time (INV-10). A **ProposalController** +renders each pending proposal two ways at once: an **amber tint** on the target +range (the in-buffer signal) and an anchored **proposal thread** — a second +Comments-API controller — whose body is a fenced unified diff (current → +proposed) with **Accept** / **Reject** actions. Accept re-resolves the anchor +(exact fingerprint text match — the existing ladder doubles as the staleness +guard, INV-11), then applies through the unchanged single-range +`applyAgentEdit` seam: the F3 substrate attributes the spans with zero new +attribution code, and the proposal leaves `proposals[]`. Reject removes the +proposal; nothing else happens. A multi-edit turn simply files N single-range +proposals sharing a `turnId` — the seam contract does not grow. + +--- + +## 3. Product Personas + +- **PP-1 Inner-loop coauthor** — the human writer/engineer (as in F2/F3), now + the ratifier of machine edits. +- **PP-2 Machine coauthor (Claude)** — proposes edits; its text enters the + document only via human acceptance. + +## 4. Product Use Cases + +- **PUC-1 (propose)** Select a region, run "Ask Claude to edit selection," give + an instruction → a proposal appears (amber-tinted range + diff thread); the + document text is unchanged. +- **PUC-2 (accept)** Accept a pending proposal → the replacement applies, the + new spans render Claude-attributed (F3), the proposal and its UI disappear. +- **PUC-3 (reject)** Reject a pending proposal → the document is untouched, the + proposal and its UI disappear. +- **PUC-4 (persist / re-anchor / stale)** Pending proposals survive save + + reopen at their re-resolved anchors. Editing **around** a proposal shifts it; + editing the **target text itself** makes it stale — flagged, not acceptable, + recoverable (undo restores acceptability), never silently applied. +- **PUC-5 (coexist)** Several pending proposals on distinct ranges coexist; + each is independently decidable, in any order. + +## 5. UX Layout + +No bespoke UI surface — native VS Code primitives only (per the +Claude-Design-vs-Claude-Code rubric this is squarely **Claude Code** territory: +code-resident native surfaces, no visual design artifacts to produce). Pending +proposals render as: (1) an **amber background tint** on the target range +(sibling of F3's indigo agent tint / green human gutter), and (2) a **proposal +thread** anchored at the range — a second Comments controller +(`cowriting.proposals`, distinct from `cowriting.threads` so discussion and +ratification never mix) whose single comment body shows the instruction (when +present) and a fenced diff of current → proposed text, with **✓ Accept** and +**✗ Reject** as thread title actions. The status bar surfaces a count of +stale/orphaned proposals (F3's orphan-count pattern). The existing +"Ask Claude to edit selection" command keeps its name and gesture — it now ends +in a proposal instead of a buffer mutation. + +--- + +## 6. Technical Design + +### 6.1 Invariants + +Parent invariants INV-1..INV-9 carry over unchanged. F4 adds: + +- **INV-10** A proposal **never mutates the document**. Proposed text lives only + in the sidecar and the review UI until accepted; the document changes only via + the seam, only on accept. (INV-9 still holds: the seam remains the single + machine-edit ingress — F4 moves its *trigger* from turn-completion to human + acceptance.) +- **INV-11** Accept is **fingerprint-guarded**: a proposal applies only when its + anchor re-resolves with an exact text match at decision time. A proposal whose + target text changed (stale) or cannot be found (orphaned) is surfaced and + undecidable-as-accept — never applied by guess (extends INV-1). +- **INV-12** Accept and reject are **human-only gestures**: no code path lets + the machine ratify its own proposal. +- **INV-13** `proposals[]` holds **pending proposals only** — state, not + history (the F3 span-list precedent). Accept converts a proposal into + attribution spans (that substrate plus git history is the durable record); + reject removes it. + +### 6.2 High-level architecture + +One new vscode-free model slice plus one new controller, mirroring the F2/F3 +unit pattern; everything else is reused unchanged: + +- **`proposals` model** — the typed `Proposal` shape replacing the `unknown[]` + placeholder (`schemaVersion` stays 1). Pure helpers for diff-body formatting + and stale-detection are vscode-free and unit-testable. +- **ProposalController** — owns the proposal lifecycle: programmatic ingress + (`proposeAgentEdit`), persistence via `CoauthorStore.update`, live range + tracking + resolve/stale/orphan on load and external change (Anchorer ladder), + rendering (amber decoration + proposal threads on its own + `CommentController`), and the accept/reject actions. +- **LiveTurn — retargeted, not rewritten:** `editSelection` keeps its + guards/turn/replacement-extraction and now ends in `proposeAgentEdit` instead + of `applyAgentEdit`. +- **Seam, AttributionController, CoauthorStore, Anchorer — reused unchanged**, + except the store's anchor prune learns about proposal anchorIds (the noted F4 + TODO). + +```mermaid +flowchart LR + sel["selection + instruction"] --> turn["@cline/sdk live turn\n(claude-code)"] + turn --> prop["proposeAgentEdit\n(anchor + persist, INV-10)"] + e2e["E2E / programmatic ingress\n(cowriting.proposeAgentEdit)"] --> prop + prop --> ui["amber tint + proposal thread\n(fenced diff, ✓/✗)"] + ui -- "✓ accept (human, INV-12)" --> guard{"re-resolve exact?\n(INV-11)"} + guard -- yes --> seam["applyAgentEdit seam\n(unchanged, INV-9)"] + seam --> attr["F3 attribution substrate\n(spans, Claude tint)"] + guard -- "no (stale/orphan)" --> flag["flagged, undecidable-as-accept,\nrecoverable"] + ui -- "✗ reject (human)" --> gone["proposal removed,\ndocument untouched"] + seam --> gone +``` + +### 6.3 Data model & ownership + +Same sidecar, same `anchors` map, same `Provenance` — `proposals[]` is filled, +not reshaped (parent INV-4; no `schemaVersion` bump): + +```jsonc +"proposals": [ + { + "id": "pr1", + "anchorId": "a9", // shared anchors map; fingerprint.text IS the + // exact target text — the staleness oracle + "replacement": "…", // the full proposed text for the anchored range + "author": { "kind": "agent", "id": "claude", + "agent": { "sdk": "@cline/sdk", "model": "…", "sessionId": "…" } }, + "createdAt": "ISO-8601", + "turnId": "turn-…", // groups N proposals born of one live turn + "instruction": "…" // optional: what the human asked for (review context) + } +] +``` + +- **Pending-only** (INV-13): accept/reject remove the entry; there is no status + field to go stale. +- **Staleness/orphanhood are not persisted** — resolve-time outcomes, exactly + like F2 threads and F3 attributions: the anchor's `fingerprint.text` is + compared at render/decision time. +- **Persisted at propose time** (the F2 thread-mutation precedent, not the F3 + save-time one): a proposal is the product of a completed paid turn — losing it + to a crash wastes the turn. `CoauthorStore.update` is the only writer; the + anchor prune's referenced-set gains `proposals[].anchorId`. +- Ownership/format rules unchanged: extension sole writer; stable key ordering; + ids via `newId()`. + +### 6.4 Interfaces & contracts + +- **ProposalController**: + `proposeAgentEdit(doc, range, replacement, provenance, opts?: { turnId?, instruction? }): Promise` + — fingerprints the range (Anchorer), persists, renders; **does not touch the + document** (INV-10). Also contributed as the hidden command + `cowriting.proposeAgentEdit` (the E2E entry point, mirroring + `cowriting.applyAgentEdit`). + `accept(proposalId): Promise` — re-resolve → exact-text guard + (INV-11) → `applyAgentEdit(doc, resolvedRange, replacement, author, + { expectedVersion: doc.version, turnId })` → on success remove + dispose UI. + `reject(proposalId): Promise` — remove + dispose UI. + `getPending(docPath)` / `getStaleCount()` — test-facing, F3's + `getSpans`/`getOrphanCount` pattern. +- **Seam — contract unchanged** (single-range `applyAgentEdit`); F4 is its + intended driver, exactly as the F3 spec reserved. +- **LiveTurn**: `runEditTurn` unchanged; the `editSelection` wiring calls + `proposeAgentEdit` with the turn's model/sessionId provenance + the + instruction. +- **Commands/menus**: `cowriting.acceptProposal` / `cowriting.rejectProposal` + on the proposal thread's title menu (palette-hidden); `editSelection` and + `createThread` context-menu entries unchanged. + +### 6.5 Per–Product-Use-Case design + +- **PUC-1 (propose):** existing `editSelection` guards + turn → on completion, + `proposeAgentEdit` anchors the selection (buildFingerprint), persists via + `store.update`, creates the proposal thread (body = instruction + fenced + diff of fingerprint.text → replacement) and applies the amber decoration. + Document version untouched; no pending-edit registration occurs. +- **PUC-2 (accept):** thread action → re-resolve the anchor on the current + document; require an exact resolve (the ladder's text match — INV-11); call + the seam with the resolved range, the stored replacement, the stored agent + provenance, `expectedVersion = document.version` captured at that instant, + and the stored `turnId`. Seam success → attribution spans land (F3, zero new + code) → `store.update` removes the proposal → thread + decoration disposed. + Seam `false` (version race) → report, proposal stays pending. +- **PUC-3 (reject):** thread action → `store.update` removes the proposal → + UI disposed. No document interaction at all. +- **PUC-4 (persist / re-anchor / stale):** on load/external change, resolve + each proposal's anchor: clean resolve → render at the new range; text + mismatch or no match → stale/orphaned — thread labeled + ("⚠ stale — target text changed"), accept action disabled-by-guard, status + bar counts it; a later edit restoring the text re-resolves it back to + decidable (recoverable, INV-1 spirit). Within a session, the thread's live + range shifts with edits (Comments API + `anchorer.shift`, the F2 pattern); + the exact-text guard runs at decision time regardless. +- **PUC-5 (coexist):** proposals are independent records on independent + anchors; accepting one shifts the others' live ranges via the normal change + event; order-independence falls out of per-proposal resolve-at-decision. + +### 6.6 Non-functional requirements & cross-cutting concerns + +O(pending proposals) render/resolve cost — trivially small at inner-loop scale. +Proposal bodies add sidecar bytes proportional to proposed text (bounded: one +selection's replacement each; pending-only keeps the array short-lived). +Accept-time work is one resolve + one seam call. No new credential surface +(INV-8 untouched); proposed text is operator content, same sensitivity class as +the document itself. Skip non-text/oversized files (F2 rule). Live-turn +latency UX unchanged (progress notification); the version-race window narrows +to accept-click → applyEdit, guarded by `expectedVersion` as today. + +### 6.7 Key decisions & alternatives considered + +| Decision | Chosen | Alternatives rejected | +| --- | --- | --- | +| `proposals[]` shape | Pending-only: anchored range + replacement + provenance (+ turnId, instruction); accept→attribution, reject→remove (INV-13) | status-field lifecycle records (state that can go stale; duplicates what removal expresses); event log of decisions (history is git's + the transcript's job; heavy churn) | +| Rendering primitive | Second Comments controller (anchored thread, fenced-diff body, ✓/✗ title actions) + amber range decoration | ghost-text decorations (effectively single-line; no action surface; can't show multi-line replacements); `vscode.diff` editor (leaves the buffer; heavyweight; no inline anchoring); refactor-preview panel (not inline; no persistence story; partial-confirm mismatches the seam registration); writing conflict-markers into the buffer (violates INV-10 — document must stay untouched) | +| Propose-by-default vs. direct mode | Wholesale flip: `editSelection` always proposes; **no user-facing direct mode** (the seam stays direct as accept path + hidden E2E command) | a `cowriting.machineEdits` setting (two modes to test/explain for no demonstrated need — YAGNI; revisit on friction); separate propose-vs-edit commands (same cost, plus gesture ambiguity) | +| Multi-range seam growth | **Not grown.** One proposal = one anchored range + one replacement; a multi-edit turn files N proposals sharing `turnId`; each accept is one single-range seam call | widening `applyAgentEdit` to ranges[] (touches the registry/minimization machinery F3 hardened, for a capability N-proposals already provides); atomic multi-range accept (no user story at 1-selection turns) | +| Accept/reject granularity | Whole-proposal (= per anchored range). Hunk-splitting deferred: the N-proposals-per-`turnId` model makes "split one turn into independently-decidable pieces" purely additive later | per-hunk decisions inside one proposal now (needs a real LCS/Myers diff + sub-hunk anchoring today, for the single-selection turn that mostly yields one coherent rewrite) | +| Staleness guard | The existing Anchorer exact-text resolve at decision time (INV-11) — fingerprint.text doubles as the base-text record | storing a separate baseText + custom comparison (duplicates the fingerprint); applying onto whatever the range now holds (silently overwrites newer human edits — unacceptable) | +| Persistence moment | At propose time (thread precedent) | save-time only, like attribution spans (a crash between turn and save discards a paid turn's output) | + +### 6.8 Testing strategy + +- **Unit (vitest, vscode-free):** `Proposal` model round-trip (serialize → + reload, stable formatting, prune interplay: proposal anchors retained, + decided-proposal anchors pruned when unreferenced); diff-body formatting; + stale-detection given (fingerprint, current text) pairs. +- **Host E2E (`@vscode/test-electron`, F2/F3 pattern, no LLM):** programmatic + `cowriting.proposeAgentEdit` → thread + decoration present **and document + text unchanged** (INV-10) → accept → text replaced, attribution spans agent + (`getSpans`), proposal gone → reject path → untouched + gone → propose → + save → reopen → still pending, re-anchored → edit target text → stale + (accept refused, status-bar count) → undo → decidable again → edit *above* + the target → shifted, still decidable → multiple pending proposals decided + out of order. +- **Live smoke (manual, documented — extends `docs/MANUAL-SMOKE-F3.md`):** + select → instruct → proposal appears (document untouched) → accept → lands + Claude-attributed. Signed-out failure path unchanged from F3. + +### 6.9 Failure modes, rollback & flags + +Stale/orphaned proposals — flagged, undecidable-as-accept, recoverable +(INV-11); reject always works. Accept-time version race — seam returns `false`, +reported, proposal stays pending (retryable). Crash mid-accept (seam applied, +removal not yet persisted) — benign on reload: the proposal resolves **stale** +against its own accepted replacement, is flagged, and the human rejects the +husk; the attribution record is already correct; never double-applies (INV-11). +Sidecar merge conflicts — plain JSON, human-resolvable (unchanged). No feature +flag: additive schema fill; an empty `proposals[]` is the off state; the +propose-by-default flip is the feature itself and shipping it is the rollout. + +--- + +## 7. Delivery Plan + +### 7.1 Approach / strategy + +One planning-and-executing session (F4 = #12), plan written just-in-time from +this spec, per the F2/F3 precedent. + +### 7.2 Slicing plan + +- **SLICE-1** Typed `proposals[]` model + store prune extension (+ round-trip + and prune unit tests). +- **SLICE-2** ProposalController core: programmatic ingress + (`proposeAgentEdit` + hidden command), persistence at propose time, + resolve/stale/orphan ladder on load/external change (+ unit tests on the + pure parts). +- **SLICE-3** Review UI: proposal threads (second Comments controller, + diff-fenced body, ✓/✗ title actions), amber pending-range decoration, + accept (fingerprint-guarded seam call) / reject wiring, status-bar + stale count. +- **SLICE-4** LiveTurn flip to propose-by-default; manual smoke doc updated + (propose → accept → attributed); graceful-failure paths re-verified. +- **SLICE-5** Host E2E per §6.8 (propose/accept/reject/persist/stale/shift/ + coexist; INV-10 asserted) + README update. + +E2E are first-class plan tasks (handbook §9 / §4; this app's required tier is +host E2E — a VS Code extension has no browser surface or deploy stage, the +F2/F3 precedent). + +### 7.3 Rollout / launch plan + +Still non-shippable (no marketplace publish). "Done" = issue #12 acceptance +met: unit + host E2E green, live smoke performed once on this machine showing +propose → accept → Claude-attributed. + +### 7.4 Risks & mitigations + +| Risk | Mitigation | +| --- | --- | +| Accept applies over text that changed since proposing | INV-11: exact fingerprint-text resolve at decision time; stale = undecidable-as-accept, recoverable | +| Proposal UI confused with discussion threads | separate `CommentController` + distinct labels/icons + amber (not indigo) tint | +| Crash between seam apply and proposal removal | reload resolves the husk as stale against its own replacement; flagged, rejectable; never double-applies | +| Comments-API rendering limits for large diffs | fenced-diff markdown body (native rendering); selection-scoped turns bound size; diff body is presentation-only (truth is `fingerprint.text` → `replacement`) | +| Flipping propose-by-default breaks F3 E2E/smoke assumptions | seam + `cowriting.applyAgentEdit` command unchanged (F3 E2E untouched); only the `editSelection` tail moves; smoke doc updated in the same slice | +| Sidecar churn from abandoned proposals | pending-only array (INV-13); reject is one gesture; stale proposals are visible debt in the status bar, not silent rot | + +--- + +## 8. Traceability matrix + +| Requirement (issue #12 acceptance) | Use case | Design | Slice | +| --- | --- | --- | --- | +| Live turn yields a proposal, not a mutation; inline diff rendered | PUC-1 | INV-10, §6.2, §6.5 | SLICE-2/3/4 | +| Accept applies via the seam; spans land Claude-attributed | PUC-2 | INV-9/11/12, §6.4, §6.5 | SLICE-3 | +| Reject leaves the document untouched | PUC-3 | §6.5 | SLICE-3 | +| Pending proposals persist git-natively + reload | PUC-4 | INV-13, §6.3 | SLICE-1/2 | +| Proposals survive edits: re-anchor; stale/orphan never applied | PUC-4 | INV-11, Anchorer ladder | SLICE-2/3 | +| Unit + host E2E coverage, no LLM in CI | — | §6.8 | SLICE-1/2/5 | + +## 9. Open Questions & Decisions log + +- **RESOLVED (this session — the five forks routed by #12):** + (a) `proposals[]` = pending-only anchored-range + replacement + provenance + records (INV-13); (b) rendering = second Comments controller with fenced-diff + thread + amber range decoration; (c) propose-by-default **wholesale** — no + user-facing direct mode, the seam stays as accept path + E2E ingress; + (d) seam stays **single-range** — multi-edit turns are N proposals sharing + `turnId`; (e) granularity = whole-proposal accept/reject, hunk-splitting + additive later via the same N-proposals model. Plus: staleness guard = + fingerprint-text resolve at decision time (INV-11); persistence at propose + time. +- **OPEN → F5:** the cross-rung format may re-home `proposals[]` with the rest + of the sidecar; the proposed→ratified→attributed story is what crosses rungs. +- **OPEN → later:** hunk-splitting one turn into multiple proposals (needs a + real diff algorithm); proposal **counter-edits** (human amends the proposed + text before accepting — today: reject and re-instruct); a queue/peek UX when + many proposals are pending (status-bar count suffices at inner-loop scale); + whether rejected proposals deserve an optional history trail for F5. + +## 10. Glossary & References + +- **Proposal** — a pending machine edit: one anchored target range + one + proposed replacement + agent provenance; never in the document until + accepted. **Stale** — a pending proposal whose target text no longer matches + its fingerprint (undecidable-as-accept, recoverable). **Ratify** — the + human's accept gesture, the only path from proposal to document (INV-12). + **Husk** — a proposal left behind by a crash mid-accept; resolves stale + against its own replacement and is rejected by the human. +- Epic #1 · Feature #12 (F4) · F3 #6 (PR #7) · F2 #4 (PR #5) · parent specs + `coauthoring-inner-loop.md` + `coauthoring-attribution.md` · POC #2 · + rfc-app#48/#46 (lineage).