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