add spec ./specs/coauthoring-propose-accept.md (status: graduated)

This commit is contained in:
Ben Stull
2026-06-10 21:50:52 -07:00
parent 8d9e86cd02
commit c55a6815ba
+472
View File
@@ -0,0 +1,472 @@
---
status: graduated
---
# Solution Design: Propose/Accept Diff Flow (F4)
| | |
| --- | --- |
| **Author(s)** | Ben Stull (with Claude) |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.0 |
| **Source artifacts** | Epic `benstull/vscode-cowriting-plugin#1` · Feature `#12` (F4) · Parent specs: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md` (graduated; §6.3 reserved `proposals[]`, §9 OPEN→F3/F4) and `specs/coauthoring-attribution.md` (graduated; §9 OPEN→F4) · F3 shipped: `#6` (PR #7, session 0007) · F2 shipped: `#4` (PR #5) · Lineage: `ben.stull/rfc-app#48` |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-10 | v0.1.0 | Initial draft — brainstorming session 0011 | Ben Stull + Claude |
---
## 1. Business Context
### 1.1 Executive Summary
F4 inverts the trust model of machine edits: instead of Claude's edits landing
in the document and being reviewed *after the fact* via the attribution tint,
they arrive as **proposals** — visible, anchored, diff-rendered — that the human
explicitly **accepts or rejects**. Accepting applies the edit through the
established `applyAgentEdit` seam, so accepted text lands Claude-attributed with
zero new attribution machinery; rejecting leaves the document untouched. Pending
proposals are first-class git-native state in the sidecar's reserved
`proposals[]` extension point, persisting and re-anchoring exactly like threads
and attributions. This delivers Epic #1's third acceptance pillar —
*"accept / reject Claude's proposed diffs (propose-by-default)"* — and completes
the inner-loop triad: threads (F2) · attribution (F3) · propose/accept (F4).
### 1.2 Background
F3 (#6) shipped the seam — `applyAgentEdit`, the single machine-edit ingress
(INV-9) — and a minimal live `@cline/sdk` turn (the edit-selection command) that
drives it **directly**: the turn completes and the buffer changes in the same
gesture. Both parent specs reserved exactly this moment: the inner-loop spec
holds `proposals: []` open in the sidecar ("F4 extension point — reuses
`anchors[]` + provenance"), and the attribution spec's §9 routes "OPEN → F4:
propose/accept drives the same seam; accepted diffs render into this attribution
substrate." Issue #12 carried five design forks to this session: the
`proposals[]` shape, the rendering primitive, propose-by-default vs. a retained
direct mode, multi-range seam growth, and accept/reject granularity. All five
are resolved here (§6.7, §9).
### 1.3 Business Actors / Roles
- **Coauthor (human)** — the writer/engineer; now also the **ratifier**: the
only actor who can move proposed text into the document.
- **Coauthor (machine)** — Claude via `@cline/sdk`; from F4 on it **proposes**
rather than writes.
### 1.4 Problem Statement
The machine writes first and the human objects later. A completed live turn
mutates the buffer immediately; the human's only recourse to an unwanted machine
edit is undo. There is no ratify-before-landing gesture — no inline diff to
inspect, no keep/reject decision point — so the epic's propose-by-default trust
model is unmet.
### 1.5 Pain Points
- No way to see *what the machine intends to change* before it changes it.
- No explicit keep/reject gesture; review is post-hoc (attribution tint + undo).
- A machine edit and a human ratification are indistinguishable events — the
provenance story stops at "agent wrote this," with no record that a human
chose to let it stand.
### 1.6 Targeted Business Outcomes
A live Claude turn produces a visible, anchored proposal (current text vs.
proposed replacement) and the document is untouched; the human accepts — the
edit applies through the seam and lands Claude-attributed — or rejects — the
proposal disappears and nothing changed. Pending proposals survive reload and
re-anchor across edits. F5 inherits a complete provenance story:
proposed → ratified → attributed.
### 1.7 Scope (business)
**In scope:** the `proposals[]` shape on the shared `anchors`/`Provenance`
primitives; flipping the live turn propose-by-default; in-editor rendering of
pending proposals as anchored diffs; the accept/reject UX; apply-on-accept
through the unchanged single-range seam; git-native persistence + reload +
re-anchor/orphan of pending proposals; a programmatic propose ingress so tests
drive the whole flow with no LLM.
**Out of scope (deferred, not forgotten):** hunk-level / partial acceptance
within one proposal (the model makes it additive later — §6.7); F5 cross-rung
format + Gitea round-trip (#46); multi-range growth of the seam contract
(not needed — §6.7); chat-panel UX; multi-file orchestration; turn-taking
choreography; attribution of out-of-session edits; any server.
### 1.8 Assumptions · Constraints · Dependencies
- **Parent:** Epic #1. **Blocked by:** F3 #6 (shipped) and F2 #4 (shipped).
**Feeds:** F5 (cross-rung provenance story).
- Builds on shipped, unchanged machinery: the shared `anchors` map +
`Fingerprint` + `Provenance` primitives (parent INV-4), the Anchorer's
resolve/orphan ladder (INV-1/INV-3), `CoauthorStore.update` (sole-writer
read-modify-write with anchor prune + counted self-writes), the seam
(`applyAgentEdit`, INV-9), the F3 attribution substrate, and the F2
Comments-API pattern.
- The sidecar's `proposals: unknown[]` is already declared and serialized
pass-through (`src/model.ts`); `SCHEMA_VERSION` stays **1** — filling a
reserved extension point is additive, the F3 precedent.
- `CoauthorStore.update`'s anchor prune currently references only thread +
attribution anchorIds (noted F4 TODO at `src/store.ts:65`) — F4 extends it.
- No new auth surface: the live turn's `claude-code` provider arrangement (F3
INV-8) is untouched; accept/reject and all tests are LLM-free.
### 1.9 Business Use Cases
- **BUC-1** A coauthor asks Claude to edit a selection and **reviews the
proposed diff before anything lands**: accept applies it (attributed),
reject discards it, the document never changes without their say-so.
- **BUC-2** A coauthor leaves proposals pending — closes the workspace, comes
back, edits other parts of the document — and the proposals are still there,
re-anchored, still decidable.
---
## 2. Solution Proposal
Fill the sidecar's reserved `proposals[]` with **pending machine edits**: each
proposal is one anchored target range + one proposed replacement + agent
provenance, born when a live turn (or the programmatic ingress) completes.
The document is untouched at propose time (INV-10). A **ProposalController**
renders each pending proposal two ways at once: an **amber tint** on the target
range (the in-buffer signal) and an anchored **proposal thread** — a second
Comments-API controller — whose body is a fenced unified diff (current →
proposed) with **Accept** / **Reject** actions. Accept re-resolves the anchor
(exact fingerprint text match — the existing ladder doubles as the staleness
guard, INV-11), then applies through the unchanged single-range
`applyAgentEdit` seam: the F3 substrate attributes the spans with zero new
attribution code, and the proposal leaves `proposals[]`. Reject removes the
proposal; nothing else happens. A multi-edit turn simply files N single-range
proposals sharing a `turnId` — the seam contract does not grow.
---
## 3. Product Personas
- **PP-1 Inner-loop coauthor** — the human writer/engineer (as in F2/F3), now
the ratifier of machine edits.
- **PP-2 Machine coauthor (Claude)** — proposes edits; its text enters the
document only via human acceptance.
## 4. Product Use Cases
- **PUC-1 (propose)** Select a region, run "Ask Claude to edit selection," give
an instruction → a proposal appears (amber-tinted range + diff thread); the
document text is unchanged.
- **PUC-2 (accept)** Accept a pending proposal → the replacement applies, the
new spans render Claude-attributed (F3), the proposal and its UI disappear.
- **PUC-3 (reject)** Reject a pending proposal → the document is untouched, the
proposal and its UI disappear.
- **PUC-4 (persist / re-anchor / stale)** Pending proposals survive save +
reopen at their re-resolved anchors. Editing **around** a proposal shifts it;
editing the **target text itself** makes it stale — flagged, not acceptable,
recoverable (undo restores acceptability), never silently applied.
- **PUC-5 (coexist)** Several pending proposals on distinct ranges coexist;
each is independently decidable, in any order.
## 5. UX Layout
No bespoke UI surface — native VS Code primitives only (per the
Claude-Design-vs-Claude-Code rubric this is squarely **Claude Code** territory:
code-resident native surfaces, no visual design artifacts to produce). Pending
proposals render as: (1) an **amber background tint** on the target range
(sibling of F3's indigo agent tint / green human gutter), and (2) a **proposal
thread** anchored at the range — a second Comments controller
(`cowriting.proposals`, distinct from `cowriting.threads` so discussion and
ratification never mix) whose single comment body shows the instruction (when
present) and a fenced diff of current → proposed text, with **✓ Accept** and
**✗ Reject** as thread title actions. The status bar surfaces a count of
stale/orphaned proposals (F3's orphan-count pattern). The existing
"Ask Claude to edit selection" command keeps its name and gesture — it now ends
in a proposal instead of a buffer mutation.
---
## 6. Technical Design
### 6.1 Invariants
Parent invariants INV-1..INV-9 carry over unchanged. F4 adds:
- **INV-10** A proposal **never mutates the document**. Proposed text lives only
in the sidecar and the review UI until accepted; the document changes only via
the seam, only on accept. (INV-9 still holds: the seam remains the single
machine-edit ingress — F4 moves its *trigger* from turn-completion to human
acceptance.)
- **INV-11** Accept is **fingerprint-guarded**: a proposal applies only when its
anchor re-resolves with an exact text match at decision time. A proposal whose
target text changed (stale) or cannot be found (orphaned) is surfaced and
undecidable-as-accept — never applied by guess (extends INV-1).
- **INV-12** Accept and reject are **human-only gestures**: no code path lets
the machine ratify its own proposal.
- **INV-13** `proposals[]` holds **pending proposals only** — state, not
history (the F3 span-list precedent). Accept converts a proposal into
attribution spans (that substrate plus git history is the durable record);
reject removes it.
### 6.2 High-level architecture
One new vscode-free model slice plus one new controller, mirroring the F2/F3
unit pattern; everything else is reused unchanged:
- **`proposals` model** — the typed `Proposal` shape replacing the `unknown[]`
placeholder (`schemaVersion` stays 1). Pure helpers for diff-body formatting
and stale-detection are vscode-free and unit-testable.
- **ProposalController** — owns the proposal lifecycle: programmatic ingress
(`proposeAgentEdit`), persistence via `CoauthorStore.update`, live range
tracking + resolve/stale/orphan on load and external change (Anchorer ladder),
rendering (amber decoration + proposal threads on its own
`CommentController`), and the accept/reject actions.
- **LiveTurn — retargeted, not rewritten:** `editSelection` keeps its
guards/turn/replacement-extraction and now ends in `proposeAgentEdit` instead
of `applyAgentEdit`.
- **Seam, AttributionController, CoauthorStore, Anchorer — reused unchanged**,
except the store's anchor prune learns about proposal anchorIds (the noted F4
TODO).
```mermaid
flowchart LR
sel["selection + instruction"] --> turn["@cline/sdk live turn\n(claude-code)"]
turn --> prop["proposeAgentEdit\n(anchor + persist, INV-10)"]
e2e["E2E / programmatic ingress\n(cowriting.proposeAgentEdit)"] --> prop
prop --> ui["amber tint + proposal thread\n(fenced diff, ✓/✗)"]
ui -- "✓ accept (human, INV-12)" --> guard{"re-resolve exact?\n(INV-11)"}
guard -- yes --> seam["applyAgentEdit seam\n(unchanged, INV-9)"]
seam --> attr["F3 attribution substrate\n(spans, Claude tint)"]
guard -- "no (stale/orphan)" --> flag["flagged, undecidable-as-accept,\nrecoverable"]
ui -- "✗ reject (human)" --> gone["proposal removed,\ndocument untouched"]
seam --> gone
```
### 6.3 Data model & ownership
Same sidecar, same `anchors` map, same `Provenance``proposals[]` is filled,
not reshaped (parent INV-4; no `schemaVersion` bump):
```jsonc
"proposals": [
{
"id": "pr1",
"anchorId": "a9", // shared anchors map; fingerprint.text IS the
// exact target text — the staleness oracle
"replacement": "…", // the full proposed text for the anchored range
"author": { "kind": "agent", "id": "claude",
"agent": { "sdk": "@cline/sdk", "model": "…", "sessionId": "…" } },
"createdAt": "ISO-8601",
"turnId": "turn-…", // groups N proposals born of one live turn
"instruction": "…" // optional: what the human asked for (review context)
}
]
```
- **Pending-only** (INV-13): accept/reject remove the entry; there is no status
field to go stale.
- **Staleness/orphanhood are not persisted** — resolve-time outcomes, exactly
like F2 threads and F3 attributions: the anchor's `fingerprint.text` is
compared at render/decision time.
- **Persisted at propose time** (the F2 thread-mutation precedent, not the F3
save-time one): a proposal is the product of a completed paid turn — losing it
to a crash wastes the turn. `CoauthorStore.update` is the only writer; the
anchor prune's referenced-set gains `proposals[].anchorId`.
- Ownership/format rules unchanged: extension sole writer; stable key ordering;
ids via `newId()`.
### 6.4 Interfaces & contracts
- **ProposalController**:
`proposeAgentEdit(doc, range, replacement, provenance, opts?: { turnId?, instruction? }): Promise<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).