@company-semantics/contracts 48.0.0 → 49.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "48.0.0",
3
+ "version": "49.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -36,6 +36,22 @@ arbitrate — see the first invariant.
36
36
  `relStart` is refused rather than accepted with the extras dropped: a client
37
37
  that sent them meant to anchor to text and got the discriminator wrong, and
38
38
  storing the mistake yields a thread that can never be placed.
39
+ - **A `text-insertion` anchor is a REAL zero-width point, and resolution never
40
+ guesses.** An insert suggestion is not anchored to an adjacent quoted run of
41
+ existing text — a persisted suggestion outlives concurrent edits, and an
42
+ adjacency convention resolves against whichever run survives, the wrong one,
43
+ silently. Instead the point carries an optional `relPos` (one
44
+ `Y.RelativePosition` at the caret) plus bilateral `leftContext`/`rightContext`
45
+ (each max 64 UTF-16 code units, required-but-may-be-empty at document edges).
46
+ Resolution, implemented client-side: rung 1 decodes `relPos` and verifies
47
+ BOTH contexts around the decoded point (stale-not-correct, like the text
48
+ anchor's quote check); rung 2 searches for the UNIQUE
49
+ `leftContext`+`rightContext` seam — any ambiguity resolves orphaned, never a
50
+ guess; rung 3 orphaned. This variant ships contracts-first: its corpus cases
51
+ were added HERE with the deliberate same-diff digest re-pin ADR-CONTRACTS-116
52
+ allows (PRD-00933), and the backend boundary adopts both in PRD-00934 — until
53
+ then the upstream diff shows expected forward drift, and `anchor-corpus:sync`
54
+ must not be run (it would erase the new cases).
39
55
  - **A comment's `body` is null exactly when `deletedAt` is set, and such a
40
56
  comment carries no mentions.** A soft-deleted comment is a REDACTED
41
57
  PROJECTION, not a removed row — the tombstone keeps its position in the
@@ -79,7 +95,7 @@ arbitrate — see the first invariant.
79
95
 
80
96
  | Export | Description |
81
97
  | --------------------------------- | --------------------------------------------------------------------------- |
82
- | `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}`, strict, discriminated on `type` |
98
+ | `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict |
83
99
  | `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple |
84
100
  | `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against |
85
101
  | `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state |
@@ -93,11 +109,17 @@ arbitrate — see the first invariant.
93
109
  | `CommentThreadListResponseSchema` | `GET /api/comments` |
94
110
  | `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
95
111
  | `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
112
+ | `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread |
113
+ | `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes |
114
+ | `SUGGESTION_STATUSES` | `open` \| `accepted` \| `rejected` — the SECOND status level, terminal |
115
+ | `SuggestionSchema` | The versioned suggestion payload (`v:1`), strict, `insertedText` optional |
96
116
 
97
117
  ## Dependencies
98
118
 
99
119
  - `zod` — schemas are canonical, types are inferred.
100
- - `./anchor` ← `./schemas`. Nothing outside this directory is imported: the
120
+ - `./anchor` ← `./schemas`; `./suggestion` is a leaf (zod only) so `./schemas`
121
+ can import the thread-kind projection from it without a cycle.
122
+ - Nothing outside this directory is imported: the
101
123
  comment vocabulary binds to the existing `commenter` band of
102
124
  `../permissions`'s `AccessLevel` and introduces no access level of its own, so
103
125
  it needs no import to say so.
@@ -23,6 +23,14 @@
23
23
  * re-copies, and ADR-CONTRACTS-116 records that the upstream half is still a
24
24
  * documented promise rather than a gate.
25
25
  *
26
+ * ONE deliberate exception is live (PRD-00933, ADR slug
27
+ * suggested-edits-wire-vocabulary): the text-insertion cases were added HERE
28
+ * first, with the same-diff re-pin ADR-CONTRACTS-116 allows, because that
29
+ * variant is published before the backend boundary adopts it. The backend
30
+ * copy catches up in PRD-00934 — until then `--check` reports expected
31
+ * forward drift where an upstream tree is reachable, and `sync` must NOT be
32
+ * run (it would overwrite the new cases with the older upstream file).
33
+ *
26
34
  * The named rejection tests below are additional rather than a substitute: the
27
35
  * corpus covers the same ground by fixture name, and these say in prose what
28
36
  * the strict variants are FOR.
@@ -41,6 +49,24 @@ interface AnchorCase {
41
49
  anchor: unknown;
42
50
  }
43
51
 
52
+ /**
53
+ * The additive fields the text-insertion rows carry beside `anchor`
54
+ * (PRD-00933): the document the anchor was authored against and the outcome
55
+ * the resolution ladder must produce for it. Inert descriptive data as far as
56
+ * the parse loops are concerned — the suite below is what keeps it honest.
57
+ */
58
+ interface InsertionFixture extends AnchorCase {
59
+ anchor: { type: "text-insertion"; leftContext: string; rightContext: string };
60
+ document: string;
61
+ expectedResolution:
62
+ { outcome: "resolved"; offset: number } | { outcome: "orphaned" };
63
+ }
64
+
65
+ const isInsertionFixture = (c: AnchorCase): c is InsertionFixture => {
66
+ const anchor = c.anchor as { type?: unknown };
67
+ return anchor !== null && anchor?.type === "text-insertion";
68
+ };
69
+
44
70
  /** The subset of `comment-anchors.provenance.json` this suite enforces. */
45
71
  interface CorpusPin {
46
72
  source: { repo: string; path: string };
@@ -118,6 +144,66 @@ describe("CommentAnchorSchema against the mirrored corpus", () => {
118
144
  }
119
145
  });
120
146
 
147
+ describe("the text-insertion fixtures resolve as they claim", () => {
148
+ // The corpus's text-insertion rows record a document and an expected
149
+ // resolution outcome (PRD-00933) so the app's resolver (PRD-00935+) has
150
+ // pinned ground truth to test against. Nothing parses those fields, so
151
+ // without this suite a fixture whose document does not actually contain the
152
+ // seam it claims — or contains it twice where it claims once — would sit
153
+ // green here and mislead the resolver's suite downstream. This block
154
+ // re-derives each outcome from the document under the rung-2 semantics
155
+ // documented on `TextInsertionAnchorSchema`: a seam is an offset where
156
+ // `leftContext` ends and `rightContext` begins, only a UNIQUE seam
157
+ // resolves, and any ambiguity is orphaned — never a guess.
158
+ const seamOffsets = (
159
+ document: string,
160
+ left: string,
161
+ right: string,
162
+ ): number[] => {
163
+ const offsets: number[] = [];
164
+ // <= length: the seam after the final character is a real insertion
165
+ // point (document end). All arithmetic is UTF-16 code units — plain
166
+ // JS string coordinates, no Unicode normalization.
167
+ for (let i = 0; i <= document.length; i++) {
168
+ if (
169
+ i >= left.length &&
170
+ document.slice(i - left.length, i) === left &&
171
+ document.startsWith(right, i)
172
+ ) {
173
+ offsets.push(i);
174
+ }
175
+ }
176
+ return offsets;
177
+ };
178
+
179
+ const insertionCases = corpus.accept.filter(isInsertionFixture);
180
+
181
+ it("has the four required insertion fixtures, so none went missing", () => {
182
+ // Position 0, document end, ambiguous repeated seam, surrogate-pair
183
+ // flanked — the user-specified minimum. A corpus edit that drops one
184
+ // must fail loudly, not shrink the loop below.
185
+ expect(insertionCases.length).toBeGreaterThanOrEqual(4);
186
+ });
187
+
188
+ for (const { name, anchor, document, expectedResolution } of insertionCases) {
189
+ it(`resolves ${name} to its recorded outcome`, () => {
190
+ const offsets = seamOffsets(
191
+ document,
192
+ anchor.leftContext,
193
+ anchor.rightContext,
194
+ );
195
+ if (expectedResolution.outcome === "resolved") {
196
+ expect(offsets).toEqual([expectedResolution.offset]);
197
+ } else {
198
+ // Orphaned-by-ambiguity: the seam occurs MORE than once. (A seam
199
+ // occurring zero times is also orphaned, but these fixtures pin the
200
+ // ambiguity rule specifically — the case where guessing is tempting.)
201
+ expect(offsets.length).toBeGreaterThan(1);
202
+ }
203
+ });
204
+ }
205
+ });
206
+
121
207
  describe("the anchor's load-bearing refusals, named", () => {
122
208
  it("rejects a text anchor with no quote", () => {
123
209
  // `quote` is the DURABLE FALLBACK — what a resolving client re-finds the
@@ -0,0 +1,136 @@
1
+ /**
2
+ * The text-insertion anchor variant, held to its published bounds
3
+ * (ADR slug suggested-edits-wire-vocabulary).
4
+ *
5
+ * HAND-WRITTEN RATHER THAN CORPUS-DRIVEN, unlike the sibling suite: each
6
+ * case here states in prose what one published bound is FOR. The mirrored
7
+ * corpus in `./fixtures/comment-anchors.json` ALSO carries text-insertion
8
+ * cases — added contracts-first under PRD-00933 with the deliberate same-diff
9
+ * digest re-pin ADR-CONTRACTS-116 allows, since this variant ships before
10
+ * the backend boundary adopts it (PRD-00934). Those corpus rows additionally
11
+ * pin document text and expected resolution outcomes, which this suite does
12
+ * not duplicate.
13
+ *
14
+ * Negative tests mutate ONE field of a well-formed factory object, so each
15
+ * fails on the field under test and nothing incidental.
16
+ */
17
+ import { describe, expect, it } from "vitest";
18
+
19
+ import {
20
+ COMMENT_ANCHOR_TYPES,
21
+ CommentAnchorSchema,
22
+ CommentAnchorTypeSchema,
23
+ } from "../anchor.js";
24
+
25
+ /** A well-formed insertion anchor; override one field per negative test. */
26
+ const makeInsertionAnchor = (over: Record<string, unknown> = {}) => ({
27
+ type: "text-insertion",
28
+ v: 1,
29
+ relPos: "AQLmzsvNAgA=",
30
+ leftContext: "the quarterly ",
31
+ rightContext: " goals",
32
+ ...over,
33
+ });
34
+
35
+ describe("the text-insertion anchor variant", () => {
36
+ it("accepts a full anchor: relPos plus bilateral context", () => {
37
+ expect(CommentAnchorSchema.safeParse(makeInsertionAnchor()).success).toBe(
38
+ true,
39
+ );
40
+ });
41
+
42
+ it("accepts an anchor with no relPos — the quote-path twin", () => {
43
+ // A client without an attached collab session cannot encode a
44
+ // Y.RelativePosition; the context seam is then the only resolution path.
45
+ // If this stops parsing, suggesting an insertion from outside the editor
46
+ // breaks.
47
+ const { relPos: _relPos, ...rest } = makeInsertionAnchor();
48
+ expect(CommentAnchorSchema.safeParse(rest).success).toBe(true);
49
+ });
50
+
51
+ it("accepts an empty leftContext — insertion at position 0", () => {
52
+ expect(
53
+ CommentAnchorSchema.safeParse(makeInsertionAnchor({ leftContext: "" }))
54
+ .success,
55
+ ).toBe(true);
56
+ });
57
+
58
+ it("accepts an empty rightContext — insertion at document end", () => {
59
+ expect(
60
+ CommentAnchorSchema.safeParse(makeInsertionAnchor({ rightContext: "" }))
61
+ .success,
62
+ ).toBe(true);
63
+ });
64
+
65
+ it("accepts contexts measured in UTF-16 code units, emoji included", () => {
66
+ // 32 astral emoji = 64 UTF-16 code units: exactly at the bound. The
67
+ // published bound is code units, not code points — the same coordinate
68
+ // system as mention offsets, with no Unicode normalization anywhere.
69
+ const emoji64 = "🎯".repeat(32);
70
+ expect(emoji64.length).toBe(64);
71
+ expect(
72
+ CommentAnchorSchema.safeParse(
73
+ makeInsertionAnchor({ leftContext: emoji64, rightContext: emoji64 }),
74
+ ).success,
75
+ ).toBe(true);
76
+ });
77
+
78
+ it("rejects a context over 64 code units", () => {
79
+ // Bounded like prefix/suffix: context disambiguates a seam, it does not
80
+ // carry the document.
81
+ expect(
82
+ CommentAnchorSchema.safeParse(
83
+ makeInsertionAnchor({ leftContext: "x".repeat(65) }),
84
+ ).success,
85
+ ).toBe(false);
86
+ });
87
+
88
+ it("rejects a missing context field", () => {
89
+ // leftContext/rightContext are REQUIRED-but-may-be-empty, so "nothing on
90
+ // that side" and "this client forgot to send it" stay distinguishable.
91
+ const { rightContext: _rightContext, ...rest } = makeInsertionAnchor();
92
+ expect(CommentAnchorSchema.safeParse(rest).success).toBe(false);
93
+ });
94
+
95
+ it("rejects an unknown version", () => {
96
+ // `v` is the evolution hatch: a new shape is a new `v`, and a resolving
97
+ // client must never guess at fields it half-recognises.
98
+ expect(
99
+ CommentAnchorSchema.safeParse(makeInsertionAnchor({ v: 2 })).success,
100
+ ).toBe(false);
101
+ });
102
+
103
+ it("rejects a relPos that is not base64", () => {
104
+ expect(
105
+ CommentAnchorSchema.safeParse(
106
+ makeInsertionAnchor({ relPos: "not base64!!" }),
107
+ ).success,
108
+ ).toBe(false);
109
+ });
110
+
111
+ it("rejects text-anchor fields on an insertion anchor", () => {
112
+ // STRICT like the other variants: a client that sent `quote` meant to
113
+ // anchor to a range and got the discriminator wrong; storing the mistake
114
+ // yields a suggestion that can never be placed.
115
+ expect(
116
+ CommentAnchorSchema.safeParse(makeInsertionAnchor({ quote: "stray" }))
117
+ .success,
118
+ ).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("the widened anchor-type vocabulary", () => {
123
+ it("carries text-insertion in the tuple and the enum", () => {
124
+ // What CommentThreadSummarySchema's denormalised `anchorType` projection
125
+ // is checked against — a suggestion thread's summary row must not fail to
126
+ // parse on its own discriminant.
127
+ expect(COMMENT_ANCHOR_TYPES).toContain("text-insertion");
128
+ expect(CommentAnchorTypeSchema.safeParse("text-insertion").success).toBe(
129
+ true,
130
+ );
131
+ });
132
+
133
+ it("still refuses discriminants outside the vocabulary", () => {
134
+ expect(CommentAnchorTypeSchema.safeParse("region").success).toBe(false);
135
+ });
136
+ });
@@ -35,6 +35,23 @@ obvious to the next reader.
35
35
  rewrites the pin in the same step. Editing this side to make a test pass fails
36
36
  the pin; editing it and re-pinning by hand defeats the check, and shows up as
37
37
  exactly that in the diff.
38
+ - **ONE deliberate exception is live (PRD-00933, ADR slug
39
+ suggested-edits-wire-vocabulary).** The `text-insertion` cases were added
40
+ HERE first, with the same-diff re-pin ADR-CONTRACTS-116 explicitly allows,
41
+ because that anchor variant is published contracts-first — the backend
42
+ boundary adopts it in PRD-00934, against the npm-published version of this
43
+ package, and only then mirrors these cases into its own fixture file. Until
44
+ that lands, `pnpm anchor-corpus:check` reports expected FORWARD drift
45
+ wherever a backend tree is reachable, and `pnpm anchor-corpus:sync` must
46
+ NOT be run: it would overwrite the new cases with the older upstream file.
47
+ - **Text-insertion rows carry two additive fields beside `anchor`:**
48
+ `document` (the text the anchor was authored against) and
49
+ `expectedResolution` (`{ outcome: "resolved", offset }` in zero-based UTF-16
50
+ code units, or `{ outcome: "orphaned" }` when the context seam is ambiguous
51
+ and resolution must never guess). The parse loops ignore them; the
52
+ fixture-consistency suite in `../anchor-corpus.test.ts` re-derives each
53
+ outcome from the document, so the recorded ground truth cannot rot silently
54
+ before the app's resolver (PRD-00935+) tests against it.
38
55
  - **It is listed in `/.prettierignore`.** Prettier reflows the long anchor
39
56
  objects, which would break the byte identity (and fail the pin). The file
40
57
  carries the backend's formatting, not this repo's. The pin beside it is NOT
@@ -1,5 +1,5 @@
1
1
  {
2
- "$comment": "STABLE CONTRACT CORPUS for the comment-anchor route boundary (PRD-00920 / ADR-BE-512 §4). Named accept/reject cases for CommentAnchorSchema (src/api/http/routes/comments/comments.schemas.ts). PRD-00922 copies this file into @company-semantics/contracts and mechanically diffs the two copies, so schema drift between the backend boundary and the published contract is caught by a file comparison. Change cases here only together with the schema; never hand-tune one side.",
2
+ "$comment": "STABLE CONTRACT CORPUS for the comment-anchor route boundary (PRD-00920 / ADR-BE-512 §4). Named accept/reject cases for CommentAnchorSchema (src/api/http/routes/comments/comments.schemas.ts). PRD-00922 copies this file into @company-semantics/contracts and mechanically diffs the two copies, so schema drift between the backend boundary and the published contract is caught by a file comparison. Change cases here only together with the schema; never hand-tune one side. EXCEPTION (PRD-00933, ADR slug suggested-edits-wire-vocabulary): the text-insertion cases below were added CONTRACTS-FIRST, with a deliberate digest re-pin in the same diff, because that anchor variant is published here before the backend boundary adopts it. The backend copy catches up in PRD-00934 — until then the upstream diff shows expected forward drift; do NOT 'fix' it with anchor-corpus:sync, which would erase these cases. Text-insertion rows additively carry 'document' and 'expectedResolution' beside 'anchor': inert descriptive data pinning the resolution-ladder outcome (offsets are zero-based UTF-16 code units; 'orphaned' means the context seam is ambiguous and resolution must never guess). The parse loops ignore the extra keys.",
3
3
  "accept": [
4
4
  {
5
5
  "name": "document-anchor",
@@ -26,6 +26,51 @@
26
26
  "prefix": "",
27
27
  "suffix": ""
28
28
  }
29
+ },
30
+ {
31
+ "name": "text-insertion-at-position-0",
32
+ "anchor": {
33
+ "type": "text-insertion",
34
+ "v": 1,
35
+ "leftContext": "",
36
+ "rightContext": "Welcome to"
37
+ },
38
+ "document": "Welcome to the quarterly plan.",
39
+ "expectedResolution": { "outcome": "resolved", "offset": 0 }
40
+ },
41
+ {
42
+ "name": "text-insertion-at-document-end",
43
+ "anchor": {
44
+ "type": "text-insertion",
45
+ "v": 1,
46
+ "leftContext": "quarterly goals.",
47
+ "rightContext": ""
48
+ },
49
+ "document": "We agreed to ship the quarterly goals.",
50
+ "expectedResolution": { "outcome": "resolved", "offset": 38 }
51
+ },
52
+ {
53
+ "name": "text-insertion-ambiguous-repeated-seam",
54
+ "anchor": {
55
+ "type": "text-insertion",
56
+ "v": 1,
57
+ "leftContext": "item ",
58
+ "rightContext": "done"
59
+ },
60
+ "document": "item done, item done, item done",
61
+ "expectedResolution": { "outcome": "orphaned" }
62
+ },
63
+ {
64
+ "name": "text-insertion-flanked-by-surrogate-pairs",
65
+ "anchor": {
66
+ "type": "text-insertion",
67
+ "v": 1,
68
+ "relPos": "AQLmzsvNAgA=",
69
+ "leftContext": "goals 🎯",
70
+ "rightContext": "🚀 shipped"
71
+ },
72
+ "document": "Q3 goals 🎯🚀 shipped early",
73
+ "expectedResolution": { "outcome": "resolved", "offset": 11 }
29
74
  }
30
75
  ],
31
76
  "reject": [
@@ -1,9 +1,9 @@
1
1
  {
2
- "$comment": "Provenance pin for ./comment-anchors.json, which is a byte-for-byte copy of company-semantics-backend/tests/fixtures/comment-anchors.json (ADR-CONTRACTS-116). anchor-corpus.test.ts re-derives this digest from the file's bytes on every run, so an edit made on THIS side fails CI instead of quietly making the corpus this repo's own. Drift introduced on the BACKEND side is not observable from contracts CI and is not gated here. Regenerate with `pnpm anchor-corpus:sync`; never hand-edit this file or the corpus.",
2
+ "$comment": "Provenance pin for ./comment-anchors.json, which is a byte-for-byte copy of company-semantics-backend/tests/fixtures/comment-anchors.json (ADR-CONTRACTS-116). anchor-corpus.test.ts re-derives this digest from the file's bytes on every run, so an edit made on THIS side fails CI instead of quietly making the corpus this repo's own. Drift introduced on the BACKEND side is not observable from contracts CI and is not gated here. Regenerate with `pnpm anchor-corpus:sync`; never hand-edit this file or the corpus — with ONE reviewed exception, the deliberate same-diff re-pin ADR-CONTRACTS-116 allows: PRD-00933 (ADR slug suggested-edits-wire-vocabulary) added the text-insertion cases contracts-first, because that variant is published here before the backend boundary adopts it. The backend copy catches up in PRD-00934; until then `anchor-corpus:check` reports expected forward drift wherever a backend tree is reachable, and `anchor-corpus:sync` must NOT be run (it would overwrite the new cases with the older upstream file).",
3
3
  "source": {
4
4
  "repo": "company-semantics-backend",
5
5
  "path": "tests/fixtures/comment-anchors.json"
6
6
  },
7
- "bytes": 2649,
8
- "sha256": "84fa0040c9acc166a5551185950e5dc8afffcc5fd63a1f17080422318b405c8c"
7
+ "bytes": 4749,
8
+ "sha256": "d4bba06575774f204effe4b2256a77208918a769cb82d73fdff8d16cdbf76a53"
9
9
  }
@@ -54,6 +54,8 @@ function makeThread(over: Record<string, unknown> = {}) {
54
54
  id: THREAD_ID,
55
55
  subjectType: "company_md",
56
56
  subjectId: DOC_ID,
57
+ kind: "comment",
58
+ suggestion: null,
57
59
  anchorType: "text",
58
60
  anchor: {
59
61
  type: "text",
@@ -240,6 +242,83 @@ describe("CommentThreadSchema", () => {
240
242
  });
241
243
  });
242
244
 
245
+ describe("CommentThreadSummarySchema kind and suggestion", () => {
246
+ it("a zero-comment suggestion thread parses through both response shapes", () => {
247
+ // The full suggestion projection: kind `suggestion`, a v1 payload, a
248
+ // zero-width text-insertion anchor, and NO comments. The backend creates a
249
+ // suggestion thread without a first comment (the payload IS the content),
250
+ // so `comments: []` must parse — a min-length on the comments array would
251
+ // refuse every freshly-created suggestion.
252
+ const suggestionThread = makeThread({
253
+ kind: "suggestion",
254
+ suggestion: {
255
+ v: 1,
256
+ op: "insert",
257
+ insertedText: "and measure adoption weekly",
258
+ status: "open",
259
+ },
260
+ anchorType: "text-insertion",
261
+ anchor: {
262
+ type: "text-insertion",
263
+ v: 1,
264
+ leftContext: "quarterly goals",
265
+ rightContext: " by March",
266
+ },
267
+ comments: [],
268
+ });
269
+ expect(CommentThreadSchema.safeParse(suggestionThread).success).toBe(true);
270
+ expect(
271
+ CommentThreadListResponseSchema.safeParse({ threads: [suggestionThread] })
272
+ .success,
273
+ ).toBe(true);
274
+ });
275
+
276
+ it("parses a legacy-shaped comment thread — kind comment, suggestion null", () => {
277
+ // Every thread written before suggestions existed arrives exactly like
278
+ // this: the backend projects kind `comment` and suggestion null onto old
279
+ // rows, so the pre-suggestion shape plus those two fields must keep
280
+ // parsing untouched.
281
+ expect(CommentThreadSchema.safeParse(makeThread()).success).toBe(true);
282
+ });
283
+
284
+ it("requires kind — the backend always projects it, so absence is malformed", () => {
285
+ const { kind: _dropped, ...withoutKind } = makeThread();
286
+ expect(CommentThreadSchema.safeParse(withoutKind).success).toBe(false);
287
+ });
288
+
289
+ it("parses a decided suggestion via the two-level status model", () => {
290
+ // The thread enum is NOT widened: a decided suggestion is thread status
291
+ // `resolved` with suggestion.status carrying which way it went, and
292
+ // resolvedByUserId/resolvedAt doubling as the decision actor/time.
293
+ expect(
294
+ CommentThreadSchema.safeParse(
295
+ makeThread({
296
+ kind: "suggestion",
297
+ suggestion: { v: 1, op: "delete", status: "accepted" },
298
+ anchorType: "text",
299
+ status: "resolved",
300
+ resolvedByUserId: USER_ID,
301
+ resolvedAt: "2026-08-02T09:00:00.000Z",
302
+ comments: [],
303
+ }),
304
+ ).success,
305
+ ).toBe(true);
306
+ });
307
+
308
+ it("rejects a suggestion payload outside the published shape", () => {
309
+ // SuggestionSchema is strict — an unknown field means a newer dialect,
310
+ // which must arrive as a new `v`, not as loose extras.
311
+ expect(
312
+ CommentThreadSchema.safeParse(
313
+ makeThread({
314
+ kind: "suggestion",
315
+ suggestion: { v: 1, op: "delete", status: "open", extra: true },
316
+ }),
317
+ ).success,
318
+ ).toBe(false);
319
+ });
320
+ });
321
+
243
322
  describe("CommentThreadListResponseSchema", () => {
244
323
  it("parses an empty subject", () => {
245
324
  expect(
@@ -0,0 +1,130 @@
1
+ /**
2
+ * The suggestion payload vocabulary, held to the invariants its JSDoc claims
3
+ * (ADR slug suggested-edits-wire-vocabulary).
4
+ *
5
+ * Every negative test mutates ONE field of a well-formed factory result, for
6
+ * the reason `./schemas.test.ts` states: a hand-built broken object can pass
7
+ * for the wrong reason — it fails on the field nobody was testing.
8
+ */
9
+ import { describe, expect, it } from "vitest";
10
+
11
+ import {
12
+ COMMENT_THREAD_KINDS,
13
+ SUGGESTION_OPS,
14
+ SUGGESTION_STATUSES,
15
+ SuggestionSchema,
16
+ } from "../suggestion.js";
17
+
18
+ function makeSuggestion(over: Record<string, unknown> = {}) {
19
+ return {
20
+ v: 1,
21
+ op: "replace",
22
+ insertedText: "ship the quarterly goals by April",
23
+ status: "open",
24
+ ...over,
25
+ };
26
+ }
27
+
28
+ describe("the vocabulary tuples", () => {
29
+ it("COMMENT_THREAD_KINDS is exactly comment and suggestion", () => {
30
+ expect([...COMMENT_THREAD_KINDS]).toEqual(["comment", "suggestion"]);
31
+ });
32
+
33
+ it("SUGGESTION_OPS is exactly insert, delete and replace", () => {
34
+ expect([...SUGGESTION_OPS]).toEqual(["insert", "delete", "replace"]);
35
+ });
36
+
37
+ it("SUGGESTION_STATUSES is exactly open, accepted and rejected", () => {
38
+ // The SECOND status level. The thread-level enum stays open|resolved —
39
+ // `./schemas.test.ts` pins that separately — and the decision lives here.
40
+ expect([...SUGGESTION_STATUSES]).toEqual(["open", "accepted", "rejected"]);
41
+ });
42
+ });
43
+
44
+ describe("SuggestionSchema", () => {
45
+ it("parses an insert payload", () => {
46
+ expect(
47
+ SuggestionSchema.safeParse(
48
+ makeSuggestion({ op: "insert", insertedText: "new sentence" }),
49
+ ).success,
50
+ ).toBe(true);
51
+ });
52
+
53
+ it("parses a replace payload", () => {
54
+ expect(SuggestionSchema.safeParse(makeSuggestion()).success).toBe(true);
55
+ });
56
+
57
+ it("parses a delete payload WITHOUT insertedText", () => {
58
+ // The reason insertedText is optional in the READ schema: a delete has
59
+ // nothing to insert. "Required for insert/replace" is the backend request
60
+ // schema's superRefine, deliberately not re-stated here.
61
+ const { insertedText: _dropped, ...deletePayload } = makeSuggestion({
62
+ op: "delete",
63
+ });
64
+ expect(SuggestionSchema.safeParse(deletePayload).success).toBe(true);
65
+ });
66
+
67
+ it("parses a decided payload", () => {
68
+ expect(
69
+ SuggestionSchema.safeParse(makeSuggestion({ status: "accepted" }))
70
+ .success,
71
+ ).toBe(true);
72
+ expect(
73
+ SuggestionSchema.safeParse(makeSuggestion({ status: "rejected" }))
74
+ .success,
75
+ ).toBe(true);
76
+ });
77
+
78
+ it("rejects an op outside the vocabulary", () => {
79
+ expect(
80
+ SuggestionSchema.safeParse(makeSuggestion({ op: "move" })).success,
81
+ ).toBe(false);
82
+ });
83
+
84
+ it("rejects a status outside the vocabulary", () => {
85
+ // Notably `resolved`: that is the THREAD's terminal state, not the
86
+ // suggestion's. A payload claiming it means the writer confused the two
87
+ // status levels.
88
+ expect(
89
+ SuggestionSchema.safeParse(makeSuggestion({ status: "resolved" }))
90
+ .success,
91
+ ).toBe(false);
92
+ });
93
+
94
+ it("rejects an empty insertedText — absent and empty stay distinguishable", () => {
95
+ expect(
96
+ SuggestionSchema.safeParse(makeSuggestion({ insertedText: "" })).success,
97
+ ).toBe(false);
98
+ });
99
+
100
+ it("rejects insertedText over 10000 code units", () => {
101
+ expect(
102
+ SuggestionSchema.safeParse(
103
+ makeSuggestion({ insertedText: "x".repeat(10001) }),
104
+ ).success,
105
+ ).toBe(false);
106
+ expect(
107
+ SuggestionSchema.safeParse(
108
+ makeSuggestion({ insertedText: "x".repeat(10000) }),
109
+ ).success,
110
+ ).toBe(true);
111
+ });
112
+
113
+ it("rejects unknown keys — a newer dialect must arrive as a new v", () => {
114
+ expect(
115
+ SuggestionSchema.safeParse(makeSuggestion({ decidedBy: "someone" }))
116
+ .success,
117
+ ).toBe(false);
118
+ });
119
+
120
+ it("rejects any v other than 1", () => {
121
+ expect(SuggestionSchema.safeParse(makeSuggestion({ v: 2 })).success).toBe(
122
+ false,
123
+ );
124
+ });
125
+
126
+ it("requires status — the projection always composes it", () => {
127
+ const { status: _dropped, ...withoutStatus } = makeSuggestion();
128
+ expect(SuggestionSchema.safeParse(withoutStatus).success).toBe(false);
129
+ });
130
+ });
@@ -96,14 +96,73 @@ const TextAnchorSchema = z.strictObject({
96
96
  });
97
97
 
98
98
  /**
99
- * The anchor types, as a vocabulary tuple.
99
+ * A ZERO-WIDTH insertion point where an `insert` suggestion proposes to add
100
+ * text (ADR slug suggested-edits-wire-vocabulary). This is a REAL
101
+ * representation of a point between two characters, deliberately NOT an anchor
102
+ * to an adjacent quoted run of existing text: a persisted suggestion outlives
103
+ * concurrent edits, and an adjacency convention ("insert after this quote")
104
+ * resolves against whichever run happens to survive — the wrong one, silently.
105
+ *
106
+ * `relPos` is a single `Y.RelativePosition` at the caret, OPTIONAL for the
107
+ * same reason `relStart`/`relEnd` are on the text anchor: a client without an
108
+ * attached collab session cannot produce one. Optional means "this client
109
+ * could not", never "this client chose not to".
110
+ *
111
+ * `leftContext`/`rightContext` are the durable fallback — the text
112
+ * immediately before and after the point. They are required-but-may-be-empty
113
+ * (max 64 UTF-16 code units each, like `prefix`/`suffix`): either may be
114
+ * empty or short at a document edge, and an empty string means "there is
115
+ * nothing on that side", which must stay distinguishable from "this client
116
+ * forgot to send it". No Unicode normalization happens anywhere on this path.
117
+ *
118
+ * RESOLUTION SEMANTICS (implemented client-side in the app; the backend is a
119
+ * passthrough that interprets nothing):
120
+ * 1. Decode `relPos` and verify BOTH contexts around the decoded point. A
121
+ * point whose surroundings no longer match is STALE, not correct — the
122
+ * same rule as the text anchor's quote check.
123
+ * 2. Search the document for the `leftContext`+`rightContext` seam. Only a
124
+ * UNIQUE match resolves; any ambiguity (the seam occurs more than once)
125
+ * resolves ORPHANED, never a guess.
126
+ * 3. Orphaned.
127
+ *
128
+ * CONTRACTS-FIRST ORDERING NOTE: unlike the two variants above, this variant
129
+ * is published here BEFORE the backend route boundary adopts it (PRD-00934
130
+ * lands the backend half against the npm-published version of this package).
131
+ * The mirrored corpus gained its text-insertion cases HERE, contracts-first,
132
+ * via the deliberate same-diff digest re-pin ADR-CONTRACTS-116 allows; the
133
+ * backend copy catches up in PRD-00934, and until it does the upstream diff
134
+ * shows expected forward drift (do not "fix" it with anchor-corpus:sync).
135
+ *
136
+ * SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00933, must_log).
137
+ * The reference writes these declarations with single-quoted strings; what
138
+ * ships below is double-quoted because that is this repo's Prettier default
139
+ * (no `.prettierrc`, so `singleQuote` is false). Same precedent as the
140
+ * PRD-00922 note above: a different spelling of the same schema, nothing
141
+ * about what is accepted or refused changed.
142
+ */
143
+ const TextInsertionAnchorSchema = z.strictObject({
144
+ type: z.literal("text-insertion"),
145
+ v: z.literal(1),
146
+ relPos: RelativePositionSchema.optional(),
147
+ leftContext: z.string().max(64),
148
+ rightContext: z.string().max(64),
149
+ });
150
+
151
+ /**
152
+ * The anchor types, as a vocabulary tuple:
153
+ * `["document", "text", "text-insertion"]` (widened by PRD-00933; Prettier
154
+ * wraps the declaration below because it exceeds the print width).
100
155
  *
101
156
  * Mirrors the union's discriminants, and is what `CommentThreadSummarySchema`'s
102
157
  * denormalised `anchorType` column is checked against — the server projects the
103
158
  * discriminant out of the jsonb so a client can filter threads without parsing
104
159
  * every anchor.
105
160
  */
106
- export const COMMENT_ANCHOR_TYPES = ["document", "text"] as const;
161
+ export const COMMENT_ANCHOR_TYPES = [
162
+ "document",
163
+ "text",
164
+ "text-insertion",
165
+ ] as const;
107
166
  export const CommentAnchorTypeSchema = z.enum(COMMENT_ANCHOR_TYPES);
108
167
  export type CommentAnchorType = z.infer<typeof CommentAnchorTypeSchema>;
109
168
 
@@ -116,5 +175,6 @@ export type CommentAnchorType = z.infer<typeof CommentAnchorTypeSchema>;
116
175
  export const CommentAnchorSchema = z.discriminatedUnion("type", [
117
176
  DocumentAnchorSchema,
118
177
  TextAnchorSchema,
178
+ TextInsertionAnchorSchema,
119
179
  ]);
120
180
  export type CommentAnchor = z.infer<typeof CommentAnchorSchema>;
@@ -41,3 +41,17 @@ export type {
41
41
  MentionableCandidate,
42
42
  MentionableResponse,
43
43
  } from "./schemas";
44
+
45
+ export {
46
+ COMMENT_THREAD_KINDS,
47
+ SUGGESTION_OPS,
48
+ SUGGESTION_STATUSES,
49
+ SuggestionSchema,
50
+ } from "./suggestion";
51
+
52
+ export type {
53
+ CommentThreadKind,
54
+ Suggestion,
55
+ SuggestionOp,
56
+ SuggestionStatus,
57
+ } from "./suggestion";
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { z } from "zod";
21
21
  import { CommentAnchorSchema, CommentAnchorTypeSchema } from "./anchor";
22
+ import { COMMENT_THREAD_KINDS, SuggestionSchema } from "./suggestion";
22
23
 
23
24
  // =============================================================================
24
25
  // Vocabulary
@@ -154,11 +155,30 @@ export type CommentProjection = z.infer<typeof CommentSchema>;
154
155
  * the server. It is redundant with `anchor.type` and that is the point: a client
155
156
  * can bucket threads into document-level and text-level without parsing every
156
157
  * anchor, and the two are written together so they cannot disagree.
158
+ *
159
+ * `kind` is REQUIRED, never optional: the backend always projects it, and
160
+ * `comment` is every thread written before suggestions existed — so absence is
161
+ * a malformed response, not a legacy row (see `COMMENT_THREAD_KINDS`).
162
+ *
163
+ * `suggestion` is null EXACTLY WHEN `kind` is `comment`. Like the redaction
164
+ * invariant on `CommentSchema`, this null-iff rule is DOCUMENTED here and
165
+ * enforced by backend CHECK constraints, not by a Zod refinement — a refinement
166
+ * would make this package refuse a response the server is willing to emit and
167
+ * would be a third wire description no parity guard can check.
168
+ *
169
+ * THE TWO-LEVEL STATUS MODEL: `status` stays exactly `open | resolved`
170
+ * (`COMMENT_THREAD_STATUSES` is NOT widened — widening a closed published enum
171
+ * is a parse outage for every deployed client). A decided suggestion arrives as
172
+ * thread status `resolved` with `suggestion.status` carrying which way it went
173
+ * (`accepted` | `rejected`), and `resolvedByUserId`/`resolvedAt` double as the
174
+ * decision actor and time — there is no second actor/timestamp pair.
157
175
  */
158
176
  export const CommentThreadSummarySchema = z.object({
159
177
  id: z.string(),
160
178
  subjectType: CommentSubjectTypeSchema,
161
179
  subjectId: z.string(),
180
+ kind: z.enum(COMMENT_THREAD_KINDS),
181
+ suggestion: SuggestionSchema.nullable(),
162
182
  anchorType: CommentAnchorTypeSchema,
163
183
  anchor: CommentAnchorSchema,
164
184
  status: CommentThreadStatusSchema,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The suggestion payload — the proposed document edit a suggestion thread
3
+ * carries (ADR slug suggested-edits-wire-vocabulary).
4
+ *
5
+ * READ VOCABULARY ONLY, like the rest of this directory: the create/accept/
6
+ * reject request bodies stay backend-side per ADR-CONT-029. This module is
7
+ * deliberately a LEAF — it imports nothing but `zod`, because `./schemas` will
8
+ * import from it (the thread summary's `kind`/`suggestion` projection) and an
9
+ * import back the other way would be a cycle.
10
+ *
11
+ * Zod-canonical: the schema is the source of truth, the type is inferred.
12
+ *
13
+ * SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00933, must_log).
14
+ * The reference block writes these declarations with single-quoted strings;
15
+ * what ships below is double-quoted because that is this repo's Prettier
16
+ * default (there is no `.prettierrc`, so `singleQuote` is false and
17
+ * `pnpm format:check` rewrites any single-quoted string here). Same precedent
18
+ * as `./anchor`'s PRD-00922 note: a different spelling of the same schema,
19
+ * nothing about what is accepted or refused changed.
20
+ */
21
+ import { z } from "zod";
22
+
23
+ /**
24
+ * Thread kinds. A suggestion IS a comment thread — same table, same replies,
25
+ * mentions and notifications — carrying a proposed document edit. `comment`
26
+ * is every thread written before suggestions existed, which is why the
27
+ * backend always projects this field rather than leaving it optional.
28
+ */
29
+ export const COMMENT_THREAD_KINDS = ["comment", "suggestion"] as const;
30
+ export type CommentThreadKind = (typeof COMMENT_THREAD_KINDS)[number];
31
+
32
+ /**
33
+ * What a suggestion proposes doing to the anchored range: `insert` at a
34
+ * zero-width point, `delete` the anchored quote, or `replace` it.
35
+ */
36
+ export const SUGGESTION_OPS = ["insert", "delete", "replace"] as const;
37
+ export type SuggestionOp = (typeof SUGGESTION_OPS)[number];
38
+
39
+ /**
40
+ * open -> accepted | rejected. Terminal states are terminal: there is no
41
+ * reopen for a decided suggestion (unlike comment resolve/reopen). This is
42
+ * the SECOND status level — the thread-level `status` enum stays exactly
43
+ * `open | resolved`, and a decided suggestion arrives as thread status
44
+ * `resolved` with this field carrying which way it went. Widening the closed
45
+ * thread enum instead would be a parse outage for every deployed client.
46
+ */
47
+ export const SUGGESTION_STATUSES = ["open", "accepted", "rejected"] as const;
48
+ export type SuggestionStatus = (typeof SUGGESTION_STATUSES)[number];
49
+
50
+ /**
51
+ * Versioned suggestion payload. Evolution = new `v` variants, never mutation
52
+ * (same rule as CommentAnchor). `status` is composed by the backend read
53
+ * projection from its dedicated status column — the stored jsonb does NOT
54
+ * carry it; one truth per field.
55
+ *
56
+ * `insertedText`: required for insert/replace, absent for delete — enforced
57
+ * by the backend request schema's superRefine, not here. This READ schema
58
+ * keeps it optional so a delete payload parses without it; refining here
59
+ * would make this package refuse a response the server is willing to emit
60
+ * (the same reasoning as `./schemas`' redaction invariant).
61
+ *
62
+ * Strict: a payload carrying fields outside this shape is refused rather than
63
+ * accepted with the extras dropped — an unknown field means the writer was
64
+ * speaking a newer dialect, and that must arrive as a new `v`, not as loose
65
+ * extras a resolving client half-recognises.
66
+ */
67
+ export const SuggestionSchema = z.strictObject({
68
+ v: z.literal(1),
69
+ op: z.enum(SUGGESTION_OPS),
70
+ insertedText: z.string().min(1).max(10000).optional(),
71
+ status: z.enum(SUGGESTION_STATUSES),
72
+ });
73
+ export type Suggestion = z.infer<typeof SuggestionSchema>;
package/src/index.ts CHANGED
@@ -333,7 +333,10 @@ export type {
333
333
  export {
334
334
  COMMENT_ANCHOR_TYPES,
335
335
  COMMENT_SUBJECT_TYPES,
336
+ COMMENT_THREAD_KINDS,
336
337
  COMMENT_THREAD_STATUSES,
338
+ SUGGESTION_OPS,
339
+ SUGGESTION_STATUSES,
337
340
  } from "./comments/index";
338
341
 
339
342
  export {
@@ -348,6 +351,7 @@ export {
348
351
  CommentThreadSummarySchema,
349
352
  MentionableCandidateSchema,
350
353
  MentionableResponseSchema,
354
+ SuggestionSchema,
351
355
  } from "./comments/index";
352
356
 
353
357
  export type {
@@ -357,11 +361,15 @@ export type {
357
361
  CommentProjection,
358
362
  CommentSubjectType,
359
363
  CommentThread,
364
+ CommentThreadKind,
360
365
  CommentThreadListResponse,
361
366
  CommentThreadStatus,
362
367
  CommentThreadSummary,
363
368
  MentionableCandidate,
364
369
  MentionableResponse,
370
+ Suggestion,
371
+ SuggestionOp,
372
+ SuggestionStatus,
365
373
  } from "./comments/index";
366
374
 
367
375
  // Chat domain types