diff --git a/specs/coauthoring-sidecar-contract.md b/specs/coauthoring-sidecar-contract.md new file mode 100644 index 0000000..b47cd09 --- /dev/null +++ b/specs/coauthoring-sidecar-contract.md @@ -0,0 +1,267 @@ +--- +status: living +--- +# Coauthoring Sidecar — the Cross-Rung Contract (v1.0) + +| | | +| --- | --- | +| **Author(s)** | Ben Stull (with Claude) | +| **Status** | `living` — this is a standing contract, not a one-shot Solution Design | +| **Contract version** | v1.0 · `schemaVersion: 1` | +| **Machine-checkable half** | `vscode-cowriting-plugin/schemas/coauthoring-sidecar.schema.json` (+ `scripts/validate-sidecar.mjs`) | +| **Reference implementation** | `vscode-cowriting-plugin/src/model.ts` (serializer), `src/mergeArtifacts.ts` (merge), `scripts/crossrung-reply.mjs` (conforming foreign writer) | +| **Source artifacts** | Solution Design `coauthoring-cross-rung-format.md` (F5, Feature `benstull/vscode-cowriting-plugin#14`) · supersedes the format sections (§6.3) of `coauthoring-inner-loop.md`, `coauthoring-attribution.md`, `coauthoring-propose-accept.md` as the format reference | + +**This document is canonical (INV-14).** Format changes land here first, then in +the JSON Schema, then in code — documentation-leads-automation applied to the +format. The Schema and every implementation conform to this prose; on +disagreement, this document wins and the others are fixed. + +## Changelog + +| Date | Contract | schemaVersion | Change | +| --- | --- | --- | --- | +| 2026-06-10 | v1.0 | 1 | Initial publication. Ratifies the shipped F2–F4 format (anchors/threads/attributions/proposals) and adds the additive cross-rung identity fields (`Provenance.email`, `agent.onBehalfOf`) plus the compatibility (INV-15/16) and merge (INV-17) rules. | + +--- + +## 1. Scope + +The **coauthoring sidecar** is the git-native record of a document's +human+machine coauthoring state: anchored discussion threads, authorship +attribution, and pending machine proposals. It is the **canonical cross-rung +carrier** — any rung of the ladder (the VS Code editor extension, a Gitea-rung +surface, rfc-app) reads and writes the same file per this contract; git +push/pull is the transport. There is no server, no sync daemon, and no +migration between rungs ("git is truth; the surfaces are clients" — +rfc-app#46). + +- **Location:** `.threads/.json`, in the same + repository as the document it describes (e.g. `docs/spec.md` → + `.threads/docs/spec.md.json`). The document path is the sidecar key; file + rename/move tracking is out of scope for v1. +- **One sidecar per document.** Each sidecar is a single JSON object, the + **Artifact** (§3.1). +- **Multiple writers are expected.** The editor extension is *a* writer, not + *the* writer. Any process conforming to §5 may append to or modify the + record. + +## 2. Serialization rules (INV-2) + +A conforming writer emits the artifact **deterministically**, so diffs stay +minimal and any two conforming writers produce byte-identical output for the +same logical content: + +1. **Pretty JSON**, 2-space indent, UTF-8, **exactly one trailing newline**, + no BOM. +2. **Root key order:** `schemaVersion`, `document`, `anchors`, `threads`, + `attributions`, `proposals` — then unknown root keys (rule 5). +3. **`anchors` keys sorted** lexicographically. +4. **Known keys of every object in the canonical order** given by the field + tables in §3 (the order shown is normative). Optional known fields that are + absent are **omitted entirely** (never `null`). +5. **Unknown keys serialize after known keys, sorted lexicographically** + (INV-15's deterministic placement — see §4). + +## 3. Data model + +### 3.1 Artifact (root) + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `schemaVersion` | integer ≥ 1 | ✓ | The format **major**. v1.0 of this contract = `1`. See §4. | +| `document` | object | ✓ | `{ "path": string }` — repo-relative path of the described document; the sidecar key. | +| `anchors` | object | ✓ | Map of anchor id → `Anchor` (§3.2). The **shared** anchoring primitive: threads, attributions, and proposals all reference entries here by id. Anchors referenced by no record are pruned by writers that own the rewrite. | +| `threads` | array | ✓ | `Thread[]` (§3.4). | +| `attributions` | array | ✓ | `AttributionRecord[]` (§3.5). **State, not history** — the current authorship map, not an edit log. | +| `proposals` | array | ✓ | `Proposal[]` (§3.6). **Pending-only** — accepted/rejected proposals are removed, never archived here. | + +All four sections are required even when empty (`{}` / `[]`). + +### 3.2 Anchor & Fingerprint + +An **Anchor** is `{ "fingerprint": Fingerprint }`. + +A **Fingerprint** locates a text region by content, not position: + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `text` | string | ✓ | The anchored text itself. **The text is the truth**: a region resolves only where this text is found (with its context). For proposals, `text` is also the exact replace target. | +| `before` | string | ✓ | ≤ ~3 lines / 120 chars of leading context. | +| `after` | string | ✓ | ≤ ~3 lines / 120 chars of trailing context. | +| `lineHint` | integer ≥ 0 | ✓ | 0-based line of the anchor start at write time. A **tie-breaker among multiple text matches, never truth**: a reader must not resolve an anchor by line number alone. | + +Resolution semantics: resolve by exact `text` match disambiguated by +`before`/`after`, tie-broken by proximity to `lineHint`; if the text cannot be +found, the record is **orphaned** — surfaced to the user, never silently moved +or guessed (the editor's INV-1). + +### 3.3 Provenance + +The reusable author field, carried by messages, attributions, and proposals. +Identity is **git's own model**: `email` is the cross-rung join key (forges +already map email → account); `id` is the display/short name. There are no +signatures in v1 — this is provenance, not authentication; the git history is +the integrity layer. + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `kind` | `"human"` \| `"agent"` | ✓ | Who kind of actor authored this. | +| `id` | string | ✓ | Short/display name (e.g. a username, `"claude"`). Not forge-resolvable on its own. | +| `email` | string | — | The actor's email — the cross-rung identity join key. Omitted when unknown (e.g. no git config). | +| `agent` | object | ✓ when `kind:"agent"` | Machine details, below. Absent on humans. | + +`agent` payload: + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `sdk` | string | ✓ | The driving SDK (e.g. `"@cline/sdk"`). | +| `model` | string | ✓ | Model name/alias used for the turn. | +| `sessionId` | string | ✓ | The SDK session/run id (may be `""` when unavailable). | +| `onBehalfOf` | object | — | `{ "id": string, "email"?: string }` — the **human operator the machine acted for** (rfc-app#46 §6.5's `On-behalf-of:` made data). Omitted when not established. | + +**PII note:** emails in a repo-committed file are exactly what git commits +already record; adopters should know the sidecar carries the same class of +data as `git log`. + +### 3.4 Thread & Message + +**Thread** — an anchored discussion: + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `id` | string | ✓ | Unique id (reference writers use `t_`). | +| `anchorId` | string | ✓ | Key into `anchors`. | +| `status` | `"open"` \| `"resolved"` | ✓ | Discussion state. | +| `messages` | array | ✓ | `Message[]`, chronological. | + +**Message:** + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `id` | string | ✓ | Unique id (`m_`). | +| `author` | Provenance | ✓ | §3.3. | +| `body` | string | ✓ | Markdown message body. | +| `createdAt` | string | ✓ | ISO-8601 UTC timestamp (`YYYY-MM-DDTHH:mm:ss.sssZ`). | + +### 3.5 AttributionRecord + +The current authorship map: an anchored, author-attributed span. **State, not +history** — records are coalesced/split as the document evolves. + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `id` | string | ✓ | Unique id (`at_`). | +| `anchorId` | string | ✓ | Key into `anchors`. | +| `author` | Provenance | ✓ | Who authored the span's text. | +| `createdAt` | string | ✓ | ISO-8601 UTC. | +| `updatedAt` | string | ✓ | ISO-8601 UTC; bumped when the span's extent/fingerprint changes. Drives merge resolution (§6). | +| `turnId` | string | — | Groups all spans applied by one machine turn. | + +### 3.6 Proposal + +A **pending** machine edit awaiting a human decision. Decisions are +human-only; a proposal never mutates the document until accepted. + +| Key | Type | Req | Semantics | +| --- | --- | --- | --- | +| `id` | string | ✓ | Unique id (`p_`). | +| `anchorId` | string | ✓ | Key into `anchors`; the anchor's `fingerprint.text` **is** the exact target text and is immutable for the proposal's life. | +| `replacement` | string | ✓ | The full proposed text for the anchored range. | +| `author` | Provenance | ✓ | The proposing machine (with `onBehalfOf` where known). | +| `createdAt` | string | ✓ | ISO-8601 UTC. | +| `turnId` | string | — | Groups N proposals born of one turn. | +| `instruction` | string | — | What the human asked for (review context). | + +## 4. Compatibility & versioning (INV-15, INV-16) + +`schemaVersion` is the **major**. The rules: + +1. **Within a major, changes are additive-only**: new *optional* fields or new + root sections. Existing documents remain valid unmodified; the major does + not bump. +2. **Readers ignore unknown optional fields** they don't implement (but see + rule 3). +3. **Writers preserve unknown fields — INV-15 (round-trip preservation).** A + conforming writer rewriting a sidecar MUST carry every field it does not + recognize, at **every object level** (root, anchor, fingerprint, thread, + message, provenance, attribution, proposal, …), serialized after known + keys, sorted (§2 rule 5). **No rung's writer may destroy another rung's + data.** +4. **A newer major is read-only — fail-safe.** A reader encountering + `schemaVersion` greater than the major it implements MUST NOT write the + sidecar: render what it understands, surface a warning naming the version, + and skip every write path. Recoverable by upgrading the older rung. +5. **Breaking changes bump the major** and are recorded in this changelog. + +The JSON Schema deliberately allows unknown fields everywhere; it validates +known-field structure only. Validation of a v-next document against the v1 +schema therefore still passes for the v1 subset — by design. + +## 5. Conforming-writer checklist + +Any process writing a sidecar (a forge surface, a CLI, an editor) MUST: + +1. **Validate before writing** (schema + the §3 semantics it touches); refuse + to operate on an invalid record rather than "fixing" it silently. +2. **Refuse newer majors** (§4 rule 4). +3. **Preserve unknown fields** (§4 rule 3). +4. **Mint fresh uuid-based ids** for new records (`m_`/`t_`/`at_`/`p_` + + UUID); never reuse or guess ids. +5. **Stamp ISO-8601 UTC timestamps** (`new Date().toISOString()` shape). +6. **Carry identity**: set `author.email` when the writer knows it; agents set + `onBehalfOf` when the operator is established. +7. **Serialize per §2** (byte-determinism) and **validate again after** the + rewrite. + +Executable example: `vscode-cowriting-plugin/scripts/crossrung-reply.mjs` — a +self-contained foreign writer that appends a thread reply per this checklist. + +## 6. Merge rules (INV-17) + +Two writers on two clones can diverge; git will surface the sidecar as a +textual conflict. The **semantic** resolution is a deterministic 2-way merge, +`merge(ours, theirs)`: + +- **Refuse differing majors** (error; a rung never merges what it can't fully + read). +- **`anchors`**: union by key. Same key, divergent value → deterministic + tie-break (below), **key reported**. +- **`threads` / `attributions` / `proposals`**: union by `id`. Records present + on one side only pass through; identical records dedupe. Same-id divergence + resolves: + - **Thread**: the **shell** (all fields except `messages`) and the messages + merge independently. Divergent shells → deterministic tie-break, thread id + reported. (A consequence: divergent `status` resolves to `"resolved"` + over `"open"`, since the tie-break is lexicographic.) `messages` union by + `id`, **ordered by (`createdAt`, `id`)**; divergent same-id messages → + tie-break, message id reported. The common forge case — one side appends a + reply while the other edits elsewhere — merges **cleanly by construction** + (fresh uuids, disjoint sections). + - **Attribution**: **newer `updatedAt` wins**; equal `updatedAt` → + tie-break. Id reported either way. + - **Proposal / Message / Anchor** (no `updatedAt`): tie-break. Id reported. +- **Unknown root keys**: union; same key divergent → tie-break, key reported. +- **`document`**: must agree; divergence → tie-break + `"document"` reported + (should not happen — sidecars are per-path). + +**The deterministic tie-break:** the **lexicographically larger +stable-stringify** wins, where stable-stringify is JSON with all object keys +sorted and no whitespace. Symmetric: `merge(a,b)` and `merge(b,a)` pick the +same winner. + +**Merge honesty (INV-17):** every rule-resolved divergence is **surfaced to +the caller** (the list of resolved ids/keys) — never silently guessed. Humans +remain the final arbiter: the file is plain pretty JSON and stays +human-resolvable; these rules give tools and humans the same answer. + +Reference implementation: +`vscode-cowriting-plugin/src/mergeArtifacts.ts` (`mergeArtifacts(ours, +theirs): { merged, conflicts }`). Wiring it as a `.gitattributes` merge driver +is a deployment convenience, not part of this contract. + +## 7. Out of scope (v1) + +File rename/move tracking · signed provenance / authentication · +`ratifiedBy` / multi-party provenance · rejected-proposal history trails · +any transport beyond git itself.