@company-semantics/contracts 32.0.0 → 33.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": "32.0.0",
3
+ "version": "33.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -125,7 +125,7 @@
125
125
  "zod": "^4.4.3"
126
126
  },
127
127
  "devDependencies": {
128
- "@types/node": "^26.1.1",
128
+ "@types/node": "^22.20.1",
129
129
  "husky": "^9.1.7",
130
130
  "lint-staged": "^17.0.8",
131
131
  "markdownlint-cli2": "^0.23.0",
@@ -125,7 +125,24 @@ export const CompanyMdDocResponseSchema = z.object({
125
125
  ]),
126
126
  parentId: z.string().nullable(),
127
127
  owningUnitId: z.string().nullable(),
128
- owner: CompanyMdPersonSchema.nullable(),
128
+ /**
129
+ * The doc's EFFECTIVE owners (ADR-CONTRACTS-084), resolved by the same
130
+ * authority as the share dialog's `owner_user_ids`: a stored personal owner,
131
+ * else the owning unit's live authority holders, else the org account owner.
132
+ *
133
+ * A list, not a scalar — co-ownership is first-class (ADR-CTRL-161) and every
134
+ * entry is owner-equivalent (ADR-BE-189), so a surface naming one owner reads
135
+ * `owners[0]` and derives any co-owner count from `owners.length` (e.g. "Ada
136
+ * +2 are owners"). Ordered deterministically so the named owner is stable
137
+ * across reads. Empty only when the org has no account owner
138
+ * (runtime-impossible) — replaces the nullable `owner` scalar and the
139
+ * `ownerCount` shadow field.
140
+ */
141
+ owners: z
142
+ .array(CompanyMdPersonSchema)
143
+ .refine((a) => new Set(a.map((p) => p.id)).size === a.length, {
144
+ message: "duplicate id in owners",
145
+ }),
129
146
  canEdit: z.boolean(),
130
147
  /**
131
148
  * UI capability signal (ADR-BE-382): whether the requesting actor holds
@@ -10,15 +10,13 @@ import type { EmailPayloads } from "../types";
10
10
 
11
11
  import {
12
12
  type Block,
13
- chatAssistant,
14
- chatCta,
15
- chatUnit,
16
13
  footer,
17
14
  greeting,
18
15
  keyValue,
19
16
  paragraph,
20
17
  signature,
21
18
  } from "./blocks";
19
+ import { chatAssistant, chatCta, chatUnit } from "./chat";
22
20
  import { COMPANY_NAME } from "./constants";
23
21
 
24
22
  export type AuthOtpPayload = EmailPayloads["auth.otp"];
@@ -7,6 +7,10 @@
7
7
  * UI is the components.** Editing a block restyles every email, HTML and plain
8
8
  * text, across the backend (real sends) and the app (Ladle preview).
9
9
  *
10
+ * This module holds the primitives — shells, paragraphs, and the CTA box. The
11
+ * chat unit builds on them from `./chat`; both are re-exported together from
12
+ * `./index`, which is the surface templates and consumers import.
13
+ *
10
14
  * INVARIANTS:
11
15
  * - Pure functions, no side effects — except `signature()`, which reads the
12
16
  * current year (`new Date().getFullYear()`) for the copyright line.
@@ -17,11 +21,14 @@
17
21
  import { COMPANY_NAME, MONO_FONT_STACK } from "./constants";
18
22
  import { escapeHtml } from "./escape-html";
19
23
 
20
- const MONO = `font-family: ${MONO_FONT_STACK};`;
24
+ /** Shared type styling. Exported for `./chat` only — not part of the package
25
+ * surface (`./index` does not re-export these), so components stay the one
26
+ * place styling is decided. */
27
+ export const MONO = `font-family: ${MONO_FONT_STACK};`;
21
28
 
22
29
  /** The single font size for every email element (HTML). Plain text is monospace
23
30
  * so it carries no size — this keeps one visual size across both surfaces. */
24
- const FONT_SIZE = "13px";
31
+ export const FONT_SIZE = "13px";
25
32
 
26
33
  // =============================================================================
27
34
  // Core types
@@ -235,8 +242,9 @@ function asciiCtaBox(label: string): string {
235
242
  }
236
243
 
237
244
  /** The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
238
- * the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it. */
239
- function ctaButton(
245
+ * the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it
246
+ * (hence the export — internal to the render layer, not re-exported by `./index`). */
247
+ export function ctaButton(
240
248
  opts: CtaBoxOptions,
241
249
  margin: string,
242
250
  ): { html: string; text: string } {
@@ -271,297 +279,3 @@ export function ctaBox(opts: CtaBoxOptions): Block {
271
279
  const { html, text } = ctaButton(opts, "0 0 20px 0");
272
280
  return { html, text, spacing: "normal" };
273
281
  }
274
-
275
- // =============================================================================
276
- // Chat bubbles — one dual-output block per role
277
- // =============================================================================
278
-
279
- /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
280
- function wrapText(text: string, width: number): string[] {
281
- const lines: string[] = [];
282
- let cur = "";
283
- for (const word of text.split(/\s+/).filter(Boolean)) {
284
- let w = word;
285
- while (w.length > width) {
286
- if (cur) {
287
- lines.push(cur);
288
- cur = "";
289
- }
290
- lines.push(w.slice(0, width));
291
- w = w.slice(width);
292
- }
293
- if (!cur) cur = w;
294
- else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
295
- else {
296
- lines.push(cur);
297
- cur = w;
298
- }
299
- }
300
- if (cur) lines.push(cur);
301
- return lines.length ? lines : [""];
302
- }
303
-
304
- /** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
305
- const MAX_MESSAGE_LINES = 3;
306
- const MESSAGE_WIDTH = 36;
307
- /** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
308
- * user box aligns with the assistant box. */
309
- const CHAT_INDENT = " ";
310
-
311
- /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
312
- function wrapClamped(text: string, width: number, maxLines: number): string[] {
313
- const lines = wrapText(text, width);
314
- if (lines.length <= maxLines) return lines;
315
- const kept = lines.slice(0, maxLines);
316
- const last = kept[maxLines - 1];
317
- kept[maxLines - 1] =
318
- (last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
319
- "...";
320
- return kept;
321
- }
322
-
323
- /**
324
- * The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
325
- * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
326
- * this, so HTML and plain text truncate at exactly the same point.
327
- */
328
- function clampMessage(text: string): string {
329
- return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
330
- }
331
-
332
- /** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
333
- * lays them out together. `from` is the user attribution (sender name). */
334
- export interface ChatMessage {
335
- role: "user" | "assistant";
336
- text: string;
337
- from?: string;
338
- }
339
-
340
- /** A user (right-aligned) chat message with an optional `from` attribution. */
341
- export function chatUser(text: string, from?: string): ChatMessage {
342
- return { role: "user", text, from };
343
- }
344
-
345
- /** An assistant (left-aligned) chat message. */
346
- export function chatAssistant(text: string): ChatMessage {
347
- return { role: "assistant", text };
348
- }
349
-
350
- /** A CTA button placed inside a chat unit (below a message). */
351
- export interface ChatCta {
352
- role: "cta";
353
- cta: CtaBoxOptions;
354
- }
355
-
356
- /** Centered continuation dots inside a chat unit — a "conversation continues"
357
- * separator placed between a message bubble and a following CTA. */
358
- export interface ChatDots {
359
- role: "dots";
360
- }
361
-
362
- /** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
363
- export type ChatItem = ChatMessage | ChatCta | ChatDots;
364
-
365
- /** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
366
- export function chatCta(cta: CtaBoxOptions): ChatCta {
367
- return { role: "cta", cta };
368
- }
369
-
370
- /** Continuation dots for a chat unit — pass it to `chatUnit` where the
371
- * conversation should read as continuing (e.g. between the preview and CTA). */
372
- export function chatDots(): ChatDots {
373
- return { role: "dots" };
374
- }
375
-
376
- /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
377
- * outer side. */
378
- function chatRuleHtml(position: "top" | "bottom"): string {
379
- const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
380
- return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
381
- }
382
-
383
- /**
384
- * Render one message to its HTML `<table>` and plain-text box lines. Both roles
385
- * share the 3-column skeleton: a fixed avatar column on each side (the visible
386
- * avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
387
- * width, so bubbles stay bounded and aligned), a middle cell that right/left-
388
- * aligns the bubble, and — for a user `from` — an attribution row below.
389
- */
390
- function renderBubble(
391
- msg: ChatMessage,
392
- margin: string,
393
- ): { html: string; text: string[] } {
394
- const clamped = clampMessage(msg.text);
395
- const isUser = msg.role === "user";
396
-
397
- const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
398
- const bubbleAlign = isUser ? " text-align: right;" : "";
399
- const cellAlign = isUser ? "right" : "left";
400
- const csHidden = isUser ? "visibility: hidden; " : "";
401
- const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
402
-
403
- const attributionRow =
404
- isUser && msg.from
405
- ? `
406
- <tr>
407
- <td></td>
408
- <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
409
- <td></td>
410
- </tr>`
411
- : "";
412
-
413
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
414
- <tr>
415
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
416
- <td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
417
- <tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
418
- </table></td>
419
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
420
- </tr>${attributionRow}
421
- </table>`;
422
-
423
- const border = "─".repeat(MESSAGE_WIDTH + 2);
424
- const lines = wrapText(clamped, MESSAGE_WIDTH);
425
- const body = lines.map(
426
- (line) =>
427
- `${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
428
- );
429
- // Avatar beside the last message line (one row up from the bottom border).
430
- const last = body.length - 1;
431
- if (isUser) body[last] += " (•̀_ರ╮)";
432
- else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
433
-
434
- const box = [
435
- `${CHAT_INDENT}┌${border}┐`,
436
- ...body,
437
- `${CHAT_INDENT}└${border}┘`,
438
- ];
439
- if (isUser && msg.from) {
440
- box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
441
- }
442
- return { html, text: box };
443
- }
444
-
445
- /** The right-edge column the plain-text CTA/dots align to under a user bubble. */
446
- const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
447
-
448
- /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
449
- * the same inline-block, so the dots span exactly the button's width). */
450
- function dotsOverCtaHtml(): string {
451
- return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
452
- }
453
-
454
- /**
455
- * A CTA button inside the chat stream — aligned to the side of the message it
456
- * follows (`right` under a user bubble, `left` otherwise). Hidden avatar mirrors
457
- * reserve both columns, so it sits in the message channel and never enters the
458
- * kaomoji column. The button carries no margin; the row's 24px matches the bubbles.
459
- * When `withDots`, continuation "⋮" render just above the button, centered over it.
460
- */
461
- function renderChatCta(
462
- cta: CtaBoxOptions,
463
- align: "left" | "right",
464
- withDots: boolean,
465
- ): { html: string; text: string[] } {
466
- const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
467
- // Dots + button share one inline-block so the dots center over the button's
468
- // exact width regardless of label length.
469
- const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
470
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
471
- <tr>
472
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
473
- <td style="width: 100%; text-align: ${align};">${stack}</td>
474
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
475
- </tr>
476
- </table>`;
477
-
478
- // Right-align to the message box's right edge (under a user bubble); else the
479
- // left avatar gutter.
480
- const btnLines = btnText
481
- .split("\n")
482
- .map((l) =>
483
- !l
484
- ? l
485
- : align === "right"
486
- ? l.padStart(CHAT_RIGHT_EDGE)
487
- : `${CHAT_INDENT}${l}`,
488
- );
489
- if (!withDots) return { html, text: btnLines };
490
-
491
- // Center "⋮" over the ascii box (its first line spans the full box width).
492
- const boxWidth = btnText.split("\n")[0].length;
493
- const dotsCol =
494
- align === "right"
495
- ? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
496
- : CHAT_INDENT.length + Math.ceil(boxWidth / 2);
497
- return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
498
- }
499
-
500
- /**
501
- * Standalone continuation dots — centered in the message channel. Used only when
502
- * `chatDots()` is NOT immediately followed by a CTA (the common case folds the
503
- * dots into the CTA via `renderChatCta`, centered over the box).
504
- */
505
- function renderChatDots(): { html: string; text: string[] } {
506
- const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
507
- <tr>
508
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
509
- <td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
510
- <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
511
- </tr>
512
- </table>`;
513
- // Center the "⋮" over the message box (avatar gutter + box width + borders).
514
- const center = Math.round(CHAT_RIGHT_EDGE / 2);
515
- return { html, text: ["⋮".padStart(center)] };
516
- }
517
-
518
- /**
519
- * Lay out one or more chat items — message bubbles and/or CTA buttons — as a
520
- * single block: a rule above and below (the real-chat "unit"), the items
521
- * between. The plain-text rule spans the widest line; a blank line follows the
522
- * top rule and the last item hugs the bottom rule. Continuation dots
523
- * (`chatDots`) sit above the CTA they precede, centered over the box.
524
- */
525
- export function chatUnit(...items: ChatItem[]): Block {
526
- const parts: { html: string; text: string[] }[] = [];
527
- items.forEach((item, i) => {
528
- if (item.role === "dots") {
529
- // Dots immediately before a CTA render with it (centered over the box);
530
- // otherwise fall back to channel-centered standalone dots.
531
- if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
532
- return;
533
- }
534
- if (item.role === "cta") {
535
- // Mirror the side of the nearest preceding message (skip any dots between),
536
- // so the CTA sits under the bubble it belongs to — right under a user.
537
- let j = i - 1;
538
- while (j >= 0 && items[j].role === "dots") j--;
539
- const align = items[j]?.role === "user" ? "right" : "left";
540
- parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
541
- return;
542
- }
543
- // A bubble directly above a CTA or continuation dots gets a tighter 16px
544
- // gap; else 24px.
545
- const next = items[i + 1]?.role;
546
- const margin =
547
- next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
548
- parts.push(renderBubble(item, margin));
549
- });
550
- const width = parts
551
- .flatMap((b) => b.text)
552
- .reduce((w, l) => Math.max(w, l.length), 0);
553
- const rule = "_".repeat(width);
554
-
555
- const html = [
556
- chatRuleHtml("top"),
557
- ...parts.map((b) => b.html),
558
- chatRuleHtml("bottom"),
559
- ].join("\n");
560
-
561
- const text =
562
- `${rule}\n\n` +
563
- parts.map((b) => b.text.join("\n")).join("\n\n") +
564
- `\n${rule}`;
565
-
566
- return { html, text, spacing: "normal" };
567
- }
@@ -6,17 +6,13 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatAssistant,
10
- chatCta,
11
- chatDots,
12
- chatUnit,
13
- chatUser,
14
9
  footer,
15
10
  greeting,
16
11
  NOTICE,
17
12
  paragraph,
18
13
  signature,
19
14
  } from "./blocks";
15
+ import { chatAssistant, chatCta, chatDots, chatUnit, chatUser } from "./chat";
20
16
  import { COMPANY_NAME } from "./constants";
21
17
 
22
18
  export type ChatSharedPayload = EmailPayloads["chat.shared"];
@@ -0,0 +1,331 @@
1
+ /**
2
+ * Chat unit — the dual-output chat transcript block.
3
+ *
4
+ * The one component with real layout of its own: message bubbles, an in-stream
5
+ * CTA, and continuation dots, laid out as a bracketed "unit". Same contract as
6
+ * every other component in `blocks.ts` (returns a `Block` = `{ html, text }`, so
7
+ * both surfaces derive from one source); it lives apart because bubble geometry
8
+ * — wrapping, truncation, avatar gutters, alignment — is a self-contained
9
+ * concern that the paragraph/CTA primitives next door do not share.
10
+ *
11
+ * INVARIANTS:
12
+ * - Pure functions, no side effects.
13
+ * - Components escape their own content; templates pass raw text.
14
+ * - HTML and plain text truncate at exactly the same point (`clampMessage` is
15
+ * the single truncation authority).
16
+ */
17
+
18
+ import {
19
+ type Block,
20
+ type CtaBoxOptions,
21
+ ctaButton,
22
+ FONT_SIZE,
23
+ MONO,
24
+ } from "./blocks";
25
+ import { escapeHtml } from "./escape-html";
26
+
27
+ // =============================================================================
28
+ // Text geometry
29
+ // =============================================================================
30
+
31
+ /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
32
+ function wrapText(text: string, width: number): string[] {
33
+ const lines: string[] = [];
34
+ let cur = "";
35
+ for (const word of text.split(/\s+/).filter(Boolean)) {
36
+ let w = word;
37
+ while (w.length > width) {
38
+ if (cur) {
39
+ lines.push(cur);
40
+ cur = "";
41
+ }
42
+ lines.push(w.slice(0, width));
43
+ w = w.slice(width);
44
+ }
45
+ if (!cur) cur = w;
46
+ else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
47
+ else {
48
+ lines.push(cur);
49
+ cur = w;
50
+ }
51
+ }
52
+ if (cur) lines.push(cur);
53
+ return lines.length ? lines : [""];
54
+ }
55
+
56
+ /** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
57
+ const MAX_MESSAGE_LINES = 3;
58
+ const MESSAGE_WIDTH = 36;
59
+ /** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
60
+ * user box aligns with the assistant box. */
61
+ const CHAT_INDENT = " ";
62
+
63
+ /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
64
+ function wrapClamped(text: string, width: number, maxLines: number): string[] {
65
+ const lines = wrapText(text, width);
66
+ if (lines.length <= maxLines) return lines;
67
+ const kept = lines.slice(0, maxLines);
68
+ const last = kept[maxLines - 1];
69
+ kept[maxLines - 1] =
70
+ (last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
71
+ "...";
72
+ return kept;
73
+ }
74
+
75
+ /**
76
+ * The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
77
+ * MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
78
+ * this, so HTML and plain text truncate at exactly the same point.
79
+ */
80
+ function clampMessage(text: string): string {
81
+ return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
82
+ }
83
+
84
+ // =============================================================================
85
+ // Chat items
86
+ // =============================================================================
87
+
88
+ /** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
89
+ * lays them out together. `from` is the user attribution (sender name). */
90
+ export interface ChatMessage {
91
+ role: "user" | "assistant";
92
+ text: string;
93
+ from?: string;
94
+ }
95
+
96
+ /** A user (right-aligned) chat message with an optional `from` attribution. */
97
+ export function chatUser(text: string, from?: string): ChatMessage {
98
+ return { role: "user", text, from };
99
+ }
100
+
101
+ /** An assistant (left-aligned) chat message. */
102
+ export function chatAssistant(text: string): ChatMessage {
103
+ return { role: "assistant", text };
104
+ }
105
+
106
+ /** A CTA button placed inside a chat unit (below a message). */
107
+ export interface ChatCta {
108
+ role: "cta";
109
+ cta: CtaBoxOptions;
110
+ }
111
+
112
+ /** Centered continuation dots inside a chat unit — a "conversation continues"
113
+ * separator placed between a message bubble and a following CTA. */
114
+ export interface ChatDots {
115
+ role: "dots";
116
+ }
117
+
118
+ /** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
119
+ export type ChatItem = ChatMessage | ChatCta | ChatDots;
120
+
121
+ /** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
122
+ export function chatCta(cta: CtaBoxOptions): ChatCta {
123
+ return { role: "cta", cta };
124
+ }
125
+
126
+ /** Continuation dots for a chat unit — pass it to `chatUnit` where the
127
+ * conversation should read as continuing (e.g. between the preview and CTA). */
128
+ export function chatDots(): ChatDots {
129
+ return { role: "dots" };
130
+ }
131
+
132
+ // =============================================================================
133
+ // Item rendering
134
+ // =============================================================================
135
+
136
+ /** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
137
+ * outer side. */
138
+ function chatRuleHtml(position: "top" | "bottom"): string {
139
+ const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
140
+ return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
141
+ }
142
+
143
+ /**
144
+ * Render one message to its HTML `<table>` and plain-text box lines. Both roles
145
+ * share the 3-column skeleton: a fixed avatar column on each side (the visible
146
+ * avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
147
+ * width, so bubbles stay bounded and aligned), a middle cell that right/left-
148
+ * aligns the bubble, and — for a user `from` — an attribution row below.
149
+ */
150
+ function renderBubble(
151
+ msg: ChatMessage,
152
+ margin: string,
153
+ ): { html: string; text: string[] } {
154
+ const clamped = clampMessage(msg.text);
155
+ const isUser = msg.role === "user";
156
+
157
+ const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
158
+ const bubbleAlign = isUser ? " text-align: right;" : "";
159
+ const cellAlign = isUser ? "right" : "left";
160
+ const csHidden = isUser ? "visibility: hidden; " : "";
161
+ const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
162
+
163
+ const attributionRow =
164
+ isUser && msg.from
165
+ ? `
166
+ <tr>
167
+ <td></td>
168
+ <td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
169
+ <td></td>
170
+ </tr>`
171
+ : "";
172
+
173
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
174
+ <tr>
175
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
176
+ <td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
177
+ <tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
178
+ </table></td>
179
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
180
+ </tr>${attributionRow}
181
+ </table>`;
182
+
183
+ const border = "─".repeat(MESSAGE_WIDTH + 2);
184
+ const lines = wrapText(clamped, MESSAGE_WIDTH);
185
+ const body = lines.map(
186
+ (line) =>
187
+ `${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
188
+ );
189
+ // Avatar beside the last message line (one row up from the bottom border).
190
+ const last = body.length - 1;
191
+ if (isUser) body[last] += " (•̀_ರ╮)";
192
+ else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
193
+
194
+ const box = [
195
+ `${CHAT_INDENT}┌${border}┐`,
196
+ ...body,
197
+ `${CHAT_INDENT}└${border}┘`,
198
+ ];
199
+ if (isUser && msg.from) {
200
+ box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
201
+ }
202
+ return { html, text: box };
203
+ }
204
+
205
+ /** The right-edge column the plain-text CTA/dots align to under a user bubble. */
206
+ const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
207
+
208
+ /** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
209
+ * the same inline-block, so the dots span exactly the button's width). */
210
+ function dotsOverCtaHtml(): string {
211
+ return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
212
+ }
213
+
214
+ /**
215
+ * A CTA button inside the chat stream — aligned to the side of the message it
216
+ * follows (`right` under a user bubble, `left` otherwise). Hidden avatar mirrors
217
+ * reserve both columns, so it sits in the message channel and never enters the
218
+ * kaomoji column. The button carries no margin; the row's 24px matches the bubbles.
219
+ * When `withDots`, continuation "⋮" render just above the button, centered over it.
220
+ */
221
+ function renderChatCta(
222
+ cta: CtaBoxOptions,
223
+ align: "left" | "right",
224
+ withDots: boolean,
225
+ ): { html: string; text: string[] } {
226
+ const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
227
+ // Dots + button share one inline-block so the dots center over the button's
228
+ // exact width regardless of label length.
229
+ const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
230
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
231
+ <tr>
232
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
233
+ <td style="width: 100%; text-align: ${align};">${stack}</td>
234
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
235
+ </tr>
236
+ </table>`;
237
+
238
+ // Right-align to the message box's right edge (under a user bubble); else the
239
+ // left avatar gutter.
240
+ const btnLines = btnText
241
+ .split("\n")
242
+ .map((l) =>
243
+ !l
244
+ ? l
245
+ : align === "right"
246
+ ? l.padStart(CHAT_RIGHT_EDGE)
247
+ : `${CHAT_INDENT}${l}`,
248
+ );
249
+ if (!withDots) return { html, text: btnLines };
250
+
251
+ // Center "⋮" over the ascii box (its first line spans the full box width).
252
+ const boxWidth = btnText.split("\n")[0].length;
253
+ const dotsCol =
254
+ align === "right"
255
+ ? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
256
+ : CHAT_INDENT.length + Math.ceil(boxWidth / 2);
257
+ return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
258
+ }
259
+
260
+ /**
261
+ * Standalone continuation dots — centered in the message channel. Used only when
262
+ * `chatDots()` is NOT immediately followed by a CTA (the common case folds the
263
+ * dots into the CTA via `renderChatCta`, centered over the box).
264
+ */
265
+ function renderChatDots(): { html: string; text: string[] } {
266
+ const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
267
+ <tr>
268
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
269
+ <td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
270
+ <td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
271
+ </tr>
272
+ </table>`;
273
+ // Center the "⋮" over the message box (avatar gutter + box width + borders).
274
+ const center = Math.round(CHAT_RIGHT_EDGE / 2);
275
+ return { html, text: ["⋮".padStart(center)] };
276
+ }
277
+
278
+ // =============================================================================
279
+ // Chat unit
280
+ // =============================================================================
281
+
282
+ /**
283
+ * Lay out one or more chat items — message bubbles and/or CTA buttons — as a
284
+ * single block: a rule above and below (the real-chat "unit"), the items
285
+ * between. The plain-text rule spans the widest line; a blank line follows the
286
+ * top rule and the last item hugs the bottom rule. Continuation dots
287
+ * (`chatDots`) sit above the CTA they precede, centered over the box.
288
+ */
289
+ export function chatUnit(...items: ChatItem[]): Block {
290
+ const parts: { html: string; text: string[] }[] = [];
291
+ items.forEach((item, i) => {
292
+ if (item.role === "dots") {
293
+ // Dots immediately before a CTA render with it (centered over the box);
294
+ // otherwise fall back to channel-centered standalone dots.
295
+ if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
296
+ return;
297
+ }
298
+ if (item.role === "cta") {
299
+ // Mirror the side of the nearest preceding message (skip any dots between),
300
+ // so the CTA sits under the bubble it belongs to — right under a user.
301
+ let j = i - 1;
302
+ while (j >= 0 && items[j].role === "dots") j--;
303
+ const align = items[j]?.role === "user" ? "right" : "left";
304
+ parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
305
+ return;
306
+ }
307
+ // A bubble directly above a CTA or continuation dots gets a tighter 16px
308
+ // gap; else 24px.
309
+ const next = items[i + 1]?.role;
310
+ const margin =
311
+ next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
312
+ parts.push(renderBubble(item, margin));
313
+ });
314
+ const width = parts
315
+ .flatMap((b) => b.text)
316
+ .reduce((w, l) => Math.max(w, l.length), 0);
317
+ const rule = "_".repeat(width);
318
+
319
+ const html = [
320
+ chatRuleHtml("top"),
321
+ ...parts.map((b) => b.html),
322
+ chatRuleHtml("bottom"),
323
+ ].join("\n");
324
+
325
+ const text =
326
+ `${rule}\n\n` +
327
+ parts.map((b) => b.text.join("\n")).join("\n\n") +
328
+ `\n${rule}`;
329
+
330
+ return { html, text, spacing: "normal" };
331
+ }
@@ -7,15 +7,13 @@ import type { EmailPayloads } from "../types";
7
7
  import {
8
8
  ACCESS_PHRASE,
9
9
  type Block,
10
- chatAssistant,
11
- chatCta,
12
- chatUnit,
13
10
  footer,
14
11
  greeting,
15
12
  keyValue,
16
13
  paragraph,
17
14
  signature,
18
15
  } from "./blocks";
16
+ import { chatAssistant, chatCta, chatUnit } from "./chat";
19
17
  import { COMPANY_NAME } from "./constants";
20
18
 
21
19
  export type AccessApprovedPayload =
@@ -6,14 +6,13 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatUnit,
10
- chatUser,
11
9
  footer,
12
10
  greeting,
13
11
  keyValue,
14
12
  paragraph,
15
13
  signature,
16
14
  } from "./blocks";
15
+ import { chatUnit, chatUser } from "./chat";
17
16
  import { COMPANY_NAME } from "./constants";
18
17
 
19
18
  export type AccessDeniedPayload =
@@ -6,16 +6,13 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatAssistant,
10
- chatCta,
11
- chatUnit,
12
- chatUser,
13
9
  footer,
14
10
  greeting,
15
11
  keyValue,
16
12
  paragraph,
17
13
  signature,
18
14
  } from "./blocks";
15
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
19
16
  import { COMPANY_NAME } from "./constants";
20
17
 
21
18
  export type AccessRequestedPayload =
@@ -23,23 +23,27 @@ export {
23
23
  signature,
24
24
  NOTICE,
25
25
  ACCESS_PHRASE,
26
- chatDots,
27
26
  ctaBox,
28
- chatUser,
29
- chatAssistant,
30
- chatCta,
31
- chatUnit,
32
27
  bold,
33
28
  type Block,
34
- type ChatMessage,
35
- type ChatCta,
36
- type ChatItem,
37
29
  type Inline,
38
30
  type InlineContent,
39
31
  type Spacing,
40
32
  type CtaBoxOptions,
41
33
  } from "./blocks";
42
34
 
35
+ // The chat unit — same `Block` contract, its own module (bubble geometry)
36
+ export {
37
+ chatDots,
38
+ chatUser,
39
+ chatAssistant,
40
+ chatCta,
41
+ chatUnit,
42
+ type ChatMessage,
43
+ type ChatCta,
44
+ type ChatItem,
45
+ } from "./chat";
46
+
43
47
  // Dispatcher
44
48
  export {
45
49
  renderEmail,
@@ -6,9 +6,6 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatAssistant,
10
- chatCta,
11
- chatUnit,
12
9
  footer,
13
10
  formatExpiry,
14
11
  greeting,
@@ -18,6 +15,7 @@ import {
18
15
  signature,
19
16
  titleCase,
20
17
  } from "./blocks";
18
+ import { chatAssistant, chatCta, chatUnit } from "./chat";
21
19
  import { COMPANY_NAME } from "./constants";
22
20
 
23
21
  export type OrgInvitePayload = EmailPayloads["org.invite"];
@@ -6,14 +6,13 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatAssistant,
10
- chatUnit,
11
9
  footer,
12
10
  greeting,
13
11
  keyValue,
14
12
  paragraph,
15
13
  signature,
16
14
  } from "./blocks";
15
+ import { chatAssistant, chatUnit } from "./chat";
17
16
  import { COMPANY_NAME } from "./constants";
18
17
 
19
18
  export type OwnershipTransferCompletedPayload =
@@ -6,10 +6,6 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatAssistant,
10
- chatCta,
11
- chatUnit,
12
- chatUser,
13
9
  footer,
14
10
  formatExpiry,
15
11
  greeting,
@@ -18,6 +14,7 @@ import {
18
14
  paragraph,
19
15
  signature,
20
16
  } from "./blocks";
17
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
21
18
  import { COMPANY_NAME } from "./constants";
22
19
 
23
20
  export type OwnershipTransferPayload = EmailPayloads["org.ownership_transfer"];
@@ -7,10 +7,6 @@ import type { EmailPayloads } from "../types";
7
7
  import {
8
8
  ACCESS_PHRASE,
9
9
  type Block,
10
- chatAssistant,
11
- chatCta,
12
- chatUnit,
13
- chatUser,
14
10
  footer,
15
11
  greeting,
16
12
  keyValue,
@@ -18,6 +14,7 @@ import {
18
14
  paragraph,
19
15
  signature,
20
16
  } from "./blocks";
17
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
21
18
  import { COMPANY_NAME } from "./constants";
22
19
 
23
20
  export type ShareGrantedPayload = EmailPayloads["share.granted"];
@@ -6,10 +6,6 @@ import type { EmailPayloads } from "../types";
6
6
 
7
7
  import {
8
8
  type Block,
9
- chatAssistant,
10
- chatCta,
11
- chatUnit,
12
- chatUser,
13
9
  footer,
14
10
  formatExpiry,
15
11
  greeting,
@@ -19,6 +15,7 @@ import {
19
15
  signature,
20
16
  titleCase,
21
17
  } from "./blocks";
18
+ import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
22
19
  import { COMPANY_NAME } from "./constants";
23
20
 
24
21
  export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
@@ -221,7 +221,21 @@ export interface CompanyMdDocCore extends CompanyMdNodeIdentity {
221
221
  }
222
222
 
223
223
  export interface CompanyMdDocCollaborators {
224
- readonly owner: { readonly id: string; readonly name: string } | null;
224
+ /**
225
+ * The doc's EFFECTIVE owners (ADR-CONTRACTS-084) — hand-mirror of
226
+ * `CompanyMdDocResponseSchema.owners`; keep the two in lockstep.
227
+ *
228
+ * A list, not a scalar: ownership is resolved rather than stored (a personal
229
+ * owner, else the owning unit's live authority holders, else the org account
230
+ * owner), and co-ownership is first-class (ADR-CTRL-161) — every entry is
231
+ * owner-equivalent (ADR-BE-189). Name one owner with `owners[0]` and derive
232
+ * the co-owner count from `owners.length`. Empty only when the org has no
233
+ * account owner (runtime-impossible).
234
+ */
235
+ readonly owners: ReadonlyArray<{
236
+ readonly id: string;
237
+ readonly name: string;
238
+ }>;
225
239
  readonly canEdit: boolean;
226
240
  readonly members: ReadonlyArray<{
227
241
  readonly id: string;
@@ -13,6 +13,15 @@ import { z } from "zod";
13
13
  import { AccessLevelSchema } from "./access-levels";
14
14
  import { AccessSourceSchema } from "./access-source";
15
15
 
16
+ /**
17
+ * Contract-level invariant: an id set contains each id at most once. A duplicate
18
+ * is a serializer bug, not a presentation concern, so we fail Zod parse at the
19
+ * API boundary on both server and client (mirrors `uniqueByUserId` on
20
+ * OrgUnitOwnersResponseSchema).
21
+ */
22
+ const uniqueValues = (arr: ReadonlyArray<string>): boolean =>
23
+ new Set(arr).size === arr.length;
24
+
16
25
  export const PrincipalTypeSchema = z.enum(["user", "unit", "org"]);
17
26
  export type PrincipalType = z.infer<typeof PrincipalTypeSchema>;
18
27
 
@@ -61,7 +70,19 @@ export type AclGrantResponse = z.infer<typeof AclGrantResponseSchema>;
61
70
  export const AclListResponseSchema = z.object({
62
71
  entity_type: z.string(),
63
72
  entity_id: z.string().uuid(),
64
- owner_user_id: z.string().uuid().nullable(),
73
+ /**
74
+ * The entity's EFFECTIVE owners (ADR-CONTRACTS-084). Ownership is resolved,
75
+ * not stored: a personal doc yields its stored owner; a structural company.md
76
+ * doc yields its owning unit's live authority holders; the org account owner
77
+ * is the guaranteed fallback.
78
+ *
79
+ * A set, not a scalar — co-ownership is first-class (ADR-CTRL-161), so a unit
80
+ * with co-owners yields > 1 and EVERY entry is owner-equivalent (ADR-BE-189).
81
+ * Empty only when the org has no account owner (runtime-impossible).
82
+ */
83
+ owner_user_ids: z
84
+ .array(z.string().uuid())
85
+ .refine(uniqueValues, { message: "duplicate id in owner_user_ids" }),
65
86
  /** General-access scope: WHO is eligible (the discoverability axis). */
66
87
  visibility: EntityVisibilitySchema,
67
88
  /**