@company-semantics/contracts 51.0.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.0.0",
3
+ "version": "51.2.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -148,9 +148,9 @@
148
148
  "markdownlint-cli2": "^0.23.2",
149
149
  "openapi-typescript": "^7.13.0",
150
150
  "prettier": "^3.9.6",
151
- "tsx": "^4.23.11",
151
+ "tsx": "^4.23.12",
152
152
  "typescript": "^5.8.3",
153
- "typescript-eslint": "^8.66.0",
153
+ "typescript-eslint": "^8.67.0",
154
154
  "vite": "^8.2.1",
155
155
  "vitest": "^4.1.10",
156
156
  "yaml": "^2.9.0"
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '6dcd82441a54' as const;
3
- export const SPEC_HASH_FULL = '6dcd82441a54409faaa94d94f36a43cd72894c7e655fb49f5eb758a0963c9a43' as const;
2
+ export const SPEC_HASH = 'b044ca016033' as const;
3
+ export const SPEC_HASH_FULL = 'b044ca016033c3a7224160a73898e3f551218dbef0139a23601f1ca4eb171d15' as const;
@@ -3565,6 +3565,23 @@ export interface paths {
3565
3565
  patch?: never;
3566
3566
  trace?: never;
3567
3567
  };
3568
+ "/api/comments/threads/{threadId}/anchor-trace": {
3569
+ parameters: {
3570
+ query?: never;
3571
+ header?: never;
3572
+ path?: never;
3573
+ cookie?: never;
3574
+ };
3575
+ get?: never;
3576
+ put?: never;
3577
+ /** Report where a thread's anchored passage used to be */
3578
+ post: operations["stampCommentAnchorTrace"];
3579
+ delete?: never;
3580
+ options?: never;
3581
+ head?: never;
3582
+ patch?: never;
3583
+ trace?: never;
3584
+ };
3568
3585
  "/api/comments/threads/{threadId}/accept-intent": {
3569
3586
  parameters: {
3570
3587
  query?: never;
@@ -4720,7 +4737,7 @@ export interface components {
4720
4737
  summary: {
4721
4738
  executionId: string;
4722
4739
  /** @enum {string} */
4723
- 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";
4724
4741
  target: {
4725
4742
  /** @constant */
4726
4743
  type: "slack";
@@ -6514,6 +6531,9 @@ export interface components {
6514
6531
  CommentThreadSummaryResponse: {
6515
6532
  thread: components["schemas"]["CommentThreadSummary"];
6516
6533
  };
6534
+ AnchorTraceRequest: {
6535
+ lastAnchoredOffset: number;
6536
+ };
6517
6537
  /** @description The acceptance lease: the opaque claim token that alone can complete the accept, and how long the lease lives. */
6518
6538
  SuggestionClaimResponse: {
6519
6539
  claimToken: string;
@@ -6550,12 +6570,13 @@ export interface components {
6550
6570
  /** @enum {string} */
6551
6571
  category: "version" | "source" | "lifecycle" | "access" | "sync";
6552
6572
  /** @enum {string} */
6553
- eventType: "doc_created" | "source_added" | "source_removed" | "content_generated" | "external_sync_completed" | "access_granted" | "access_revoked" | "access_updated" | "sharing_policy_changed" | "visibility_changed" | "owner_transferred" | "discoverability_changed";
6573
+ eventType: "doc_created" | "doc_renamed" | "doc_moved" | "source_added" | "source_attached" | "source_detached" | "source_removed" | "source_extraction_started" | "source_extraction_succeeded" | "source_extraction_failed" | "content_generated" | "external_sync_completed" | "access_granted" | "access_revoked" | "access_updated" | "sharing_policy_changed" | "visibility_changed" | "owner_transferred" | "discoverability_changed";
6554
6574
  actor: components["schemas"]["CompanyMdDocVersionActor"] | null;
6555
6575
  subject: string | null;
6556
6576
  target: components["schemas"]["CompanyMdDocHistoryTarget"] | null;
6557
6577
  previousValue: string | null;
6558
6578
  newValue: string | null;
6579
+ correlationId: string | null;
6559
6580
  };
6560
6581
  /** @description The singular actor a version is attributed to, in product vocabulary. */
6561
6582
  CompanyMdDocVersionActor: {
@@ -6612,6 +6633,7 @@ export interface components {
6612
6633
  createdByUserId: string | null;
6613
6634
  resolvedByUserId: string | null;
6614
6635
  resolvedAt: string | null;
6636
+ lastAnchoredOffset: number | null;
6615
6637
  createdAt: string;
6616
6638
  updatedAt: string;
6617
6639
  comments: components["schemas"]["Comment"][];
@@ -6686,6 +6708,7 @@ export interface components {
6686
6708
  createdByUserId: string | null;
6687
6709
  resolvedByUserId: string | null;
6688
6710
  resolvedAt: string | null;
6711
+ lastAnchoredOffset: number | null;
6689
6712
  createdAt: string;
6690
6713
  updatedAt: string;
6691
6714
  };
@@ -8519,7 +8542,7 @@ export interface operations {
8519
8542
  query?: {
8520
8543
  cursor?: string;
8521
8544
  limit?: number;
8522
- 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";
8523
8546
  targetType?: string;
8524
8547
  periodStart?: string;
8525
8548
  periodEnd?: string;
@@ -8654,7 +8677,7 @@ export interface operations {
8654
8677
  query?: {
8655
8678
  limit?: number;
8656
8679
  offset?: number;
8657
- 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";
8658
8681
  kindPrefix?: string;
8659
8682
  };
8660
8683
  header?: never;
@@ -12833,6 +12856,46 @@ export interface operations {
12833
12856
  };
12834
12857
  };
12835
12858
  };
12859
+ stampCommentAnchorTrace: {
12860
+ parameters: {
12861
+ query?: never;
12862
+ header?: never;
12863
+ path: {
12864
+ threadId: string;
12865
+ };
12866
+ cookie?: never;
12867
+ };
12868
+ requestBody: {
12869
+ content: {
12870
+ "application/json": components["schemas"]["AnchorTraceRequest"];
12871
+ };
12872
+ };
12873
+ responses: {
12874
+ /** @description Offset recorded, or the already-recorded thread returned unchanged (first write wins) */
12875
+ 200: {
12876
+ headers: {
12877
+ [name: string]: unknown;
12878
+ };
12879
+ content: {
12880
+ "application/json": components["schemas"]["CommentThreadSummaryResponse"];
12881
+ };
12882
+ };
12883
+ /** @description Invalid body */
12884
+ 400: {
12885
+ headers: {
12886
+ [name: string]: unknown;
12887
+ };
12888
+ content?: never;
12889
+ };
12890
+ /** @description Thread not found, or the caller may not comment on its subject */
12891
+ 404: {
12892
+ headers: {
12893
+ [name: string]: unknown;
12894
+ };
12895
+ content?: never;
12896
+ };
12897
+ };
12898
+ };
12836
12899
  acceptSuggestionIntent: {
12837
12900
  parameters: {
12838
12901
  query?: 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.