--- 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).