diff --git a/src/model.ts b/src/model.ts index 5499382..fe519b4 100644 --- a/src/model.ts +++ b/src/model.ts @@ -116,6 +116,14 @@ export function emptyArtifact(docPath: string): Artifact { }; } +/** + * INV-16 fail-safe: schemaVersion IS the major. A sidecar from a future major + * is render-only — a conforming writer never rewrites what it can't fully read. + */ +export function isNewerMajor(a: Pick): boolean { + return a.schemaVersion > SCHEMA_VERSION; +} + /** Stable, generated id (spec §6.3). */ export function newId(prefix: string): string { return `${prefix}_${randomUUID()}`; diff --git a/src/store.ts b/src/store.ts index e8c467a..3d6c451 100644 --- a/src/store.ts +++ b/src/store.ts @@ -8,7 +8,7 @@ */ import * as fs from "node:fs"; import * as path from "node:path"; -import { emptyArtifact, serializeArtifact, type Artifact } from "./model"; +import { SCHEMA_VERSION, emptyArtifact, isNewerMajor, serializeArtifact, type Artifact } from "./model"; export class CoauthorStore { /** @@ -67,6 +67,13 @@ export class CoauthorStore { */ update(docPath: string, mutate: (artifact: Artifact) => void): Artifact { const artifact = this.load(docPath) ?? emptyArtifact(docPath); + if (isNewerMajor(artifact)) { + // INV-16 backstop: the controllers gate first (VersionGuard); this throw + // guarantees no missed write path can ever destroy a newer rung's data. + throw new Error( + `refusing to write ${docPath}: sidecar schemaVersion ${artifact.schemaVersion} > supported ${SCHEMA_VERSION} (INV-16)`, + ); + } mutate(artifact); const referenced = new Set([ ...artifact.threads.map((t) => t.anchorId), diff --git a/test/model.test.ts b/test/model.test.ts index 3815baa..7baf525 100644 --- a/test/model.test.ts +++ b/test/model.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest"; import { SCHEMA_VERSION, emptyArtifact, + isNewerMajor, serializeArtifact, type Artifact, } from "../src/model"; @@ -103,6 +104,13 @@ describe("cross-rung identity (F5 SLICE-2)", () => { }); }); +describe("isNewerMajor (F5 SLICE-2, INV-16)", () => { + it("is false for v1 and true for a newer major", () => { + expect(isNewerMajor(emptyArtifact("d.md"))).toBe(false); + expect(isNewerMajor({ schemaVersion: 2 })).toBe(true); + }); +}); + describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => { it("a rewrite preserves unknown fields at every level, after known keys, sorted", () => { const foreign = { diff --git a/test/store.test.ts b/test/store.test.ts index 9cc8448..3a43ab3 100644 --- a/test/store.test.ts +++ b/test/store.test.ts @@ -107,3 +107,20 @@ describe("CoauthorStore", () => { expect(store.consumeSelfWrite(p)).toBe(false); }); }); + +describe("newer-major write refusal (F5 SLICE-2, INV-16 backstop)", () => { + it("update REFUSES to write a sidecar from a newer major, leaving bytes untouched", () => { + const store = new CoauthorStore(root); + const p = store.sidecarPath("docs/v2.md"); + fs.mkdirSync(path.dirname(p), { recursive: true }); + const v2 = { ...emptyArtifact("docs/v2.md"), schemaVersion: 2 }; + fs.writeFileSync(p, serializeArtifact(v2), "utf8"); + const before = fs.readFileSync(p, "utf8"); + expect(() => + store.update("docs/v2.md", (a) => { + a.threads = []; + }), + ).toThrow(/INV-16/); + expect(fs.readFileSync(p, "utf8")).toBe(before); + }); +});