add spec ./specs/coauthoring-out-of-workspace.md (status: graduated)

This commit is contained in:
Ben Stull
2026-06-11 16:11:45 -07:00
parent 02c2e57fdb
commit 3875a60d71
+611
View File
@@ -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/<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`.