Files
vscode-cowriting-plugin-con…/plans/2026-06-10-f5-cross-rung-format.md

65 KiB
Raw Permalink Blame History

F5 Cross-Rung Format + Round-Trip (Feature #14) Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Graduate the .threads/ sidecar into the published cross-rung contract: contract doc + JSON Schema + validator, identity extension (email, agent.onBehalfOf), unknown-field preservation (INV-15), newer-major fail-safe (INV-16), deterministic mergeArtifacts (INV-17), and a proven out-of-editor round-trip via a Gitea-rung stand-in writer.

Architecture: All changes are additive (schemaVersion stays 1). Two artifacts (contract prose in the content repo; JSON Schema + validator in the plugin repo), two vscode-free modules (mergeArtifacts, identity), serializer-level unknown-field preservation in model.ts, a shared VersionGuard gating every store.update path across the three controllers, and a self-contained stand-in writer script exercised by unit + host E2E tests. Spec: vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md.

Tech Stack: TypeScript (strict), vitest (unit), @vscode/test-electron + mocha (host E2E), ajv (JSON Schema 2020-12, devDependency only), esbuild bundle untouched (no runtime deps added).


Task 1: Branch + ajv devDependency

Files:

  • Modify: package.json (devDependencies)

  • Step 1: Create the feature branch

git checkout -b feature/14-cross-rung-format
  • Step 2: Install ajv as a devDependency
npm install --save-dev ajv

Expected: ajv ^8.x in devDependencies; bundle externals unchanged (ajv is dev/scripts-only, spec §6.6).

  • Step 3: Commit
git add package.json package-lock.json
git commit -m "chore: add ajv devDependency for sidecar schema validation (F5 SLICE-1, #14)"

Task 2: JSON Schema + validator script + schema unit suite (SLICE-1, PUC-5)

Files:

  • Create: schemas/coauthoring-sidecar.schema.json

  • Create: scripts/validate-sidecar.mjs

  • Create: test/helpers/validateSidecar.ts

  • Create: test/schema.test.ts

  • Step 1: Write the failing schema test

test/schema.test.ts:

import { describe, it, expect } from "vitest";
import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model";
import { expectValidSidecar, validateSidecar } from "./helpers/validateSidecar";

function fullArtifact(): Artifact {
  const a = emptyArtifact("docs/x.md");
  a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: " beta", lineHint: 0 } };
  a.threads.push({
    id: "t_1", anchorId: "a_1", status: "open",
    messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
  });
  a.attributions.push({
    id: "at_1", anchorId: "a_1",
    author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
    createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", turnId: "turn-1",
  });
  a.proposals.push({
    id: "p_1", anchorId: "a_1", replacement: "ALPHA",
    author: { kind: "agent", id: "claude", agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1" } },
    createdAt: "2026-06-10T00:00:00.000Z", instruction: "shout",
  });
  return a;
}

describe("coauthoring-sidecar JSON Schema (PUC-5)", () => {
  it("validates the empty artifact and a full artifact", () => {
    expectValidSidecar(emptyArtifact("docs/x.md"));
    expectValidSidecar(fullArtifact());
  });

  it("rejects a structurally broken sidecar with error paths", () => {
    const broken = JSON.parse(serializeArtifact(fullArtifact()));
    broken.threads[0].status = "closed"; // not in the enum
    delete broken.schemaVersion;
    const { valid, errors } = validateSidecar(broken);
    expect(valid).toBe(false);
    expect(errors.some((e) => e.includes("schemaVersion"))).toBe(true);
  });

  it("accepts unknown fields everywhere (forward compat, INV-16)", () => {
    const data = JSON.parse(serializeArtifact(fullArtifact()));
    data.futureSection = [{ id: "f_1" }];
    data.threads[0].futureField = true;
    data.threads[0].messages[0].reactions = ["+1"];
    const { valid } = validateSidecar(data);
    expect(valid).toBe(true);
  });
});

test/helpers/validateSidecar.ts:

/**
 * Schema-conformance helper (F5 SLICE-1, PUC-5): every serialized artifact in
 * the unit suite must validate against the published cross-rung schema —
 * contract drift fails CI (spec §7.4).
 */
import { readFileSync } from "node:fs";
import Ajv from "ajv/dist/2020";
import { serializeArtifact, type Artifact } from "../../src/model";

const schemaUrl = new URL("../../schemas/coauthoring-sidecar.schema.json", import.meta.url);
const schema = JSON.parse(readFileSync(schemaUrl, "utf8"));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);

