@company-semantics/contracts 48.0.0 → 50.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": "50.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '6aca68a12d82' as const;
3
- export const SPEC_HASH_FULL = '6aca68a12d828a1efb9e1d1865a3955e8540d9c83563d2d6be6b04ae3eac7da1' as const;
2
+ export const SPEC_HASH = '7ed2b7218b4b' as const;
3
+ export const SPEC_HASH_FULL = '7ed2b7218b4b4a160ba6e9d8854f2e9277d540c33658448d9a0452e96f1cd581' as const;
@@ -3565,6 +3565,74 @@ export interface paths {
3565
3565
  patch?: never;
3566
3566
  trace?: never;
3567
3567
  };
3568
+ "/api/comments/threads/{threadId}/accept-intent": {
3569
+ parameters: {
3570
+ query?: never;
3571
+ header?: never;
3572
+ path?: never;
3573
+ cookie?: never;
3574
+ };
3575
+ get?: never;
3576
+ put?: never;
3577
+ /** Claim the acceptance lease on an open suggestion */
3578
+ post: operations["acceptSuggestionIntent"];
3579
+ delete?: never;
3580
+ options?: never;
3581
+ head?: never;
3582
+ patch?: never;
3583
+ trace?: never;
3584
+ };
3585
+ "/api/comments/threads/{threadId}/accept-complete": {
3586
+ parameters: {
3587
+ query?: never;
3588
+ header?: never;
3589
+ path?: never;
3590
+ cookie?: never;
3591
+ };
3592
+ get?: never;
3593
+ put?: never;
3594
+ /** Record the accept decision by claim token */
3595
+ post: operations["acceptSuggestionComplete"];
3596
+ delete?: never;
3597
+ options?: never;
3598
+ head?: never;
3599
+ patch?: never;
3600
+ trace?: never;
3601
+ };
3602
+ "/api/comments/threads/{threadId}/accept-release": {
3603
+ parameters: {
3604
+ query?: never;
3605
+ header?: never;
3606
+ path?: never;
3607
+ cookie?: never;
3608
+ };
3609
+ get?: never;
3610
+ put?: never;
3611
+ /** Release a held acceptance lease without deciding */
3612
+ post: operations["releaseSuggestionClaim"];
3613
+ delete?: never;
3614
+ options?: never;
3615
+ head?: never;
3616
+ patch?: never;
3617
+ trace?: never;
3618
+ };
3619
+ "/api/comments/threads/{threadId}/reject": {
3620
+ parameters: {
3621
+ query?: never;
3622
+ header?: never;
3623
+ path?: never;
3624
+ cookie?: never;
3625
+ };
3626
+ get?: never;
3627
+ put?: never;
3628
+ /** Reject an open suggestion (or withdraw as its author) */
3629
+ post: operations["rejectSuggestion"];
3630
+ delete?: never;
3631
+ options?: never;
3632
+ head?: never;
3633
+ patch?: never;
3634
+ trace?: never;
3635
+ };
3568
3636
  }
3569
3637
  export type webhooks = Record<string, never>;
