@company-semantics/contracts 62.11.0 → 63.1.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.
@@ -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
 
@@ -10,6 +10,7 @@
10
10
  import { describe, expect, it } from "vitest";
11
11
 
12
12
  import {
13
+ AGENT_OUTCOME_REASONS,
13
14
  COMMENT_SUBJECT_TYPES,
14
15
  COMMENT_THREAD_STATUSES,
15
16
  CommentMentionSchema,
@@ -28,7 +29,7 @@ const DOC_ID = "44444444-4444-4444-8444-444444444444";
28
29
 
29
30
  function makeMention(over: Record<string, unknown> = {}) {
30
31
  return {
31
- userId: USER_ID,
32
+ target: { kind: "user", userId: USER_ID },
32
33
  startOffset: 0,
33
34
  endOffset: 9,
34
35
  displayName: "Sam Chen",
@@ -40,7 +41,7 @@ function makeComment(over: Record<string, unknown> = {}) {
40
41
  return {
41
42
  id: COMMENT_ID,
42
43
  threadId: THREAD_ID,
43
- authorUserId: USER_ID,
44
+ author: { kind: "user", userId: USER_ID },
44
45
  body: "Should this say Q3 or Q4?",
45
46
  editedAt: null,
46
47
  deletedAt: null,
@@ -66,6 +67,8 @@ function makeThread(over: Record<string, unknown> = {}) {
66
67
  suffix: " by March",
67
68
  },
68
69
  status: "open",
70
+ origin: { kind: "comment" },
71
+ agentOutcomes: [],
69
72
  createdByUserId: USER_ID,
70
73
  resolvedByUserId: null,
71
74
  resolvedAt: null,
@@ -149,9 +152,42 @@ describe("CommentSchema and the redaction invariant", () => {
149
152
  });
150
153
 
151
154
  it("parses an unattributed comment — a tombstoned author is not an error", () => {
155
+ expect(CommentSchema.safeParse(makeComment({ author: null })).success).toBe(
156
+ true,
157
+ );
158
+ });
159
+
160
+ it("parses an agent-authored comment, and the agent ref carries no id", () => {
161
+ // The whole point of the ref: the agent has no users row, so a comment it
162
+ // wrote could not previously be attributed at all. `{ kind: 'agent' }` is
163
+ // the ONLY agent author — a userId beside it is refused rather than
164
+ // stripped, because a ref that parsed cleanly while carrying an id would
165
+ // hand the app a second, contradictory identity for a singleton.
152
166
  expect(
153
- CommentSchema.safeParse(makeComment({ authorUserId: null })).success,
167
+ CommentSchema.safeParse(makeComment({ author: { kind: "agent" } }))
168
+ .success,
154
169
  ).toBe(true);
170
+ expect(
171
+ CommentSchema.safeParse(
172
+ makeComment({ author: { kind: "agent", userId: USER_ID } }),
173
+ ).success,
174
+ ).toBe(false);
175
+ });
176
+
177
+ it("carries the author ref and no legacy author id beside it", () => {
178
+ // A legacy id kept beside the ref is how one consumer quietly keeps a
179
+ // second opinion about who wrote a comment. Asserting the key set makes
180
+ // adding it back a red test rather than a silently tolerated extra.
181
+ expect(Object.keys(CommentSchema.shape).sort()).toEqual([
182
+ "author",
183
+ "body",
184
+ "createdAt",
185
+ "deletedAt",
186
+ "editedAt",
187
+ "id",
188
+ "mentions",
189
+ "threadId",
190
+ ]);
155
191
  });
156
192
 
157
193
  it("requires the mentions array — absent is not the same as empty", () => {
@@ -208,6 +244,20 @@ describe("CommentMentionSchema offsets", () => {
208
244
  CommentMentionSchema.safeParse(makeMention({ endOffset: 2.5 })).success,
209
245
  ).toBe(false);
210
246
  });
247
+
248
+ it("names the agent as a target, and rejects a bare user id", () => {
249
+ // The mark is a PRINCIPAL now, not a string. A bare id could never name the
250
+ // agent, which is why every surface used to re-derive it from the body
251
+ // characters under the range — the exact inversion this contract forbids.
252
+ expect(
253
+ CommentMentionSchema.safeParse(
254
+ makeMention({ target: { kind: "agent" }, displayName: "c_S" }),
255
+ ).success,
256
+ ).toBe(true);
257
+ expect(
258
+ CommentMentionSchema.safeParse(makeMention({ target: USER_ID })).success,
259
+ ).toBe(false);
260
+ });
211
261
  });
212
262
 
213
263
  describe("CommentThreadSchema", () => {
@@ -358,6 +408,106 @@ describe("CommentThreadSummarySchema kind and suggestion", () => {
358
408
  });
359
409
  });
360
410
 
411
+ describe("CommentThreadSummarySchema origin and agentOutcomes", () => {
412
+ it("a thread summary requires origin and agentOutcomes", () => {
413
+ // REQUIRED, like `kind` and `capabilities`. Optional is how one consumer
414
+ // keeps a local derivation of what started a thread or whether the agent
415
+ // answered — and both of those are precisely what this vocabulary exists to
416
+ // stop being guessed. The backend projects `{kind:'comment'}` and `[]` onto
417
+ // every thread that predates them, so absence is malformed, not legacy.
418
+ const { origin: _noOrigin, ...withoutOrigin } = makeThread();
419
+ expect(CommentThreadSummarySchema.safeParse(withoutOrigin).success).toBe(
420
+ false,
421
+ );
422
+ const { agentOutcomes: _noOutcomes, ...withoutOutcomes } = makeThread();
423
+ expect(CommentThreadSummarySchema.safeParse(withoutOutcomes).success).toBe(
424
+ false,
425
+ );
426
+ });
427
+
428
+ it("parses a thread born from a mention in the document body", () => {
429
+ // The second origin arm, published ahead of the doc-body work so it needs
430
+ // no further release. `introducedAt` is when the mention appeared in the
431
+ // BODY and is deliberately not `createdAt`: the thread is materialised when
432
+ // the mention is noticed, which can be after replies already answered it.
433
+ const parsed = CommentThreadSummarySchema.parse(
434
+ makeThread({
435
+ origin: {
436
+ kind: "doc_mention",
437
+ byUser: { kind: "user", userId: USER_ID },
438
+ introducedAt: "2026-07-30T08:00:00.000Z",
439
+ },
440
+ }),
441
+ );
442
+ expect(parsed.origin.kind).toBe("doc_mention");
443
+ // A tombstoned introducer is not an error, same rule as a comment author.
444
+ expect(
445
+ CommentThreadSummarySchema.safeParse(
446
+ makeThread({
447
+ origin: {
448
+ kind: "doc_mention",
449
+ byUser: null,
450
+ introducedAt: "2026-07-30T08:00:00.000Z",
451
+ },
452
+ }),
453
+ ).success,
454
+ ).toBe(true);
455
+ });
456
+
457
+ it("rejects a doc_mention origin missing introducedAt", () => {
458
+ // The discriminant alone must not carry the arm: a `doc_mention` without a
459
+ // time is a genesis row a client can only place by falling back to
460
+ // `createdAt`, which is the wrong number.
461
+ expect(
462
+ CommentThreadSummarySchema.safeParse(
463
+ makeThread({ origin: { kind: "doc_mention", byUser: null } }),
464
+ ).success,
465
+ ).toBe(false);
466
+ });
467
+
468
+ it("carries a terminal unanswered outcome with a reason and no reply", () => {
469
+ // The marker that makes a trigger terminate VISIBLY. Without it a mention
470
+ // that will never be answered is indistinguishable from one still being
471
+ // worked, and the reader waits forever. `reason` is vocabulary only — the
472
+ // app owns the sentence, so wording changes need no release here.
473
+ expect([...AGENT_OUTCOME_REASONS]).toContain("delegate_unknown");
474
+ const parsed = CommentThreadSummarySchema.parse(
475
+ makeThread({
476
+ agentOutcomes: [
477
+ {
478
+ triggerId: "66666666-6666-4666-8666-666666666666",
479
+ status: "unanswered",
480
+ reason: "delegate_unknown",
481
+ replyCommentId: null,
482
+ },
483
+ ],
484
+ }),
485
+ );
486
+ expect(parsed.agentOutcomes[0].status).toBe("unanswered");
487
+ });
488
+
489
+ it("rejects an outcome status or reason outside the published vocabulary", () => {
490
+ // Both enums are closed. A free string would let the backend invent a
491
+ // fourth state the app renders as nothing at all — silence again.
492
+ const outcome = {
493
+ triggerId: "66666666-6666-4666-8666-666666666666",
494
+ status: "pending",
495
+ reason: null,
496
+ replyCommentId: null,
497
+ };
498
+ expect(
499
+ CommentThreadSummarySchema.safeParse(
500
+ makeThread({ agentOutcomes: [{ ...outcome, status: "working" }] }),
501
+ ).success,
502
+ ).toBe(false);
503
+ expect(
504
+ CommentThreadSummarySchema.safeParse(
505
+ makeThread({ agentOutcomes: [{ ...outcome, reason: "gave_up" }] }),
506
+ ).success,
507
+ ).toBe(false);
508
+ });
509
+ });
510
+
361
511
  describe("CommentThreadListResponseSchema", () => {
362
512
  it("parses an empty subject", () => {
363
513
  expect(
@@ -377,7 +527,25 @@ describe("MentionableResponseSchema", () => {
377
527
  it("parses a candidate list", () => {
378
528
  expect(
379
529
  MentionableResponseSchema.safeParse({
380
- items: [{ userId: USER_ID, displayName: "Sam Chen", avatarUrl: null }],
530
+ items: [
531
+ {
532
+ principal: { kind: "user", userId: USER_ID },
533
+ displayName: "Sam Chen",
534
+ avatarUrl: null,
535
+ },
536
+ ],
537
+ }).success,
538
+ ).toBe(true);
539
+ });
540
+
541
+ it("offers the agent as a candidate — it has no users row to be keyed by", () => {
542
+ // A picker keyed on user ids could only ever offer people, which is why the
543
+ // agent had to be typed rather than searched for in the body text.
544
+ expect(
545
+ MentionableResponseSchema.safeParse({
546
+ items: [
547
+ { principal: { kind: "agent" }, displayName: "c_S", avatarUrl: null },
548
+ ],
381
549
  }).success,
382
550
  ).toBe(true);
383
551
  });
@@ -386,8 +554,9 @@ describe("MentionableResponseSchema", () => {
386
554
  // The candidates ARE the document's readers. Returning the band each one
387
555
  // holds would turn an autocomplete into a way to enumerate who can do what,
388
556
  // so the field must stay absent from the shape rather than merely unset.
557
+ // The same assertion pins that `userId` did not survive beside `principal`.
389
558
  expect(
390
559
  Object.keys(MentionableResponseSchema.shape.items.element.shape).sort(),
391
- ).toEqual(["avatarUrl", "displayName", "userId"]);
560
+ ).toEqual(["avatarUrl", "displayName", "principal"]);
392
561
  });
393
562
  });
@@ -34,6 +34,8 @@ export {
34
34
  export type { AnchorResolution, Occurrence } from "./resolve";
35
35
 
36
36
  export {
37
+ AGENT_OUTCOME_REASONS,
38
+ AgentOutcomeSchema,
37
39
  COMMENT_SUBJECT_TYPES,
38
40
  COMMENT_THREAD_STATUSES,
39
41
  CommentMentionSchema,
@@ -45,9 +47,11 @@ export {
45
47
  CommentThreadSummarySchema,
46
48
  MentionableCandidateSchema,
47
49
  MentionableResponseSchema,
50
+ ThreadOriginSchema,
48
51
  } from "./schemas";
49
52
 
50
53
  export type {
54
+ AgentOutcome,
51
55
  CommentMention,
52
56
  CommentProjection,
53
57
  CommentSubjectType,
@@ -57,6 +61,7 @@ export type {
57
61
  CommentThreadSummary,
58
62
  MentionableCandidate,
59
63
  MentionableResponse,
64
+ ThreadOrigin,
60
65
  } from "./schemas";
61
66
 
62
67
  export {
@@ -9,15 +9,26 @@
9
9
  *
10
10
  * Zod-canonical: the schema is the source of truth, the type is inferred.
11
11
  *
12
- * MIRRORED, NOT INVENTED. Every field below matches the backend route
12
+ * MIRRORED, WITH ONE DECLARED LEAD. Every field below matches the backend route
13
13
  * boundary's response schemas (`src/api/http/routes/comments/comments.schemas.ts`
14
- * and `.../company-md/mentionable.schemas.ts`) field-for-field, and therefore
15
- * matches the already-generated `components["schemas"]` view in `../api`. Two
16
- * descriptions of one wire shape that disagree is worse than one, so where the
17
- * backend is looser than looks right `userId` is `z.string()` and not a uuid,
18
- * mention offsets are nullable — this mirrors the backend and says why.
14
+ * and `.../company-md/mentionable.schemas.ts`) field-for-field EXCEPT the
15
+ * principal vocabulary of ADR-CONTRACTS-160 (`target`, `author`, `principal`,
16
+ * `origin`, `agentOutcomes`), which lands HERE FIRST by the contracts-first
17
+ * protocol of ADR-CONT-029: the schema ships, the route registry adopts it,
18
+ * then `openapi/backend.yaml` and the generated `components["schemas"]` view in
19
+ * `../api` catch up. Until the backend side lands, `../api` still carries the
20
+ * superseded id-keyed fields, and NOTHING IN THIS REPO CAN SEE THE DIVERGENCE —
21
+ * the parity guard that would catch it compares the spec to the backend, never
22
+ * to this file. So this paragraph is the whole record of it, and it comes out
23
+ * when the spec agrees.
24
+ *
25
+ * Two descriptions of one wire shape that disagree is worse than one, so where
26
+ * the backend is looser than looks right — a principal's `userId` is
27
+ * `z.string()` and not a uuid, mention offsets are nullable — this mirrors the
28
+ * backend and says why.
19
29
  */
20
30
  import { z } from "zod";
31
+ import { PrincipalRefSchema } from "../identity/agent";
21
32
  import { CommentAnchorSchema, CommentAnchorTypeSchema } from "./anchor";
22
33
  import { COMMENT_THREAD_KINDS, SuggestionSchema } from "./suggestion";
23
34
 
@@ -89,13 +100,16 @@ export type CommentSubjectType = z.infer<typeof CommentSubjectTypeSchema>;
89
100
  * identity the reader can no longer resolve arrives as a neutral placeholder
90
101
  * rather than failing the whole thread load.
91
102
  *
92
- * `userId` is `z.string()` and not `.uuid()` on purpose it mirrors the
93
- * backend boundary exactly. Tightening it here would make this package reject
94
- * responses the server considers valid, which is a client-side outage for no
95
- * security gain.
103
+ * `target` IS A PRINCIPAL, NOT A USER ID (ADR-CONTRACTS-160). The agent is a
104
+ * legal mention target and has no users row to point at, so a mention keyed on
105
+ * a bare id could not name it at all — which is how the mark ended up rendered
106
+ * from body characters everywhere it appeared. A user target's `userId` stays
107
+ * `z.string()` and not `.uuid()`, mirroring the backend boundary exactly:
108
+ * tightening it here would make this package reject responses the server
109
+ * considers valid, which is a client-side outage for no security gain.
96
110
  */
97
111
  export const CommentMentionSchema = z.object({
98
- userId: z.string(),
112
+ target: PrincipalRefSchema,
99
113
  startOffset: z.number().int().min(0).nullable(),
100
114
  endOffset: z.number().int().min(0).nullable(),
101
115
  displayName: z.string(),
@@ -126,8 +140,12 @@ export type CommentMention = z.infer<typeof CommentMentionSchema>;
126
140
  * cannot check. `./__tests__/schemas.test.ts` pins that the redacted projection
127
141
  * parses.
128
142
  *
129
- * `authorUserId` is nullable for the same tombstoning reason: an author whose
130
- * user record is gone leaves the comment readable and unattributed.
143
+ * `author` is a `PrincipalRef` and nullable, and null keeps EXACTLY ONE
144
+ * meaning: a USER author whose user record is gone, leaving the comment
145
+ * readable and unattributed. An agent author is `{ kind: "agent" }` and is
146
+ * NEVER null — the agent is a singleton with no record to lose
147
+ * (ADR-CONTRACTS-160), so a client that reads null as "the agent wrote this"
148
+ * attributes every tombstoned author's comment to the agent.
131
149
  *
132
150
  * The inferred type is `CommentProjection`, not `Comment`, for the reason
133
151
  * `NotificationElement` is not `Element` (`../notifications/content`): `Comment`
@@ -138,7 +156,7 @@ export type CommentMention = z.infer<typeof CommentMentionSchema>;
138
156
  export const CommentSchema = z.object({
139
157
  id: z.string(),
140
158
  threadId: z.string(),
141
- authorUserId: z.string().nullable(),
159
+ author: PrincipalRefSchema.nullable(),
142
160
  body: z.string().nullable(),
143
161
  editedAt: z.string().nullable(),
144
162
  deletedAt: z.string().nullable(),
@@ -151,6 +169,82 @@ export type CommentProjection = z.infer<typeof CommentSchema>;
151
169
  // Threads
152
170
  // =============================================================================
153
171
 
172
+ /**
173
+ * HOW A THREAD CAME TO EXIST — not WHO made it (`createdByUserId` answers that)
174
+ * but WHAT ACT did.
175
+ *
176
+ * Every thread in existence projects `{ kind: "comment" }`: somebody wrote a
177
+ * comment. The `doc_mention` arm is the thread that a mention in the DOCUMENT
178
+ * BODY brings into being, where nobody wrote a comment at all — so
179
+ * `createdByUserId` alone cannot tell the two apart, and a reader shown a
180
+ * body-born thread as an ordinary comment is being told someone said something
181
+ * they never said. Both arms publish NOW so the doc-body work emits the second
182
+ * one with no further release of this package.
183
+ *
184
+ * `byUser` is a `PrincipalRef` and nullable for the same tombstoning reason as
185
+ * `CommentSchema.author`. `introducedAt` is when the mention appeared in the
186
+ * BODY, which is NOT the thread's `createdAt`: the thread is materialised when
187
+ * the mention is noticed, which can be later, and ordering a genesis row by
188
+ * `createdAt` puts it after replies that answered it.
189
+ *
190
+ * NON-STRICT, like the thread shapes it sits inside and unlike
191
+ * `SuggestionSchema`: a suggestion is a negotiated payload that must grow by a
192
+ * new `v`, while this is a server projection that mirrors the backend's
193
+ * looseness rather than tightening past it.
194
+ */
195
+ export const ThreadOriginSchema = z.discriminatedUnion("kind", [
196
+ z.object({ kind: z.literal("comment") }),
197
+ z.object({
198
+ kind: z.literal("doc_mention"),
199
+ byUser: PrincipalRefSchema.nullable(),
200
+ introducedAt: z.string(),
201
+ }),
202
+ ]);
203
+ export type ThreadOrigin = z.infer<typeof ThreadOriginSchema>;
204
+
205
+ /** Why the agent will not reply. Vocabulary only — the app owns the sentence. */
206
+ export const AGENT_OUTCOME_REASONS = [
207
+ "replies_off",
208
+ "mentioner_unavailable",
209
+ "delegate_unknown",
210
+ "subject_unreadable",
211
+ "rate_capped",
212
+ "turn_failed",
213
+ ] as const;
214
+
215
+ /**
216
+ * ONE DURABLE OUTCOME PER TRIGGER — what became of each mention of the agent on
217
+ * this thread. The array is empty on every thread that never named it.
218
+ *
219
+ * `pending` IS DERIVED FROM THE DURABLE TRIGGER ROW, never from worker state
220
+ * and never from an SSE stream. Both of those are the wrong source in opposite
221
+ * directions: a client inferring "still working" from a live stream shows
222
+ * nothing to a reader who connected after the turn began, and a client
223
+ * inferring it from the absence of a reply spins forever over a worker that
224
+ * died. The trigger row outlives both, which is why it is the only one.
225
+ *
226
+ * `unanswered` IS SYSTEM-OWNED AND TERMINAL: the agent will not reply and
227
+ * nothing further is queued. It exists so a mention cannot simply go quiet —
228
+ * silence reads as "still thinking" indefinitely, and the whole point of a
229
+ * durable outcome is that every trigger terminates visibly. `reason` says which
230
+ * way, and the APP owns the copy for each one: this package publishes the
231
+ * vocabulary, not the sentence, so wording changes need no release.
232
+ *
233
+ * `reason` is null EXACTLY when `status` is `pending` or `replied`, and
234
+ * `replyCommentId` is non-null EXACTLY when `status` is `replied`. Both rules
235
+ * are DOCUMENTED and writer-enforced rather than expressed as refinements, for
236
+ * the same reason as the redaction invariant above: a refinement would make
237
+ * this package refuse a response the server is willing to emit, and it cannot
238
+ * be said in the OpenAPI surface either.
239
+ */
240
+ export const AgentOutcomeSchema = z.object({
241
+ triggerId: z.string(),
242
+ status: z.enum(["pending", "replied", "unanswered"]),
243
+ reason: z.enum(AGENT_OUTCOME_REASONS).nullable(),
244
+ replyCommentId: z.string().nullable(),
245
+ });
246
+ export type AgentOutcome = z.infer<typeof AgentOutcomeSchema>;
247
+
154
248
  /**
155
249
  * A thread WITHOUT its comments — the shape a resolve/reopen transition returns.
156
250
  *
@@ -187,6 +281,13 @@ export type CommentProjection = z.infer<typeof CommentSchema>;
187
281
  * thread status `resolved` with `suggestion.status` carrying which way it went
188
282
  * (`accepted` | `rejected`), and `resolvedByUserId`/`resolvedAt` double as the
189
283
  * decision actor and time — there is no second actor/timestamp pair.
284
+ *
285
+ * `origin` and `agentOutcomes` are BOTH REQUIRED, for the reason `kind` and
286
+ * `capabilities` are: an optional field is how one consumer quietly keeps a
287
+ * local derivation, and a second opinion about what started a thread or whether
288
+ * the agent answered is exactly the drift this vocabulary exists to delete. The
289
+ * backend projects `{ kind: "comment" }` and `[]` onto every thread that
290
+ * predates them, so absence is a malformed response and not a legacy row.
190
291
  */
191
292
  export const CommentThreadSummarySchema = z.object({
192
293
  id: z.string(),
@@ -197,6 +298,8 @@ export const CommentThreadSummarySchema = z.object({
197
298
  anchorType: CommentAnchorTypeSchema,
198
299
  anchor: CommentAnchorSchema,
199
300
  status: CommentThreadStatusSchema,
301
+ origin: ThreadOriginSchema,
302
+ agentOutcomes: z.array(AgentOutcomeSchema),
200
303
  createdByUserId: z.string().nullable(),
201
304
  resolvedByUserId: z.string().nullable(),
202
305
  resolvedAt: z.string().nullable(),
@@ -291,12 +394,15 @@ export type CommentThreadListResponse = z.infer<
291
394
  * readers — so returning the band each one holds would turn an autocomplete into
292
395
  * a way to enumerate who can do what.
293
396
  *
294
- * `userId` is what the mention row will carry; `displayName` is resolved fresh
295
- * on every read and never frozen into a stored row, exactly as on
296
- * `CommentMentionSchema`.
397
+ * `principal` is what the mention row will carry, and it is a `PrincipalRef`
398
+ * rather than a user id so that the AGENT is offerable here at all — it holds
399
+ * no grants of its own and has no users row, so a candidate list keyed on ids
400
+ * could only ever offer people (ADR-CONTRACTS-160). `displayName` is resolved
401
+ * fresh on every read and never frozen into a stored row, exactly as on
402
+ * `CommentMentionSchema`; for the agent it is `CS_AGENT.displayName`.
297
403
  */
298
404
  export const MentionableCandidateSchema = z.object({
299
- userId: z.string(),
405
+ principal: PrincipalRefSchema,
300
406
  displayName: z.string(),
301
407
  avatarUrl: z.string().nullable(),
302
408
  });
@@ -20,6 +20,7 @@ TypeScript types and functions for user identity and display name resolution.
20
20
 
21
21
  ## Public API
22
22
 
23
+ - `AGENT_PRINCIPAL` — The one agent principal.
23
24
  - `AccountCancelDeletionResponse` _(type)_
24
25
  - `AccountCancelDeletionResponseSchema`
25
26
  - `AccountConfirmDeletionResponse` _(type)_
@@ -42,6 +43,8 @@ TypeScript types and functions for user identity and display name resolution.
42
43
  - `BannerDismissResponseSchema`
43
44
  - `BannerDismissedListResponse` _(type)_
44
45
  - `BannerDismissedListResponseSchema`
46
+ - `CS_AGENT` — The agent's identity, in the three forms a surface needs: the handle a mention is typed as, the name a chip…
47
+ - `CS_AGENT_HANDLE` — THE AGENT PRINCIPAL (control INVARIANTS.md, INV-AGENT-IDENTITY).
45
48
  - `DeletionBlocker` _(type)_ — Conditions that block account deletion.
46
49
  - `ISODateString` _(type)_ — ISO 8601 date-time string.
47
50
  - `IdentityLink` _(type)_
@@ -65,6 +68,8 @@ TypeScript types and functions for user identity and display name resolution.
65
68
  - `PersonSchema`
66
69
  - `PositionRef` _(type)_
67
70
  - `PositionRefSchema` — A reference to a POSITION in the org graph.
71
+ - `PrincipalRef` _(type)_
72
+ - `PrincipalRefSchema` — A principal: a person by id, or the one agent.
68
73
  - `ProfileResponse` _(type)_
69
74
  - `ProfileResponseSchema`
70
75
  - `ReportingRelationshipType` _(type)_
@@ -77,8 +82,11 @@ TypeScript types and functions for user identity and display name resolution.
77
82
  - `deriveFullName`
78
83
  - `extractFirstWord` — Extract the first word from a full name.
79
84
  - `generateInitials` — Generate initials from a full name.
85
+ - `isAgentPrincipal` — Narrows a ref to the agent arm.
86
+ - `principalKey` — Stable key for Set/Map use: 'agent' | 'user:id'.
80
87
  - `resolveAvatar` — Resolve avatar from user identity.
81
88
  - `resolveDisplayName` — Resolve the display name for assistant addressing.
89
+ - `userPrincipal` — A person as a principal.
82
90
 
83
91
  <!-- END GENERATED: readme-public-api -->
84
92