Files

27 KiB
Raw Permalink Blame History

status
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).
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 Provenanceproposals[] is filled, not reshaped (parent INV-4; no schemaVersion bump):

"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<Proposal> — 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<boolean> — re-resolve → exact-text guard (INV-11) → applyAgentEdit(doc, resolvedRange, replacement, author, { expectedVersion: doc.version, turnId }) → on success remove + dispose UI. reject(proposalId): Promise<void> — 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 PerProduct-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.textreplacement)
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).