3570
3638
  export interface components {
@@ -6397,7 +6465,16 @@ export interface components {
6397
6465
  /** Format: uuid */
6398
6466
  subjectId: string;
6399
6467
  anchor: components["schemas"]["CommentAnchor"];
6400
- body: string;
6468
+ /** @enum {string} */
6469
+ kind?: "comment" | "suggestion";
6470
+ suggestion?: {
6471
+ /** @constant */
6472
+ v: 1;
6473
+ /** @enum {string} */
6474
+ op: "insert" | "delete" | "replace";
6475
+ insertedText?: string;
6476
+ };
6477
+ body?: string;
6401
6478
  mentions?: {
6402
6479
  /** Format: uuid */
6403
6480
  userId: string;
@@ -6436,6 +6513,14 @@ export interface components {
6436
6513
  CommentThreadSummaryResponse: {
6437
6514
  thread: components["schemas"]["CommentThreadSummary"];
6438
6515
  };
6516
+ /** @description The acceptance lease: the opaque claim token that alone can complete the accept, and how long the lease lives. */
6517
+ SuggestionClaimResponse: {
6518
+ claimToken: string;
6519
+ leaseDurationMs: number;
6520
+ };
6521
+ SuggestionClaimRequest: {
6522
+ claimToken: string;
6523
+ };
6439
6524
  /** @description Server-enforced collaboration size limits, published so clients can pre-check. */
6440
6525
  CollabSyncLimits: {
6441
6526
  maxUpdateBytes: number;
@@ -6473,10 +6558,13 @@ export interface components {
6473
6558
  subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
6474
6559
  subjectId: string;
6475
6560
  /** @enum {string} */
6476
- anchorType: "document" | "text";
6561
+ anchorType: "document" | "text" | "text-insertion";
6477
6562
  anchor: components["schemas"]["CommentAnchor"];
6478
6563
  /** @enum {string} */
6479
6564
  status: "open" | "resolved";
6565
+ /** @enum {string} */
6566
+ kind: "comment" | "suggestion";
6567
+ suggestion: components["schemas"]["Suggestion"] | null;
6480
6568
  createdByUserId: string | null;
6481
6569
  resolvedByUserId: string | null;
6482
6570
  resolvedAt: string | null;
@@ -6484,7 +6572,7 @@ export interface components {
6484
6572
  updatedAt: string;
6485
6573
  comments: components["schemas"]["Comment"][];
6486
6574
  };
6487
- /** @description Opaque, versioned comment anchor: document-level, or a text anchor with a required quote and optional relative positions. */
6575
+ /** @description Opaque, versioned comment anchor: document-level, a text anchor with a required quote and optional relative positions, or a zero-width text-insertion point for insert suggestions. */
6488
6576
  CommentAnchor: {
6489
6577
  /** @constant */
6490
6578
  type: "document";
@@ -6500,6 +6588,24 @@ export interface components {
6500
6588
  quote: string;
6501
6589
  prefix: string;
6502
6590
  suffix: string;
6591
+ } | {
6592
+ /** @constant */
6593
+ type: "text-insertion";
6594
+ /** @constant */
6595
+ v: 1;
6596
+ relPos?: string;
6597
+ leftContext: string;
6598
+ rightContext: string;
6599
+ };
6600
+ /** @description A suggested edit as proposed: the operation, its insertedText (insert/replace only), and the decision status composed from the thread row. */
6601
+ Suggestion: {
6602
+ /** @constant */
6603
+ v: 1;
6604
+ /** @enum {string} */
6605
+ op: "insert" | "delete" | "replace";
6606
+ insertedText?: string;
6607
+ /** @enum {string} */
6608
+ status: "open" | "accepted" | "rejected";
6503
6609
  };
6504
6610
  /** @description One comment; body is null iff soft-deleted (redacted projection with empty mentions). */
6505
6611
  Comment: {
@@ -6526,10 +6632,13 @@ export interface components {
6526
6632
  subjectType: "company_md" | "strategy_doc" | "work_item" | "meeting_recording";
6527
6633
  subjectId: string;
6528
6634
  /** @enum {string} */
6529
- anchorType: "document" | "text";
6635
+ anchorType: "document" | "text" | "text-insertion";
6530
6636
  anchor: components["schemas"]["CommentAnchor"];
6531
6637
  /** @enum {string} */
6532
6638
  status: "open" | "resolved";
6639
+ /** @enum {string} */
6640
+ kind: "comment" | "suggestion";
6641
+ suggestion: components["schemas"]["Suggestion"] | null;
6533
6642
  createdByUserId: string | null;
6534
6643
  resolvedByUserId: string | null;
6535
6644
  resolvedAt: string | null;
@@ -12635,6 +12744,13 @@ export interface operations {
12635
12744
  };
12636
12745
  content?: never;
12637
12746
  };
12747
+ /** @description Not a comment thread: a suggestion is decided via accept or reject, never resolved */
12748
+ 409: {
12749
+ headers: {
12750
+ [name: string]: unknown;
12751
+ };
12752
+ content?: never;
12753
+ };
12638
12754
  };
12639
12755
  };
12640
12756
  reopenCommentThread: {
@@ -12664,6 +12780,179 @@ export interface operations {
12664
12780
  };
12665
12781
  content?: never;
12666
12782
  };
12783
+ /** @description Not a comment thread: a decided suggestion is terminal and never reopens */
12784
+ 409: {
12785
+ headers: {
12786
+ [name: string]: unknown;
12787
+ };
12788
+ content?: never;
12789
+ };
12790
+ };
12791
+ };
12792
+ acceptSuggestionIntent: {
12793
+ parameters: {
12794
+ query?: never;
12795
+ header?: never;
12796
+ path: {
12797
+ threadId: string;
12798
+ };
12799
+ cookie?: never;
12800
+ };
12801
+ requestBody?: never;
12802
+ responses: {
12803
+ /** @description Lease claimed: the opaque claim token (the only artifact that can complete the accept) and its duration */
12804
+ 200: {
12805
+ headers: {
12806
+ [name: string]: unknown;
12807
+ };
12808
+ content: {
12809
+ "application/json": components["schemas"]["SuggestionClaimResponse"];
12810
+ };
12811
+ };
12812
+ /** @description Thread not found, or the caller may not decide suggestions on it */
12813
+ 404: {
12814
+ headers: {
12815
+ [name: string]: unknown;
12816
+ };
12817
+ content?: never;
12818
+ };
12819
+ /** @description Not a suggestion thread, already claimed by a live lease, or already decided */
12820
+ 409: {
12821
+ headers: {
12822
+ [name: string]: unknown;
12823
+ };
12824
+ content?: never;
12825
+ };
12826
+ };
12827
+ };
12828
+ acceptSuggestionComplete: {
12829
+ parameters: {
12830
+ query?: never;
12831
+ header?: never;
12832
+ path: {
12833
+ threadId: string;
12834
+ };
12835
+ cookie?: never;
12836
+ };
12837
+ requestBody: {
12838
+ content: {
12839
+ "application/json": components["schemas"]["SuggestionClaimRequest"];
12840
+ };
12841
+ };
12842
+ responses: {
12843
+ /** @description Suggestion accepted (thread resolved); valid after lease expiry and retry-safe for the same actor */
12844
+ 200: {
12845
+ headers: {
12846
+ [name: string]: unknown;
12847
+ };
12848
+ content: {
12849
+ "application/json": components["schemas"]["CommentThreadSummaryResponse"];
12850
+ };
12851
+ };
12852
+ /** @description Malformed claim token */
12853
+ 400: {
12854
+ headers: {
12855
+ [name: string]: unknown;
12856
+ };
12857
+ content?: never;
12858
+ };
12859
+ /** @description Thread not found, or the caller may not read its subject */
12860
+ 404: {
12861
+ headers: {
12862
+ [name: string]: unknown;
12863
+ };
12864
+ content?: never;
12865
+ };
12866
+ /** @description Not a suggestion thread, or the token does not match a live claim on an open suggestion */
12867
+ 409: {
12868
+ headers: {
12869
+ [name: string]: unknown;
12870
+ };
12871
+ content?: never;
12872
+ };
12873
+ };
12874
+ };
12875
+ releaseSuggestionClaim: {
12876
+ parameters: {
12877
+ query?: never;
12878
+ header?: never;
12879
+ path: {
12880
+ threadId: string;
12881
+ };
12882
+ cookie?: never;
12883
+ };
12884
+ requestBody: {
12885
+ content: {
12886
+ "application/json": components["schemas"]["SuggestionClaimRequest"];
12887
+ };
12888
+ };
12889
+ responses: {
12890
+ /** @description Lease released; the suggestion stays open and claimable */
12891
+ 200: {
12892
+ headers: {
12893
+ [name: string]: unknown;
12894
+ };
12895
+ content: {
12896
+ "application/json": components["schemas"]["CommentThreadSummaryResponse"];
12897
+ };
12898
+ };
12899
+ /** @description Malformed claim token */
12900
+ 400: {
12901
+ headers: {
12902
+ [name: string]: unknown;
12903
+ };
12904
+ content?: never;
12905
+ };
12906
+ /** @description Thread not found, or the caller may not read its subject */
12907
+ 404: {
12908
+ headers: {
12909
+ [name: string]: unknown;
12910
+ };
12911
+ content?: never;
12912
+ };
12913
+ /** @description Not a suggestion thread, or the token does not match a live claim on an open suggestion */
12914
+ 409: {
12915
+ headers: {
12916
+ [name: string]: unknown;
12917
+ };
12918
+ content?: never;
12919
+ };
12920
+ };
12921
+ };
12922
+ rejectSuggestion: {
12923
+ parameters: {
12924
+ query?: never;
12925
+ header?: never;
12926
+ path: {
12927
+ threadId: string;
12928
+ };
12929
+ cookie?: never;
12930
+ };
12931
+ requestBody?: never;
12932
+ responses: {
12933
+ /** @description Suggestion rejected (thread resolved) */
12934
+ 200: {
12935
+ headers: {
12936
+ [name: string]: unknown;
12937
+ };
12938
+ content: {
12939
+ "application/json": components["schemas"]["CommentThreadSummaryResponse"];
12940
+ };
12941
+ };
12942
+ /** @description Thread not found, or the caller may not decide suggestions on it */
12943
+ 404: {
12944
+ headers: {
12945
+ [name: string]: unknown;
12946
+ };
12947
+ content?: never;
12948
+ };
12949
+ /** @description Not a suggestion thread, a live acceptance lease is held, or already decided */
12950
+ 409: {
12951
+ headers: {
12952
+ [name: string]: unknown;
12953
+ };
12954
+ content?: never;
12955
+ };
12667
12956
  };
12668
12957
  };
12669
12958
  }
@@ -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>;
@@ -23,7 +23,11 @@ export const openApiRoutes = {
23
23
  '/api/chats/{id}/pin': ['DELETE', 'POST'],
24
24
  '/api/comments': ['GET'],
25
25
  '/api/comments/threads': ['POST'],
26
+ '/api/comments/threads/{threadId}/accept-complete': ['POST'],
27
+ '/api/comments/threads/{threadId}/accept-intent': ['POST'],
28
+ '/api/comments/threads/{threadId}/accept-release': ['POST'],
26
29
  '/api/comments/threads/{threadId}/comments': ['POST'],
30
+ '/api/comments/threads/{threadId}/reject': ['POST'],
27
31
  '/api/comments/threads/{threadId}/reopen': ['POST'],
28
32
  '/api/comments/threads/{threadId}/resolve': ['POST'],
29
33
  '/api/comments/{commentId}': ['DELETE', 'PATCH'],
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