add specs/coauthoring-sidecar-contract.md (cross-rung contract v1.0, F5 #14)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-10 22:55:56 -07:00
parent 8a6409cea4
commit 856553a7ed
+267
View File
@@ -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 F2F4 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/<repo-relative-document-path>.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_<uuid>`). |
| `anchorId` | string | ✓ | Key into `anchors`. |
| `status` | `"open"` \| `"resolved"` | ✓ | Discussion state. |
| `messages` | array | ✓ | `Message[]`, chronological. |
**Message:**
| Key | Type | Req | Semantics |
| --- | --- | --- | --- |
| `id` | string | ✓ | Unique id (`m_<uuid>`). |
| `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_<uuid>`). |
| `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_<uuid>`). |
| `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.