Files
vscode-cowriting-plugin-con…/specs/coauthoring-propose-accept.md

473 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<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.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).