export function validateSidecar(data: unknown): { valid: boolean; errors: string[] } {
  const valid = validate(data) as boolean;
  const errors = (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message}`);
  return { valid, errors };
}

export function expectValidSidecar(artifact: Artifact): void {
  const { valid, errors } = validateSidecar(JSON.parse(serializeArtifact(artifact)));
  if (!valid) throw new Error(`sidecar failed schema validation:\n${errors.join("\n")}`);
}
  • Step 2: Run to verify failure

Run: npx vitest run test/schema.test.ts Expected: FAIL (schema file missing).

  • Step 3: Write the schema

schemas/coauthoring-sidecar.schema.json — JSON Schema 2020-12 for the v1 artifact. Known fields validated strictly; unknown fields allowed everywhere (no additionalProperties: false — INV-15/16):

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://git.benstull.org/benstull/vscode-cowriting-plugin/schemas/coauthoring-sidecar.schema.json",
  "title": "Coauthoring sidecar artifact — cross-rung contract, schemaVersion 1",
  "description": "Machine-checkable half of specs/coauthoring-sidecar-contract.md (the prose contract is normative, INV-14). Unknown fields are deliberately allowed at every level: minors are additive and conforming writers preserve fields they do not recognize (INV-15/INV-16).",
  "type": "object",
  "required": ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"],
  "properties": {
    "schemaVersion": { "type": "integer", "minimum": 1 },
    "document": {
      "type": "object",
      "required": ["path"],
      "properties": { "path": { "type": "string", "minLength": 1 } }
    },
    "anchors": {
      "type": "object",
      "additionalProperties": { "$ref": "#/$defs/anchor" }
    },
    "threads": { "type": "array", "items": { "$ref": "#/$defs/thread" } },
    "attributions": { "type": "array", "items": { "$ref": "#/$defs/attribution" } },
    "proposals": { "type": "array", "items": { "$ref": "#/$defs/proposal" } }
  },
  "$defs": {
    "fingerprint": {
      "type": "object",
      "required": ["text", "before", "after", "lineHint"],
      "properties": {
        "text": { "type": "string" },
        "before": { "type": "string" },
        "after": { "type": "string" },
        "lineHint": { "type": "integer", "minimum": 0 }
      }
    },
    "anchor": {
      "type": "object",
      "required": ["fingerprint"],
      "properties": { "fingerprint": { "$ref": "#/$defs/fingerprint" } }
    },
    "onBehalfOf": {
      "type": "object",
      "required": ["id"],
      "properties": { "id": { "type": "string" }, "email": { "type": "string" } }
    },
    "agentPayload": {
      "type": "object",
      "required": ["sdk", "model", "sessionId"],
      "properties": {
        "sdk": { "type": "string" },
        "model": { "type": "string" },
        "sessionId": { "type": "string" },
        "onBehalfOf": { "$ref": "#/$defs/onBehalfOf" }
      }
    },
    "provenance": {
      "type": "object",
      "required": ["kind", "id"],
      "properties": {
        "kind": { "enum": ["human", "agent"] },
        "id": { "type": "string" },
        "email": { "type": "string" },
        "agent": { "$ref": "#/$defs/agentPayload" }
      },
      "if": { "properties": { "kind": { "const": "agent" } } },
      "then": { "required": ["kind", "id", "agent"] }
    },
    "message": {
      "type": "object",
      "required": ["id", "author", "body", "createdAt"],
      "properties": {
        "id": { "type": "string" },
        "author": { "$ref": "#/$defs/provenance" },
        "body": { "type": "string" },
        "createdAt": { "type": "string" }
      }
    },
    "thread": {
      "type": "object",
      "required": ["id", "anchorId", "status", "messages"],
      "properties": {
        "id": { "type": "string" },
        "anchorId": { "type": "string" },
        "status": { "enum": ["open", "resolved"] },
        "messages": { "type": "array", "items": { "$ref": "#/$defs/message" } }
      }
    },
    "attribution": {
      "type": "object",
      "required": ["id", "anchorId", "author", "createdAt", "updatedAt"],
      "properties": {
        "id": { "type": "string" },
        "anchorId": { "type": "string" },
        "author": { "$ref": "#/$defs/provenance" },
        "createdAt": { "type": "string" },
        "updatedAt": { "type": "string" },
        "turnId": { "type": "string" }
      }
    },
    "proposal": {
      "type": "object",
      "required": ["id", "anchorId", "replacement", "author", "createdAt"],
      "properties": {
        "id": { "type": "string" },
        "anchorId": { "type": "string" },
        "replacement": { "type": "string" },
        "author": { "$ref": "#/$defs/provenance" },
        "createdAt": { "type": "string" },
        "turnId": { "type": "string" },
        "instruction": { "type": "string" }
      }
    }
  }
}
  • Step 4: Run to verify pass

Run: npx vitest run test/schema.test.ts Expected: PASS (3 tests).

  • Step 5: Write the standalone validator script

scripts/validate-sidecar.mjs:

#!/usr/bin/env node
// validate-sidecar — rung-neutral conformance check (spec §6.4, PUC-5).
// usage:
//   node scripts/validate-sidecar.mjs <sidecar.json>
// exit 0 = valid, 1 = invalid (per-error JSON paths on stderr), 2 = usage/IO.
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import Ajv from "ajv/dist/2020.js";

const here = path.dirname(fileURLToPath(import.meta.url));
const schema = JSON.parse(readFileSync(path.join(here, "..", "schemas", "coauthoring-sidecar.schema.json"), "utf8"));

const file = process.argv[2];
if (!file) {
  console.error("usage: validate-sidecar.mjs <sidecar.json>");
  process.exit(2);
}

let data;
try {
  data = JSON.parse(readFileSync(file, "utf8"));
} catch (err) {
  console.error(`cannot read/parse ${file}: ${err.message}`);
  process.exit(2);
}

const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
if (validate(data)) {
  console.log(`OK ${file} (schemaVersion ${data.schemaVersion})`);
  process.exit(0);
}
for (const e of validate.errors ?? []) console.error(`${e.instancePath || "/"} ${e.message}`);
process.exit(1);
  • Step 6: Scripted check of the validator (good + broken)
node -e 'const {emptyArtifact,serializeArtifact}=await import("./src/model.ts").catch(()=>({}));' 2>/dev/null || true

Simpler — use a committed test run via temp files:

printf '{"schemaVersion":1,"document":{"path":"d.md"},"anchors":{},"threads":[],"attributions":[],"proposals":[]}\n' > /tmp/good-sidecar.json
node scripts/validate-sidecar.mjs /tmp/good-sidecar.json
printf '{"document":{"path":"d.md"}}\n' > /tmp/bad-sidecar.json
node scripts/validate-sidecar.mjs /tmp/bad-sidecar.json; echo "exit=$?"

Expected: first prints OK … (schemaVersion 1); second prints errors and exit=1.

  • Step 7: Commit
git add schemas/ scripts/validate-sidecar.mjs test/helpers/validateSidecar.ts test/schema.test.ts
git commit -m "feat: publish coauthoring-sidecar JSON Schema + rung-neutral validator (F5 SLICE-1, PUC-5, #14)"

Task 3: The contract document (SLICE-1, INV-14) — content repo

Files:

  • Create: /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md

  • Step 1: Write the contract — normative prose, status-stamped (status: living front-matter like the specs), versioned v1.0 with a changelog. Required sections (content drawn from spec §6.3/§6.4 + the shipped F2/F3/F4 §6.3 field tables, which it supersedes as format reference):

  1. Scope & status — what the sidecar is (.threads/<repo-relative-doc-path>.json, per-document, git-carried); this doc is canonical (INV-14): format changes land here first, then schema, then code; the JSON Schema (vscode-cowriting-plugin/schemas/coauthoring-sidecar.schema.json) is the machine-checkable half.
  2. Serialization rules (INV-2) — pretty JSON, 2-space indent, trailing newline; root key order schemaVersion, document, anchors, threads, attributions, proposals; anchors sorted by key; per-record known-key order as the reference serializer emits; unknown keys after known keys, sorted lexicographically.
  3. Field tables — one table per section: root, document, anchors/Anchor/Fingerprint (INV-3 semantics: text is truth, lineHint a tie-breaker), Provenance (incl. new email, agent.onBehalfOf {id, email?} — git's identity model, email is the cross-rung join key; PII note per spec §6.6), Thread/Message, AttributionRecord (state-not-history), Proposal (pending-only, INV-13).
  4. Compatibility (INV-15/INV-16) — within a major: additive-only; readers ignore unknown optionals; writers preserve unknown fields at every object level; newer major ⇒ fail-safe read-only (render what you understand, write nothing). Breaking changes bump the major and are recorded in the changelog.
  5. Merge rules (INV-17) — 2-way merge(ours, theirs): refuse differing majors; anchors union by key; threads/attributions/proposals union by id; same-id thread: messages union by id ordered by (createdAt, id), shell (non-messages fields) divergence resolved by lexicographically-larger stable-stringify; attribution divergence: newer updatedAt wins, ties by stable-stringify; message/proposal/anchor divergence: lexicographically-larger stable-stringify; root unknown-key value divergence: lexicographically-larger; every rule-resolved divergence is surfaced to the caller (never silent). Stable-stringify = JSON with all object keys sorted, no whitespace. Reference implementation: vscode-cowriting-plugin/src/mergeArtifacts.ts.
  6. Conforming-writer checklist — validate before and after; preserve unknowns; never write a newer major; fresh uuids for new records; ISO-8601 UTC timestamps. Stand-in example: scripts/crossrung-reply.mjs.
  7. Changelogv1.0 (2026-06-10) initial publication, ratifying the shipped F2F4 format + the F5 additive identity fields.
  • Step 2: Commit + push (content repo, direct to main — established content-repo pattern)
git -C /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content add specs/coauthoring-sidecar-contract.md
git -C /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content commit -m "add specs/coauthoring-sidecar-contract.md (cross-rung contract v1.0, F5 #14)"
git -C /Users/benstull/git/benstull.org/benstull/vscode-cowriting-plugin-content push

Task 4: Provenance identity extension (SLICE-2, fork c)

Files:

  • Modify: src/model.ts (Provenance type + serializeProvenance)

  • Test: test/model.test.ts

  • Step 1: Write the failing tests (append to test/model.test.ts):

describe("cross-rung identity (F5 SLICE-2)", () => {
  it("round-trips email on human and agent provenance, omitted when absent", () => {
    const a = emptyArtifact("docs/x.md");
    a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } };
    a.threads.push({
      id: "t_1", anchorId: "a_1", status: "open",
      messages: [
        { id: "m_1", author: { kind: "human", id: "ben", email: "ben@wiggleverse.org" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" },
        { id: "m_2", author: { kind: "human", id: "anon" }, body: "yo", createdAt: "2026-06-10T00:00:01.000Z" },
      ],
    });
    const out = serializeArtifact(a);
    const parsed = JSON.parse(out) as Artifact;
    expect(parsed.threads[0].messages[0].author).toEqual({ kind: "human", id: "ben", email: "ben@wiggleverse.org" });
    expect("email" in parsed.threads[0].messages[1].author).toBe(false);
    expect(serializeArtifact(parsed)).toBe(out);
  });

  it("round-trips agent.onBehalfOf and omits it when absent", () => {
    const a = emptyArtifact("docs/x.md");
    a.attributions.push({
      id: "at_1", anchorId: "a_1",
      author: {
        kind: "agent", id: "claude",
        agent: { sdk: "@cline/sdk", model: "sonnet", sessionId: "run-1", onBehalfOf: { id: "benstull", email: "ben@wiggleverse.org" } },
      },
      createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
    });
    const out = serializeArtifact(a);
    const parsed = JSON.parse(out) as Artifact;
    const author = parsed.attributions[0].author;
    expect(author.kind).toBe("agent");
    if (author.kind === "agent") {
      expect(author.agent.onBehalfOf).toEqual({ id: "benstull", email: "ben@wiggleverse.org" });
    }
    expect(out.includes("onBehalfOf")).toBe(true);
    const a2 = emptyArtifact("docs/x.md");
    expect(serializeArtifact(a2).includes("onBehalfOf")).toBe(false);
  });
});
  • Step 2: Run to verify failurenpx vitest run test/model.test.ts → FAIL (type errors: email not on Provenance).

  • Step 3: Implement. In src/model.ts replace the Provenance type and serializeProvenance:

/**
 * The reusable author field (contract §Provenance). `agent` only present when
 * kind=agent. F5 (spec §6.3, fork c): `email` is the cross-rung join key —
 * git's own identity model (forges map email → account); `onBehalfOf` records
 * the operator a machine acted for (rfc-app#46 §6.5 made data).
 */
export type Provenance =
  | { kind: "human"; id: string; email?: string }
  | {
      kind: "agent";
      id: string;
      email?: string;
      agent: {
        sdk: string;
        model: string;
        sessionId: string;
        onBehalfOf?: { id: string; email?: string };
      };
    };
function serializeProvenance(p: Provenance): Record<string, unknown> {
  const known: Record<string, unknown> = {
    kind: p.kind,
    id: p.id,
    ...(p.email !== undefined ? { email: p.email } : {}),
  };
  if (p.kind === "agent") {
    known.agent = {
      sdk: p.agent.sdk,
      model: p.agent.model,
      sessionId: p.agent.sessionId,
      ...(p.agent.onBehalfOf !== undefined
        ? {
            onBehalfOf: {
              id: p.agent.onBehalfOf.id,
              ...(p.agent.onBehalfOf.email !== undefined ? { email: p.agent.onBehalfOf.email } : {}),
            },
          }
        : {}),
    };
  }
  return known;
}

(Unknown-field preservation for provenance comes in Task 5 — keep this step minimal.)

  • Step 4: Run to verify passnpx vitest run test/model.test.ts && npm run typecheck → PASS.

  • Step 5: Commit

git add src/model.ts test/model.test.ts
git commit -m "feat: Provenance gains email + agent.onBehalfOf — cross-rung identity (F5 SLICE-2, #14)"

Task 5: Unknown-field preservation in the serializer (SLICE-2, INV-15)

Files:

  • Modify: src/model.ts (serializeArtifact + helpers)

  • Test: test/model.test.ts

  • Step 1: Write the failing tests (append to test/model.test.ts):

describe("unknown-field preservation (F5 SLICE-2, INV-15)", () => {
  it("a rewrite preserves unknown fields at every level, after known keys, sorted", () => {
    const foreign = {
      schemaVersion: 1,
      document: { path: "docs/x.md", zFuture: "keep-me" },
      anchors: {
        a_1: { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0, confidence: 0.9 }, label: "intro" },
      },
      threads: [
        {
          id: "t_1", anchorId: "a_1", status: "open",
          messages: [
            { id: "m_1", author: { kind: "human", id: "ben", forgeLogin: "bstull" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z", reactions: ["+1"] },
          ],
          locked: false,
        },
      ],
      attributions: [
        { id: "at_1", anchorId: "a_1", author: { kind: "human", id: "ben" }, createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z", reviewState: "approved" },
      ],
      proposals: [
        { id: "p_1", anchorId: "a_1", replacement: "ALPHA", author: { kind: "human", id: "ben" }, createdAt: "2026-06-10T00:00:00.000Z", priority: 2 },
      ],
      futureSection: [{ id: "f_1" }],
      aaaEarly: true,
    };
    const out = serializeArtifact(foreign as unknown as Artifact);
    const parsed = JSON.parse(out);
    expect(parsed.futureSection).toEqual([{ id: "f_1" }]);
    expect(parsed.aaaEarly).toBe(true);
    expect(parsed.document.zFuture).toBe("keep-me");
    expect(parsed.anchors.a_1.label).toBe("intro");
    expect(parsed.anchors.a_1.fingerprint.confidence).toBe(0.9);
    expect(parsed.threads[0].locked).toBe(false);
    expect(parsed.threads[0].messages[0].reactions).toEqual(["+1"]);
    expect(parsed.threads[0].messages[0].author.forgeLogin).toBe("bstull");
    expect(parsed.attributions[0].reviewState).toBe("approved");
    expect(parsed.proposals[0].priority).toBe(2);
    // unknown ROOT keys serialize AFTER known keys, sorted: aaaEarly then futureSection, both after proposals
    expect(out.indexOf('"proposals"')).toBeLessThan(out.indexOf('"aaaEarly"'));
    expect(out.indexOf('"aaaEarly"')).toBeLessThan(out.indexOf('"futureSection"'));
    // byte-stable on re-serialize (INV-2 holds with unknowns present)
    expect(serializeArtifact(parsed as Artifact)).toBe(out);
  });
});
  • Step 2: Run to verify failurenpx vitest run test/model.test.ts → FAIL (unknowns dropped today).

  • Step 3: Implement. In src/model.ts add a helper and thread it through every object the serializer rebuilds (root, document, anchor, fingerprint, thread, message, attribution, proposal, provenance, agent payload, onBehalfOf):

/**
 * INV-15 (round-trip preservation): after rebuilding the canonical known keys,
 * append any fields of `src` this writer does not recognize, sorted
 * lexicographically — no rung's writer may destroy another rung's data, and
 * placement stays deterministic (INV-2).
 */
function withUnknowns(known: Record<string, unknown>, src: unknown): Record<string, unknown> {
  const rec = src as Record<string, unknown>;
  for (const k of Object.keys(rec).filter((k) => !(k in known)).sort()) {
    known[k] = rec[k];
  }
  return known;
}

Then rewrite serializeProvenance to wrap each constructed object: withUnknowns(known, p), agent payload withUnknowns(agentKnown, p.agent), onBehalfOf withUnknowns(oboKnown, p.agent.onBehalfOf). And rewrite serializeArtifact so every nested literal goes through withUnknowns(..., sourceObject) — e.g.:

export function serializeArtifact(a: Artifact): string {
  const canonical = withUnknowns(
    {
      schemaVersion: a.schemaVersion,
      document: withUnknowns({ path: a.document.path }, a.document),
      anchors: Object.fromEntries(
        Object.keys(a.anchors)
          .sort()
          .map((k) => [
            k,
            withUnknowns(
              {
                fingerprint: withUnknowns(
                  {
                    text: a.anchors[k].fingerprint.text,
                    before: a.anchors[k].fingerprint.before,
                    after: a.anchors[k].fingerprint.after,
                    lineHint: a.anchors[k].fingerprint.lineHint,
                  },
                  a.anchors[k].fingerprint,
                ),
              },
              a.anchors[k],
            ),
          ]),
      ),
      threads: a.threads.map((t) =>
        withUnknowns(
          {
            id: t.id,
            anchorId: t.anchorId,
            status: t.status,
            messages: t.messages.map((m) =>
              withUnknowns(
                { id: m.id, author: serializeProvenance(m.author), body: m.body, createdAt: m.createdAt },
                m,
              ),
            ),
          },
          t,
        ),
      ),
      attributions: a.attributions.map((at) =>
        withUnknowns(
          {
            id: at.id,
            anchorId: at.anchorId,
            author: serializeProvenance(at.author),
            createdAt: at.createdAt,
            updatedAt: at.updatedAt,
            ...(at.turnId !== undefined ? { turnId: at.turnId } : {}),
          },
          at,
        ),
      ),
      proposals: a.proposals.map((p) =>
        withUnknowns(
          {
            id: p.id,
            anchorId: p.anchorId,
            replacement: p.replacement,
            author: serializeProvenance(p.author),
            createdAt: p.createdAt,
            ...(p.turnId !== undefined ? { turnId: p.turnId } : {}),
            ...(p.instruction !== undefined ? { instruction: p.instruction } : {}),
          },
          p,
        ),
      ),
    },
    a,
  );
  return JSON.stringify(canonical, null, 2) + "\n";
}

Gotcha: withUnknowns uses !(k in known) — an omitted optional (e.g. turnId undefined) is not in known, so a parsed record that HAS turnId keeps it via the spread above (it's a known key when present). Correct by construction. Update the schema-suite + add expectValidSidecar calls where natural.

  • Step 4: Run full unit suitenpx vitest run && npm run typecheck → PASS (existing byte-stability tests prove no regression for clean v1 sidecars).

  • Step 5: Commit

git add src/model.ts test/model.test.ts
git commit -m "feat: serializer preserves unknown fields at every level (F5 SLICE-2, INV-15, #14)"

Task 6: isNewerMajor + store write-refusal backstop (SLICE-2, INV-16)

Files:

  • Modify: src/model.ts (isNewerMajor), src/store.ts (update refusal)

  • Test: test/model.test.ts, test/store.test.ts

  • Step 1: Write the failing tests. Append to test/model.test.ts:

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);
  });
});

Append to test/store.test.ts (match its existing tmpdir pattern):

it("update REFUSES to write a sidecar from a newer major (INV-16 backstop)", () => {
  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);
});
  • Step 2: Run to verify failurenpx vitest run test/model.test.ts test/store.test.ts → FAIL.

  • Step 3: Implement. src/model.ts:

/**
 * 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<Artifact, "schemaVersion">): boolean {
  return a.schemaVersion > SCHEMA_VERSION;
}

src/store.ts update() — after the fresh load, before mutate:

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)`,
  );
}

