40 KiB
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 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).#24fixed 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#24guard fix; Epic #1 is closed. - The artifact shape is stable across storage homes:
Artifact(model.ts) andSCHEMA_VERSION = 1(model.ts:11) are untouched — only where the JSON is written differs. SomergeArtifactsand 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 undercontext.globalStorageUrikeyed bysha256(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-rootedCoauthorStore, 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:anduntitled:docs → a newGlobalSidecarStoremirroringBaselineStore: per-documentArtifactJSON 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 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:noruntitled:(e.g. anoutput:orgit: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
SidecarStoreabstraction, routed per-document by membership: an in-workspacefile:document persists to the committable repo.threads/<repo-relative-path>.jsonsidecar byte-for-byte unchanged (INV-2 preserved — no migration, no behavior change); an out-of-workspacefile:oruntitled:document persists to a global-storage sidecar keyed bysha256(documentUri)undercontext.globalStorageUri, never the repo (mirroring INV-19). TheArtifactshape andSCHEMA_VERSIONare 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, asartifact.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
mergeArtifactsor shared across rungs (F5); out-of-workspace/untitled authoring is session-/machine-local by construction. Itsdocument.pathis 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 existingCoauthorStorealready satisfies this shape (it becomes the in-workspace implementation, unchanged).GlobalSidecarStore(src/globalSidecarStore.ts, vscode-free) — the out-of-workspace/untitled implementation, mirroringBaselineStore: per-keyArtifactJSON at<storageDir>/sidecars/<key>.jsonwherekey = sha256(documentUri);consumeSelfWriteis a no-op returningfalse(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) — implementsSidecarStoreand 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 whenscheme === "file" && isUnderRoot(fsPath, root), else the URI string — and (b) dispatchesload/save/updatetoCoauthorStore(repo) orGlobalSidecarStore(global) by the same predicate. ExposeskeyOf(document) : stringso controllers and tests share the one identity. Vscode-free (takes the extracted identity primitives, not avscode.TextDocument), so it is unit-testable.isAuthorable(document)(insrc/workspacePath.ts) — scheme ∈ {file:,untitled:}. Replaces the three controllers' near-identicalisInRoot/isTracked(scheme === "file" && isUnderRoot(...)) gate.#24'sisUnderRootis retained — now consumed by the router for store selection, never as an authoring gate.- The three controllers (
threadController.ts,attributionController.ts,proposalController.ts) — constructorstoreparam re-typedCoauthorStore → SidecarStore(receives the router);rootDirno longer used for gating (membership →isAuthorable); per-document key viastore.keyOf(document)(replacing directasRelativePath). The seam (applyAgentEdit, INV-9) and all artifact logic are unchanged — they already operate onArtifactand adocPathstring. VersionGuard— re-pointed at theSidecarStoreinterface 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()), matchingBaselineStore.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;CoauthorStorealready conforms):load(key): Artifact | null·save(key, artifact): void·update(key, mutate): Artifact·consumeSelfWrite(fsPath): boolean.GlobalSidecarStore(vscode-free): sameSidecarStoresurface; constructed with the global storage dir (context.globalStorageUri?.fsPath, the F6 wiring atextension.ts:57); `sidecarPath(key) = /sidecars/.json`; `consumeSelfWrite` ⇒ `false`. Untitled keys resolve to an in-memory map, never disk.SidecarRouter(implementsSidecarStore): constructed withCoauthorStore,GlobalSidecarStore, and the workspace root (orundefinedwhen no folder is open ⇒ everything routes global).keyOf(docIdentity): string;load/save/update/consumeSelfWritedispatch byisUnderRoot.isAuthorable(document): boolean(workspacePath.ts) — scheme ∈ {file:,untitled:}. Used by the three controllers' membership predicate and byselectionRejection.selectionRejection(workspacePath.ts:42-56) — thescheme !== "file"and!isUnderRootbranches are replaced by a single!isAuthorablebranch (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;rootDirdropped from the gate (membership =isAuthorable), key viastore.keyOf. TheCowritingApitest 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#19precedent): live even with no folder open. The**/.threads/**/*.jsonwatcher (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):
editSelectionpassesisAuthorable; the router keys by the file URI and routes toGlobalSidecarStore; propose/accept and threads run exactly as in-workspace (the controllers are storage-agnostic above theSidecarStoreseam). The seam (INV-9) lands text and firesonDidApplyAgentEdit— F6's baseline advance still works (F6 already serves this doc). Reload:ensureStateloads 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
renderAllreloads 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 ⇒
GlobalSidecarStoredegrades to in-memory + one warning (F6's exact degrade) — authoring works for the session, reload-survival lost. A non-{file,untitled} scheme ⇒selectionRejectionwarns. 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):
GlobalSidecarStoreround-trip (save → load bysha256(uri)key, overwrite-in-place, missing → null,consumeSelfWrite⇒false); in-memory untitled branch (save→load in-session, absent after a simulated reload).SidecarRouter:keyOfreturns the repo-relative path for an in-root file and the URI string for an out-of-root file / untitled;load/save/updatedispatch to the correct impl perisUnderRoot;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 (extendstest/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.proposeAgentEditseam → accept → text replaced + Claude-attributed + proposal removed (INV-9/11/13) → a thread opens →renderAllafter 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>.jsonand 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
<root>/.threads/<repo-rel>.jsonwith the repo-relativedocument.path— proving the hybrid routing left the committable path untouched.
- Out-of-workspace file: open a saved file outside the workspace folder →
programmatic propose via the
- 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 →
GlobalSidecarStoredegrades 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
updatewrite is synchronous in the handler to keep the window minimal (theCoauthorStoreprecedent). - 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
SidecarStoreinterface +GlobalSidecarStore(vscode-free, mirroringBaselineStore; in-memory untitled branch; no-opconsumeSelfWrite) +SidecarRouter(keyOf+ per-document routing) +isAuthorable. Unit tests (round-trip, routing,keyOf, predicate). - SLICE-2 Re-point the three controllers (
threadController,attributionController,proposalController) andVersionGuardontoSidecarStore(the router); replace theisInRoot/isTrackedgate withisAuthorable; key viastore.keyOf. Construct the router inextension.tsfromCoauthorStore+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#19precedent) 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-workspacefile:+untitled:global-storage sidecar keyed bysha256(uri)(INV-19/24); uniform global storage rejected (loses committable threads — INV-2); (b) abstraction shape =SidecarStoreinterface +SidecarRouterfaçade, two impls (existingCoauthorStore, newGlobalSidecarStoremirroringBaselineStore), selected by#24'sisUnderRoot; (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 bymergeArtifacts; (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 = sharedisAuthorable(scheme ∈ file/untitled);isUnderRootdemoted 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 —
onDidRenameFilesis workspace-only).
- untitled→saved artifact migration — carry the in-memory artifact to the new
- Deferred decisions (autonomous-mode calls logged for the operator): the
no-migration-on-save choice for untitled buffers and the demotion of
isUnderRootfrom 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
Artifactis 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) andGlobalSidecarStore(global). Orphaning — the loss of binding between a global artifact and its file when the file's URI changes (rename/move), because the key issha256(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, theisUnderRootguard fix) · F5 #14 (PR #15) · F4 #12 (PR #13) · F3 #6 (PR #7) · F2 #4 (PR #5) · parent specscoauthoring-inner-loop.md,coauthoring-attribution.md,coauthoring-propose-accept.md,coauthoring-cross-rung-format.md,coauthoring-sidecar-contract.md,coauthoring-diff-view.md· lineageben.stull/rfc-app#48.