@company-semantics/contracts 45.2.0 → 45.3.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.
Files changed (31) hide show
  1. package/package.json +9 -2
  2. package/src/__tests__/resource-keys.test.ts +59 -0
  3. package/src/api/generated-spec-hash.ts +2 -2
  4. package/src/api/generated.ts +93 -2
  5. package/src/comments/README.md +115 -0
  6. package/src/comments/__tests__/README.md +49 -0
  7. package/src/comments/__tests__/anchor-corpus.test.ts +202 -0
  8. package/src/comments/__tests__/fixtures/README.md +58 -0
  9. package/src/comments/__tests__/fixtures/comment-anchors.json +94 -0
  10. package/src/comments/__tests__/fixtures/comment-anchors.provenance.json +9 -0
  11. package/src/comments/__tests__/schemas.test.ts +274 -0
  12. package/src/comments/anchor.ts +120 -0
  13. package/src/comments/index.ts +43 -0
  14. package/src/comments/schemas.ts +227 -0
  15. package/src/generated/openapi-routes.ts +1 -0
  16. package/src/index.ts +42 -0
  17. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +4 -0
  18. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +741 -0
  19. package/src/notifications/__tests__/definition.test.ts +4 -3
  20. package/src/notifications/__tests__/fixtures.ts +34 -0
  21. package/src/notifications/__tests__/kinds.test.ts +11 -4
  22. package/src/notifications/__tests__/registry.test.ts +7 -5
  23. package/src/notifications/__tests__/render-snapshot.test.ts +36 -0
  24. package/src/notifications/kinds/comment-mention.ts +82 -0
  25. package/src/notifications/kinds/comment-reply.ts +79 -0
  26. package/src/notifications/kinds/index.ts +2 -0
  27. package/src/notifications/kinds.ts +12 -3
  28. package/src/notifications/payloads.ts +49 -0
  29. package/src/notifications/registry.ts +6 -2
  30. package/src/resource-keys.ts +68 -0
  31. package/src/user-notifications/kinds.ts +13 -1
