27 KiB
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
anchorsmap +Fingerprint+Provenanceprimitives (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_VERSIONstays 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 atsrc/store.ts:65) — F4 extends it.- No new auth surface: the live turn's
claude-codeprovider 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:
proposalsmodel — the typedProposalshape replacing theunknown[]placeholder (schemaVersionstays 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 viaCoauthorStore.update, live range tracking + resolve/stale/orphan on load and external change (Anchorer ladder), rendering (amber decoration + proposal threads on its ownCommentController), and the accept/reject actions. - LiveTurn — retargeted, not rewritten:
editSelectionkeeps its guards/turn/replacement-extraction and now ends inproposeAgentEditinstead ofapplyAgentEdit. - 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 Provenance — proposals[] 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.textis 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.updateis the only writer; the anchor prune's referenced-set gainsproposals[].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 commandcowriting.proposeAgentEdit(the E2E entry point, mirroringcowriting.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'sgetSpans/getOrphanCountpattern. - Seam — contract unchanged (single-range
applyAgentEdit); F4 is its intended driver, exactly as the F3 spec reserved. - LiveTurn:
runEditTurnunchanged; theeditSelectionwiring callsproposeAgentEditwith the turn's model/sessionId provenance + the instruction. - Commands/menus:
cowriting.acceptProposal/cowriting.rejectProposalon the proposal thread's title menu (palette-hidden);editSelectionandcreateThreadcontext-menu entries unchanged.
6.5 Per–Product-Use-Case design
- PUC-1 (propose): existing
editSelectionguards + turn → on completion,proposeAgentEditanchors the selection (buildFingerprint), persists viastore.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.versioncaptured at that instant, and the storedturnId. Seam success → attribution spans land (F3, zero new code) →store.updateremoves the proposal → thread + decoration disposed. Seamfalse(version race) → report, proposal stays pending. - PUC-3 (reject): thread action →
store.updateremoves 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):
Proposalmodel 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): programmaticcowriting.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 sharingturnId; (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).