@company-semantics/contracts 62.11.0 → 63.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "62.11.0",
3
+ "version": "63.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '9829bca605e4' as const;
3
- export const SPEC_HASH_FULL = '9829bca605e4a464d73b067d1d063d2a68c5871b9fae570470a43987e481bb3a' as const;
2
+ export const SPEC_HASH = 'e90aa233fbf5' as const;
3
+ export const SPEC_HASH_FULL = 'e90aa233fbf5c821f7a67d53e8da52152166bf1eefb029e1999fde02e8e66a4f' as const;
@@ -4079,6 +4079,19 @@ export interface components {
4079
4079
  sequenceNumber: number;
4080
4080
  /** Format: date-time */
4081
4081
  createdAt: string;
4082
+ mentions: {
4083
+ target: {
4084
+ /** @constant */
4085
+ kind: "user";
4086
+ userId: string;
4087
+ } | {
4088
+ /** @constant */
4089
+ kind: "agent";
4090
+ };
4091
+ startOffset: number | null;
4092
+ endOffset: number | null;
4093
+ displayName: string;
4094
+ }[];
4082
4095
  }[];
4083
4096
  };
4084
4097
  SuccessResponse: {
@@ -24,6 +24,13 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
24
24
  - Page context carries NO ORG IDENTITY, though the app's org routes have one in the URL. Org scope is derived from the session; a request-supplied org identifier would be an authority claim in a field the server otherwise reads for itself
25
25
  - `compactedThroughSequence` on the chat detail is ABSENT or NULL exactly when the conversation has never been compacted. It is never coerced to `0` — sequence numbers are zero-indexed, so `0` is a boundary a chat can really have, and a zero default would make "never compacted" indistinguishable from "compacted through sequence 0". It is optional for the same reason `proactiveKind` is a bare string: a client that does not know the field renders the transcript exactly as before (ADR-CONTRACTS-154)
26
26
  - `compactedThroughSequence` is a BOUNDARY MARKER, not a context gauge. It is the one sequence number the summary covers through — never a token count, never a percentage of the context window, and never the summary text itself. The reader's question it answers is "why did it forget the beginning", not "how full is the window" (ADR-CONTRACTS-154)
27
+ - `mentions` on a chat message is REQUIRED, and an empty array is the common value. Absent is not the same as empty — this shipped in a MAJOR precisely so no consumer keeps a second opinion about which one it holds. Contrast the proactive wire fields above, which are optional so consumer object literals survive a minor (ADR-CONTRACTS-160)
28
+ - A mention TARGET is a `PrincipalRef`, never a user id, so the agent — which has no users row to point at — is a legal mention target on the chat surface exactly as on a comment (ADR-CONTRACTS-160)
29
+ - Chat mention offsets are ZERO-BASED UTF-16 CODE UNITS into the exact `content` string with `endOffset` EXCLUSIVE, and no Unicode normalization happens anywhere on the path — the same convention as `CommentMention`, because a mention means the same thing on both surfaces. An astral character counts as TWO units
30
+ - Chat mention offsets are NULLABLE and null means "mentioned somewhere in this turn, highlight nothing", never an error. A user turn carries a real range; an assistant turn's mention is a typed part with no character range, and the nullability is what lets that land with no second schema
31
+ - Identity on a chat mention renders from the mention ROW, never from the content characters under the range. A stale range can only mis-highlight; it can never misattribute
32
+ - `SharedChatMessage` deliberately has NO mentions. The token-only share viewer is granted the CONTENT of a shared chat and nothing else; a mention row is identity, and identity is not in that grant
33
+ - `ChatMentionableResponse` carries `MentionableCandidate` items imported from `comments`, not a chat-flavoured copy. A candidate is a principal, a display name and an avatar on both surfaces — and on both, deliberately no email and no access level, because a picker that returned the band each candidate holds would be a way to enumerate who can do what
27
34
  - `CHAT_PAGE_VIEWS` and `CHAT_PAGE_SCOPES` are a projection of the app's `Route` union (`company-semantics-app/src/platform/route-parser.ts`), not a parallel vocabulary. A view the parser can emit but the schema rejects fails the send outright rather than degrading
28
35
 
29
36
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
@@ -53,7 +60,11 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
53
60
  - `ChatListFilters` _(type)_ — Filters for listing chats.
54
61
  - `ChatListResponse` _(type)_ — Response for GET /api/chats
55
62
  - `ChatListResponseSchema` — Response for GET /api/chats.
63
+ - `ChatMentionableResponse` _(type)_ — Response for the chat composer's mentionable-candidate endpoint.
64
+ - `ChatMentionableResponseSchema` — Who the chat composer may offer as a mention target.
56
65
  - `ChatMessage` _(type)_ — Individual message in a chat response (GET /api/chats/:id).
66
+ - `ChatMessageMention` _(type)_ — One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16 code-unit range into `content`, and a…
67
+ - `ChatMessageMentionSchema` — ONE mention on a chat turn — field-for-field `CommentMentionSchema` (`../comments/schemas`), and deliberately…
57
68
  - `ChatMessageSchema` — Individual message in a chat, as returned by GET /api/chats/:id.
58
69
  - `ChatOrigin` _(type)_ — How a chat was born: `user` or `proactive` (the system spoke first).
59
70
  - `ChatOriginSchema` — How a chat was born: by the user, or by the system speaking first (a proactive occurrence projected onto the…
@@ -117,6 +128,8 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
117
128
  **Internal domains:**
118
129
 
119
130
  - `api`
131
+ - `comments`
132
+ - `identity`
120
133
  - `proactive`
121
134
 
122
135
  **External packages:**
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Mentions on a chat turn (ADR-CONTRACTS-160).
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - `mentions` is REQUIRED on a chat message. Absent is not the same as empty:
6
+ * an empty array says "this turn named nobody", a missing key says nothing at
7
+ * all, and the whole point of shipping it in a MAJOR is that no consumer gets
8
+ * to keep a second opinion about which one it is holding.
9
+ * - A mention TARGET is a `PrincipalRef`, so the agent — which has no users row
10
+ * to point at — is a legal target on the chat surface exactly as on a comment.
11
+ * A bare user id is not a target, and never was: that is the shape this
12
+ * vocabulary replaced.
13
+ * - OFFSETS ARE NULLABLE and null is not an error. A user turn carries a real
14
+ * range because the composer measured it; an assistant turn's mention (the
15
+ * later multi-party phase) is a typed part with no character range at all.
16
+ * The nullability is what lets that land with no second schema.
17
+ * - OFFSETS ARE UTF-16 CODE UNITS into the exact `content` string, `endOffset`
18
+ * exclusive. An astral character counts as TWO. Asserted against a real slice
19
+ * rather than stated, because a client measuring code POINTS produces ranges
20
+ * that look right until an emoji precedes the mention.
21
+ * - `SharedChatMessageSchema` did NOT gain mentions. The token-only share viewer
22
+ * renders content, not identity chips, and a mention row is identity a
23
+ * tokenholder was never granted.
24
+ * - `ChatMentionableResponseSchema` carries `MentionableCandidateSchema` items —
25
+ * the comments shape imported, not a chat-flavoured copy of it.
26
+ */
27
+ import { describe, expect, it } from "vitest";
28
+ import { AGENT_PRINCIPAL, CS_AGENT, userPrincipal } from "../../identity/agent";
29
+ import {
30
+ ChatMentionableResponseSchema,
31
+ ChatMessageMentionSchema,
32
+ ChatMessageSchema,
33
+ GetChatResponseSchema,
34
+ SharedChatMessageSchema,
35
+ } from "../schemas.js";
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Fixtures — one well-formed value per shape; every negative case spreads it
39
+ // and changes exactly ONE field, so a failure cannot mean something else.
40
+ // ---------------------------------------------------------------------------
41
+
42
+ const makeMention = (over: Record<string, unknown> = {}) => ({
43
+ target: userPrincipal("user_1"),
44
+ startOffset: 0,
45
+ endOffset: 5,
46
+ displayName: "Ada",
47
+ ...over,
48
+ });
49
+
50
+ const makeMessage = (over: Record<string, unknown> = {}) => ({
51
+ id: "msg_1",
52
+ role: "user" as const,
53
+ content: "Hello",
54
+ sequenceNumber: 0,
55
+ createdAt: "2026-09-02T00:00:00.000Z",
56
+ mentions: [],
57
+ ...over,
58
+ });
59
+
60
+ describe("ChatMessageSchema mentions", () => {
61
+ it("a chat message requires a mentions array", () => {
62
+ const { mentions: _omitted, ...withoutMentions } = makeMessage();
63
+
64
+ const r = ChatMessageSchema.safeParse(withoutMentions);
65
+ expect(r.success).toBe(false);
66
+ if (!r.success) expect(r.error.issues[0].path).toEqual(["mentions"]);
67
+ });
68
+
69
+ it("an empty mentions array is the common value, not an absence", () => {
70
+ const parsed = ChatMessageSchema.parse(makeMessage());
71
+ expect(parsed.mentions).toEqual([]);
72
+ });
73
+
74
+ it("carries the mention rows a turn named", () => {
75
+ const parsed = ChatMessageSchema.parse(
76
+ makeMessage({ mentions: [makeMention()] }),
77
+ );
78
+
79
+ expect(parsed.mentions).toHaveLength(1);
80
+ expect(parsed.mentions[0].target).toEqual({
81
+ kind: "user",
82
+ userId: "user_1",
83
+ });
84
+ });
85
+
86
+ it("rides the transcript in GET /api/chats/:id", () => {
87
+ const parsed = GetChatResponseSchema.parse({
88
+ chat: {
89
+ id: "chat_1",
90
+ title: "A conversation",
91
+ interactionId: "int_1",
92
+ createdAt: "2026-09-02T00:00:00.000Z",
93
+ updatedAt: "2026-09-02T01:00:00.000Z",
94
+ },
95
+ messages: [makeMessage({ mentions: [makeMention()] })],
96
+ });
97
+
98
+ expect(parsed.messages[0].mentions).toHaveLength(1);
99
+ });
100
+
101
+ it("the share viewer's message shape did not gain mentions", () => {
102
+ // A token holder is granted the CONTENT of a shared chat and nothing else.
103
+ // Mention rows are identity, and identity is not in the grant.
104
+ expect(Object.keys(SharedChatMessageSchema.shape)).not.toContain(
105
+ "mentions",
106
+ );
107
+ });
108
+ });
109
+
110
+ describe("ChatMessageMentionSchema", () => {
111
+ it("the agent is a legal mention target and needs no user record", () => {
112
+ const parsed = ChatMessageMentionSchema.parse(
113
+ makeMention({
114
+ target: AGENT_PRINCIPAL,
115
+ displayName: CS_AGENT.displayName,
116
+ }),
117
+ );
118
+
119
+ expect(parsed.target).toEqual({ kind: "agent" });
120
+ expect(parsed.displayName).toBe("c_S");
121
+ });
122
+
123
+ it("a bare user id is not a target", () => {
124
+ // The shape this vocabulary replaced. A string where a ref belongs must
125
+ // refuse, or the agent silently becomes unnameable again.
126
+ expect(
127
+ ChatMessageMentionSchema.safeParse(makeMention({ target: "user_1" }))
128
+ .success,
129
+ ).toBe(false);
130
+ });
131
+
132
+ it("null offsets mean highlight nothing, and are never an error", () => {
133
+ // The assistant-turn case: a typed part with no character range.
134
+ const parsed = ChatMessageMentionSchema.parse(
135
+ makeMention({ startOffset: null, endOffset: null }),
136
+ );
137
+
138
+ expect(parsed.startOffset).toBeNull();
139
+ expect(parsed.endOffset).toBeNull();
140
+ });
141
+
142
+ it("offsets are UTF-16 code units, with endOffset exclusive", () => {
143
+ // The emoji is TWO code units, so the mention starts at 6. A client
144
+ // measuring code POINTS (`[...content].indexOf`) would say 5 and slice one
145
+ // unit early — one unit per preceding astral character.
146
+ const content = "hi 🎉 @Ada";
147
+ const startOffset = content.indexOf("@Ada");
148
+ const mention = ChatMessageMentionSchema.parse(
149
+ makeMention({ startOffset, endOffset: startOffset + "@Ada".length }),
150
+ );
151
+
152
+ expect(startOffset).toBe(6);
153
+ expect([...content].indexOf("@")).toBe(5);
154
+ expect(content.slice(mention.startOffset!, mention.endOffset!)).toBe(
155
+ "@Ada",
156
+ );
157
+ });
158
+
159
+ it("rejects a negative startOffset", () => {
160
+ expect(
161
+ ChatMessageMentionSchema.safeParse(makeMention({ startOffset: -1 }))
162
+ .success,
163
+ ).toBe(false);
164
+ });
165
+ });
166
+
167
+ describe("ChatMentionableResponseSchema", () => {
168
+ it("offers the agent alongside people", () => {
169
+ const parsed = ChatMentionableResponseSchema.parse({
170
+ items: [
171
+ {
172
+ principal: AGENT_PRINCIPAL,
173
+ displayName: CS_AGENT.displayName,
174
+ avatarUrl: null,
175
+ },
176
+ {
177
+ principal: userPrincipal("user_1"),
178
+ displayName: "Ada",
179
+ avatarUrl: null,
180
+ },
181
+ ],
182
+ });
183
+
184
+ expect(parsed.items).toHaveLength(2);
185
+ expect(parsed.items[0].principal).toEqual({ kind: "agent" });
186
+ });
187
+
188
+ it("an empty candidate list parses", () => {
189
+ expect(ChatMentionableResponseSchema.parse({ items: [] }).items).toEqual(
190
+ [],
191
+ );
192
+ });
193
+
194
+ it("carries the comments candidate shape, not a chat copy of it", () => {
195
+ // No email and no access level: this is a picker, not an
196
+ // access-inspection surface.
197
+ const r = ChatMentionableResponseSchema.safeParse({
198
+ items: [{ userId: "user_1", displayName: "Ada", avatarUrl: null }],
199
+ });
200
+
201
+ expect(r.success).toBe(false);
202
+ });
203
+ });
package/src/chat/index.ts CHANGED
@@ -50,6 +50,10 @@ export type {
50
50
  ListSharesResponse,
51
51
  UpdateShareResponse,
52
52
  ChatMessage,
53
+ // Mentions on a chat turn (ADR-CONTRACTS-160): a PrincipalRef target, so the
54
+ // agent is a legal mention target on the chat surface too.
55
+ ChatMessageMention,
56
+ ChatMentionableResponse,
53
57
  ChatDetail,
54
58
  GetChatResponse,
55
59
  // Page context (request-side): where the reader is, as identifiers
@@ -91,6 +95,8 @@ export {
91
95
  ListSharesResponseSchema,
92
96
  UpdateShareResponseSchema,
93
97
  ChatMessageSchema,
98
+ ChatMessageMentionSchema,
99
+ ChatMentionableResponseSchema,
94
100
  ChatDetailSchema,
95
101
  GetChatResponseSchema,
96
102
  // Page context (request-side) — the schema plus the closed vocabularies it
@@ -1,5 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { IsoDateTime } from "../api/primitives";
3
+ import { MentionableCandidateSchema } from "../comments/schemas";
4
+ import { PrincipalRefSchema } from "../identity/agent";
3
5
 
4
6
  // =============================================================================
5
7
  // Enums
@@ -232,7 +234,65 @@ export const ChatSseEventSchema = z.discriminatedUnion("type", [
232
234
  // Message Types
233
235
  // =============================================================================
234
236
 
235
- /** Individual message in a chat, as returned by GET /api/chats/:id. */
237
+ /**
238
+ * ONE mention on a chat turn — field-for-field `CommentMentionSchema`
239
+ * (`../comments/schemas`), and deliberately the same shape rather than a
240
+ * chat-flavoured variant: a mention means the same thing on both surfaces, and
241
+ * two descriptions of it would drift.
242
+ *
243
+ * The comments conventions carry over WHOLE and are restated here because a
244
+ * reader of this file will not necessarily open that one:
245
+ *
246
+ * - OFFSETS ARE ZERO-BASED UTF-16 CODE UNITS INTO THE EXACT `content` STRING,
247
+ * `endOffset` EXCLUSIVE — plain JavaScript string coordinates, the same ones a
248
+ * `textarea`'s `selectionStart` reports. An astral character counts as TWO
249
+ * units, so a client measuring in code POINTS slides off the mention by one
250
+ * per preceding emoji.
251
+ * - NO UNICODE NORMALIZATION happens anywhere on this path. Normalising the
252
+ * content before measuring changes its length and desynchronises every offset
253
+ * after the first composed character.
254
+ * - IDENTITY RENDERS FROM THIS ROW, NEVER FROM THE CONTENT UNDER THE RANGE. A
255
+ * stale range can therefore only mis-HIGHLIGHT; it can never misattribute.
256
+ *
257
+ * OFFSETS ARE NULLABLE, and null means "mentioned somewhere in this turn,
258
+ * highlight nothing" — never an error. A USER turn always carries a real range,
259
+ * because the composer measured it. An ASSISTANT turn's mention arrives as a
260
+ * typed part with no character range at all (the later multi-party phase), and
261
+ * the nullability is what lets that land without a second schema.
262
+ *
263
+ * `target` is a `PrincipalRef`, so the agent — which has no users row — is a
264
+ * legal mention target here exactly as it is on a comment (ADR-CONTRACTS-160).
265
+ * `displayName` is resolved AT READ TIME and never frozen onto the row: a rename
266
+ * re-renders with no backfill.
267
+ */
268
+ export const ChatMessageMentionSchema = z.object({
269
+ target: PrincipalRefSchema,
270
+ startOffset: z.number().int().min(0).nullable(),
271
+ endOffset: z.number().int().min(0).nullable(),
272
+ displayName: z.string(),
273
+ });
274
+
275
+ /**
276
+ * Individual message in a chat, as returned by GET /api/chats/:id.
277
+ *
278
+ * `mentions` is REQUIRED, not optional, and an empty array is the common value.
279
+ * This is the `CommentSchema` side of the choice rather than the
280
+ * `proactiveChatFields` side directly above: those are optional so consumer
281
+ * object literals keep compiling across a minor, whereas this ships in a MAJOR
282
+ * where the whole point is that no consumer keeps a second opinion about
283
+ * whether a turn has mentions. The server always projects the array; a missing
284
+ * one is a backend that has not adopted this release, and refusing it is the
285
+ * intended failure (ADR-CONTRACTS-160).
286
+ *
287
+ * MIRRORED, WITH ONE DECLARED LEAD. `mentions` lands HERE FIRST by the
288
+ * contracts-first protocol of ADR-CONT-029: the schema ships, the backend route
289
+ * registry adopts it, then `openapi/backend.yaml` and the generated
290
+ * `components["schemas"]` view in `../api` catch up. Until then `../api` still
291
+ * describes a chat message without mentions, and NOTHING IN THIS REPO CAN SEE
292
+ * THE DIVERGENCE — the parity guard that would catch it compares the spec to the
293
+ * backend, never to this file. This paragraph is the whole record of it, and it
294
+ * comes out when the spec agrees.
295
+ */
236
296
  export const ChatMessageSchema = z.object({
237
297
  id: z.string(),
238
298
  role: z.enum(["user", "assistant"]),
@@ -240,6 +300,7 @@ export const ChatMessageSchema = z.object({
240
300
  parts: z.array(z.unknown()).optional(),
241
301
  sequenceNumber: z.number().int(),
242
302
  createdAt: IsoDateTime,
303
+ mentions: z.array(ChatMessageMentionSchema),
243
304
  });
244
305
 
245
306
  // =============================================================================
@@ -357,6 +418,27 @@ export const GetChatResponseSchema = z.object({
357
418
  messages: z.array(ChatMessageSchema),
358
419
  });
359
420
 
421
+ /**
422
+ * Who the chat composer may offer as a mention target.
423
+ *
424
+ * REUSES `MentionableCandidateSchema` from `../comments/schemas` rather than
425
+ * declaring a chat-shaped twin. A candidate is a principal, a display name and
426
+ * an avatar on both surfaces, and the reasons that shape carries no email and no
427
+ * access level hold here identically: it is a picker, not an
428
+ * access-inspection surface, so returning the band each candidate holds would
429
+ * turn an autocomplete into a way to enumerate who can do what.
430
+ *
431
+ * The ENVELOPE is separate from `MentionableResponseSchema` only because the two
432
+ * endpoints are separate and may diverge in what rides beside `items` — the item
433
+ * shape itself is one definition, imported, never copied.
434
+ *
435
+ * WHO IS IN THE SET is the server's decision and a client cannot widen it: the
436
+ * candidates are the chat's participants plus the agent, derived server-side.
437
+ */
438
+ export const ChatMentionableResponseSchema = z.object({
439
+ items: z.array(MentionableCandidateSchema),
440
+ });
441
+
360
442
  // =============================================================================
361
443
  // Page Context (request-side: where the reader is, as identifiers)
362
444
  // =============================================================================
package/src/chat/types.ts CHANGED
@@ -37,6 +37,8 @@ import {
37
37
  ListSharesResponseSchema,
38
38
  UpdateShareResponseSchema,
39
39
  ChatMessageSchema,
40
+ ChatMessageMentionSchema,
41
+ ChatMentionableResponseSchema,
40
42
  ChatDetailSchema,
41
43
  GetChatResponseSchema,
42
44
  ChatPageContextSchema,
@@ -291,6 +293,17 @@ export type UpdateShareResponse = z.infer<typeof UpdateShareResponseSchema>;
291
293
  /** Individual message in a chat response (GET /api/chats/:id). */
292
294
  export type ChatMessage = z.infer<typeof ChatMessageSchema>;
293
295
 
296
+ /**
297
+ * One mention on a chat turn — a `PrincipalRef` target, a nullable UTF-16
298
+ * code-unit range into `content`, and a read-time display name.
299
+ */
300
+ export type ChatMessageMention = z.infer<typeof ChatMessageMentionSchema>;
301
+
302
+ /** Response for the chat composer's mentionable-candidate endpoint. */
303
+ export type ChatMentionableResponse = z.infer<
304
+ typeof ChatMentionableResponseSchema
305
+ >;
306
+
294
307
  /** Chat detail object in GET /api/chats/:id — includes interactionId for append-on-edit flows. */
295
308
  export type ChatDetail = z.infer<typeof ChatDetailSchema>;
296
309
 
@@ -181,8 +181,39 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
181
181
  Offsets are nullable — treat null as "highlight nothing", not as an error.
182
182
  Mentions written since ADR-BE-522 carry a real range; older rows carry null
183
183
  permanently, since recovering one would mean searching the body for a name.
184
+ - **EVERY ACTOR HERE IS A `PrincipalRef`, NOT A USER ID** (ADR-CONTRACTS-160). A
185
+ comment's `author`, a mention's `target` and a picker candidate's `principal`
186
+ are all `{kind:'user', userId} | {kind:'agent'}`, because the agent is a legal
187
+ author and a legal mention target and has no users row to be keyed by — a
188
+ surface keyed on ids could only name people, which is why the mark used to be
189
+ re-derived from the body characters under the range, the exact
190
+ identity-from-body inversion this domain exists to prevent. `author` is
191
+ nullable for ONE reason only, a USER author whose record is gone; an agent
192
+ author is never null, so reading null as "the agent wrote this" attributes
193
+ every anonymous comment to it. No legacy id survives beside a ref — pre-launch,
194
+ an optional or duplicated field is how one consumer quietly keeps a second
195
+ opinion.
196
+ - **`origin` says what ACT made a thread; `createdByUserId` says who.** They are
197
+ not the same question, and only `origin` distinguishes a thread somebody wrote
198
+ from one a mention in the DOCUMENT BODY brought into being — a body-born thread
199
+ shown as an ordinary comment tells a reader that someone said something they
200
+ never said. `introducedAt` on the `doc_mention` arm is when the mention
201
+ appeared in the body and is deliberately NOT the thread's `createdAt`: the
202
+ thread is materialised when the mention is noticed, which can be after replies
203
+ have already answered it.
204
+ - **`pending` COMES FROM THE DURABLE TRIGGER ROW, and `unanswered` is
205
+ system-owned.** Every mention of the agent leaves exactly one `AgentOutcome`,
206
+ because a mention that simply goes quiet reads as "still thinking" forever.
207
+ Deriving `pending` from worker state or an SSE stream is wrong in both
208
+ directions — a reader who connects after the turn began sees nothing, and a
209
+ reader watching for a reply that a dead worker will never send spins
210
+ indefinitely. `reason` is published as VOCABULARY only; the app owns the
211
+ sentence for each one, so wording changes need no release here. As with the
212
+ redaction rule, "`reason` non-null iff terminal-unanswered" and
213
+ "`replyCommentId` non-null iff `replied`" are documented and writer-enforced
214
+ rather than expressed as Zod refinements.
184
215
  - **These shapes are MIRRORED from the backend route boundary, not authored
185
- here.** Every bound matches
216
+ here — with ONE declared lead.** Every bound matches
186
217
  `company-semantics-backend/src/api/http/routes/comments/comments.schemas.ts`,
187
218
  and therefore matches the generated `components["schemas"]` view in `../api`.
188
219
  The accept/reject corpus in `__tests__/fixtures/comment-anchors.json` is
@@ -192,7 +223,14 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
192
223
  test run, so the corpus cannot be edited here quietly. A change made at the
193
224
  boundary is NOT caught from this repo — nothing in a single-repo CI run can
194
225
  see that file, and this package must not depend on the backend to look. See
195
- ADR-CONTRACTS-116 for the split and for where the upstream gate belongs.
226
+ ADR-CONTRACTS-116 for the split and for where the upstream gate belongs. The
227
+ lead is the principal vocabulary above: `target`, `author`, `principal`,
228
+ `origin` and `agentOutcomes` land HERE FIRST by the contracts-first protocol of
229
+ ADR-CONT-029, so until the backend adopts them `../api` still carries the
230
+ superseded id-keyed fields. Nothing mechanical notices — the parity guard
231
+ compares the spec to the backend, never to this directory — so the divergence
232
+ is recorded in prose here and in `./schemas.ts`, and both notes come out when
233
+ the spec agrees.
196
234
  - **Request bodies are NOT here** (ADR-CONT-029), and one of them must never
197
235
  be: the mention set on a write is validated against a candidate list the
198
236
  server derives from `effective_acl_grants`, so nothing a client sends may
@@ -209,12 +247,15 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
209
247
  | `CommentThreadStatusSchema` | Zod mirror of `COMMENT_THREAD_STATUSES` |
210
248
  | `COMMENT_SUBJECT_TYPES` | What a thread can hang off — closed, sized to what admits comments |
211
249
  | `CommentSubjectTypeSchema` | Zod mirror of `COMMENT_SUBJECT_TYPES` |
212
- | `CommentMentionSchema` | One mention: user id, nullable UTF-16 range, read-time display name |
250
+ | `CommentMentionSchema` | One mention: `PrincipalRef` target, nullable UTF-16 range, display name |
213
251
  | `CommentSchema` | One comment; `body` null iff soft-deleted (infers `CommentProjection`) |
252
+ | `ThreadOriginSchema` | `{comment}` \| `{doc_mention, byUser, introducedAt}` — what ACT made a thread |
253
+ | `AGENT_OUTCOME_REASONS` | Why the agent will not reply — vocabulary only; the app owns the sentence |
254
+ | `AgentOutcomeSchema` | One durable outcome per trigger; `pending` comes from the trigger row |
214
255
  | `CommentThreadSummarySchema` | A thread without its comments — the resolve/reopen response |
215
256
  | `CommentThreadSchema` | A thread with all its comments, oldest-first, tombstones included |
216
257
  | `CommentThreadListResponseSchema` | `GET /api/comments` |
217
- | `MentionableCandidateSchema` | One offerable mention target — no email, no access level |
258
+ | `MentionableCandidateSchema` | One offerable mention target — a `PrincipalRef`; no email, no access level |
218
259
  | `MentionableResponseSchema` | `GET /api/company-md/docs/{id}/mentionable` |
219
260
  | `COMMENT_THREAD_KINDS` | `comment` \| `suggestion` — a suggestion IS a comment thread |
220
261
  | `SUGGESTION_OPS` | `insert` \| `delete` \| `replace` — what a suggestion proposes |
@@ -262,7 +303,14 @@ it stays inside the vocabulary-guard's rules — request bodies still do not.
262
303
  projection from it without a cycle. `./receipt` is a leaf for the same
263
304
  reason — zod only, no reach for the anchor or the ladder — so anything in this
264
305
  directory can import it later without one.
265
- - Nothing outside this directory is imported: the
306
+ - `../identity/agent` `PrincipalRefSchema`, imported at RUNTIME by
307
+ `./schemas`. This directory once imported nothing outside itself; it now
308
+ imports exactly one thing, and deliberately. A locally-declared user-or-agent
309
+ union would be a second description of who the agent is, in the one package
310
+ whose job is to have exactly one — and `../identity/agent` is a zod-only leaf,
311
+ so the dependency adds no reach. The arrow runs comments → identity and never
312
+ back.
313
+ - Nothing else outside this directory is imported: the
266
314
  comment vocabulary binds to the existing `commenter` band of
267
315
  `../permissions`'s `AccessLevel` and introduces no access level of its own, so
268
316
  it needs no import to say so.
@@ -79,6 +79,13 @@ deleteLength: 0, insert: "" }` is a valid mutation that applies without error
79
79
  - **Negative tests mutate ONE field of a well-formed factory result.** A
80
80
  hand-built broken object can pass for the wrong reason: it fails because of
81
81
  the field nobody was testing.
82
+ - **A field's ABSENCE is pinned by asserting the whole key set, not by hoping.**
83
+ `Object.keys(...).sort()` on `CommentSchema` and on the candidate element is
84
+ what makes re-adding a legacy id field beside its `PrincipalRef` a red test.
85
+ A parse-based test cannot do this job: these shapes
86
+ are non-strict, so an extra key is accepted silently, and a legacy id kept
87
+ beside the ref is exactly how one consumer would quietly keep a second opinion
88
+ about who wrote a comment.
82
89
 
83
90
  ## Public API
84
91