@company-semantics/contracts 62.10.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.
@@ -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
 
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+
3
+ import {
4
+ AGENT_PRINCIPAL,
5
+ CS_AGENT,
6
+ CS_AGENT_HANDLE,
7
+ PrincipalRefSchema,
8
+ isAgentPrincipal,
9
+ principalKey,
10
+ userPrincipal,
11
+ } from "../agent.js";
12
+
13
+ const USER_ID = "33333333-3333-4333-8333-333333333333";
14
+
15
+ describe("CS_AGENT", () => {
16
+ // The point of the constant: the renderers derive the glyph rather than
17
+ // retyping it, so this assertion is what keeps them honest.
18
+ it("the glyph is derived from the handle", () => {
19
+ expect(CS_AGENT.glyph).toBe(`[${CS_AGENT.handle}]`);
20
+ });
21
+
22
+ it("names the agent once — handle, displayName and the constant agree", () => {
23
+ expect(CS_AGENT.handle).toBe(CS_AGENT_HANDLE);
24
+ expect(CS_AGENT.displayName).toBe(CS_AGENT_HANDLE);
25
+ });
26
+ });
27
+
28
+ describe("PrincipalRefSchema", () => {
29
+ it("accepts the agent arm with no id", () => {
30
+ expect(PrincipalRefSchema.parse({ kind: "agent" })).toEqual({
31
+ kind: "agent",
32
+ });
33
+ });
34
+
35
+ it("accepts a user arm carrying a userId", () => {
36
+ expect(PrincipalRefSchema.parse({ kind: "user", userId: USER_ID })).toEqual(
37
+ {
38
+ kind: "user",
39
+ userId: USER_ID,
40
+ },
41
+ );
42
+ });
43
+
44
+ // Strict, not stripping: a stray userId on an agent ref means the caller
45
+ // believes the agent has a users row. Refuse rather than silently drop it.
46
+ it("an agent ref rejects a userId", () => {
47
+ expect(
48
+ PrincipalRefSchema.safeParse({ kind: "agent", userId: "x" }).success,
49
+ ).toBe(false);
50
+ });
51
+
52
+ it("a user ref requires a userId", () => {
53
+ expect(PrincipalRefSchema.safeParse({ kind: "user" }).success).toBe(false);
54
+ });
55
+
56
+ it("rejects an unknown kind", () => {
57
+ expect(PrincipalRefSchema.safeParse({ kind: "system" }).success).toBe(
58
+ false,
59
+ );
60
+ });
61
+
62
+ // Mirrors the comments boundary rather than the uuid-tight rest of this
63
+ // domain: tightening here would reject ids the server considers valid.
64
+ it("does not require the userId to be a uuid", () => {
65
+ expect(
66
+ PrincipalRefSchema.safeParse({ kind: "user", userId: "u_1" }).success,
67
+ ).toBe(true);
68
+ });
69
+ });
70
+
71
+ describe("principal helpers", () => {
72
+ it("AGENT_PRINCIPAL parses as the agent arm", () => {
73
+ expect(PrincipalRefSchema.parse(AGENT_PRINCIPAL)).toEqual({
74
+ kind: "agent",
75
+ });
76
+ });
77
+
78
+ it("userPrincipal builds a parseable user ref", () => {
79
+ expect(PrincipalRefSchema.parse(userPrincipal(USER_ID))).toEqual({
80
+ kind: "user",
81
+ userId: USER_ID,
82
+ });
83
+ });
84
+
85
+ it("isAgentPrincipal narrows the agent arm only", () => {
86
+ expect(isAgentPrincipal(AGENT_PRINCIPAL)).toBe(true);
87
+ expect(isAgentPrincipal(userPrincipal(USER_ID))).toBe(false);
88
+ });
89
+
90
+ it("principalKey is stable and cannot collide across arms", () => {
91
+ expect(principalKey(AGENT_PRINCIPAL)).toBe("agent");
92
+ expect(principalKey(userPrincipal(USER_ID))).toBe(`user:${USER_ID}`);
93
+ expect(new Set([principalKey(AGENT_PRINCIPAL), "agent"]).size).toBe(1);
94
+ });
95
+ });