@company-semantics/contracts 51.1.0 → 51.2.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": "51.1.0",
3
+ "version": "51.2.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 = '544058ec2d0f' as const;
3
- export const SPEC_HASH_FULL = '544058ec2d0ffc4b77cd02a9121088dadc5618d5bc04ed33707298a8a1ce6114' as const;
2
+ export const SPEC_HASH = 'b044ca016033' as const;
3
+ export const SPEC_HASH_FULL = 'b044ca016033c3a7224160a73898e3f551218dbef0139a23601f1ca4eb171d15' as const;
@@ -4737,7 +4737,7 @@ export interface components {
4737
4737
  summary: {
4738
4738
  executionId: string;
4739
4739
  /** @enum {string} */
4740
- kind: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
4740
+ kind: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
4741
4741
  target: {
4742
4742
  /** @constant */
4743
4743
  type: "slack";
@@ -8542,7 +8542,7 @@ export interface operations {
8542
8542
  query?: {
8543
8543
  cursor?: string;
8544
8544
  limit?: number;
8545
- kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
8545
+ kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
8546
8546
  targetType?: string;
8547
8547
  periodStart?: string;
8548
8548
  periodEnd?: string;
@@ -8677,7 +8677,7 @@ export interface operations {
8677
8677
  query?: {
8678
8678
  limit?: number;
8679
8679
  offset?: number;
8680
- kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest";
8680
+ kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
8681
8681
  kindPrefix?: string;
8682
8682
  };
8683
8683
  header?: never;
@@ -2,26 +2,46 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- The published **read** vocabulary of the comment surface: where a thread hangs
5
+ The published **read** vocabulary of the comment surface where a thread hangs
6
6
  (`CommentAnchorSchema`), what a thread and its comments look like on the wire
7
7
  (`CommentThreadSchema`, `CommentSchema`, `CommentMentionSchema`), and who a
8
- comment may name (`MentionableResponseSchema`).
8
+ comment may name (`MentionableResponseSchema`) — together with the one published
9
+ INTERPRETATION of an anchor, in both directions: reading one against a document
10
+ (`resolveAnchorFromText`, `anchorStillReads`, `plannedEdit`), composing one from
11
+ a range (`createTextAnchorFromRange`, `createTextInsertionAnchorAt`), the size
12
+ bounds both halves share (`./anchor-constants`), and the receipt that proves an
13
+ accepted suggestion's edit already landed (`SUGGESTION_RECEIPTS_MAP`).
9
14
 
10
15
  This exists as a published contract rather than an app-local type because two
11
16
  independent clients have to agree about it without a server in the middle to
12
- arbitrate — see the first invariant.
17
+ arbitrate — see the first two invariants. Publishing the SHAPE bounded that
18
+ risk; publishing the interpretation closes it (ADR-CONTRACTS-127), because a
19
+ second implementation is now an IMPORT rather than a second description.
20
+ Interpretation is pure, dependency-free derivation of published vocabulary, so
21
+ it stays inside the vocabulary-guard's rules — request bodies still do not.
13
22
 
14
23
  ## Invariants
15
24
 
16
- - **The anchor is the contract between the client that WRITES it and the client
17
- that later RESOLVES it, and the backend is a passthrough that cannot catch
18
- drift in it.** The route boundary validates the anchor's SHAPE and stores the
25
+ - **STORAGE IS STILL A PASSTHROUGH, and it still cannot catch drift in an
26
+ anchor.** The route boundary validates the anchor's SHAPE and stores the
19
27
  result as opaque jsonb; the service, the database and every projection treat
20
28
  it as bytes and interpret nothing. So if the writing client and the resolving
21
29
  client disagree about what these fields mean, the write succeeds, the read
22
30
  succeeds, and the comment silently lands on the wrong text. There is no
23
31
  server-side tripwire, and there cannot be one — which is precisely why the
24
- shape is published here instead of being declared twice.
32
+ shape is published here instead of being declared twice. ADR-CONTRACTS-127
33
+ left this half exactly as it was.
34
+ - **INTERPRETATION IS NO LONGER PER-CONSUMER — that is the half that did
35
+ change, and both halves are true at once.** `resolveAnchorFromText`,
36
+ `anchorStillReads`, `plannedEdit`, `createTextAnchorFromRange` and
37
+ `createTextInsertionAnchorAt` are the ONE published reading of those bytes
38
+ (ADR-CONTRACTS-127), so the backend becoming an eligible applier and a comment
39
+ pass running server-side are imports rather than a third and fourth
40
+ description of the same rules. This does NOT make the server an arbiter: the
41
+ anchor column is still opaque jsonb, still uninterpreted by the database and
42
+ by every projection, and the boundary still validates shape only. A reader who
43
+ takes either half alone gets the model wrong — one way they expect the server
44
+ to catch a bad anchor, the other way they write a second resolver.
25
45
  - **`v` is the evolution hatch, and it is the only thing that bounds that
26
46
  risk.** A client that meets an anchor it cannot resolve sees an unfamiliar `v`
27
47
  and degrades on purpose instead of guessing at fields it half-recognises. New
@@ -43,15 +63,102 @@ arbitrate — see the first invariant.
43
63
  silently. Instead the point carries an optional `relPos` (one
44
64
  `Y.RelativePosition` at the caret) plus bilateral `leftContext`/`rightContext`
45
65
  (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
66
+ Resolution: rung 1 decodes `relPos` and verifies BOTH contexts around the
67
+ decoded point (stale-not-correct, like the text anchor's quote check) and
68
+ stays with the consumer, since decoding needs `yjs` and a live replica; rung 2
69
+ is published here as `resolveAnchorFromText` and searches for the UNIQUE
49
70
  `leftContext`+`rightContext` seam — any ambiguity resolves orphaned, never a
50
71
  guess; rung 3 orphaned. This variant ships contracts-first: its corpus cases
51
72
  were added HERE with the deliberate same-diff digest re-pin ADR-CONTRACTS-116
52
73
  allows (PRD-00933), and the backend boundary adopts both in PRD-00934 — until
53
74
  then the upstream diff shows expected forward drift, and `anchor-corpus:sync`
54
75
  must not be run (it would erase the new cases).
76
+ - **AMBIGUITY RESOLVES ORPHANED, AT BOTH LEVELS, AND NEVER BINDS TO THE FIRST
77
+ OCCURRENCE.** For a `text` anchor, `resolveAnchorFromText` searches the
78
+ context-qualified `prefix`+`quote`+`suffix` first, and TWO occurrences of that
79
+ string mean the context FAILED to identify a candidate — so the answer is
80
+ `orphaned`, and it does not then fall back to the bare quote, which would only
81
+ re-ask a question already known to be ambiguous. Absence is not ambiguity: an
82
+ ABSENT context does fall through to the bare `quote`, where a repeated quote is
83
+ orphaned in its own right. A repeated insertion seam gets the same answer for
84
+ the same reason, and an EMPTY seam is refused on a non-empty document rather
85
+ than treated as matching everywhere — an empty needle is a fact about the
86
+ search, not evidence about the point. (The one exception is the empty
87
+ document, where 0 is the only point there is.)
88
+ Under-anchoring is the deliberate error direction — a comment shown as
89
+ unanchored is legible and recoverable, while a comment shown against the wrong
90
+ sentence reads as a considered objection to a paragraph its author never saw.
91
+ Nothing outside this package enforces the rule: a consumer composing its own
92
+ rung 1 can resolve ambiguously THERE and never reach the published ladder's
93
+ refusal, so this is written down rather than typed (ADR-CONTRACTS-127).
94
+ - **The applier must not trust the finder.** `anchorStillReads` is a SECOND
95
+ assertion, independent of `resolveAnchorFromText` by construction: it reads the
96
+ anchor's own stored evidence against the live text and takes the resolution
97
+ only as a position. That a rung already checked something like it does not make
98
+ it redundant — the rung answers "where does this land", the assertion answers
99
+ "is what lands there still the thing", and a consumer composing the Yjs rung on
100
+ top supplies a position this package never computed. A `document` anchor
101
+ answers `false`: it names no characters, so it can vouch for no range.
102
+ - **A no-op is a refusal, not a cheap success.** `plannedEdit` returns `null` for
103
+ an `insert`/`replace` carrying no text and for a `delete`/`replace` over a
104
+ collapsed range. Each of those would otherwise run a transaction that writes a
105
+ receipt and changes not one character — the thread reported as `accepted` over
106
+ a document that never received the proposal. Both shapes mean the payload and
107
+ the anchor disagree about what kind of edit this is, and settling that
108
+ disagreement is not this package's to do.
109
+ - **The receipt is the durable proof that the edit ALREADY LANDED, and its KEY
110
+ is the contract.** Acceptance is apply-then-record, so there is one window —
111
+ between the transaction and the acknowledgement — in which the document has
112
+ changed and the thread still says `open`. `SUGGESTION_RECEIPTS_MAP` names the
113
+ root map the receipt is written into, in the SAME transaction as the edit, so
114
+ either both survived or neither did. A receipt written under any other name
115
+ lands in a map nobody reads while the reader watches a map nobody writes:
116
+ nothing throws, and the accept control stays live over an edit already in the
117
+ body. The name is spelled once, here, exactly as `COMPANY_MD_COLLAB_TEXT_KEY`
118
+ is for the body text.
119
+ - **The receipt lives BESIDE the body, never inside it, and ABSENCE IS NOT
120
+ PROOF.** It is a root map on the same document as the text, not a span in the
121
+ text — it must never reach the markdown source, an export, or the content
122
+ hash. And a reader holding no replica holds no receipts, so an empty map means
123
+ "no receipt is KNOWN HERE", never "no receipt exists": every consumer acts on
124
+ PRESENCE only. `readSuggestionReceipt` drops a malformed row to `null` rather
125
+ than surfacing a partial one precisely because that lands in the state every
126
+ consumer already has to handle correctly.
127
+ - **The receipt row is STRICT and carries no `v`, unlike the anchor and the
128
+ suggestion.** Those two are negotiated payloads that must grow while old
129
+ readers keep parsing. The receipt is three fields the shipped applier already
130
+ writes, promoted here rather than redesigned — a required `v` would make the
131
+ published parser reject every receipt in existence. A fourth fact arrives as a
132
+ new key or a versioned successor schema, never by widening this row.
133
+ - **CAPTURE AND ACCEPT ARE DIFFERENT NUMBERS, and collapsing them is a SILENT
134
+ REJECTION.** `ANCHOR_CONTEXT_CHARS` (32) is how much context a CONSTRUCTOR
135
+ captures either side of a range; `ANCHOR_AFFIX_MAX_CHARS` (64) is the largest
136
+ affix `CommentAnchorSchema` will ACCEPT. The gap is the point, not an
137
+ oversight: the capture width is a write-side choice that may change, while the
138
+ accept bound is a promise made to every anchor already in storage — durable
139
+ jsonb the server never re-validates. Narrowing accept to capture refuses
140
+ anchors a client with a wider capture already wrote; widening capture past
141
+ accept composes anchors the boundary then refuses, losing a comment AFTER it
142
+ was written. Both numbers now have exactly one declaration each, in
143
+ `./anchor-constants`, which is what keeps a schema change and a constructor
144
+ change from drifting apart — but nothing mechanical stops the two from being
145
+ set equal, so the distinction lives here.
146
+ - **Composing an anchor refuses rather than truncates, for the same reason
147
+ resolving it refuses rather than guesses.** `createTextAnchorFromRange` and
148
+ `createTextInsertionAnchorAt` return `null` for a non-integer or out-of-order
149
+ range, a range outside the text, an EMPTY range (the zero-width case has its
150
+ own constructor) and a quote past `QUOTE_MAX_CHARS`. A truncated quote yields
151
+ an anchor that parses, passes the boundary and resolves — onto a range the
152
+ caller never selected. Both validate the object they built through
153
+ `CommentAnchorSchema` before returning it, so the only anchors this package
154
+ emits are anchors the route boundary accepts: an anchor rejected there is a
155
+ comment lost AFTER it was composed.
156
+ - **The relative-position fields are never produced here, and their absence is
157
+ a fact, not an omission.** Encoding a `Y.RelativePosition` needs `yjs`, which
158
+ the vocabulary-guard forbids this package from importing, and it needs a live
159
+ replica besides. A consumer holding a `Y.Text` merges `relStart`/`relEnd` (or
160
+ `relPos`) onto the returned anchor itself — the same seam `./resolve` holds,
161
+ where a consumer composes rung 1 on top of the published text-only ladder.
55
162
  - **A comment's `body` is null exactly when `deletedAt` is set, and such a
56
163
  comment carries no mentions.** A soft-deleted comment is a REDACTED
57
164
  PROJECTION, not a removed row — the tombstone keeps its position in the
@@ -93,32 +200,68 @@ arbitrate — see the first invariant.
93
200
 
94
201
  ## Public API
95
202
 
96
- | Export | Description |
97
- | --------------------------------- | --------------------------------------------------------------------------- |
98
- | `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict |
99
- | `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple |
100
- | `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against |
101
- | `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state |
102
- | `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES` |
103
- | `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments |
104
- | `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES` |
105
- | `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name |
106
- | `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`) |
107
- | `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response |
108
- | `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included |
109
- | `CommentThreadListResponseSchema` | `GET /api/comments` |
110
- | `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
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 |
203
+ | Export | Description |
204
+ | --------------------------------- | ------------------------------------------------------------------------------ |
205
+ | `CommentAnchorSchema` | `{document,v:1}` \| `{text,v:1,…}` \| `{text-insertion,v:1,…}`, strict |
206
+ | `COMMENT_ANCHOR_TYPES` | The anchor discriminants, as a vocabulary tuple |
207
+ | `CommentAnchorTypeSchema` | Zod mirror of `COMMENT_ANCHOR_TYPES` — what `anchorType` is checked against |
208
+ | `COMMENT_THREAD_STATUSES` | `open` \| `resolved` — there is no third state |
209
+ | `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES` |
210
+ | `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments |
211
+ | `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES` |
212
+ | `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name |
213
+ | `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`) |
214
+ | `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response |
215
+ | `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included |
216
+ | `CommentThreadListResponseSchema` | `GET /api/comments` |
217
+ | `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
218
+ | `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
219
+ | `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread |
220
+ | `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes |
221
+ | `SUGGESTION_STATUSES` | `open` \| `accepted` \| `rejected` — the SECOND status level, terminal |
222
+ | `SuggestionSchema` | The versioned suggestion payload (`v:1`), strict, `insertedText` optional |
223
+ | `ANCHOR_CONTEXT_CHARS` | `32` — how much context a CONSTRUCTOR captures either side of a range |
224
+ | `ANCHOR_AFFIX_MAX_CHARS` | `64` — the largest affix the schema ACCEPTS (prefix/suffix/left/right) |
225
+ | `QUOTE_MAX_CHARS` | `2000` — the largest `quote` a text anchor may carry |
226
+ | `RELATIVE_POSITION_MAX_CHARS` | `512` — cap on one encoded `Y.RelativePosition`, in base64 chars |
227
+ | `resolveAnchorFromText` | Anchor + text → `AnchorResolution`. Total, pure, text-only; never throws |
228
+ | `AnchorResolution` | `resolved{start,end,rung}` \| `document` \| `orphaned` — three outcomes |
229
+ | `findSoleOccurrence` | `absent` \| `unique{index}` \| `ambiguous`; overlaps count, empty is ambiguous |
230
+ | `Occurrence` | The result of `findSoleOccurrence` |
231
+ | `contextsMatchAt` | Do these contexts still flank this point — with the two length guards |
232
+ | `contextBefore` / `contextAfter` | Capture `ANCHOR_CONTEXT_CHARS` of context, never splitting a surrogate pair |
233
+ | `anchorStillReads` | The SECOND assertion: does the resolved place still hold what the anchor says |
234
+ | `plannedEdit` | Suggestion + resolution → the one mutation, or `null` when it would do nothing |
235
+ | `PlannedEdit` | `{at, deleteLength, insert}` — delete then insert, at one point |
236
+ | `createTextAnchorFromRange` | Range → validated text anchor, or `null`; refuses rather than truncates |
237
+ | `createTextInsertionAnchorAt` | Point → validated zero-width anchor, or `null`; no relative position |
238
+ | `TextRange` | `{text, start, end}` — the offsets travel WITH the string they index |
239
+ | `SUGGESTION_RECEIPTS_MAP` | `suggestionReceipts` — the root map key, keyed within by thread id |
240
+ | `SuggestionReceiptSchema` | `{claimToken, appliedAt, byUserId}`, strict; no `v`, and that is deliberate |
241
+ | `SuggestionReceipt` | One applied suggestion's durable proof that its edit landed in the body |
242
+ | `readSuggestionReceipt` | Unknown → receipt or `null`; a malformed row is dropped, never surfaced |
116
243
 
117
244
  ## Dependencies
118
245
 
119
246
  - `zod` — schemas are canonical, types are inferred.
120
- - `./anchor` ← `./schemas`; `./suggestion` is a leaf (zod only) so `./schemas`
121
- can import the thread-kind projection from it without a cycle.
247
+ - `./anchor-constants` ← `./anchor` ← `./schemas`; `./anchor-constants` is a
248
+ leaf with no imports at all (four numbers and their rationale), so the bounds
249
+ can be read by a constructor without pulling in the schema. `./resolve` sits
250
+ beside `./schemas` on that chain — it imports `./anchor-constants` for the
251
+ capture width and the anchor union as a TYPE only, so nothing about the
252
+ interpretation can drift from the shape it interprets. `./verify` sits one
253
+ step further out, importing `contextsMatchAt` from `./resolve` at runtime
254
+ rather than re-deriving it — a private copy that forgot a length guard would
255
+ compare against an unrelated substring and answer `true`, which is the one
256
+ wrong answer that mutates the document. `./create` sits beside `./verify`,
257
+ importing `contextBefore`/`contextAfter` from `./resolve` at runtime and
258
+ `CommentAnchorSchema` from `./anchor` — the construction half must capture
259
+ exactly what the resolution half searches for, and validate against exactly
260
+ the schema the boundary applies, so neither is re-derived. `./suggestion` is
261
+ likewise a leaf (zod only) so `./schemas` can import the thread-kind
262
+ projection from it without a cycle. `./receipt` is a leaf for the same
263
+ reason — zod only, no reach for the anchor or the ladder — so anything in this
264
+ directory can import it later without one.
122
265
  - Nothing outside this directory is imported: the
123
266
  comment vocabulary binds to the existing `commenter` band of
124
267
  `../permissions`'s `AccessLevel` and introduces no access level of its own, so
@@ -33,6 +33,49 @@ text-anchor fields` name the two failure modes the strict variants exist for,
33
33
  anything the typechecker reaches would fail the build. Reading it keeps the
34
34
  fixture inert regardless of which file loads it. `node:fs` is fine in here —
35
35
  the vocabulary-guard skips `__tests__/`.
36
+ - **Every `orphaned` expectation in `resolve.test.ts` is a case where a
37
+ FIRST-MATCH resolver returns a well-formed range.** That is the whole point of
38
+ those fixtures: the plausible wrong implementation never throws and never
39
+ looks broken — it produces a confident highlight on a paragraph the comment's
40
+ author never saw. A suite that only asserted the happy path would sit green
41
+ over it, so the refusals are the load-bearing half.
42
+ - **Every `toBeNull()` expectation in `verify.test.ts` is a case where a
43
+ no-op-tolerant `plannedEdit` returns a well-formed edit.** `{ at,
44
+ deleteLength: 0, insert: "" }` is a valid mutation that applies without error
45
+ and changes not one character; its symptom is a thread reported as `accepted`
46
+ over a document that never received the proposal. Same shape of argument as
47
+ the `orphaned` expectations above: the refusals are the load-bearing half.
48
+ - **`verify.test.ts` builds the `document`-anchor resolution BY HAND, and that
49
+ is not a smell.** The shipped ladder answers `{ status: "document" }` for that
50
+ variant and never a `resolved`, so the case is unreachable through
51
+ `resolveAnchorFromText` — but a consumer's own Yjs rung can hand one in, and
52
+ the refusal is what makes that harmless.
53
+ - **`create.test.ts` asserts against the SHIPPED resolver, not against the
54
+ anchor's fields.** A suite that only checked `prefix`/`quote`/`suffix` would
55
+ sit green over a capture width that disagreed with what the resolver searches
56
+ for — the two halves have to agree about the same geometry, and the round-trip
57
+ is the only assertion that says so. Its `toBeNull()` cases are the same shape
58
+ of argument as the `orphaned` ones: the truncating constructor returns a
59
+ well-formed anchor that parses, passes the boundary and resolves onto a range
60
+ the caller never selected.
61
+ - **The surrogate round-trip is built so BOTH context windows land inside an
62
+ astral character.** The prefix window opens on the low half of one emoji and
63
+ the suffix window closes on the high half of another, because a blind slice
64
+ only emits a lone surrogate when the boundary actually falls there — a fixture
65
+ with emoji merely present would pass under the broken implementation.
66
+ - **Every `toBeNull()` in `receipt.test.ts` is a row the PASS-THROUGH parser
67
+ hands back as an object.** `(v) => v as SuggestionReceipt` compiles, is green
68
+ on every well-formed row and never throws; it fails only when a peer writes
69
+ something else into the map, and then a row whose `claimToken` is `undefined`
70
+ reads as "this edit already landed" — the accept control is withdrawn from a
71
+ live suggestion with no way back. The `NaN`/`Infinity` cases are not
72
+ theoretical: a CRDT map replicates raw JavaScript values with no schema of its
73
+ own, so a peer can put a literal `NaN` in `appliedAt`.
74
+ - **`SUGGESTION_RECEIPTS_MAP` is tested by restating its wire literal.** It
75
+ names a root map, so a rename is silent on both ends — the writer's receipt
76
+ lands where nobody reads, the reader watches where nobody writes. Asserting
77
+ `toBe("suggestionReceipts")` makes a rename a red test rather than a second
78
+ name. Same convention as `COMPANY_MD_COLLAB_TEXT_KEY` in `../../org`.
36
79
  - **Negative tests mutate ONE field of a well-formed factory result.** A
37
80
  hand-built broken object can pass for the wrong reason: it fails because of
38
81
  the field nobody was testing.
@@ -46,4 +89,20 @@ None — test-only.
46
89
  - `vitest` — `describe` / `it` / `expect`.
47
90
  - `node:fs`, `node:path`, `node:url` — to read the corpus and its pin from disk.
48
91
  - `node:crypto` — to re-derive the corpus digest.
49
- - `../anchor`, `../schemas` — the schemas under test.
92
+ - `../anchor`, `../anchor-constants`, `../schemas` — the schemas and bounds
93
+ under test.
94
+ - `../resolve` — the published resolver. `resolve.test.ts` drives it over the
95
+ SAME corpus rows `anchor-corpus.test.ts` re-derives by hand, which is what
96
+ makes that hand-rolled seam scan a cross-check of the shipped implementation
97
+ rather than of itself.
98
+ - `../suggestion`, `../verify` — the canonical payload and the two total
99
+ functions the acceptance path runs before it writes. `verify.test.ts` takes
100
+ its resolutions from the SHIPPED resolver wherever the ladder can produce one,
101
+ so the geometry under test is the geometry a real applier is handed.
102
+ - `../create` — the construction half. `create.test.ts` feeds every constructed
103
+ anchor back through `../resolve`, so it is a cross-check of the two published
104
+ halves against each other rather than of either against a restatement of
105
+ itself.
106
+ - `../receipt` — the application receipt's key, schema and parser.
107
+ `receipt.test.ts` asserts the parser and the schema agree on the same row, so
108
+ a hand-rolled second set of checks beside the schema cannot drift from it.
@@ -0,0 +1,161 @@
1
+ /**
2
+ * The published anchor size bounds, and the proof that `CommentAnchorSchema`
3
+ * actually reads them.
4
+ *
5
+ * WHY THIS SUITE EXISTS. The bounds used to be inline literals in `../anchor`,
6
+ * duplicated by hand in the app's constructor. Naming them removes the
7
+ * duplication only if two things stay true, and neither is mechanically
8
+ * enforced: the VALUES must not have moved during the extraction, and the
9
+ * schema must be reading the named constant rather than a leftover literal
10
+ * that happens to agree with it today.
11
+ *
12
+ * The mirrored corpus in `./fixtures/comment-anchors.json` proves the 64
13
+ * affix bound from both sides (its `oversized-prefix`/`oversized-suffix` rows
14
+ * are 65 chars), but NOTHING anywhere exercised the quote bound at 2000 or a
15
+ * relative position at 512 — a typo in either would have shipped green. Each
16
+ * boundary case below is that missing tripwire: accept at exactly the bound,
17
+ * refuse one code unit past it, phrased in terms of the constant so the test
18
+ * moves with the value it is protecting rather than re-pinning it a third time.
19
+ *
20
+ * The bare value assertions are the other half: they are what catches a bound
21
+ * being CHANGED (as opposed to being read from the wrong place), which no
22
+ * schema-relative assertion can see.
23
+ */
24
+ import { describe, expect, it } from "vitest";
25
+
26
+ import {
27
+ ANCHOR_AFFIX_MAX_CHARS,
28
+ ANCHOR_CONTEXT_CHARS,
29
+ QUOTE_MAX_CHARS,
30
+ RELATIVE_POSITION_MAX_CHARS,
31
+ } from "../anchor-constants.js";
32
+ import { CommentAnchorSchema } from "../anchor.js";
33
+
34
+ /** A well-formed text anchor; override one field per boundary test. */
35
+ const makeTextAnchor = (over: Record<string, unknown> = {}) => ({
36
+ type: "text",
37
+ v: 1,
38
+ quote: "the quarterly goals",
39
+ prefix: "we restated ",
40
+ suffix: " last week",
41
+ ...over,
42
+ });
43
+
44
+ /** A well-formed insertion anchor; override one field per boundary test. */
45
+ const makeInsertionAnchor = (over: Record<string, unknown> = {}) => ({
46
+ type: "text-insertion",
47
+ v: 1,
48
+ leftContext: "the quarterly ",
49
+ rightContext: " goals",
50
+ ...over,
51
+ });
52
+
53
+ describe("the published anchor size bounds", () => {
54
+ it("holds the values the wire contract was published with", () => {
55
+ // These are durable promises, not knobs: anchors already in storage were
56
+ // written against them, and the backend route boundary declares the same
57
+ // numbers independently. Changing one here is a wire change.
58
+ expect(ANCHOR_CONTEXT_CHARS).toBe(32);
59
+ expect(ANCHOR_AFFIX_MAX_CHARS).toBe(64);
60
+ expect(QUOTE_MAX_CHARS).toBe(2000);
61
+ expect(RELATIVE_POSITION_MAX_CHARS).toBe(512);
62
+ });
63
+
64
+ it("keeps capture strictly narrower than accept", () => {
65
+ // The two "context" numbers are NOT a duplication waiting to be collapsed.
66
+ // A constructor captures ANCHOR_CONTEXT_CHARS; the schema accepts up to
67
+ // ANCHOR_AFFIX_MAX_CHARS, so an anchor written by a client with a wider
68
+ // capture still parses. Narrowing accept to capture would refuse durable
69
+ // jsonb the server never re-validates.
70
+ expect(ANCHOR_CONTEXT_CHARS).toBeLessThan(ANCHOR_AFFIX_MAX_CHARS);
71
+ });
72
+ });
73
+
74
+ describe("CommentAnchorSchema reads the published bounds", () => {
75
+ it("accepts a quote of exactly QUOTE_MAX_CHARS and refuses one more", () => {
76
+ // The quote is the durable fallback the passage is re-found by, so it is
77
+ // bounded generously — but bounded, or an anchor becomes a second copy of
78
+ // the document in a column nothing on the server reads.
79
+ const atBound = "q".repeat(QUOTE_MAX_CHARS);
80
+ expect(
81
+ CommentAnchorSchema.safeParse(makeTextAnchor({ quote: atBound })).success,
82
+ ).toBe(true);
83
+ expect(
84
+ CommentAnchorSchema.safeParse(makeTextAnchor({ quote: `${atBound}q` }))
85
+ .success,
86
+ ).toBe(false);
87
+ });
88
+
89
+ it("refuses an empty quote", () => {
90
+ // `quote` is required AND non-empty: an anchor with no quote is unplaceable
91
+ // the first time the document is edited.
92
+ expect(
93
+ CommentAnchorSchema.safeParse(makeTextAnchor({ quote: "" })).success,
94
+ ).toBe(false);
95
+ });
96
+
97
+ it("bounds prefix and suffix at ANCHOR_AFFIX_MAX_CHARS", () => {
98
+ const atBound = "x".repeat(ANCHOR_AFFIX_MAX_CHARS);
99
+ expect(
100
+ CommentAnchorSchema.safeParse(
101
+ makeTextAnchor({ prefix: atBound, suffix: atBound }),
102
+ ).success,
103
+ ).toBe(true);
104
+ expect(
105
+ CommentAnchorSchema.safeParse(makeTextAnchor({ prefix: `${atBound}x` }))
106
+ .success,
107
+ ).toBe(false);
108
+ expect(
109
+ CommentAnchorSchema.safeParse(makeTextAnchor({ suffix: `${atBound}x` }))
110
+ .success,
111
+ ).toBe(false);
112
+ });
113
+
114
+ it("bounds leftContext and rightContext at the SAME affix bound", () => {
115
+ // One constant, four fields: the insertion seam is context in the same
116
+ // sense prefix/suffix are, and a reader must not have to check whether the
117
+ // two variants drifted apart.
118
+ const atBound = "x".repeat(ANCHOR_AFFIX_MAX_CHARS);
119
+ expect(
120
+ CommentAnchorSchema.safeParse(
121
+ makeInsertionAnchor({ leftContext: atBound, rightContext: atBound }),
122
+ ).success,
123
+ ).toBe(true);
124
+ expect(
125
+ CommentAnchorSchema.safeParse(
126
+ makeInsertionAnchor({ rightContext: `${atBound}x` }),
127
+ ).success,
128
+ ).toBe(false);
129
+ });
130
+
131
+ it("bounds every relative position at RELATIVE_POSITION_MAX_CHARS", () => {
132
+ // relStart/relEnd/relPos are opaque base64 the backend never decodes, so
133
+ // the length and the charset are the ONLY things anyone checks. Applied to
134
+ // all three fields because a position that is bounded on one path and
135
+ // unbounded on another is not bounded.
136
+ const atBound = "A".repeat(RELATIVE_POSITION_MAX_CHARS);
137
+ const overBound = `${atBound}A`;
138
+
139
+ expect(
140
+ CommentAnchorSchema.safeParse(
141
+ makeTextAnchor({ relStart: atBound, relEnd: atBound }),
142
+ ).success,
143
+ ).toBe(true);
144
+ expect(
145
+ CommentAnchorSchema.safeParse(makeTextAnchor({ relStart: overBound }))
146
+ .success,
147
+ ).toBe(false);
148
+ expect(
149
+ CommentAnchorSchema.safeParse(makeTextAnchor({ relEnd: overBound }))
150
+ .success,
151
+ ).toBe(false);
152
+ expect(
153
+ CommentAnchorSchema.safeParse(makeInsertionAnchor({ relPos: atBound }))
154
+ .success,
155
+ ).toBe(true);
156
+ expect(
157
+ CommentAnchorSchema.safeParse(makeInsertionAnchor({ relPos: overBound }))
158
+ .success,
159
+ ).toBe(false);
160
+ });
161
+ });