Files
vscode-cowriting-plugin-con…/specs/coauthoring-out-of-workspace.md
T

612 lines
40 KiB
Markdown
Raw 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: 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/<repo-relative-path>.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 F2F4 (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/<path>.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
`<context.globalStorageUri>/sidecars/<sha256(documentUri)>.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 F2F7); 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 F2F4 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/<repo-relative-path>.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 `<storageDir>/sidecars/<key>.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/<repo-rel>.json\n(committable, INV-2)\n→ F5 cross-rung"]
isroot -- "no (out-of-folder / untitled)" --> glob["GlobalSidecarStore\n<globalStorageUri>/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<string, Anchor>; // 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`) | `<root>/.threads/<path>.json` | **yes** (F5, INV-2) |
| Out-of-workspace `file:` | the URI string `file:///abs/path` | `<globalStorageUri>/sidecars/<sha256(uri)>.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) =
<dir>/sidecars/<key>.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 PerProduct-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 F2F6 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
`<globalStorageUri>/sidecars/<hash>.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 F2F4 E2E continue to
pass unchanged, and an assertion that the in-workspace artifact still lands at
`<root>/.threads/<repo-rel>.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 F2F7 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 F2F7 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`.