@@ -0,0 +1,58 @@
1
+ # comments/\_\_tests\_\_/fixtures/
2
+
3
+ ## Purpose
4
+
5
+ Holds `comment-anchors.json`, the **stable contract corpus** for the comment
6
+ anchor — named accept and reject cases that `CommentAnchorSchema` is run over by
7
+ `../anchor-corpus.test.ts`.
8
+
9
+ The file is a directory of its own rather than a `fixtures.ts` module (the shape
10
+ used elsewhere under `src/`) for one reason: it is **not this repo's file**. It
11
+ is a byte-for-byte copy of
12
+ `company-semantics-backend/tests/fixtures/comment-anchors.json`, and keeping it
13
+ as inert data in a directory of its own is what makes "copied, never edited"
14
+ obvious to the next reader.
15
+
16
+ ## Invariants
17
+
18
+ - **The file is copied BYTE-FOR-BYTE from the backend suite.** The backend route
19
+ boundary is the only place a comment anchor is ever validated (it stores
20
+ opaque jsonb and interprets nothing), so the corpus the boundary is tested
21
+ with and the corpus the published contract is tested with must be the same
22
+ bytes. If they were merely "kept in sync by hand", the one risk this whole
23
+ domain exists to manage — silent app/contracts drift on a shape no server
24
+ checks — would be back.
25
+ - **Only ONE direction of that copy is gated, and it is this one.**
26
+ `comment-anchors.provenance.json` pins the copy's sha256 and byte length, and
27
+ `../anchor-corpus.test.ts` re-derives both on every run — so an edit made
28
+ here fails CI. A change made in the BACKEND file is invisible from this repo:
29
+ contracts CI checks out one repo and must not depend on the backend to look.
30
+ `pnpm anchor-corpus:check` diffs the live upstream file when a backend tree is
31
+ reachable and says so when it is not. See ADR-CONTRACTS-116 for where the
32
+ upstream gate belongs (backend CI, via `node_modules`).
33
+ - **Never hand-tune this file.** Change the anchor schema and the corpus
34
+ together, in the backend, then re-copy with `pnpm anchor-corpus:sync`, which
35
+ rewrites the pin in the same step. Editing this side to make a test pass fails
36
+ the pin; editing it and re-pinning by hand defeats the check, and shows up as
37
+ exactly that in the diff.
38
+ - **It is listed in `/.prettierignore`.** Prettier reflows the long anchor
39
+ objects, which would break the byte identity (and fail the pin). The file
40
+ carries the backend's formatting, not this repo's. The pin beside it is NOT
41
+ ignored — it is this repo's file and Prettier owns its formatting.
42
+ - **Cases are addressed by `name`, not by index.** Both tests iterate the
43
+ `accept` and `reject` arrays and use each case's `name` as its test label, so
44
+ a case added upstream is covered here the moment it is copied over, with no
45
+ edit to the test.
46
+
47
+ ## Public API
48
+
49
+ None — test-only fixture data. `comment-anchors.provenance.json` is generated;
50
+ regenerate it with `pnpm anchor-corpus:sync` rather than editing it.
51
+
52
+ ## Dependencies
53
+
54
+ - Upstream source: `company-semantics-backend/tests/fixtures/comment-anchors.json`
55
+ (authored alongside the anchor boundary; see backend ADR slug
56
+ `polymorphic-comment-threads`).
57
+ - `scripts/anchor-corpus.ts` — writes the pin (`--sync`) and verifies it
58
+ (`--check`, the default).
@@ -0,0 +1,94 @@
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.",
3
+ "accept": [
4
+ {
5
+ "name": "document-anchor",
6
+ "anchor": { "type": "document", "v": 1 }
7
+ },
8
+ {
9
+ "name": "text-anchor-with-relative-positions",
10
+ "anchor": {
11
+ "type": "text",
12
+ "v": 1,
13
+ "relStart": "AAECAwQ=",
14
+ "relEnd": "BQYHCAk=",
15
+ "quote": "ship the quarterly goals",
16
+ "prefix": "agreed to ",
17
+ "suffix": " by March"
18
+ }
19
+ },
20
+ {
21
+ "name": "text-anchor-quote-only",
22
+ "anchor": {
23
+ "type": "text",
24
+ "v": 1,
25
+ "quote": "ship the quarterly goals",
26
+ "prefix": "",
27
+ "suffix": ""
28
+ }
29
+ }
30
+ ],
31
+ "reject": [
32
+ {
33
+ "name": "text-anchor-missing-quote",
34
+ "anchor": { "type": "text", "v": 1, "prefix": "agreed to ", "suffix": " by March" }
35
+ },
36
+ {
37
+ "name": "document-anchor-carrying-text-fields",
38
+ "anchor": {
39
+ "type": "document",
40
+ "v": 1,
41
+ "quote": "stray quote",
42
+ "prefix": "",
43
+ "suffix": ""
44
+ }
45
+ },
46
+ {
47
+ "name": "unknown-version-document",
48
+ "anchor": { "type": "document", "v": 2 }
49
+ },
50
+ {
51
+ "name": "unknown-version-text",
52
+ "anchor": { "type": "text", "v": 2, "quote": "q", "prefix": "", "suffix": "" }
53
+ },
54
+ {
55
+ "name": "unknown-anchor-type",
56
+ "anchor": { "type": "region", "v": 1 }
57
+ },
58
+ {
59
+ "name": "oversized-prefix",
60
+ "anchor": {
61
+ "type": "text",
62
+ "v": 1,
63
+ "quote": "q",
64
+ "prefix": "ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppp",
65
+ "suffix": ""
66
+ }
67
+ },
68
+ {
69
+ "name": "oversized-suffix",
70
+ "anchor": {
71
+ "type": "text",
72
+ "v": 1,
73
+ "quote": "q",
74
+ "prefix": "",
75
+ "suffix": "sssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssssss"
76
+ }
77
+ },
78
+ {
79
+ "name": "empty-quote",
80
+ "anchor": { "type": "text", "v": 1, "quote": "", "prefix": "", "suffix": "" }
81
+ },
82
+ {
83
+ "name": "relstart-not-base64",
84
+ "anchor": {
85
+ "type": "text",
86
+ "v": 1,
87
+ "relStart": "not base64!!",
88
+ "quote": "q",
89
+ "prefix": "",
90
+ "suffix": ""
91
+ }
92
+ }
93
+ ]
94
+ }
@@ -0,0 +1,9 @@
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.",
3
+ "source": {
4
+ "repo": "company-semantics-backend",
5
+ "path": "tests/fixtures/comment-anchors.json"
6
+ },
7
+ "bytes": 2649,
8
+ "sha256": "84fa0040c9acc166a5551185950e5dc8afffcc5fd63a1f17080422318b405c8c"
9
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * The thread / comment / mention projections, held to the invariants their
3
+ * JSDoc claims (ADR-CONTRACTS-116).
4
+ *
5
+ * Every negative test mutates ONE field of a well-formed factory result. A
6
+ * hand-built broken object can pass for the wrong reason — it fails on the
7
+ * field nobody was testing — and these shapes have enough required fields for
8
+ * that to happen easily.
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import {
13
+ COMMENT_SUBJECT_TYPES,
14
+ COMMENT_THREAD_STATUSES,
15
+ CommentMentionSchema,
16
+ CommentSchema,
17
+ CommentThreadListResponseSchema,
18
+ CommentThreadSchema,
19
+ CommentThreadStatusSchema,
20
+ MentionableResponseSchema,
21
+ } from "../schemas.js";
22
+
23
+ const THREAD_ID = "11111111-1111-4111-8111-111111111111";
24
+ const COMMENT_ID = "22222222-2222-4222-8222-222222222222";
25
+ const USER_ID = "33333333-3333-4333-8333-333333333333";
26
+ const DOC_ID = "44444444-4444-4444-8444-444444444444";
27
+
28
+ function makeMention(over: Record<string, unknown> = {}) {
29
+ return {
30
+ userId: USER_ID,
31
+ startOffset: 0,
32
+ endOffset: 9,
33
+ displayName: "Sam Chen",
34
+ ...over,
35
+ };
36
+ }
37
+
38
+ function makeComment(over: Record<string, unknown> = {}) {
39
+ return {
40
+ id: COMMENT_ID,
41
+ threadId: THREAD_ID,
42
+ authorUserId: USER_ID,
43
+ body: "Should this say Q3 or Q4?",
44
+ editedAt: null,
45
+ deletedAt: null,
46
+ createdAt: "2026-08-01T12:00:00.000Z",
47
+ mentions: [],
48
+ ...over,
49
+ };
50
+ }
51
+
52
+ function makeThread(over: Record<string, unknown> = {}) {
53
+ return {
54
+ id: THREAD_ID,
55
+ subjectType: "company_md",
56
+ subjectId: DOC_ID,
57
+ anchorType: "text",
58
+ anchor: {
59
+ type: "text",
60
+ v: 1,
61
+ quote: "ship the quarterly goals",
62
+ prefix: "agreed to ",
63
+ suffix: " by March",
64
+ },
65
+ status: "open",
66
+ createdByUserId: USER_ID,
67
+ resolvedByUserId: null,
68
+ resolvedAt: null,
69
+ createdAt: "2026-08-01T12:00:00.000Z",
70
+ updatedAt: "2026-08-01T12:00:00.000Z",
71
+ comments: [makeComment()],
72
+ ...over,
73
+ };
74
+ }
75
+
76
+ describe("CommentThreadStatus", () => {
77
+ it("is exactly open and resolved", () => {
78
+ expect([...COMMENT_THREAD_STATUSES]).toEqual(["open", "resolved"]);
79
+ });
80
+
81
+ it("rejects a thread status outside the enum", () => {
82
+ // Notably `deleted`: a thread whose comments are all soft-deleted is still
83
+ // an ordinary resolvable thread, and inventing a third state client-side
84
+ // would give the resolve/reopen transition somewhere it can never return
85
+ // from.
86
+ expect(CommentThreadStatusSchema.safeParse("deleted").success).toBe(false);
87
+ expect(CommentThreadStatusSchema.safeParse("").success).toBe(false);
88
+ });
89
+
90
+ it("rejects a thread carrying a status outside the enum", () => {
91
+ expect(
92
+ CommentThreadSchema.safeParse(makeThread({ status: "archived" })).success,
93
+ ).toBe(false);
94
+ });
95
+ });
96
+
97
+ describe("CommentSubjectType", () => {
98
+ it("is closed, not a free string", () => {
99
+ // Sized to the ACL entity types that admit comments. A free string here
100
+ // would let a client address a subject class the server has no policy for
101
+ // and read the 404 as "not found" rather than "never supported".
102
+ expect([...COMMENT_SUBJECT_TYPES]).toEqual([
103
+ "company_md",
104
+ "strategy_doc",
105
+ "work_item",
106
+ "meeting_recording",
107
+ ]);
108
+ expect(
109
+ CommentThreadSchema.safeParse(makeThread({ subjectType: "chat" }))
110
+ .success,
111
+ ).toBe(false);
112
+ });
113
+ });
114
+
115
+ describe("CommentSchema and the redaction invariant", () => {
116
+ it("parses a well-formed comment", () => {
117
+ expect(CommentSchema.safeParse(makeComment()).success).toBe(true);
118
+ });
119
+
120
+ it("parses a soft-deleted comment projection with a null body", () => {
121
+ // THE TOMBSTONE. A soft-deleted comment is a REDACTED PROJECTION, not a
122
+ // removed row: body null, mentions empty, everything else retained. If this
123
+ // stops parsing, a single deleted comment fails the whole thread load —
124
+ // and the tombstone is what keeps the surrounding replies in order and
125
+ // keeps an all-deleted thread distinguishable from an empty one.
126
+ const parsed = CommentSchema.safeParse(
127
+ makeComment({
128
+ body: null,
129
+ deletedAt: "2026-08-02T09:00:00.000Z",
130
+ mentions: [],
131
+ }),
132
+ );
133
+ expect(parsed.success).toBe(true);
134
+ });
135
+
136
+ it("parses an unattributed comment — a tombstoned author is not an error", () => {
137
+ expect(
138
+ CommentSchema.safeParse(makeComment({ authorUserId: null })).success,
139
+ ).toBe(true);
140
+ });
141
+
142
+ it("requires the mentions array — absent is not the same as empty", () => {
143
+ const { mentions: _dropped, ...withoutMentions } = makeComment();
144
+ expect(CommentSchema.safeParse(withoutMentions).success).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("CommentMentionSchema offsets", () => {
149
+ it("parses a mention whose range is null", () => {
150
+ // Null on every row today — `comment_mentions` has no offset columns yet.
151
+ // A client must render these as "highlight nothing", so they must parse.
152
+ expect(
153
+ CommentMentionSchema.safeParse(
154
+ makeMention({ startOffset: null, endOffset: null }),
155
+ ).success,
156
+ ).toBe(true);
157
+ });
158
+
159
+ it("measures in UTF-16 CODE UNITS, so an emoji costs two", () => {
160
+ // The convention, pinned by arithmetic rather than by comment. `"🎉 hi "`
161
+ // is SIX code units — the emoji is a surrogate pair — so the mention that
162
+ // follows it starts at 6. A client measuring in code points would compute 5
163
+ // and highlight one character to the left, and would keep sliding by one
164
+ // per preceding emoji. Nothing normalizes the body on this path, which is
165
+ // what makes the count on the wire the same count a client can reproduce.
166
+ const body = "🎉 hi @Sam Chen";
167
+ expect(body.length).toBe(15);
168
+ const start = body.indexOf("@");
169
+ expect(start).toBe(6);
170
+
171
+ const comment = CommentSchema.safeParse(
172
+ makeComment({
173
+ body,
174
+ mentions: [makeMention({ startOffset: start, endOffset: body.length })],
175
+ }),
176
+ );
177
+ expect(comment.success).toBe(true);
178
+ // endOffset is EXCLUSIVE: slicing [start, end) yields exactly the mention.
179
+ expect(body.slice(start, body.length)).toBe("@Sam Chen");
180
+ });
181
+
182
+ it("rejects a negative offset", () => {
183
+ expect(
184
+ CommentMentionSchema.safeParse(makeMention({ startOffset: -1 })).success,
185
+ ).toBe(false);
186
+ });
187
+
188
+ it("rejects a fractional offset", () => {
189
+ // A code-unit index is an integer by construction; a fraction means someone
190
+ // measured in something else.
191
+ expect(
192
+ CommentMentionSchema.safeParse(makeMention({ endOffset: 2.5 })).success,
193
+ ).toBe(false);
194
+ });
195
+ });
196
+
197
+ describe("CommentThreadSchema", () => {
198
+ it("parses a well-formed thread with its comments", () => {
199
+ expect(CommentThreadSchema.safeParse(makeThread()).success).toBe(true);
200
+ });
201
+
202
+ it("parses a document-anchored thread", () => {
203
+ expect(
204
+ CommentThreadSchema.safeParse(
205
+ makeThread({
206
+ anchorType: "document",
207
+ anchor: { type: "document", v: 1 },
208
+ }),
209
+ ).success,
210
+ ).toBe(true);
211
+ });
212
+
213
+ it("rejects a thread whose anchor is invalid", () => {
214
+ // The thread is the only place an anchor reaches a client, so a thread must
215
+ // not parse around a broken one — that would hand the app an anchor it
216
+ // cannot resolve while telling it the response was fine.
217
+ expect(
218
+ CommentThreadSchema.safeParse(
219
+ makeThread({ anchor: { type: "text", v: 1, prefix: "", suffix: "" } }),
220
+ ).success,
221
+ ).toBe(false);
222
+ });
223
+
224
+ it("keeps tombstones in the comment list rather than filtering them", () => {
225
+ const thread = CommentThreadSchema.parse(
226
+ makeThread({
227
+ comments: [
228
+ makeComment({
229
+ id: "55555555-5555-4555-8555-555555555555",
230
+ body: null,
231
+ deletedAt: "2026-08-02T09:00:00.000Z",
232
+ }),
233
+ makeComment(),
234
+ ],
235
+ }),
236
+ );
237
+ expect(thread.comments).toHaveLength(2);
238
+ expect(thread.comments[0].body).toBeNull();
239
+ });
240
+ });
241
+
242
+ describe("CommentThreadListResponseSchema", () => {
243
+ it("parses an empty subject", () => {
244
+ expect(
245
+ CommentThreadListResponseSchema.safeParse({ threads: [] }).success,
246
+ ).toBe(true);
247
+ });
248
+
249
+ it("parses a subject with threads", () => {
250
+ expect(
251
+ CommentThreadListResponseSchema.safeParse({ threads: [makeThread()] })
252
+ .success,
253
+ ).toBe(true);
254
+ });
255
+ });
256
+
257
+ describe("MentionableResponseSchema", () => {
258
+ it("parses a candidate list", () => {
259
+ expect(
260
+ MentionableResponseSchema.safeParse({
261
+ items: [{ userId: USER_ID, displayName: "Sam Chen", avatarUrl: null }],
262
+ }).success,
263
+ ).toBe(true);
264
+ });
265
+
266
+ it("carries no access level — a picker is not an access-inspection surface", () => {
267
+ // The candidates ARE the document's readers. Returning the band each one
268
+ // holds would turn an autocomplete into a way to enumerate who can do what,
269
+ // so the field must stay absent from the shape rather than merely unset.
270
+ expect(
271
+ Object.keys(MentionableResponseSchema.shape.items.element.shape).sort(),
272
+ ).toEqual(["avatarUrl", "displayName", "userId"]);
273
+ });
274
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * The comment anchor — where on a subject a thread hangs (ADR-CONTRACTS-116).
3
+ *
4
+ * THE ANCHOR IS THE CONTRACT BETWEEN THE CLIENT THAT WRITES IT AND THE CLIENT
5
+ * THAT LATER RESOLVES IT. The backend is a PASSTHROUGH: it validates this shape
6
+ * at the route boundary, stores the result as opaque jsonb, and interprets
7
+ * nothing (backend ADR slug polymorphic-comment-threads §4). So a disagreement
8
+ * between the writing client and the resolving client about what these fields
9
+ * MEAN produces silently mis-placed comments with no server-side tripwire — the
10
+ * write succeeds, the read succeeds, and the highlight lands on the wrong text.
11
+ * That is why the shape is published here rather than declared app-locally.
12
+ *
13
+ * `v` is the evolution hatch and the only thing that bounds the risk above: a
14
+ * client that meets an anchor it cannot resolve sees an unfamiliar `v` and can
15
+ * degrade deliberately, instead of guessing at fields it half-recognises. New
16
+ * anchor shapes therefore arrive as new `v` variants, never as a migration of an
17
+ * existing one.
18
+ *
19
+ * Zod-canonical: the schema is the source of truth, the type is inferred.
20
+ *
21
+ * MIRRORED, NOT INVENTED. Every bound below is the backend route boundary's
22
+ * (`src/api/http/routes/comments/comments.schemas.ts`), and the accept/reject
23
+ * corpus at `./__tests__/fixtures/comment-anchors.json` is copied byte-for-byte
24
+ * from that suite's fixtures. Editing that copy HERE fails the anchor suite —
25
+ * its digest is pinned beside it. A change made at the BOUNDARY is not caught
26
+ * from this repo; a single-repo CI run cannot see that file, and this package
27
+ * must not depend on the backend to look. ADR-CONTRACTS-116 records which half
28
+ * is gated and where the missing gate belongs.
29
+ */
30
+ import { z } from "zod";
31
+
32
+ /**
33
+ * A base64 `Y.RelativePosition` payload, encoded by the client.
34
+ *
35
+ * Bounded so an anchor can never smuggle document-scale content through a field
36
+ * nothing on the server reads. The backend never decodes it — the bound and the
37
+ * charset are the only things anyone checks, so they have to be checked here
38
+ * too or a client can write a position no other client can decode.
39
+ */
40
+ const RelativePositionSchema = z
41
+ .string()
42
+ .min(1)
43
+ .max(512)
44
+ .regex(/^[A-Za-z0-9+/]+={0,2}$/, "must be base64");
45
+
46
+ /**
47
+ * The thread hangs off the subject AS A WHOLE — a comment on the document, not
48
+ * on a passage in it.
49
+ *
50
+ * Strict: a document anchor carrying `quote`/`prefix`/`relStart` is refused
51
+ * rather than silently accepted with the extra fields ignored. A client that
52
+ * sends those meant to anchor to text and got the discriminator wrong; storing
53
+ * the mistake would produce a thread that can never be placed.
54
+ *
55
+ * SPELLING DEVIATION FROM THE AUTHORITATIVE REFERENCE (PRD-00922, must_log).
56
+ * The reference block writes this variant as
57
+ * `z.object({ type: z.literal('document'), v: z.literal(1) }).strict()`.
58
+ * What ships below is the same schema in two different spellings, not a
59
+ * different schema: `z.strictObject` is zod v4's idiom for `z.object().strict()`,
60
+ * and the double quotes are this repo's Prettier default (there is no
61
+ * `.prettierrc`, so `singleQuote` is false and `pnpm format:check` rewrites any
62
+ * single-quoted string here). Recorded because the reference is marked
63
+ * authoritative, and a reader diffing the two should be able to tell at a glance
64
+ * that nothing about what is accepted or refused changed.
65
+ */
66
+ const DocumentAnchorSchema = z.strictObject({
67
+ type: z.literal("document"),
68
+ v: z.literal(1),
69
+ });
70
+
71
+ /**
72
+ * A text-anchored thread.
73
+ *
74
+ * `quote` is REQUIRED and is the durable fallback — it is what a resolving
75
+ * client re-finds the passage by when the relative positions no longer resolve,
76
+ * and it is also the composer's `Comment on "…"` label. An anchor with no quote
77
+ * is unplaceable the first time the document is edited, so it is refused.
78
+ *
79
+ * `relStart`/`relEnd` are OPTIONAL by design: a comment authored from a card or
80
+ * a list view has no attached collab session to encode a position from, and has
81
+ * only the quote path. Optional here means "this client could not produce one",
82
+ * never "this client chose not to".
83
+ *
84
+ * `prefix`/`suffix` disambiguate a quote that appears more than once. They are
85
+ * required-but-may-be-empty rather than optional, so "there is no surrounding
86
+ * context" and "this client forgot to send it" stay distinguishable.
87
+ */
88
+ const TextAnchorSchema = z.strictObject({
89
+ type: z.literal("text"),
90
+ v: z.literal(1),
91
+ relStart: RelativePositionSchema.optional(),
92
+ relEnd: RelativePositionSchema.optional(),
93
+ quote: z.string().min(1).max(2000),
94
+ prefix: z.string().max(64),
95
+ suffix: z.string().max(64),
96
+ });
97
+
98
+ /**
99
+ * The anchor types, as a vocabulary tuple.
100
+ *
101
+ * Mirrors the union's discriminants, and is what `CommentThreadSummarySchema`'s
102
+ * denormalised `anchorType` column is checked against — the server projects the
103
+ * discriminant out of the jsonb so a client can filter threads without parsing
104
+ * every anchor.
105
+ */
106
+ export const COMMENT_ANCHOR_TYPES = ["document", "text"] as const;
107
+ export const CommentAnchorTypeSchema = z.enum(COMMENT_ANCHOR_TYPES);
108
+ export type CommentAnchorType = z.infer<typeof CommentAnchorTypeSchema>;
109
+
110
+ /**
111
+ * The opaque, versioned anchor payload.
112
+ *
113
+ * Discriminated on `type` with STRICT variants, which is what makes the two
114
+ * failure modes above real refusals rather than silent coercions.
115
+ */
116
+ export const CommentAnchorSchema = z.discriminatedUnion("type", [
117
+ DocumentAnchorSchema,
118
+ TextAnchorSchema,
119
+ ]);
120
+ export type CommentAnchor = z.infer<typeof CommentAnchorSchema>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * comments/ — the published wire contract for comment threads: the anchor
3
+ * union, the thread/comment/mention read projections, and the mention picker's
4
+ * response.
5
+ *
6
+ * See ./README.md for the domain, and ADR-CONTRACTS-116 for why each of these
7
+ * meets the promotion rule — and for what is deliberately NOT here (request
8
+ * bodies, per ADR-CONT-029).
9
+ */
10
+
11
+ export {
12
+ COMMENT_ANCHOR_TYPES,
13
+ CommentAnchorTypeSchema,
14
+ CommentAnchorSchema,
15
+ } from "./anchor";
16
+
17
+ export type { CommentAnchor, CommentAnchorType } from "./anchor";
18
+
19
+ export {
20
+ COMMENT_SUBJECT_TYPES,
21
+ COMMENT_THREAD_STATUSES,
22
+ CommentMentionSchema,
23
+ CommentSchema,
24
+ CommentSubjectTypeSchema,
25
+ CommentThreadListResponseSchema,
26
+ CommentThreadSchema,
27
+ CommentThreadStatusSchema,
28
+ CommentThreadSummarySchema,
29
+ MentionableCandidateSchema,
30
+ MentionableResponseSchema,
31
+ } from "./schemas";
32
+
33
+ export type {
34
+ CommentMention,
35
+ CommentProjection,
36
+ CommentSubjectType,
37
+ CommentThread,
38
+ CommentThreadListResponse,
39
+ CommentThreadStatus,
40
+ CommentThreadSummary,
41
+ MentionableCandidate,
42
+ MentionableResponse,
43
+ } from "./schemas";