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

This commit is contained in:
Ben Stull
2026-06-10 09:14:15 -07:00
parent 62fe6857f9
commit b7637d9edb
+397
View File
@@ -0,0 +1,397 @@
---
status: graduated
---
# Solution Design: Live Human/Claude Attribution in the Buffer (F3)
| | |
| --- | --- |
| **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 `#6` (F3) · Parent spec: `vscode-cowriting-plugin-content/specs/coauthoring-inner-loop.md` (graduated; §6.3 `attributions[]`/`provenance`, §9 OPEN→F3/F4) · F2 shipped: `#4` (PR #5, session 0004) · Lineage: `ben.stull/rfc-app#48` |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-10 | v0.1.0 | Initial draft — brainstorming session 0006 | Ben Stull + Claude |
---
## 1. Business Context
### 1.1 Executive Summary
F3 makes the buffer an **honest record of authorship**: as a document is
coauthored, the editor shows — live — which spans were written by the **human**
and which by **Claude**, and persists that record git-natively in the same
sidecar F2 shipped, filling the reserved `attributions[]` extension point on the
shared `anchors`/`Provenance` primitives. It also lands the project's **first
live `@cline/sdk` turn** — the minimal machine-edit ingress that gives
attribution something real to attribute — behind a programmatic apply-edit
**seam** that F4's propose/accept will later drive.
### 1.2 Background
F2 (#4) gave regions durable *discussion*; nothing yet records who *wrote* what.
Epic #1's second acceptance pillar is "see, live in the buffer, which spans are
human vs. Claude." The parent spec deliberately reserved `attributions: []`
(INV-4) and deferred "where Claude runs + provider/auth" to "the first live
`@cline/sdk` turn, F3/F4" — i.e., to exactly this design. Issue #6 routed the
open fork (what counts as a machine edit before F4 exists; the `attributions[]`
shape; mixed-edit granularity) to this brainstorming session.
### 1.3 Business Actors / Roles
- **Coauthor (human)** — the writer/engineer authoring prose/specs in VS Code.
- **Coauthor (machine)** — Claude via `@cline/sdk`; **active for the first time**
(F2 reserved the `agent` provenance author; F3 uses it).
### 1.4 Problem Statement
Once a machine edit lands in the buffer it is indistinguishable from human
typing. There is no basis for trust ("did I write this claim or did it?"), no
provenance to carry up the ladder, and no substrate for F4's propose/accept to
render against.
### 1.5 Pain Points
- No visible, live distinction between human-authored and Claude-authored spans.
- No durable authorship record: attribution (if any) dies on reload.
- No machine-edit pathway at all yet — F2 is human-only by design.
### 1.6 Targeted Business Outcomes
Human and Claude spans visibly distinguished as edits happen; the attribution
survives further editing (re-anchor) and reload (sidecar persistence); a real
Claude edit can be invoked from the editor; F4 inherits both the attribution
substrate and a clean apply-edit seam.
### 1.7 Scope (business)
**In scope:** live in-buffer rendering of human/Claude span attribution;
capturing human keystrokes and applied machine edits as attributed anchored
spans; the `attributions[]` shape; character-precise split/merge of mixed edits;
attribution survival across edits (re-anchor / orphan); git-native persistence +
reload; the programmatic apply-edit seam; a minimal live `@cline/sdk` turn
(edit-selection command) on the `claude-code` provider.
**Out of scope (deferred, not forgotten):** F4 propose/accept (keep/reject UX,
inline diff — it will drive this seam); F5 cross-rung format + Gitea round-trip
(#46); attribution of edits made outside the session (external editors, git
merges) beyond the orphan rule; per-character blame depth (the span is the
unit); multi-file orchestration; Anthropic API-key auth (config option later);
chat-panel UX; any server.
### 1.8 Assumptions · Constraints · Dependencies
- **Parent:** Epic #1. **Blocked by:** F2 #4 (shipped). **Feeds:** F4
(propose/accept renders into this substrate and drives the seam); relates to
F5.
- Builds on the graduated parent spec's primitives: shared `anchors`
(INV-4), `Provenance` (`kind: human | agent`), the Anchorer's
resolve/orphan ladder (INV-1/INV-3), the per-document sidecar (INV-2).
- **F2's no-credentials invariant (parent INV-5) ends here by design** — but
with zero key handling: the live turn uses the SDK's built-in **`claude-code`
provider** ("Use Claude Code SDK with Claude Pro/Max subscription"), riding
the operator's existing local Claude Code login. The extension stores no
secret of any kind.
- Requires Claude Code installed + signed in for the live turn; everything else
(tracking, rendering, persistence, tests) works without it.
- Native primitives: `vscode.window.createTextEditorDecorationType` (rendering),
`onDidChangeTextDocument` (edit deltas — the Anchorer already consumes it),
`WorkspaceEdit` (the seam's application path).
### 1.9 Business Use Cases
- **BUC-1** A coauthor watches authorship accumulate honestly as they and Claude
write: each author's spans are visibly theirs, live, and the record survives
editing and reopening the workspace.
- **BUC-2** A coauthor selects a region, asks Claude to edit it, and the
resulting machine edit lands visibly Claude-attributed.
---
## 2. Solution Proposal
Fill the sidecar's reserved `attributions[]` with **anchored, author-attributed
spans** maintained live by a pure span-algebra tracker: human keystrokes produce
human spans; edits applied through a new programmatic **apply-edit seam**
produce agent spans. Mixed edits split character-precisely; adjacent same-author
spans coalesce. Spans persist via the existing Store/Anchorer (fingerprint on
save; resolve-or-orphan on load — never guess). A minimal live turn — an
edit-selection command on the SDK's `claude-code` provider — drives the seam so
there is a real machine author from day one. Rendering is the native Decorations
API: Claude spans tinted, human spans gutter-marked, unattributed text plain.
---
## 3. Product Personas
- **PP-1 Inner-loop coauthor** — the human writer/engineer (as in F2).
- **PP-2 Machine coauthor (Claude)** — now active: authors edits via the live
turn; its spans carry `agent` provenance.
## 4. Product Use Cases
- **PUC-1** Type in a tracked document → the new/changed span renders
human-attributed, live.
- **PUC-2** Select a region, run "Ask Claude to edit selection," give an
instruction → the replacement lands Claude-attributed, live.
- **PUC-3** Edit inside a Claude-attributed span → the span splits
character-precisely; the human's characters are human-attributed; the
remainder stays Claude's.
- **PUC-4** Reopen the workspace → attributed spans reload from the sidecar at
their re-resolved anchors; un-resolvable spans surface as orphaned, never
silently moved.
- **PUC-5** Toggle attribution decorations on/off.
## 5. UX Layout
No new UI surface. Attribution renders as **decorations**: a subtle background
tint on Claude-attributed spans, a thin gutter bar on human-attributed spans,
nothing on unattributed text (text predating tracking — absence of a span is
the honest record). Two contributed commands: **"Cowriting: Ask Claude to edit
selection"** (selection + instruction input box → machine edit) and
**"Cowriting: Toggle attribution"**. Orphaned attributions are dropped from
decoration and surfaced as a **status-bar count** (details in the output
channel) — visible, flagged, recoverable, per F2's orphan precedent.
---
## 6. Technical Design
### 6.1 Invariants
Parent-spec invariants INV-1..INV-4 carry over unchanged. F3 adds:
- **INV-6** An attribution span is **never silently moved or re-authored**: its
location is re-resolved by fingerprint (or orphaned), and its author only
changes by the explicit split/clip rules of the span algebra.
- **INV-7** Attribution is **char-honest**: the span algebra attributes exactly
the characters each author produced (split character-precisely; coalesce only
adjacent same-author spans). No threshold heuristics, no whole-span flips.
- **INV-8** The extension **stores no credentials**. The live turn delegates
auth entirely to the local Claude Code installation (`claude-code` provider);
if it is absent or signed out, the live turn fails gracefully with guidance —
tracking, rendering, and persistence are unaffected.
- **INV-9** Machine edits enter the buffer **only through the seam**
(`applyAgentEdit`), so attribution can never mistake an agent edit for typing;
F4 inherits the same guarantee.
### 6.2 High-level architecture
Two new vscode-free units (unit-testable, like the POC's `cline.ts` and F2's
model/anchorer) plus thin editor-facing wiring:
- **`attributions` model** — the typed `attributions[]` shape on the artifact
(replaces the `unknown[]` placeholder; `schemaVersion` stays 1 — additive).
- **AttributionTracker** — pure span algebra over offset ranges:
`applyChange(spans, edit, author) → spans` (shift/split/clip/coalesce).
- **Apply-edit seam** — `applyAgentEdit(doc, range, newText, provenance)`:
applies a `WorkspaceEdit` and registers it (keyed by document version) so the
tracker attributes the resulting change event to the agent. Exposed as an
internal API + a command, so tests drive it with no LLM.
- **LiveTurn** — the edit-selection command: instruction → `@cline/sdk`
(`claude-code` provider, building on the POC's vscode-free `cline.ts`
driver) → replacement text → the seam.
- **AttributionRenderer** — two `TextEditorDecorationType`s + the toggle;
re-renders on tracker change.
- **CoauthorStore / Anchorer — reused unchanged.**
```
keystroke ─────────────▶ AttributionTracker (author = human)
applyAgentEdit (seam) ─▶ WorkspaceEdit + pending-edit registry ─▶ AttributionTracker (author = agent)
live turn ─▶ selection + instruction ─▶ @cline/sdk (claude-code) ─▶ seam
document change ───────▶ shift/split/clip/coalesce ─▶ AttributionRenderer (decorations)
save ──────────────────▶ buildFingerprint per span ─▶ sidecar attributions[] (CoauthorStore)
load / external change ▶ resolve per span ─▶ render | orphaned (Anchorer ladder, INV-1)
```
### 6.3 Data model & ownership
Same sidecar, same `anchors` map, same `Provenance``attributions[]` is filled,
not reshaped (parent INV-4; no `schemaVersion` bump):
```jsonc
"attributions": [
{
"id": "at1",
"anchorId": "a7", // shared anchors map — same Fingerprint primitive
"author": { "kind": "agent", "id": "claude",
"agent": { "sdk": "@cline/sdk", "model": "…", "sessionId": "…" } },
"createdAt": "ISO-8601",
"updatedAt": "ISO-8601", // bumped when the span's extent/fingerprint changes
"turnId": "turn-…" // optional; groups all spans applied by one live turn
}
]
```
- **Orphanhood is not persisted** — exactly like F2 threads, it is a
resolve-time outcome: fingerprint fails → orphaned (visible, flagged), never
guessed.
- **Unattributed text is the absence of a span** — text predating tracking has
no attribution record, honestly.
- Ownership/format rules unchanged: extension is the sole writer; stable key
ordering; ids via `newId()`.
### 6.4 Interfaces & contracts
- **AttributionTracker** (pure): `applyChange(spans, edit, author): spans` ·
`coalesce(spans): spans` · span = `{ range: OffsetRange, author: Provenance,
id, turnId? }`. Reuses `anchorer.shift` semantics for untouched spans.
- **Seam**: `applyAgentEdit(doc, range, newText, provenance): Promise<boolean>`
— applies + registers; also contributed as an internal command (E2E entry
point).
- **LiveTurn**: `editSelection(doc, selection, instruction): Promise<void>`
resolves the `claude-code` provider, runs one SDK turn producing replacement
text for the selection, calls the seam with `agent` provenance (model +
sessionId from the turn).
- **AttributionRenderer**: `render(editor, spans)` · `setVisible(boolean)`.
- **Store/Anchorer**: existing contracts, now also carrying attributions.
### 6.5 PerProduct-Use-Case design
- **PUC-1 (type):** `onDidChangeTextDocument` → for each content change, the
tracker shifts spans past the edit, clips/deletes spans overlapping a
deletion, splits a different-author span around an insertion, attributes the
inserted range to **human** (no pending seam registration for this document
version), then coalesces. Render.
- **PUC-2 (live turn):** command guards (selection non-empty, document tracked,
Claude Code available) → input box → SDK turn (replacement text only —
single-range, the selection) → `applyAgentEdit` → the change event arrives
with a matching registration → attributed to **agent** with `turnId`. Render.
- **PUC-3 (mixed edit):** the insertion-inside-a-span case of PUC-1's algebra:
split at the insertion point, new human span between the two agent halves
(INV-7). Same rule symmetric for agent edits inside human spans.
- **PUC-4 (reload / external change):** on open or sidecar/document external
change: `load` → per attribution `resolve(fingerprint)` → live span or
orphaned (counted, not rendered). On save: refresh fingerprints from live
spans (`updatedAt` bumped only for changed spans), persist.
- **PUC-5 (toggle):** flips renderer visibility; tracking continues regardless.
### 6.6 Non-functional requirements & cross-cutting concerns
Tracking is O(spans) per change event — fine at human scale; coalescing bounds
span growth. Persistence happens on save (not per keystroke), as F2 does.
Resolution cost on load matches F2 (O(document) per anchor). No secrets at all
(INV-8). Skip non-text/oversized files (F2 rule). Single editor writer.
Live-turn latency is user-visible: show progress notification; the buffer is not
locked (edits during a turn are handled by the version-keyed registration — a
stale turn result fails to apply and reports, rather than corrupting).
### 6.7 Key decisions & alternatives considered
| Decision | Chosen | Alternatives rejected |
| --- | --- | --- |
| Machine-edit ingress | Programmatic seam **+** minimal live SDK turn driving it | seam-only (no real machine author; demo is simulated); live-turn-only (couples tracking to the SDK; F4 has no clean ingress to drive) |
| Provider/auth | `claude-code` provider (local Claude Code Pro/Max login; zero key handling) | Anthropic API key in SecretStorage (ignores the plan already paid for; adds key-handling surface — deferred as a config option); Cline-account OAuth (couples UX to Cline's auth service); env var (no user story) |
| Mixed-edit granularity | Character-precise split + same-author coalescing (INV-7) | whole-span re-attribution (dishonest at the edges); threshold-based (arbitrary, not strictly honest) |
| Attribution record | Span-list on shared `anchors` (state, not history) | event log + replay (truthful history but heavy sidecar churn; re-anchoring a log is hard); hybrid (YAGNI) |
| Rendering | Claude tint + human gutter bar + plain unattributed; toggle | Claude-only (human record invisible); both tinted (visually heavy prose) |
| Live-turn UX | Edit-selection command (selection + instruction → replacement) | whole-document multi-range edits (big bite for "minimal ingress"); chat panel (new surface F4 will reshape) |
| Orphan persistence | Resolve-time (not persisted), per F2 threads | persisted status field (duplicates what resolution derives; risks stale state) |
### 6.8 Testing strategy
- **Unit (vitest, vscode-free):** AttributionTracker span algebra exhaustively —
insert/delete at/around/inside spans, split, clip, coalesce, multi-edit
sequences, both author directions; model round-trip (serialize → reload,
stable formatting); resolve→orphan for attribution fingerprints.
- **Host E2E (`@vscode/test-electron`, F2 pattern):** type → human span → seam
command edit → agent span → save → reload → spans restored → edit above →
re-anchored → mangle anchored text → orphaned. **No LLM in CI** — E2E drives
the seam.
- **Live-turn smoke (manual, documented):** select → instruct → Claude edit
lands attributed; run on a signed-in Claude Code machine. Failure-path check:
signed-out / missing Claude Code → graceful error (INV-8).
### 6.9 Failure modes, rollback & flags
Orphaned attributions (INV-1/INV-6) — handled, recoverable (re-resolve if the
text reappears). Live-turn failures (no Claude Code, signed out, model error,
stale document version) — surfaced as notifications; never partial-applied.
Sidecar merge conflicts — plain JSON, human-resolvable (unchanged). No feature
flag: additive; decorations have a toggle; absence of `attributions[]` entries
is the off state.
---
## 7. Delivery Plan
### 7.1 Approach / strategy
One planning-and-executing session (F3 = #6), plan written just-in-time from
this spec, per the F2 precedent.
### 7.2 Slicing plan
- **SLICE-1** Typed `attributions[]` model (+ round-trip tests).
- **SLICE-2** AttributionTracker span algebra (+ exhaustive unit tests).
- **SLICE-3** Seam (`applyAgentEdit` + registry) and human-edit wiring +
decoration rendering (tint/gutter/toggle).
- **SLICE-4** Persistence on save; reload / external-change resolve; orphan
surfacing.
- **SLICE-5** Live `claude-code` turn (edit-selection command) + documented
manual smoke + graceful-failure paths.
- **SLICE-6** Host E2E (type → attribute → seam edit → attribute → reload →
re-anchor → orphan).
E2E are first-class plan tasks (handbook §4 / parent §6.8), not a follow-up.
### 7.3 Rollout / launch plan
Still non-shippable (no marketplace publish). "Done" = issue #6 acceptance met:
unit + host E2E green, live smoke performed once on this machine.
### 7.4 Risks & mitigations
| Risk | Mitigation |
| --- | --- |
| Span churn from heavy typing (many tiny spans) | coalescing (INV-7's merge half); persistence only on save |
| Misattributing a seam edit to the human (or vice versa) | version-keyed pending-edit registry; seam is the only machine ingress (INV-9); algebra unit-tested both directions |
| `claude-code` provider/SDK surface shifts under us (SDK 0.0.x) | provider use isolated in vscode-free LiveTurn module; seam keeps everything else LLM-free |
| Buffer edited mid-turn | stale-version application fails gracefully, reports, never partial-applies |
| Fingerprint mis-resolution on duplicated text | unchanged F2 ladder: context + lineHint, orphan over guess |
---
## 8. Traceability matrix
| Requirement (issue #6 acceptance) | Use case | Design | Slice |
| --- | --- | --- | --- |
| Human spans recorded/rendered live | PUC-1 | §6.2, §6.5, INV-7 | SLICE-2/3 |
| Machine edits recorded/rendered as Claude's | PUC-2 | seam + LiveTurn, INV-9 | SLICE-3/5 |
| Attribution survives edits (re-anchor / orphan) | PUC-4 | Anchorer ladder, INV-1/6 | SLICE-4 |
| Git-native persistence + reload | PUC-4 | §6.3, parent INV-2/4 | SLICE-1/4 |
| Mixed-edit split/merge | PUC-3 | INV-7, §6.5 | SLICE-2 |
| Unit + host E2E coverage | — | §6.8 | SLICE-2/6 |
## 9. Open Questions & Decisions log
- **RESOLVED (this session):** ingress = seam + minimal live turn; auth =
`claude-code` provider only (no extension-held credentials; Anthropic-key
fallback deferred — not needed for testing since tests drive the seam);
granularity = character-precise split + coalesce; record = span-list on shared
anchors, orphanhood resolve-time; rendering = Claude tint + human gutter +
toggle; live-turn UX = edit-selection command.
- **OPEN → F4:** propose/accept drives the same seam; accepted diffs render into
this attribution substrate. The seam's contract (single-range apply) may grow
multi-range then.
- **OPEN → F5:** cross-rung persistence format may re-home `attributions[]`
alongside the rest of the sidecar.
- **OPEN → later:** attribution of out-of-session edits (git merges, external
editors) beyond the orphan rule; span-level → finer blame; Anthropic API-key
config option for machines without Claude Code.
## 10. Glossary & References
- **Attribution span** — an anchored, author-attributed range of a document; the
unit of provenance. **Seam**`applyAgentEdit`, the single programmatic
ingress for machine edits (INV-9). **Unattributed** — text with no span (it
predates tracking). **Live turn** — one `@cline/sdk` invocation producing a
machine edit. **`claude-code` provider** — the SDK's built-in provider that
rides the local Claude Code Pro/Max login.
- Epic #1 · Feature #6 (F3) · F2 #4 (PR #5) · parent spec
`coauthoring-inner-loop.md` · POC #2 · rfc-app#48/#46 (lineage).