feat: VersionGuard — newer-major sidecars are read-only with one warning (F5 SLICE-2, INV-16/PUC-4, #14)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 23:01:30 -07:00
parent ef4ec8dbe9
commit ef7d9403c0
5 changed files with 66 additions and 6 deletions
+7 -1
View File
@@ -16,6 +16,7 @@ import { buildFingerprint, resolve, type OffsetRange } from "./anchorer";
import { gitUserEmail } from "./identity";
import { applyChange, coalesce, type LiveSpan } from "./attributionTracker";
import { minimizeReplace, PendingEditRegistry } from "./pendingEdits";
import type { VersionGuard } from "./versionGuard";
/** Test-facing snapshot of live attribution state for a document. */
export interface RenderedSpan {
@@ -62,7 +63,11 @@ export class AttributionController implements vscode.Disposable {
private readonly output = vscode.window.createOutputChannel("Cowriting Attribution");
private visible = true;
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
constructor(
private readonly store: CoauthorStore,
private readonly rootDir: string,
private readonly guard: VersionGuard,
) {
this.disposables.push(this.agentType, this.humanType, this.statusItem, this.output);
this.disposables.push(
vscode.commands.registerCommand("cowriting.toggleAttribution", () => this.toggle()),
@@ -277,6 +282,7 @@ export class AttributionController implements vscode.Disposable {
private onDidSave(document: vscode.TextDocument): void {
if (!this.isTracked(document)) return;
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return;
const docPath = this.docPathOf(document.uri);
const s = this.docs.get(docPath);
// Allow save when hadAttributions is true even if spans/orphans are now empty:
+9 -4
View File
@@ -5,6 +5,7 @@ import { ThreadController } from "./threadController";
import { AttributionController } from "./attributionController";
import { ProposalController } from "./proposalController";
import { buildFingerprint } from "./anchorer";
import { VersionGuard } from "./versionGuard";
const CHANNEL_NAME = "Cowriting (Cline SDK)";
@@ -12,6 +13,7 @@ export interface CowritingApi {
threadController: ThreadController;
attributionController: AttributionController;
proposalController: ProposalController;
versionGuard: VersionGuard;
}
export function activate(context: vscode.ExtensionContext): CowritingApi | undefined {
@@ -67,15 +69,18 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
return undefined;
}
const store = new CoauthorStore(root);
const threadController = new ThreadController(store, root);
// F5 (INV-16): one shared guard — newer-major sidecars are read-only, one
// warning per doc across the three co-owning controllers.
const versionGuard = new VersionGuard(store);
const threadController = new ThreadController(store, root, versionGuard);
context.subscriptions.push(threadController);
// --- F3: live attribution (Feature #6) ---
const attributionController = new AttributionController(store, root);
const attributionController = new AttributionController(store, root, versionGuard);
context.subscriptions.push(attributionController);
// --- F4: propose/accept (Feature #12) ---
const proposalController = new ProposalController(store, attributionController, root);
const proposalController = new ProposalController(store, attributionController, root, versionGuard);
context.subscriptions.push(proposalController);
// One SHARED sidecar watcher for both controllers; self-writes are
@@ -228,7 +233,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef
vscode.workspace.textDocuments.forEach(renderIfOpen);
context.subscriptions.push(vscode.workspace.onDidOpenTextDocument(renderIfOpen));
return { threadController, attributionController, proposalController };
return { threadController, attributionController, proposalController, versionGuard };
}
export function deactivate(): void {
+5
View File
@@ -15,6 +15,7 @@ import { emptyArtifact, type Artifact, type Fingerprint, type Proposal, type Pro
import { resolve, shift, type OffsetRange } from "./anchorer";
import { addProposal, proposalBody, removeProposal } from "./proposalModel";
import type { AttributionController } from "./attributionController";
import type { VersionGuard } from "./versionGuard";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedProposal {
@@ -53,6 +54,7 @@ export class ProposalController implements vscode.Disposable {
private readonly store: CoauthorStore,
private readonly attribution: AttributionController,
private readonly rootDir: string,
private readonly guard: VersionGuard,
) {
// No commentingRangeProvider: humans never open proposal threads by hand —
// proposals are born of machine turns only (INV-12 keeps decisions human).
@@ -103,6 +105,7 @@ export class ProposalController implements vscode.Disposable {
opts?: { turnId?: string; instruction?: string },
): Promise<string | undefined> {
if (!this.isTracked(document)) return undefined;
if (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined;
const docPath = this.docPathOf(document.uri);
let proposalId: string | undefined;
this.store.update(docPath, (a) => {
@@ -137,6 +140,7 @@ export class ProposalController implements vscode.Disposable {
}
private async accept(state: DocState, proposal: Proposal): Promise<boolean> {
if (this.guard.isReadOnly(state.docPath)) return false;
const document = this.openDoc(state);
if (!document) return false;
// INV-11: the fingerprint-guard — exact re-resolve at decision time.
@@ -167,6 +171,7 @@ export class ProposalController implements vscode.Disposable {
}
private reject(state: DocState, proposal: Proposal): void {
if (this.guard.isReadOnly(state.docPath)) return;
this.store.update(state.docPath, (a) => removeProposal(a, proposal.id));
const document = this.openDoc(state);
if (document) this.renderAll(document);
+10 -1
View File
@@ -13,6 +13,7 @@ import { emptyArtifact, type Artifact, type Provenance } from "./model";
import { buildFingerprint, resolve, shift, type OffsetRange } from "./anchorer";
import { gitUserEmail } from "./identity";
import { addThread, appendMessage, setStatus } from "./threadModel";
import type { VersionGuard } from "./versionGuard";
/** Test-facing snapshot of what is currently rendered for a document. */
export interface RenderedThread {
@@ -39,7 +40,11 @@ export class ThreadController implements vscode.Disposable {
private readonly disposables: vscode.Disposable[] = [];
private readonly docs = new Map<string, DocState>(); // keyed by docPath
constructor(private readonly store: CoauthorStore, private readonly rootDir: string) {
constructor(
private readonly store: CoauthorStore,
private readonly rootDir: string,
private readonly guard: VersionGuard,
) {
this.controller = vscode.comments.createCommentController("cowriting.threads", "Coauthoring Threads");
this.controller.commentingRangeProvider = {
provideCommentingRanges: (document) => {
@@ -108,6 +113,7 @@ export class ThreadController implements vscode.Disposable {
async createThreadOnSelection(firstBody = "New thread"): Promise<string | undefined> {
const editor = vscode.window.activeTextEditor;
if (!editor || editor.selection.isEmpty || !this.isInRoot(editor.document.uri)) return undefined;
if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined;
const document = editor.document;
const state = this.ensureState(document);
const offsets: OffsetRange = {
@@ -127,6 +133,7 @@ export class ThreadController implements vscode.Disposable {
const threadId = this.threadIdOf(r.thread);
const state = this.stateOfThread(r.thread);
if (!threadId || !state) return;
if (this.guard.isReadOnly(state.docPath)) return;
appendMessage(state.artifact, threadId, { author: this.currentAuthor(), body: r.text });
this.persist(state);
this.refreshComments(r.thread, state, threadId);
@@ -136,6 +143,7 @@ export class ThreadController implements vscode.Disposable {
const threadId = this.threadIdOf(vsThread);
const state = this.stateOfThread(vsThread);
if (!threadId || !state) return;
if (this.guard.isReadOnly(state.docPath)) return;
setStatus(state.artifact, threadId, status);
this.persist(state);
vsThread.state = status === "resolved" ? vscode.CommentThreadState.Resolved : vscode.CommentThreadState.Unresolved;
@@ -198,6 +206,7 @@ export class ThreadController implements vscode.Disposable {
private onDidSave(document: vscode.TextDocument): void {
const state = this.docs.get(this.docPathOf(document.uri));
if (!state || state.live.size === 0) return;
if (this.guard.isReadOnly(state.docPath)) return;
const text = document.getText();
let changed = false;
for (const thread of state.artifact.threads) {
+35
View File
@@ -0,0 +1,35 @@
/**
* VersionGuard INV-16's editor face (spec §6.2/§6.4, PUC-4): a sidecar whose
* schemaVersion major is newer than this extension understands is READ-ONLY.
* Controllers render best-effort but skip every store.update path; the user is
* warned ONCE per document. One shared instance (extension.ts) because the
* sidecar is co-owned by three controllers one warning, not three. The
* store's update() throw is the backstop; this gate is the UX.
*/
import * as vscode from "vscode";
import { SCHEMA_VERSION, isNewerMajor } from "./model";
import type { CoauthorStore } from "./store";
export class VersionGuard {
private readonly warned = new Set<string>();
constructor(private readonly store: CoauthorStore) {}
/** True → skip the write path (and warn once per doc). */
isReadOnly(docPath: string): boolean {
const artifact = this.store.load(docPath);
if (!artifact || !isNewerMajor(artifact)) return false;
if (!this.warned.has(docPath)) {
this.warned.add(docPath);
void vscode.window.showWarningMessage(
`Cowriting: ${docPath}'s coauthoring sidecar is schemaVersion ${artifact.schemaVersion} — newer than this extension understands (${SCHEMA_VERSION}). Showing what it can; writing nothing (INV-16). Upgrade the extension to edit the record.`,
);
}
return true;
}
/** Test-facing: did the newer-major warning fire for this doc? */
wasWarned(docPath: string): boolean {
return this.warned.has(docPath);
}
}