(import isNewerMajor, SCHEMA_VERSION from ./model.)

  • Step 4: Run to verify passnpx vitest run && npm run typecheck → PASS.

  • Step 5: Commit

git add src/model.ts src/store.ts test/model.test.ts test/store.test.ts
git commit -m "feat: isNewerMajor + store write-refusal backstop (F5 SLICE-2, INV-16, #14)"

Task 7: gitUserEmail + currentAuthor() email population (SLICE-2)

Files:

  • Create: src/identity.ts

  • Modify: src/threadController.ts:73-76, src/attributionController.ts:80-83

  • Test: test/identity.test.ts

  • Step 1: Write the failing testtest/identity.test.ts:

import { describe, it, expect, beforeAll, afterAll } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { execFileSync } from "node:child_process";
import { gitUserEmail } from "../src/identity";

let repo: string;

beforeAll(() => {
  repo = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-identity-"));
  execFileSync("git", ["init"], { cwd: repo });
  execFileSync("git", ["config", "user.email", "rung@example.org"], { cwd: repo });
});
afterAll(() => fs.rmSync(repo, { recursive: true, force: true }));

describe("gitUserEmail (F5 SLICE-2)", () => {
  it("reads the workspace git config user.email", () => {
    expect(gitUserEmail(repo)).toBe("rung@example.org");
  });
  it("fails open (undefined) outside any usable config", () => {
    // GIT_CONFIG_* env isolation makes even global config invisible
    expect(gitUserEmail("/nonexistent-dir-xyz")).toBeUndefined();
  });
});
  • Step 2: Run to verify failurenpx vitest run test/identity.test.ts → FAIL (module missing).

  • Step 3: Implement src/identity.ts (vscode-free):

