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

40 KiB
Raw Permalink Blame History

status
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>.jsonbyte-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 keyasRelativePath-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.
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:

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) = /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: CoauthorStorestore: 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, consumeSelfWritefalse); 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 failureGlobalSidecarStore 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.