diff --git a/specs/coauthoring-out-of-workspace.md b/specs/coauthoring-out-of-workspace.md new file mode 100644 index 0000000..731fa16 --- /dev/null +++ b/specs/coauthoring-out-of-workspace.md @@ -0,0 +1,611 @@ +--- +status: graduated +--- +# Solution Design: Out-of-Workspace Authoring (F8) + +| | | +| --- | --- | +| **Author(s)** | Ben Stull (with Claude) | +| **Reviewers / approvers** | Ben Stull | +| **Status** | `draft` | +| **Version** | v0.1.0 | +| **Source artifacts** | Feature `benstull/vscode-cowriting-plugin#25` (F8) · Epic `#1` (closed) · Capture session `vscode-cowriting-plugin-0023` · Parent specs (all graduated): `specs/coauthoring-inner-loop.md`, `specs/coauthoring-attribution.md`, `specs/coauthoring-propose-accept.md`, `specs/coauthoring-cross-rung-format.md`, `specs/coauthoring-sidecar-contract.md`, `specs/coauthoring-diff-view.md` (F6, the global-storage-by-URI-hash precedent) · Direct precursors: F6 `#17`/`#19` (PR #18/#20), session-0022 guard fix `#24` (PR #24) · Lineage: `ben.stull/rfc-app#48` | + +**Change log** + +| Date | Version | Change | By | +| --- | --- | --- | --- | +| 2026-06-11 | v0.1.0 | Initial draft — brainstorming session 0024. Hybrid persistence model (repo sidecar in-workspace · global-storage sidecar out-of-workspace/untitled); F5 non-shareability and URI-rename orphaning settled. INV-24/INV-25. | Ben Stull + Claude | + +--- + +## 1. Business Context + +### 1.1 Executive Summary + +F8 removes a surprising asymmetry in the coauthoring surface. F6 (diff-view, +`#19`) and F7 (rendered preview, `#21`) were broadened to work on **any** +document the editor will show — in-folder, out-of-folder, or untitled — because +they persist nothing in the repo (F6's baseline lives in VS Code **global +storage** keyed by a URI hash, INV-19; F7 persists nothing). But the +**authoring** surfaces — F2 threads, F3 attribution, F4 "Ask Claude to Edit +Selection" — still refuse any document that is not a saved `file:` under +`workspaceFolders[0]`, because they persist a git-native +`.threads/.json` sidecar under the workspace root, which has +no home for an out-of-workspace file. The result is the "why does diff/preview +work here but I can't ask Claude to edit?" friction the operator hit during F7 +manual testing. + +F8's load-bearing move is a **hybrid persistence model**: a single +`SidecarStore` abstraction the three authoring controllers share, routed +per-document — in-workspace `file:` docs keep their committable repo `.threads/` +sidecar **byte-for-byte unchanged** (INV-2 preserved; no migration), while +out-of-workspace `file:` docs and `untitled:` buffers fall back to a +**global-storage sidecar keyed by `sha256(documentUri)`**, mirroring F6's +`BaselineStore` (INV-19). The persisted **artifact JSON is identical either +way** — same shape, same `SCHEMA_VERSION` — so `mergeArtifacts` and the F5 +cross-rung contract need no change. Two consequences are stated as design +contract, not discovered later: a global-storage artifact is **not a committed +file**, so it is **never cross-rung-shareable** (acceptable — an out-of-repo file +isn't shared via the repo anyway), and URI-hash keying means **renaming/moving +an out-of-workspace file orphans its artifact** (the repo sidecar, by contrast, +moves with the file in git). + +### 1.2 Background + +The inner-loop triad shipped across F2–F4 (threads · attribution · +propose/accept); F5 made the sidecar a cross-rung contract; F6 added a +diff-view; F7 a rendered track-changes preview. F6's `#19` follow-up established +the pattern this Feature generalizes: a view that needs only a **stable document +identity and a storage home** — not a `.threads/` sidecar — can serve **any** +document by persisting its state in global storage under `sha256(uri)`. F8 asks +the symmetric question for *authoring*: the authoring controllers also need only +a stable identity and a storage home for their artifact; the sole reason they +refuse out-of-workspace docs is that their **one** storage implementation +(`CoauthorStore`) is workspace-root-bound. Issue #25 routes the design forks — +the storage abstraction shape, untitled identity, F5 non-shareability, and the +rename-orphaning caveat — to this session; all are resolved here (§6.7, §9). + +### 1.3 Business Actors / Roles + +- **Coauthor (human)** — the writer/engineer (PP-1); F8's sole user. F8 exists + to make "ask Claude to edit" available on the same documents diff/preview + already serve. +- **Coauthor (machine)** — Claude via `@cline/sdk`; not a new actor in F8. Its + edits still enter only through the F4 seam (INV-9); F8 changes *where the + artifact is stored*, not how machine text lands. + +### 1.4 Problem Statement + +The human can **diff** (F6) and **preview** (F7) a document the extension is not +anchored to, but cannot **ask Claude to edit** it: threads, attribution, and +propose/accept refuse any document that is not a saved `file:` under the opened +workspace folder, because they persist to a workspace-rooted `.threads/` +sidecar. Authoring is the only surface still gated to in-workspace files. + +### 1.5 Pain Points + +- A file opened from a sibling repo, a scratch file outside the folder, or an + untitled buffer can be diffed and previewed but **not edited by Claude** — an + inconsistency with no cause the user can see. +- Until session-0022 (`#24`) the refusal even **misreported the reason** ("select + some text" for an out-of-workspace file). `#24` fixed the *message*; F8 fixes + what is *accepted*. +- The asymmetry undercuts the "ask Claude to edit anything you're looking at" + mental model the universal diff/preview already set. + +### 1.6 Targeted Business Outcomes + +The human can select text and ask Claude to edit it in **any** document the +editor will show — saved-in-folder, saved-out-of-folder, or untitled — so the +authoring loop matches the (already universal) diff/preview loop. Authoring on a +workspace file is **byte-for-byte unchanged**; authoring on an +out-of-workspace/untitled file simply works, with its state kept where a repo +sidecar can't live. + +### 1.7 Scope (business) + +**In scope:** a **hybrid** persistence model — one `SidecarStore` abstraction the +three authoring controllers (thread · attribution · proposal) share, routed +per-document: in-workspace `file:` → repo `.threads/` (`CoauthorStore`, +unchanged); out-of-workspace `file:` + `untitled:` → global-storage sidecar +(`GlobalSidecarStore`, URI-hash key). Widening the `editSelection` guard and the +three controllers' membership predicate to **accept** the additional documents +(routing them to the global store). Re-anchoring (content-based, F2) and +reload-restore for global-storage docs. Host E2E (no LLM) covering an +out-of-workspace file and an untitled buffer. Docs: the hybrid model + the F5 +non-shareability note. + +**Out of scope (deferred, not forgotten):** untitled→saved **artifact +migration** (when an untitled buffer is saved, its in-memory artifact is **not** +carried to the new `file:` sidecar in v1 — §6.7/§9); a **re-key/recover** gesture +for an orphaned global artifact after a rename (§9); any per-author coloring or +UI change beyond accepting more documents. + +**Non-goals (firm, from #25):** **uniform** global storage — moving in-workspace +docs *off* the committable sidecar is explicitly rejected (it would lose +committable threads/attribution, the point of INV-2); **migrating** existing repo +sidecars; **cross-rung sharing** of global-storage artifacts (designed-out, §6.7); +adding any LLM/network/credential surface (INV-8 untouched). + +### 1.8 Assumptions · Constraints · Dependencies + +- **Anchor:** Feature #25 (F8). Builds on F2 `#4` · F3 `#6` · F4 `#12` · F5 `#14` + · F6 `#17`/`#19` · the `#24` guard fix; Epic #1 is closed. +- The artifact shape is **stable across storage homes**: `Artifact` + (`model.ts`) and `SCHEMA_VERSION = 1` (`model.ts:11`) are untouched — only + *where* the JSON is written differs. So `mergeArtifacts` and the cross-rung + contract (INV-14..17) need no change (INV-25). +- The global-storage precedent is **load-bearing and proven**: F6's + `BaselineStore` (`src/baselineStore.ts`) already persists per-document JSON + under `context.globalStorageUri` keyed by `sha256(uri)`, with untitled buffers + held **in-memory only**. F8 mirrors it for the artifact. +- This touches **INV-2** (git-native sidecar) by adding an *alternative* + persistence path — a **load-bearing** change, hence this Feature needs a + Solution Design before implementation (handbook §3.4 R3); INV-2 itself is + **preserved** for in-workspace docs (no migration). +- No LLM/network/credential surface added (INV-8 untouched); the seam (INV-9/10) + is unchanged. + +### 1.9 Business Use Cases + +- **BUC-1 (sibling-repo file)** The operator opens a markdown file from a sibling + repo (outside the workspace folder), selects a paragraph, asks Claude to edit + it, reviews the proposal, accepts — and the thread/attribution survive a reload. +- **BUC-2 (scratch / untitled)** The operator drafts in an untitled buffer, asks + Claude to edit a selection, accepts — works within the session (state is + in-memory; the rename/orphaning limits are surfaced, §6.7). + +--- + +## 2. Solution Proposal + +Introduce **one persistence abstraction** the three authoring controllers depend +on — `SidecarStore` (load / save / update / `consumeSelfWrite` over a per-document +**document key**) — and a **router** that selects the implementation per document +by the same `isUnderRoot` predicate `#24` added: + +- **In-workspace `file:` docs** → the existing repo-rooted `CoauthorStore`, + keyed by the **repo-relative path**, writing `.threads/.json` — + **byte-for-byte unchanged** (INV-2). This is the only path the cross-rung + contract (F5) ever sees. +- **Out-of-workspace `file:` and `untitled:` docs** → a new `GlobalSidecarStore` + mirroring `BaselineStore`: per-document `Artifact` JSON under + `/sidecars/.json` (INV-19's + storage home, never the repo). Untitled buffers, having no durable identity, + are held **in-memory only** (the F6 degrade). + +The **document key** — repo-relative path for in-workspace docs, the **URI +string** otherwise — is the single identity used as the store key, as the +artifact's `document.path`, and in each controller's in-memory state map and +test-facing surface. Because the key for an out-of-workspace artifact is a +machine-local URI (and the file isn't committed), such artifacts are **never +read by `mergeArtifacts` / shared across rungs** (INV-25) — stated as contract, +not happenstance. + +The authoring **gate widens to accept** the additional documents: the +`editSelection` `selectionRejection` and the three controllers' membership +predicate become a shared `isAuthorable(document)` = scheme ∈ {`file:`, +`untitled:`}; `isUnderRoot` is **demoted from a gate to a routing input** (it now +only picks the store, never refuses the document). As in F6, the authoring +commands therefore become **workspace-independent** — live even with no folder +open (every doc then routes to global storage). + +--- + +## 3. Product Personas + +- **PP-1 Inner-loop coauthor** — the human writer/engineer (as F2–F7); the only + persona F8 serves. + +## 4. Product Use Cases + +- **PUC-1 (edit an out-of-folder file)** Open a saved file outside the workspace + folder, select text, run "Ask Claude to Edit Selection" → a proposal appears + (F4); accept → the text lands Claude-attributed (F3) and a thread can be opened + (F2); the artifact persists to global storage and **survives a reload**. +- **PUC-2 (edit an untitled buffer)** In an untitled buffer, select text, ask + Claude to edit → propose/accept/thread all work **within the session**; state + is in-memory and is lost on reload or on save (§6.7) — the rest of the loop is + identical. +- **PUC-3 (in-workspace unchanged)** On a saved file inside the workspace folder, + every authoring gesture behaves **exactly as before F8** — same `.threads/` + sidecar, same cross-rung shareability, byte-for-byte (INV-2). +- **PUC-4 (re-anchor + reload, global doc)** After Claude edits an out-of-folder + file, the operator edits around the anchored span; anchors re-resolve + content-based (F2); on reload the threads/attributions/proposals are restored + from the global-storage sidecar at their re-anchored positions. +- **PUC-5 (graceful edges)** Global storage unavailable → in-memory fallback + + one warning (the session still works; reload-survival is lost) — the F6 + degrade. A focus that is neither `file:` nor `untitled:` (e.g. an `output:` or + `git:` read-only doc) → the existing per-condition warning, no authoring. A + renamed out-of-folder file → its prior artifact is orphaned (a self-describing, + recoverable empty state — §6.7/§9). + +## 5. UX Layout + +No bespoke UI surface — the existing F2–F4 affordances unchanged (comment +threads, attribution tints, proposal diffs). The **only** visible change is that +"Ask Claude to Edit Selection" (and threads/attribution) now **succeed** on more +documents instead of warning. The session-0022 (`#24`) per-condition messaging is +retained for the documents F8 still declines (non-text-editor focus; a scheme +other than `file:`/`untitled:`; an empty selection). The authoring commands are +registered **workspace-independently** (the F6 `#19` precedent) so they are live +folder-less. No status-bar item, no new command, no keybinding. + +--- + +## 6. Technical Design + +### 6.1 Invariants + +Parent invariants INV-1..INV-23 carry over unchanged. F8 adds: + +- **INV-24 (hybrid persistence routing)** Every authoring artifact is persisted + through one `SidecarStore` abstraction, routed per-document by membership: + an **in-workspace `file:`** document persists to the committable repo + `.threads/.json` sidecar **byte-for-byte unchanged** + (INV-2 preserved — no migration, no behavior change); an **out-of-workspace + `file:`** or **`untitled:`** document persists to a **global-storage sidecar** + keyed by `sha256(documentUri)` under `context.globalStorageUri`, **never the + repo** (mirroring INV-19). The `Artifact` shape and `SCHEMA_VERSION` are + **identical** across both homes — only the storage location differs. The + document key (repo-relative path in-workspace; the URI string otherwise) is the + single identity used as store key, as `artifact.document.path`, and in the + controllers' in-memory state. +- **INV-25 (global artifacts are single-rung)** A global-storage artifact is not + a committed file, so it is **never** read by `mergeArtifacts` or shared across + rungs (F5); out-of-workspace/untitled authoring is **session-/machine-local by + construction**. Its `document.path` is a machine-local URI that carries no + cross-rung meaning. The cross-rung contract (INV-14..17) and the in-workspace + committable sidecar (INV-2) are untouched. + +### 6.2 High-level architecture + +One new vscode-free store + one routing façade + one identity helper; the three +controllers are re-pointed from the concrete `CoauthorStore` onto the +`SidecarStore` interface, and their workspace-root gate is replaced by a shared +authorability predicate. + +- **`SidecarStore` (interface, `src/sidecarStore.ts`)** — the surface the + controllers depend on: `load(key) : Artifact | null` · `save(key, artifact)` · + `update(key, mutate) : Artifact` · `consumeSelfWrite(fsPath) : boolean`. The + existing `CoauthorStore` already satisfies this shape (it becomes the + in-workspace implementation, unchanged). +- **`GlobalSidecarStore` (`src/globalSidecarStore.ts`, vscode-free)** — the + out-of-workspace/untitled implementation, mirroring `BaselineStore`: per-key + `Artifact` JSON at `/sidecars/.json` where `key = + sha256(documentUri)`; `consumeSelfWrite` is a **no-op returning `false`** + (global storage is outside the `**/.threads/**` watcher — no self-write storm + to suppress). Untitled docs are held **in-memory only** (no disk write), the + F6 degrade. +- **`SidecarRouter` (`src/sidecarRouter.ts`)** — implements `SidecarStore` and + owns the routing: given a document's identity (`{ uri, fsPath, scheme }`) and + the workspace root, it (a) computes the **document key** — + `asRelativePath`-style repo-relative path when `scheme === "file" && + isUnderRoot(fsPath, root)`, else the URI string — and (b) dispatches + `load/save/update` to `CoauthorStore` (repo) or `GlobalSidecarStore` (global) + by the same predicate. Exposes `keyOf(document) : string` so controllers and + tests share the one identity. Vscode-free (takes the extracted identity + primitives, not a `vscode.TextDocument`), so it is unit-testable. +- **`isAuthorable(document)` (in `src/workspacePath.ts`)** — scheme ∈ {`file:`, + `untitled:`}. Replaces the three controllers' near-identical + `isInRoot`/`isTracked` (`scheme === "file" && isUnderRoot(...)`) gate. `#24`'s + `isUnderRoot` is **retained** — now consumed by the router for *store + selection*, never as an authoring gate. +- **The three controllers** (`threadController.ts`, `attributionController.ts`, + `proposalController.ts`) — constructor `store` param re-typed `CoauthorStore → + SidecarStore` (receives the router); `rootDir` no longer used for gating + (membership → `isAuthorable`); per-document key via `store.keyOf(document)` + (replacing direct `asRelativePath`). The seam (`applyAgentEdit`, INV-9) and all + artifact logic are **unchanged** — they already operate on `Artifact` and a + `docPath` string. +- **`VersionGuard`** — re-pointed at the `SidecarStore` interface so INV-16's + schemaVersion check applies uniformly to both homes (a global artifact can be + stale from an older plugin build even though it never *merges*). +- **Everything else reused unchanged:** Anchorer (content-based, storage-agnostic + already), the F4 seam, `mergeArtifacts` (only ever sees committed repo + sidecars), F6/F7 controllers. + +```mermaid +flowchart TD + cmd["editSelection / thread / attribution\nentry points"] --> gate{"isAuthorable?\n(scheme ∈ file/untitled)"} + gate -- no --> warn["per-condition warning (#24)"] + gate -- yes --> router["SidecarRouter\nkeyOf(doc) + route"] + router --> isroot{"scheme=file &&\nisUnderRoot(fsPath, root)?"} + isroot -- yes --> repo["CoauthorStore\n.threads/.json\n(committable, INV-2)\n→ F5 cross-rung"] + isroot -- "no (out-of-folder / untitled)" --> glob["GlobalSidecarStore\n/sidecars/\nsha256(uri).json (INV-19)\nuntitled: in-memory only\n→ single-rung (INV-25)"] + repo & glob --> art["identical Artifact JSON\n(SCHEMA_VERSION=1, INV-24)"] +``` + +### 6.3 Data model & ownership + +The persisted artifact is the **existing `Artifact`** (`model.ts:95-106`), +unchanged in shape or version: + +```typescript +interface Artifact { + schemaVersion: number; // SCHEMA_VERSION = 1, identical both homes + document: { path: string }; // the document key (see below) + anchors: Record; // SHARED primitive (INV-4) + threads: Thread[]; + attributions: AttributionRecord[]; + proposals: Proposal[]; +} +``` + +**The document key (`document.path`) is the single identity**, and its value +depends on home: + +| Document | Key (`document.path`) | On-disk location | Cross-rung | +| --- | --- | --- | --- | +| In-workspace `file:` | repo-relative path (e.g. `notes/ch-1.md`) | `/.threads/.json` | **yes** (F5, INV-2) | +| Out-of-workspace `file:` | the URI string `file:///abs/path` | `/sidecars/.json` | **no** (INV-25) | +| `untitled:` | the URI string `untitled:Untitled-1` | **in-memory only** (no disk) | **no** (INV-25) | + +- **One artifact per document** — the store key is the document key; no history. +- **Owner:** for in-workspace, the repo (committable, mergeable — unchanged). For + global, the **extension alone**: the file lives in VS Code's machine-wide + global storage, outside any repo, so it can never be committed, merged, or read + by another rung — unknown-field preservation (INV-17) is moot for it. +- **Capture/identity source** is the document URI (`document.uri.toString()`), + matching `BaselineStore.uri` — the same identity F6 already keys on, so a doc's + baseline and its authoring artifact share a hash. + +### 6.4 Interfaces & contracts + +- **`SidecarStore`** (the controllers' dependency; `CoauthorStore` already + conforms): `load(key): Artifact | null` · `save(key, artifact): void` · + `update(key, mutate): Artifact` · `consumeSelfWrite(fsPath): boolean`. +- **`GlobalSidecarStore`** (vscode-free): same `SidecarStore` surface; + constructed with the global storage dir (`context.globalStorageUri?.fsPath`, + the F6 wiring at `extension.ts:57`); `sidecarPath(key) = + /sidecars/.json`; `consumeSelfWrite` ⇒ `false`. Untitled keys resolve + to an in-memory map, never disk. +- **`SidecarRouter`** (implements `SidecarStore`): constructed with + `CoauthorStore`, `GlobalSidecarStore`, and the workspace root (or `undefined` + when no folder is open ⇒ everything routes global). `keyOf(docIdentity): + string`; `load/save/update/consumeSelfWrite` dispatch by `isUnderRoot`. +- **`isAuthorable(document): boolean`** (`workspacePath.ts`) — scheme ∈ + {`file:`, `untitled:`}. Used by the three controllers' membership predicate and + by `selectionRejection`. +- **`selectionRejection`** (`workspacePath.ts:42-56`) — the + `scheme !== "file"` and `!isUnderRoot` branches are **replaced** by a single + `!isAuthorable` branch (rejecting only schemes outside {file, untitled}); the + no-editor and empty-selection branches are retained verbatim. The "save this + to a file first" / "outside your workspace folder" messages are **removed** + (those documents are now accepted). +- **Controllers** — constructor `store: CoauthorStore` → `store: SidecarStore`; + `rootDir` dropped from the gate (membership = `isAuthorable`), key via + `store.keyOf`. The `CowritingApi` test handles (`threadController`, + `attributionController`, `proposalController`) and their test-facing surfaces + (`getRendered(key)`, `getSpans(key)`) are unchanged — they accept the document + key, which for in-workspace docs is still the repo-relative path the existing + tests pass. +- **`package.json` / activation** — the authoring commands move to the + **workspace-independent** registration path (the F6 `#19` precedent): live even + with no folder open. The `**/.threads/**/*.json` watcher (`extension.ts:127`) + is unchanged (it only ever matters for repo sidecars). + +### 6.5 Per–Product-Use-Case design + +- **PUC-1 (out-of-folder file):** `editSelection` passes `isAuthorable`; the + router keys by the file URI and routes to `GlobalSidecarStore`; propose/accept + and threads run exactly as in-workspace (the controllers are storage-agnostic + above the `SidecarStore` seam). The seam (INV-9) lands text and fires + `onDidApplyAgentEdit` — F6's baseline advance still works (F6 already serves + this doc). Reload: `ensureState` loads the global sidecar by the same hash. +- **PUC-2 (untitled):** identical control flow; the router routes to + `GlobalSidecarStore`'s **in-memory** branch (no durable URI). State lives for + the session; lost on reload or on save (§6.7). The diff/preview (F6/F7) already + behave this way for untitled, so the whole stack is consistent. +- **PUC-3 (in-workspace unchanged):** the router computes the repo-relative key + and dispatches to `CoauthorStore`; not one byte of the persisted path, + filename, or JSON differs from pre-F8 (INV-2). This is the byte-for-byte + acceptance criterion and is asserted directly in E2E (§6.8). +- **PUC-4 (re-anchor + reload, global):** anchoring is content-based and + storage-agnostic (Anchorer is unchanged), so re-anchoring works identically; on + reload `renderAll` reloads the global sidecar and re-resolves anchors against + the current buffer — the F2 restore path, now fed by the router. +- **PUC-5 (edges):** global storage write failure ⇒ `GlobalSidecarStore` degrades + to in-memory + one warning (F6's exact degrade) — authoring works for the + session, reload-survival lost. A non-{file,untitled} scheme ⇒ `selectionRejection` + warns. A renamed out-of-folder file ⇒ new URI ⇒ new hash ⇒ empty artifact (its + prior state orphaned under the old hash); recovery is re-authoring (§9 notes a + future re-key gesture). + +### 6.6 Non-functional requirements & cross-cutting concerns + +Storage is one document-sized `Artifact` JSON per out-of-workspace doc in VS +Code's global storage (local machine, same trust domain as the working tree; +artifact content is operator/coauthoring data, the document's own sensitivity +class — identical to F6 baselines, which already store **full document text** +there, a strictly larger footprint than F8's anchored metadata). No LLM, no +network, no new credential surface (INV-8). The router adds O(1) dispatch per +store call; keying is one `sha256` of a URI per call (F6's cost). The +`**/.threads/**` watcher never fires for global artifacts (they're outside the +workspace), so there is no self-write/re-anchor storm — `consumeSelfWrite` is a +no-op there. Non-text/oversized skipping (the F2 rule) is unchanged and applies +in both homes. + +### 6.7 Key decisions & alternatives considered + +| Decision | Chosen | Alternatives rejected | +| --- | --- | --- | +| **Persistence model (the load-bearing fork)** | **Hybrid**: in-workspace `file:` keep the committable repo `.threads/` sidecar (INV-2, byte-for-byte); out-of-workspace `file:` + `untitled:` use a global-storage sidecar keyed by `sha256(uri)` (INV-19/24). One `SidecarStore` abstraction, routed per-document by `#24`'s `isUnderRoot` | **Uniform global storage** (move *all* docs off the committable sidecar — explicitly rejected by #25: loses committable threads/attribution, the entire point of INV-2, and breaks F5 cross-rung for in-workspace docs); **per-document opt-in/config** (a setting for where to store — no user story, just friction); **gitignored repo cache for out-of-folder docs** (litters the working tree, risks accidental commit, needs `.gitignore` management — F6 rejected this for the same reason) | +| **Storage abstraction shape** | **`SidecarStore` interface + `SidecarRouter` façade** with two impls (existing `CoauthorStore`, new `GlobalSidecarStore` mirroring `BaselineStore`); controllers depend on the interface; the router owns identity (`keyOf`) and routing | **Branch inside each controller** (`if isUnderRoot … else …` in all three — triples the routing logic, three places to drift, couples each controller to both stores); **a second store passed alongside** the first with per-call selection in the controller (same drift, leaks routing into callers); **subclass `CoauthorStore`** (the workspace-root binding is in the base — inheritance fights it) | +| **Document key / `artifact.document.path`** | **One key**: repo-relative path in-workspace (unchanged, cross-rung-meaningful), the **URI string** otherwise (self-consistent, machine-local — reinforces INV-25). The router's `keyOf` is the single source, used as store key, `document.path`, in-memory map key, and test surface | **Always the URI** (would change the in-workspace `document.path` ⇒ breaks INV-2/F5 and existing sidecars); **a synthetic opaque id** (needs a side mapping to recover identity; URI already is the identity); **hash as the key everywhere** (loses the human-readable repo-relative path the cross-rung contract relies on) | +| **Untitled identity & on-save migration** | **In-memory only** (no durable URI — the F6 degrade); on save the in-memory artifact is **not** migrated to the new `file:` sidecar in v1 (a fresh empty artifact is created for the saved file). Documented limitation; migration is an additive follow-up (§9) | **Persist by the untitled URI** (`untitled:Untitled-1` is *not* stable across reloads — a persisted artifact would mis-bind to a different buffer next session: worse than losing it); **block authoring on untitled** (re-introduces the very asymmetry F8 removes — diff/preview serve untitled); **auto-migrate on save in v1** (real value but needs old-URI→new-URI artifact transfer with edge cases — deferred, not blocking the core win) | +| **F5 cross-rung shareability of global artifacts** | **Not shareable, by design (INV-25)** — a global artifact is not a committed file, so `mergeArtifacts`/the cross-rung sync never see it; stated as contract. Acceptable: an out-of-repo file isn't shared via the repo anyway | **Force-share via a synthetic committed file** (would have to invent a repo home for an out-of-repo file — defeats the hybrid model and pollutes the repo); **export/import command** (no user story; out-of-scope per #25) | +| **URI-rename orphaning** | **Accepted & documented (§6.5/§9, INV-24 commentary)** — `sha256(uri)` keying means rename/move ⇒ new hash ⇒ orphaned artifact; recovery is re-authoring. This is the inherent cost of URI-hash keying (F6 baselines already have it; less painful there because a baseline is disposable) | **Track renames via `onDidRenameFiles`** (only fires for workspace files — useless for the out-of-folder docs this affects); **content-hash keying** (a single edit changes the content hash ⇒ orphans on every keystroke — far worse); **store-by-inode** (non-portable, fragile) — a re-key/recover gesture is the right *additive* answer (§9), not v1 | +| **Membership gate** | Replace the three controllers' `scheme=file && isUnderRoot` gate with shared **`isAuthorable`** (scheme ∈ file/untitled); `isUnderRoot` demoted to a **router input** (store selection), never a refusal. Authoring commands registered **folder-independently** (F6 `#19` precedent) | **Keep `isUnderRoot` as a gate and add a parallel out-of-root gate** (two predicates to keep in sync across three controllers); **accept every scheme** (read-only `git:`/`output:` docs aren't editable — must still decline non-{file,untitled}) | + +### 6.8 Testing strategy + +- **Unit (vitest, vscode-free):** + - `GlobalSidecarStore` round-trip (save → load by `sha256(uri)` key, overwrite-in-place, + missing → null, `consumeSelfWrite` ⇒ `false`); in-memory untitled branch (save→load + in-session, absent after a simulated reload). + - `SidecarRouter`: `keyOf` returns the repo-relative path for an in-root file and the + URI string for an out-of-root file / untitled; `load/save/update` dispatch to the + correct impl per `isUnderRoot`; `root = undefined` ⇒ everything routes global. + - `isAuthorable` / `selectionRejection`: accepts in-root file, out-of-root file, and + untitled; still rejects no-editor, empty-selection, and a non-{file,untitled} scheme, + each with its own message (extends `test/workspacePath.test.ts`). +- **Host E2E (`@vscode/test-electron`, the F2–F6 seam pattern, no LLM):** + - **Out-of-workspace file:** open a saved file outside the workspace folder → + programmatic propose via the `cowriting.proposeAgentEdit` seam → accept → text + replaced + Claude-attributed + proposal removed (INV-9/11/13) → a thread opens → + `renderAll` after a simulated reload restores threads/attributions/proposals from the + **global** sidecar at the re-anchored span → assert the artifact file exists under + `/sidecars/.json` and **no** `.threads/` was written. + - **Untitled buffer:** same propose→accept→thread loop in an untitled doc; assert state + is present in-session and **no** disk artifact was written (in-memory only). + - **In-workspace regression (byte-for-byte, INV-2):** the existing F2–F4 E2E continue to + pass unchanged, and an assertion that the in-workspace artifact still lands at + `/.threads/.json` with the repo-relative `document.path` — proving the + hybrid routing left the committable path untouched. +- **Live smoke (manual, `docs/MANUAL-SMOKE-F8.md`):** open a file from a sibling repo → + select → "Ask Claude to Edit Selection" → review proposal → accept → open a thread → + reload → state restored; repeat in an untitled buffer (note the in-memory/reload caveat). + +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–F7 precedent). + +### 6.9 Failure modes, rollback & flags + +- **Global storage unavailable / write failure** → `GlobalSidecarStore` degrades to + in-memory + one warning (F6's exact path); authoring works for the session, reload- + survival lost. +- **Renamed/moved out-of-folder file** → orphaned artifact (old hash stranded, new file + empty); self-describing (the new doc simply has no threads) and recoverable by + re-authoring; a future re-key gesture is the additive fix (§9). +- **Untitled saved mid-session** → in-memory artifact dropped (no v1 migration); the saved + file starts fresh under its new `file:` home (repo sidecar if in-folder, global if not). + Documented; deferred migration (§9). +- **Crash between a seam landing and a global-sidecar write** → the stored global artifact + is one update behind (the same one-landing-stale window F6's baseline has); the next + edit/save reconciles. The `update` write is synchronous in the handler to keep the + window minimal (the `CoauthorStore` precedent). +- **No feature flag:** F8 only *widens what is accepted* and *adds an alternative storage + path*; in-workspace behavior is byte-for-byte unchanged (INV-2), so rollback is reverting + the PR with **zero data migration** — in-workspace sidecars are untouched and + global-storage artifacts are disposable by design. + +--- + +## 7. Delivery Plan + +### 7.1 Approach / strategy + +One planning-and-executing session (F8 = #25), plan written just-in-time from this spec — +the F2–F7 precedent. The work is a focused refactor (introduce one seam, route through it, +widen one gate) plus one new store and its tests; no contract or schema change. + +### 7.2 Slicing plan + +- **SLICE-1** `SidecarStore` interface + `GlobalSidecarStore` (vscode-free, mirroring + `BaselineStore`; in-memory untitled branch; no-op `consumeSelfWrite`) + `SidecarRouter` + (`keyOf` + per-document routing) + `isAuthorable`. Unit tests (round-trip, routing, + `keyOf`, predicate). +- **SLICE-2** Re-point the three controllers (`threadController`, + `attributionController`, `proposalController`) and `VersionGuard` onto `SidecarStore` + (the router); replace the `isInRoot`/`isTracked` gate with `isAuthorable`; key via + `store.keyOf`. Construct the router in `extension.ts` from `CoauthorStore` + + `GlobalSidecarStore` + root. +- **SLICE-3** Widen `selectionRejection` (drop the file-scheme/under-root rejections for + `!isAuthorable`); move the authoring commands to the **workspace-independent** + registration path (F6 `#19` precedent) so they are live folder-less. +- **SLICE-4** Host E2E per §6.8 (out-of-workspace file + untitled + in-workspace + byte-for-byte regression) + `docs/MANUAL-SMOKE-F8.md` + README note on the hybrid model + and F5 non-shareability. + +### 7.3 Rollout / launch plan + +Still non-shippable (no marketplace publish). "Done" = issue #25 acceptance met: +"Ask Claude to Edit Selection" (and threads/attribution) succeed on a saved out-of-folder +file and an untitled buffer; in-workspace files byte-for-byte unchanged (INV-2); +out-of-workspace/untitled artifacts persist in global storage keyed by URI hash +(in-memory for untitled); re-anchoring + reload restore for a global-storage doc; unit + +host E2E green; live smoke performed once on this machine. + +### 7.4 Risks & mitigations + +| Risk | Mitigation | +| --- | --- | +| Routing refactor regresses the in-workspace path (INV-2) | The E2E byte-for-byte regression (§6.8) asserts the `.threads/` path, filename, and `document.path` are unchanged; `CoauthorStore` itself is **not modified** (only its consumers re-point to the interface it already satisfies) | +| Operator surprised that untitled state is lost on reload/save | Documented in §6.7/§9 and `MANUAL-SMOKE-F8.md`; consistent with F6/F7 untitled behavior the operator already knows; migration is the named follow-up | +| Operator surprised an out-of-folder thread vanished after a rename | The orphaning caveat is stated (INV-24 commentary, §6.5); the new file shows an honest empty state; re-key gesture is the additive fix (§9) | +| Someone expects to cross-rung-share an out-of-folder artifact | INV-25 states non-shareability as contract; the README note makes it explicit; the in-repo path (the only shareable one) is unchanged | +| A read-only scheme (`git:`, `output:`) slips through as authorable | `isAuthorable` is an allowlist (scheme ∈ {file, untitled}); unit-tested to reject others | + +--- + +## 8. Traceability matrix + +| Requirement (issue #25 acceptance) | Use case | Design | Slice | +| --- | --- | --- | --- | +| Authoring succeeds on out-of-folder file + untitled buffer | PUC-1/2 | §6.2 (`isAuthorable` + router), §6.4 | SLICE-2/3 | +| In-workspace files byte-for-byte unchanged (INV-2) | PUC-3 | INV-24, §6.5, §6.8 regression | SLICE-2 | +| Out-of-workspace/untitled persist in global storage by URI hash | PUC-1/2 | INV-24, §6.3 (`GlobalSidecarStore`) | SLICE-1 | +| Re-anchoring + reload restore for a global-storage doc | PUC-4 | §6.5 (Anchorer unchanged) | SLICE-1/4 | +| One storage abstraction shared by the three controllers | — | §6.2 (`SidecarStore` + router) | SLICE-1/2 | +| Accurate scope in the `editSelection` guard | PUC-5 | §6.4 (`selectionRejection` widen) | SLICE-3 | +| F5 non-shareability of global artifacts stated | — | INV-25, §6.7 | SLICE-4 (docs) | +| URI-rename orphaning caveat called out | PUC-5 | §6.5/§6.7/§9 | SLICE-4 (docs) | +| Host E2E coverage, no LLM | — | §6.8 | SLICE-1/4 | +| Artifact shape / SCHEMA_VERSION / cross-rung format unchanged | — | INV-24/25, §6.3 | all | + +## 9. Open Questions & Decisions log + +- **RESOLVED (this session — the forks routed by #25):** + (a) **persistence model** = **hybrid** — in-workspace repo `.threads/` sidecar + (INV-2, byte-for-byte) · out-of-workspace `file:` + `untitled:` global-storage + sidecar keyed by `sha256(uri)` (INV-19/24); uniform global storage rejected + (loses committable threads — INV-2); + (b) **abstraction shape** = `SidecarStore` interface + `SidecarRouter` façade, + two impls (existing `CoauthorStore`, new `GlobalSidecarStore` mirroring + `BaselineStore`), selected by `#24`'s `isUnderRoot`; + (c) **document key** = repo-relative path in-workspace · URI string otherwise, + the one identity (`keyOf`) used as store key, `document.path`, and in-memory + state; + (d) **F5 cross-rung** = global artifacts are **not shareable**, by design + (INV-25) — not a committed file, never seen by `mergeArtifacts`; + (e) **URI-rename orphaning** = accepted & documented (inherent to URI-hash + keying; F6 baselines already have it); + (f) **untitled** = in-memory only (F6 degrade), no on-save migration in v1; + (g) **gate** = shared `isAuthorable` (scheme ∈ file/untitled); `isUnderRoot` + demoted to a routing input; authoring commands registered folder-independently. +- **OPEN → later (additive, on the same abstraction):** + - **untitled→saved artifact migration** — carry the in-memory artifact to the new + `file:` sidecar on save (needs old-URI→new-URI transfer; edge cases when the saved + location is in- vs out-of-folder). + - **re-key / recover gesture** — a command to re-bind an orphaned global artifact to a + renamed file's new URI hash (or, more generally, a "import sidecar from URI" pick). + - **auto-rename tracking** for out-of-folder files (no reliable VS Code signal today — + `onDidRenameFiles` is workspace-only). +- **Deferred decisions (autonomous-mode calls logged for the operator):** the + **no-migration-on-save** choice for untitled buffers and the **demotion of + `isUnderRoot` from gate to routing input** were decided without operator input; + both are cheap to revisit and are the least-churn, F6-consistent answers. Surface at + review if either should change before implementation. + +## 10. Glossary & References + +- **Hybrid persistence** — the F8 model: the same `Artifact` is written to a committable + repo `.threads/` sidecar for in-workspace docs and to a global-storage sidecar for + out-of-workspace/untitled docs (INV-24). **Document key** — the single per-document + identity (`SidecarRouter.keyOf`): repo-relative path in-workspace, the URI string + otherwise; serves as store key, `artifact.document.path`, and in-memory state key. + **`SidecarStore`** — the interface the three authoring controllers depend on; + implementations: `CoauthorStore` (repo) and `GlobalSidecarStore` (global). **Orphaning** + — the loss of binding between a global artifact and its file when the file's URI changes + (rename/move), because the key is `sha256(uri)`. **Single-rung** — an artifact never + shared across rungs (INV-25), because it is not a committed file. +- Feature #25 (F8) · Epic #1 (closed) · capture `vscode-cowriting-plugin-0023` · + F6 #17/#19 (PR #18/#20, the global-storage-by-URI-hash precedent) · `#24` (PR #24, + the `isUnderRoot` guard fix) · F5 #14 (PR #15) · F4 #12 (PR #13) · F3 #6 (PR #7) · + F2 #4 (PR #5) · parent specs `coauthoring-inner-loop.md`, `coauthoring-attribution.md`, + `coauthoring-propose-accept.md`, `coauthoring-cross-rung-format.md`, + `coauthoring-sidecar-contract.md`, `coauthoring-diff-view.md` · lineage + `ben.stull/rfc-app#48`.