/**
 * Cross-rung identity (F5, spec §6.3 fork c): `email` is git's own join key —
 * forges already map email → account. Resolved from the workspace git config,
 * cached per root, FAIL-OPEN: absence just omits the optional field and the
 * sidecar stays valid (spec §6.9).
 */
import { execFileSync } from "node:child_process";

const cache = new Map<string, string | undefined>();

export function gitUserEmail(rootDir: string): string | undefined {
  if (!cache.has(rootDir)) {
    try {
      const out = execFileSync("git", ["config", "user.email"], {
        cwd: rootDir,
        encoding: "utf8",
        stdio: ["ignore", "pipe", "ignore"],
      }).trim();
      cache.set(rootDir, out || undefined);
    } catch {
      cache.set(rootDir, undefined);
    }
  }
  return cache.get(rootDir);
}
  • Step 4: Wire into both controllers. Identical change in src/threadController.ts and src/attributionController.ts (import { gitUserEmail } from "./identity";):
private currentAuthor(): Provenance {
  const id = vscode.workspace.getConfiguration("git").get<string>("user.name") || process.env.USER || "human";
  const email = gitUserEmail(this.rootDir);
  return { kind: "human", id, ...(email !== undefined ? { email } : {}) };
}

(ThreadController already has rootDir as a constructor field; AttributionController too.)

  • Step 5: Runnpx vitest run && npm run typecheck → PASS.

  • Step 6: Commit

git add src/identity.ts src/threadController.ts src/attributionController.ts test/identity.test.ts
git commit -m "feat: currentAuthor() carries git user.email — fail-open identity (F5 SLICE-2, #14)"

Task 8: VersionGuard — controllers go read-only on newer major (SLICE-2, PUC-4)

Files:

  • Create: src/versionGuard.ts
  • Modify: src/threadController.ts (ctor + createThreadOnSelection/reply/setThreadStatus/onDidSave), src/attributionController.ts (ctor + onDidSave), src/proposalController.ts (ctor + propose/accept/reject), src/extension.ts (wiring + CowritingApi)

(Host-E2E coverage lands in Task 11; this task is wiring + typecheck + existing suites stay green.)

  • Step 1: Implement src/versionGuard.ts:
/**
 * 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.
 */
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);
  }
}
  • Step 2: Gate ThreadController. Constructor becomes constructor(private readonly store: CoauthorStore, private readonly rootDir: string, private readonly guard: VersionGuard). Add an early return to each write gesture:

    • createThreadOnSelection: after the isInRoot check — if (this.guard.isReadOnly(this.docPathOf(editor.document.uri))) return undefined;
    • reply and setThreadStatus: after resolving stateif (this.guard.isReadOnly(state.docPath)) return;
    • onDidSave: after the !state || state.live.size === 0 check — if (this.guard.isReadOnly(state.docPath)) return;
  • Step 3: Gate AttributionController. Constructor gains the same third param. In onDidSave, after if (!this.isTracked(document)) return;if (this.guard.isReadOnly(this.docPathOf(document.uri))) return;

  • Step 4: Gate ProposalController. Constructor becomes (store, attribution, rootDir, guard). In propose, after isTrackedif (this.guard.isReadOnly(this.docPathOf(document.uri))) return undefined;. In accept and reject, first line — if (this.guard.isReadOnly(state.docPath)) return false; (reject's signature is void-ish: just return;).

  • Step 5: Wire extension.ts. After const store = new CoauthorStore(root);:

const versionGuard = new VersionGuard(store);
const threadController = new ThreadController(store, root, versionGuard);
// …
const attributionController = new AttributionController(store, root, versionGuard);
// …
const proposalController = new ProposalController(store, attributionController, root, versionGuard);

Extend the API type + return:

export interface CowritingApi {
  threadController: ThreadController;
  attributionController: AttributionController;
  proposalController: ProposalController;
  versionGuard: VersionGuard;
}
// …
return { threadController, attributionController, proposalController, versionGuard };
  • Step 6: Verifynpm run typecheck && npx vitest run && npm run build → PASS.

  • Step 7: Commit

git add src/versionGuard.ts src/threadController.ts src/attributionController.ts src/proposalController.ts src/extension.ts
git commit -m "feat: VersionGuard — newer-major sidecars are read-only with one warning (F5 SLICE-2, INV-16/PUC-4, #14)"

Task 9: mergeArtifacts (SLICE-3, INV-17)

Files:

  • Create: src/mergeArtifacts.ts

  • Test: test/mergeArtifacts.test.ts

  • Step 1: Write the failing teststest/mergeArtifacts.test.ts, covering every documented case (spec §6.5 PUC-3):

import { describe, it, expect } from "vitest";
import { emptyArtifact, type Artifact, type Thread } from "../src/model";
import { mergeArtifacts } from "../src/mergeArtifacts";
import { expectValidSidecar } from "./helpers/validateSidecar";

const FP = { text: "alpha", before: "", after: "", lineHint: 0 };
function base(): Artifact {
  const a = emptyArtifact("docs/x.md");
  a.anchors["a_1"] = { fingerprint: FP };
  a.threads.push({
    id: "t_1", anchorId: "a_1", status: "open",
    messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
  });
  return a;
}
const clone = (a: Artifact): Artifact => JSON.parse(JSON.stringify(a));

describe("mergeArtifacts (F5 SLICE-3, INV-17)", () => {
  it("reply-append vs disjoint edit unions cleanly, no conflicts (the forge case)", () => {
    const ours = base();
    ours.attributions.push({
      id: "at_1", anchorId: "a_1", author: { kind: "human", id: "ben" },
      createdAt: "2026-06-10T01:00:00.000Z", updatedAt: "2026-06-10T01:00:00.000Z",
    });
    const theirs = base();
    theirs.threads[0].messages.push({
      id: "m_2", author: { kind: "human", id: "forge", email: "forge@example.org" }, body: "reply", createdAt: "2026-06-10T02:00:00.000Z",
    });
    const { merged, conflicts } = mergeArtifacts(ours, theirs);
    expect(conflicts).toEqual([]);
    expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_2"]);
    expect(merged.attributions.map((a) => a.id)).toEqual(["at_1"]);
    expectValidSidecar(merged);
  });

  it("both sides append to the same thread: messages interleave by createdAt, no conflicts", () => {
    const ours = base();
    ours.threads[0].messages.push({ id: "m_b", author: { kind: "human", id: "ben" }, body: "B", createdAt: "2026-06-10T03:00:00.000Z" });
    const theirs = base();
    theirs.threads[0].messages.push({ id: "m_a", author: { kind: "human", id: "forge" }, body: "A", createdAt: "2026-06-10T02:00:00.000Z" });
    const { merged, conflicts } = mergeArtifacts(ours, theirs);
    expect(conflicts).toEqual([]);
    expect(merged.threads[0].messages.map((m) => m.id)).toEqual(["m_1", "m_a", "m_b"]);
  });

  it("same attribution id divergent: newer updatedAt wins and the id is reported", () => {
    const rec = {
      id: "at_1", anchorId: "a_1", author: { kind: "human", id: "ben" } as const,
      createdAt: "2026-06-10T00:00:00.000Z", updatedAt: "2026-06-10T00:00:00.000Z",
    };
    const ours = base(); ours.attributions.push({ ...rec, updatedAt: "2026-06-10T05:00:00.000Z" });
    const theirs = base(); theirs.attributions.push({ ...rec, updatedAt: "2026-06-10T04:00:00.000Z", turnId: "turn-x" });
    const { merged, conflicts } = mergeArtifacts(ours, theirs);
    expect(conflicts).toEqual(["at_1"]);
    expect(merged.attributions[0].updatedAt).toBe("2026-06-10T05:00:00.000Z");
    expect(merged.attributions[0].turnId).toBeUndefined();
  });

  it("divergent same-id proposals: deterministic tie-break, id reported, symmetric result", () => {
    const p = {
      id: "p_1", anchorId: "a_1", replacement: "AAA", author: { kind: "human", id: "ben" } as const,
      createdAt: "2026-06-10T00:00:00.000Z",
    };
    const ours = base(); ours.proposals.push({ ...p });
    const theirs = base(); theirs.proposals.push({ ...p, replacement: "ZZZ" });
    const r1 = mergeArtifacts(ours, theirs);
    const r2 = mergeArtifacts(theirs, ours);
    expect(r1.conflicts).toEqual(["p_1"]);
    expect(r1.merged.proposals[0].replacement).toBe("ZZZ");
    expect(r2.merged.proposals[0].replacement).toBe("ZZZ");
  });

  it("thread status divergence resolves deterministically and reports the thread id", () => {
    const ours = base();
    const theirs = clone(ours);
    theirs.threads[0].status = "resolved";
    const { merged, conflicts } = mergeArtifacts(ours, theirs);
    expect(merged.threads[0].status).toBe("resolved");
    expect(conflicts).toEqual(["t_1"]);
  });

  it("anchors union by key; divergent same key reported", () => {
    const ours = base();
    const theirs = base();
    theirs.anchors["a_2"] = { fingerprint: { ...FP, text: "beta" } };
    const clean = mergeArtifacts(ours, theirs);
    expect(Object.keys(clean.merged.anchors).sort()).toEqual(["a_1", "a_2"]);
    expect(clean.conflicts).toEqual([]);
    const theirs2 = clone(ours);
    theirs2.anchors["a_1"] = { fingerprint: { ...FP, lineHint: 9 } };
    const dirty = mergeArtifacts(ours, theirs2);
    expect(dirty.conflicts).toEqual(["a_1"]);
  });

  it("unknown fields ride with the winning record and root unknowns union", () => {
    const ours = base() as Artifact & Record<string, unknown>;
    ours.futureSection = [1];
    const theirs = base() as Artifact & Record<string, unknown>;
    theirs.otherFuture = "x";
    const { merged, conflicts } = mergeArtifacts(ours, theirs);
    expect((merged as unknown as Record<string, unknown>).futureSection).toEqual([1]);
    expect((merged as unknown as Record<string, unknown>).otherFuture).toBe("x");
    expect(conflicts).toEqual([]);
  });

  it("throws on differing majors (INV-16)", () => {
    const ours = base();
    const theirs = { ...base(), schemaVersion: 2 };
    expect(() => mergeArtifacts(ours, theirs)).toThrow(/schemaVersion/);
  });
});
  • Step 2: Run to verify failurenpx vitest run test/mergeArtifacts.test.ts → FAIL (module missing).

  • Step 3: Implement src/mergeArtifacts.ts (pure, vscode-free — the contract §Merge's reference implementation):

/**
 * mergeArtifacts — the contract's merge semantics (INV-17) as a pure, vscode-
 * free reference implementation. 2-way union-by-id; every same-id divergence
 * is resolved by the documented deterministic rules AND surfaced in
 * `conflicts` — never silently guessed (INV-1's spirit at the artifact level).
 * Refuses differing majors (INV-16: never merge what you can't fully read).
 * Not wired into any UI — rungs and humans invoke it; a .gitattributes merge
 * driver is a later convenience (spec §1.7).
 */
import type { Artifact, Message, Thread } from "./model";

export interface MergeResult {
  merged: Artifact;
  /** ids (or root keys) whose divergence was rule-resolved — surfaced, never silent. */
  conflicts: string[];
}

type Rec = Record<string, unknown>;

/** Order-insensitive canonical form: all object keys sorted, no whitespace. */
function stableStringify(v: unknown): string {
  if (Array.isArray(v)) return `[${v.map(stableStringify).join(",")}]`;
  if (v !== null && typeof v === "object") {
    const rec = v as Rec;
    return `{${Object.keys(rec).sort().map((k) => `${JSON.stringify(k)}:${stableStringify(rec[k])}`).join(",")}}`;
  }
  return JSON.stringify(v);
}

const same = (a: unknown, b: unknown): boolean => stableStringify(a) === stableStringify(b);

/** Deterministic tie-break: the lexicographically larger canonical form wins. */
function lexLarger<T>(a: T, b: T): T {
  return stableStringify(a) >= stableStringify(b) ? a : b;
}

function unionById<T extends { id: string }>(
  ours: T[],
  theirs: T[],
  resolveDivergent: (o: T, t: T) => T,
  conflicts: string[],
): T[] {
  const theirsById = new Map(theirs.map((t) => [t.id, t]));
  const ourIds = new Set(ours.map((o) => o.id));
  const out: T[] = [];
  for (const o of ours) {
    const t = theirsById.get(o.id);
    if (t === undefined || same(o, t)) {
      out.push(o);
    } else {
      conflicts.push(o.id);
      out.push(resolveDivergent(o, t));
    }
  }
  for (const t of theirs) if (!ourIds.has(t.id)) out.push(t);
  return out;
}

/** Same-id threads: shells resolve deterministically; messages union and interleave. */
function mergeThread(o: Thread, t: Thread, conflicts: string[]): Thread {
  const { messages: oMsgs, ...oShell } = o;
  const { messages: tMsgs, ...tShell } = t;
  if (!same(oShell, tShell)) conflicts.push(o.id);
  const shell = same(oShell, tShell) ? oShell : lexLarger(oShell, tShell);
  const msgConflicts: string[] = [];
  const messages = unionById(oMsgs, tMsgs, (a, b) => lexLarger(a, b), msgConflicts).sort(
    (a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : a.id < b.id ? -1 : 1),
  );
  conflicts.push(...msgConflicts);
  return { ...shell, messages } as Thread;
}

export function mergeArtifacts(ours: Artifact, theirs: Artifact): MergeResult {
  if (ours.schemaVersion !== theirs.schemaVersion) {
    throw new Error(
      `cannot merge differing schemaVersion majors: ${ours.schemaVersion} vs ${theirs.schemaVersion} (INV-16)`,
    );
  }
  const conflicts: string[] = [];

  // anchors: union by key; divergent same key → deterministic tie-break, reported.
  const anchors: Artifact["anchors"] = { ...theirs.anchors, ...ours.anchors };
  for (const k of Object.keys(ours.anchors)) {
    const t = theirs.anchors[k];
    if (t !== undefined && !same(ours.anchors[k], t)) {
      conflicts.push(k);
      anchors[k] = lexLarger(ours.anchors[k], t);
    }
  }

  const threads = unionById(ours.threads, theirs.threads, (o, t) => {
    // mergeThread reports its own conflicts (shell and per-message).
    const before = conflicts.length;
    const merged = mergeThread(o, t, conflicts);
    if (conflicts.length === before) conflicts.push(o.id);
    conflicts.splice(before, conflicts.length - before, ...new Set(conflicts.slice(before)));
    return merged;
  }, []);

  const attributions = unionById(
    ours.attributions,
    theirs.attributions,
    (o, t) => (o.updatedAt === t.updatedAt ? lexLarger(o, t) : o.updatedAt > t.updatedAt ? o : t),
    conflicts,
  );

  const proposals = unionById(ours.proposals, theirs.proposals, (o, t) => lexLarger(o, t), conflicts);

  // root: known sections merged above; unknown root keys union, divergence reported.
  const KNOWN_ROOT = new Set(["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"]);
  const merged = {
    schemaVersion: ours.schemaVersion,
    document: same(ours.document, theirs.document)
      ? ours.document
      : (conflicts.push("document"), lexLarger(ours.document, theirs.document)),
    anchors,
    threads,
    attributions,
    proposals,
  } as Artifact & Rec;
  const oRec = ours as unknown as Rec;
  const tRec = theirs as unknown as Rec;
  for (const k of [...new Set([...Object.keys(oRec), ...Object.keys(tRec)])].sort()) {
    if (KNOWN_ROOT.has(k)) continue;
    const inO = k in oRec, inT = k in tRec;
    if (inO && inT && !same(oRec[k], tRec[k])) {
      conflicts.push(k);
      merged[k] = lexLarger(oRec[k], tRec[k]);
    } else {
      merged[k] = inO ? oRec[k] : tRec[k];
    }
  }
  return { merged, conflicts };
}

NOTE for the engineer: the threads unionById call above passes [] as its conflicts sink and handles reporting inside the resolver via the outer conflicts — when implementing, SIMPLIFY this: make unionById take the resolver only and let the THREAD resolver push to the outer conflicts itself (a divergent thread reports its own id once via mergeThread; the dedupe splice is defensive noise — drop it if mergeThread already pushes o.id exactly once when the shell diverges, and per-message ids when messages diverge). The tests define the required behavior: divergent shell → thread id reported once; divergent same-id messages → message ids reported; clean interleave → no conflicts.

  • Step 4: Run to verify passnpx vitest run test/mergeArtifacts.test.ts && npm run typecheck → PASS.

  • Step 5: Commit

git add src/mergeArtifacts.ts test/mergeArtifacts.test.ts
git commit -m "feat: mergeArtifacts — deterministic union-by-id with surfaced conflicts (F5 SLICE-3, INV-17, #14)"

Task 10: The Gitea-rung stand-in writer (SLICE-4, fork e)

Files:

  • Create: scripts/crossrung-reply.mjs

  • Test: test/crossrungReply.test.ts

  • Step 1: Write the failing testtest/crossrungReply.test.ts (spawns the script with the repo's node, asserts append + validity + BYTE-FIDELITY against the reference serializer — the drift tripwire):

import { describe, it, expect, beforeEach, afterEach } from "vitest";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { execFileSync } from "node:child_process";
import { emptyArtifact, serializeArtifact, type Artifact } from "../src/model";
import { validateSidecar } from "./helpers/validateSidecar";

const SCRIPT = path.resolve(__dirname, "../scripts/crossrung-reply.mjs");
let dir: string;
let sidecar: string;

beforeEach(() => {
  dir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-crossrung-"));
  sidecar = path.join(dir, "sample.md.json");
  const a = emptyArtifact("docs/sample.md");
  a.anchors["a_1"] = { fingerprint: { text: "alpha", before: "", after: "", lineHint: 0 } };
  a.threads.push({
    id: "t_1", anchorId: "a_1", status: "open",
    messages: [{ id: "m_1", author: { kind: "human", id: "ben" }, body: "hi", createdAt: "2026-06-10T00:00:00.000Z" }],
  });
  (a as unknown as Record<string, unknown>).futureSection = [{ id: "f_1" }]; // INV-15 through a foreign writer
  fs.writeFileSync(sidecar, serializeArtifact(a), "utf8");
});
afterEach(() => fs.rmSync(dir, { recursive: true, force: true }));

function run(...args: string[]): string {
  return execFileSync(process.execPath, [SCRIPT, ...args], { encoding: "utf8" });
}

describe("crossrung-reply stand-in (F5 SLICE-4, PUC-2)", () => {
  it("appends a conforming reply, preserves unknowns, byte-identical to the reference serializer", () => {
    run(sidecar, "t_1", "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org");
    const raw = fs.readFileSync(sidecar, "utf8");
    const parsed = JSON.parse(raw) as Artifact;
    expect(parsed.threads[0].messages).toHaveLength(2);
    const reply = parsed.threads[0].messages[1];
    expect(reply.body).toBe("Reply from the forge");
    expect(reply.author).toEqual({ kind: "human", id: "gitea", email: "forge@example.org" });
    expect(reply.id).toMatch(/^m_/);
    expect((parsed as unknown as Record<string, unknown>).futureSection).toEqual([{ id: "f_1" }]);
    expect(validateSidecar(parsed).valid).toBe(true);
    // the stand-in's serialization is byte-identical to the editor's (INV-2 across writers)
    expect(serializeArtifact(parsed)).toBe(raw);
  });

  it("refuses an unknown thread id and leaves the file untouched", () => {
    const before = fs.readFileSync(sidecar, "utf8");
    expect(() => run(sidecar, "t_nope", "x")).toThrow();
    expect(fs.readFileSync(sidecar, "utf8")).toBe(before);
  });

  it("refuses an invalid sidecar (validates BEFORE writing)", () => {
    fs.writeFileSync(sidecar, '{"schemaVersion":1}\n', "utf8");
    expect(() => run(sidecar, "t_1", "x")).toThrow();
  });
});
  • Step 2: Run to verify failurenpx vitest run test/crossrungReply.test.ts → FAIL.

  • Step 3: Implement scripts/crossrung-reply.mjs — a SELF-CONTAINED foreign writer (it deliberately re-implements the contract's serialization rather than importing the extension's serializer: it IS the executable documentation of how a second rung behaves; the byte-fidelity unit test above is the anti-drift tripwire):

#!/usr/bin/env node
// crossrung-reply — the Gitea-rung STAND-IN writer (F5 spec §6.2, fork e):
// exactly what a forge-side surface does to the record, as a local process.
// Conforming-writer behavior per specs/coauthoring-sidecar-contract.md:
// validate → append a Message (fresh uuid, ISO-8601 UTC, identity w/ email) →
// stable-serialize (known-key order, unknowns after sorted, 2-space, trailing
// newline) → validate again → write.
//
// usage:
//   node scripts/crossrung-reply.mjs <sidecar.json> <threadId> <body> \
//     [--author-id id] [--author-email email]
import { readFileSync, writeFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import { fileURLToPath } from "node:url";
import path from "node:path";
import Ajv from "ajv/dist/2020.js";

const here = path.dirname(fileURLToPath(import.meta.url));
const schema = JSON.parse(readFileSync(path.join(here, "..", "schemas", "coauthoring-sidecar.schema.json"), "utf8"));
const ajv = new Ajv({ allErrors: true });
const checks = ajv.compile(schema);

function fail(msg) {
  console.error(`crossrung-reply: ${msg}`);
  process.exit(1);
}
function validateOrFail(data, when) {
  if (!checks(data)) {
    fail(`${when} validation failed:\n` + (checks.errors ?? []).map((e) => `  ${e.instancePath || "/"} ${e.message}`).join("\n"));
  }
}

// --- args ---
const [file, threadId, body] = process.argv.slice(2, 5);
if (!file || !threadId || body === undefined) {
  console.error("usage: crossrung-reply.mjs <sidecar.json> <threadId> <body> [--author-id id] [--author-email email]");
  process.exit(2);
}
const flags = process.argv.slice(5);
const flagVal = (name) => {
  const i = flags.indexOf(name);
  return i >= 0 ? flags[i + 1] : undefined;
};
const authorId = flagVal("--author-id") ?? "crossrung";
const authorEmail = flagVal("--author-email");

// --- contract serialization (independent implementation, INV-2 + INV-15) ---
const ORDER = {
  root: ["schemaVersion", "document", "anchors", "threads", "attributions", "proposals"],
  document: ["path"],
  anchor: ["fingerprint"],
  fingerprint: ["text", "before", "after", "lineHint"],
  thread: ["id", "anchorId", "status", "messages"],
  message: ["id", "author", "body", "createdAt"],
  provenance: ["kind", "id", "email", "agent"],
  agent: ["sdk", "model", "sessionId", "onBehalfOf"],
  onBehalfOf: ["id", "email"],
  attribution: ["id", "anchorId", "author", "createdAt", "updatedAt", "turnId"],
  proposal: ["id", "anchorId", "replacement", "author", "createdAt", "turnId", "instruction"],
};
// known keys first (contract order, present ones only), unknown keys after, sorted
function ordered(obj, kind, mapKnown = {}) {
  const out = {};
  const known = ORDER[kind] ?? [];
  for (const k of known) if (k in obj) out[k] = k in mapKnown ? mapKnown[k](obj[k]) : obj[k];
  for (const k of Object.keys(obj).filter((k) => !known.includes(k)).sort()) out[k] = obj[k];
  return out;
}
const provenance = (p) => ordered(p, "provenance", { agent: (ag) => ordered(ag, "agent", { onBehalfOf: (o) => ordered(o, "onBehalfOf") }) });
function canonicalize(a) {
  return ordered(a, "root", {
    document: (d) => ordered(d, "document"),
    anchors: (anchors) =>
      Object.fromEntries(
        Object.keys(anchors).sort().map((k) => [k, ordered(anchors[k], "anchor", { fingerprint: (fp) => ordered(fp, "fingerprint") })]),
      ),
    threads: (ts) => ts.map((t) => ordered(t, "thread", { messages: (ms) => ms.map((m) => ordered(m, "message", { author: provenance })) })),
    attributions: (ats) => ats.map((at) => ordered(at, "attribution", { author: provenance })),
    proposals: (ps) => ps.map((p) => ordered(p, "proposal", { author: provenance })),
  });
}
const serialize = (a) => JSON.stringify(canonicalize(a), null, 2) + "\n";

// --- the write ---
const data = JSON.parse(readFileSync(file, "utf8"));
validateOrFail(data, "pre-write");
if (data.schemaVersion > 1) fail(`schemaVersion ${data.schemaVersion} is newer than this writer understands (1) — refusing to write (INV-16)`);
const thread = data.threads.find((t) => t.id === threadId);
if (!thread) fail(`no thread ${threadId} in ${file}`);
thread.messages.push({
  id: `m_${randomUUID()}`,
  author: { kind: "human", id: authorId, ...(authorEmail ? { email: authorEmail } : {}) },
  body,
  createdAt: new Date().toISOString(),
});
const out = serialize(data);
validateOrFail(JSON.parse(out), "post-write");
writeFileSync(file, out, "utf8");
console.log(`appended reply to ${threadId} in ${file}`);
  • Step 4: Run to verify passnpx vitest run test/crossrungReply.test.ts → PASS. The byte-fidelity assertion is the load-bearing one: if the stand-in's serialize ever disagrees with serializeArtifact, this fails.

  • Step 5: Commit

git add scripts/crossrung-reply.mjs test/crossrungReply.test.ts
git commit -m "feat: crossrung-reply stand-in writer — validate, append, stable-serialize (F5 SLICE-4, #14)"

Task 11: Host E2E — round-trip (PUC-2) + newer-major fail-safe (PUC-4)

Files:

  • Create: test/e2e/fixtures/workspace/docs/crossrung.md

  • Create: test/e2e/fixtures/workspace/docs/v2.md

  • Create: test/e2e/suite/crossrung.test.ts

  • Step 1: Add fixtures.

test/e2e/fixtures/workspace/docs/crossrung.md:

# Crossrung doc

The portable record travels with ordinary git.

Nothing else here.

test/e2e/fixtures/workspace/docs/v2.md:

# Future doc

Anchored text from the future.
  • Step 2: Write the E2E suitetest/e2e/suite/crossrung.test.ts (follows threads.test.ts idioms; the stand-in runs via process.execPath + ELECTRON_RUN_AS_NODE):
import * as assert from "assert";
import * as fs from "fs";
import * as path from "path";
import { execFileSync } from "child_process";
import * as vscode from "vscode";
import type { CowritingApi } from "../../../src/extension";
import type { Artifact } from "../../../src/model";

const WS = process.env.E2E_WORKSPACE!;
const PROJECT_ROOT = path.resolve(__dirname, "../../../..");
const STANDIN = path.join(PROJECT_ROOT, "scripts", "crossrung-reply.mjs");

function sidecarPath(docRel: string): string {
  return path.join(WS, ".threads", `${docRel}.json`);
}
async function open(docRel: string): Promise<vscode.TextDocument> {
  const uri = vscode.Uri.file(path.join(WS, docRel));
  const doc = await vscode.workspace.openTextDocument(uri);
  await vscode.window.showTextDocument(doc);
  return doc;
}
async function getApi(): Promise<CowritingApi> {
  const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!;
  const api = (await ext.activate()) as CowritingApi;
  assert.ok(api?.versionGuard, "extension exports versionGuard (F5)");
  return api;
}
function runStandin(...args: string[]): void {
  execFileSync(process.execPath, [STANDIN, ...args], {
    encoding: "utf8",
    env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
  });
}
function commentsOf(api: CowritingApi, docRel: string, threadId: string): number {
  const docs = (api.threadController as unknown as {
    docs: Map<string, { vsThreads: Map<string, vscode.CommentThread> }>;
  }).docs;
  const vsThread = docs.get(docRel)?.vsThreads.get(threadId);
  return vsThread ? vsThread.comments.length : -1;
}
async function until(cond: () => boolean, ms = 5000): Promise<void> {
  const t0 = Date.now();
  while (!cond()) {
    if (Date.now() - t0 > ms) throw new Error("timed out waiting for condition");
    await new Promise((r) => setTimeout(r, 100));
  }
}

suite("F5 cross-rung round-trip (host E2E)", () => {
  test("stand-in reply lands in the editor's thread via external change (PUC-2)", async () => {
    const DOC = "docs/crossrung.md";
    const doc = await open(DOC);
    const api = await getApi();
    const target = "portable record";
    const start = doc.getText().indexOf(target);
    const editor = vscode.window.activeTextEditor!;
    editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length));
    const threadId = await api.threadController.createThreadOnSelection("Editor note");
    assert.ok(threadId, "thread created in the editor");

    // the Gitea-rung stand-in appends a reply OUT OF EDITOR (spec fork e)
    runStandin(sidecarPath(DOC), threadId!, "Reply from the forge", "--author-id", "gitea", "--author-email", "forge@example.org");

    // watcher → external change → re-render with the new message (F2 path)
    await until(() => commentsOf(api, DOC, threadId!) === 2);
    const rendered = api.threadController.getRendered(DOC);
    const mine = rendered.find((r) => r.id === threadId)!;
    assert.strictEqual(mine.orphaned, false, "anchor still resolves after the foreign write");
    const anchorLine = doc.positionAt(doc.getText().indexOf(target)).line;
    assert.strictEqual(mine.range.startLine, anchorLine, "rendered at the re-resolved anchor");

    const art = JSON.parse(fs.readFileSync(sidecarPath(DOC), "utf8")) as Artifact;
    const msgs = art.threads.find((t) => t.id === threadId)!.messages;
    assert.strictEqual(msgs.length, 2);
    assert.deepStrictEqual(msgs[1].author, { kind: "human", id: "gitea", email: "forge@example.org" });
  });

  test("newer-major sidecar: warning + read-only, file bytes never change (PUC-4)", async () => {
    const DOC = "docs/v2.md";
    const scPath = sidecarPath(DOC);
    fs.mkdirSync(path.dirname(scPath), { recursive: true });
    // a v2 sidecar: valid v1 sections plus future content this build can't know
    const v2 = {
      schemaVersion: 2,
      document: { path: DOC },
      anchors: { a_1: { fingerprint: { text: "Anchored text", before: "", after: " from the future.", lineHint: 2 } } },
      threads: [
        { id: "t_1", anchorId: "a_1", status: "open",
          messages: [{ id: "m_1", author: { kind: "human", id: "future" }, body: "from v2", createdAt: "2026-06-10T00:00:00.000Z" }] },
      ],
      attributions: [],
      proposals: [],
      v2OnlySection: { shiny: true },
    };
    fs.writeFileSync(scPath, JSON.stringify(v2, null, 2) + "\n", "utf8");
    const before = fs.readFileSync(scPath, "utf8");

    const doc = await open(DOC);
    const api = await getApi();
    api.threadController.renderAll(doc); // render best-effort: v1 sections show
    const rendered = api.threadController.getRendered(DOC);
    assert.strictEqual(rendered.length, 1, "renders what it understands");

    // write gestures are refused + warned
    const target = "Anchored text";
    const start = doc.getText().indexOf(target);
    const editor = vscode.window.activeTextEditor!;
    editor.selection = new vscode.Selection(doc.positionAt(start), doc.positionAt(start + target.length));
    const created = await api.threadController.createThreadOnSelection("should not land");
    assert.strictEqual(created, undefined, "createThread refused on newer major");
    assert.strictEqual(api.versionGuard.wasWarned(DOC), true, "user warned once (PUC-4)");

    // an edit + save must NOT rewrite the sidecar
    await editor.edit((b) => b.insert(doc.positionAt(0), "edited "));
    await doc.save();
    await new Promise((r) => setTimeout(r, 500));
    assert.strictEqual(fs.readFileSync(scPath, "utf8"), before, "sidecar bytes untouched (INV-16)");
  });
});
  • Step 3: Run the full E2E suite
npm run pretest:e2e && npm run test:e2e

Expected: all suites pass (existing threads/attribution/proposals + new crossrung). Note the F2 suites mutate docs/sample.md only; the new tests use their own fixture docs, so ordering can't cross-contaminate.

  • Step 4: Commit
git add test/e2e/fixtures/workspace/docs/crossrung.md test/e2e/fixtures/workspace/docs/v2.md test/e2e/suite/crossrung.test.ts
git commit -m "test: host E2E — crossrung round-trip + newer-major fail-safe (F5 SLICE-4, PUC-2/PUC-4, #14)"

Task 12: Docs + full gate (SLICE-5)

Files:

  • Modify: README.md (new cross-rung section)

  • Step 1: README. Add a ## Cross-rung format (F5) section: the sidecar is the published cross-rung contract; normative doc lives at vscode-cowriting-plugin-content/specs/coauthoring-sidecar-contract.md (INV-14); machine-checkable schema at schemas/coauthoring-sidecar.schema.json; validate any sidecar with node scripts/validate-sidecar.mjs <file>; foreign-writer example scripts/crossrung-reply.mjs; merge semantics src/mergeArtifacts.ts (INV-17); newer-major sidecars are read-only (INV-16); writers preserve unknown fields (INV-15).

  • Step 2: Full gate

npm run typecheck && npm run test && npm run build && npm run pretest:e2e && npm run test:e2e

Expected: all green (unit + host E2E; round-trip, merge, fail-safe, unknown-field preservation all exercised — issue #14's acceptance).

  • Step 3: Commit
git add README.md
git commit -m "docs: cross-rung contract pointers in README (F5 SLICE-5, #14)"

Task 13: PR + merge

  • Step 1: Push the branch + open the PR (SSH remote; Gitea — use the established tea/API flow from prior sessions or git push -u origin feature/14-cross-rung-format + Gitea API):

PR title: F5: cross-rung format contract + round-trip (#14). Body cites the spec, lists the five slices, notes INV-14..17, and Closes #14.

  • Step 2: Merge (autonomous posture; merge after CI/local gate green), then:
git checkout main && git pull

Self-review notes

  • Spec coverage: SLICE-1 → Tasks 23; SLICE-2 → Tasks 48; SLICE-3 → Task 9; SLICE-4 → Tasks 1011; SLICE-5 → Task 12. Traceability §8 rows all land: contract+rules (T3), identity (T4/T7), round-trip/no-rehoming (T5/T10/T11), concurrent-writer (T9), validation-outside-extension (T2), unit+E2E (T2T11).
  • Deliberate scope cuts (per spec §1.7): no .gitattributes merge driver, no Gitea API, no UI for merge.
  • Known judgment calls (log to Deferred decisions): thread-shell lexicographic rule for status divergence (deterministic, documented in contract); preservation implemented at serializer level only (controller rebuild paths own their records — spec §6.2 wording); stand-in re-implements serialization deliberately (byte-fidelity test is the tripwire).