@company-semantics/contracts 52.0.0 → 53.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +122 -69
  4. package/src/generated/openapi-routes.ts +3 -1
  5. package/src/identity/README.md +2 -2
  6. package/src/identity/__tests__/people-org-chart.test.ts +52 -17
  7. package/src/identity/__tests__/position-ref.test.ts +44 -0
  8. package/src/identity/index.ts +6 -2
  9. package/src/identity/people-org-chart.ts +26 -15
  10. package/src/identity/position-ref.ts +24 -0
  11. package/src/index.ts +21 -2
  12. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
  13. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
  14. package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
  15. package/src/notifications/renderers/README.md +8 -4
  16. package/src/notifications/renderers/ascii/README.md +75 -0
  17. package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
  18. package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
  19. package/src/notifications/renderers/ascii/chat.ts +179 -0
  20. package/src/notifications/renderers/ascii/cta.ts +57 -0
  21. package/src/notifications/renderers/ascii/geometry.ts +112 -0
  22. package/src/notifications/renderers/ascii/index.ts +40 -0
  23. package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
  24. package/src/notifications/renderers/ascii/rule.ts +53 -0
  25. package/src/notifications/renderers/ascii/runs.ts +84 -0
  26. package/src/notifications/renderers/ascii/signature.ts +41 -0
  27. package/src/notifications/renderers/ascii/wrap.ts +96 -0
  28. package/src/notifications/renderers/brand.ts +12 -0
  29. package/src/notifications/renderers/email/chat.ts +62 -146
  30. package/src/notifications/renderers/email/constants.ts +17 -2
  31. package/src/notifications/renderers/email/cta.ts +8 -13
  32. package/src/notifications/renderers/email/render.ts +29 -5
  33. package/src/notifications/renderers/layout.ts +31 -0
  34. package/src/notifications/renderers/slack/README.md +135 -79
  35. package/src/notifications/renderers/slack/__tests__/README.md +3 -2
  36. package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
  37. package/src/notifications/renderers/slack/blocks.ts +149 -0
  38. package/src/notifications/renderers/slack/chat.ts +167 -0
  39. package/src/notifications/renderers/slack/cta.ts +69 -0
  40. package/src/notifications/renderers/slack/index.ts +136 -229
  41. package/src/notifications/renderers/slack/message.ts +23 -0
  42. package/src/org/README.md +10 -2
  43. package/src/org/__tests__/org-units.test.ts +1 -1
  44. package/src/org/__tests__/set-seat-manager.test.ts +177 -0
  45. package/src/org/index.ts +20 -2
  46. package/src/org/reconciliation.ts +162 -0
  47. package/src/org/schemas.ts +43 -17
  48. package/src/identity/org-chart-actor.ts +0 -24
@@ -0,0 +1,112 @@
1
+ /**
2
+ * How wide the drawing may be, and everything that follows from it.
3
+ *
4
+ * One number is chosen — the column budget — and every other measurement in
5
+ * this directory is DERIVED from it. That is what makes the budget retunable:
6
+ * changing it is one edit, not a hunt for the constants that silently assumed
7
+ * the old value.
8
+ *
9
+ * **What the budget binds.** Lines this layer GENERATES: box borders, bubble
10
+ * frames, rules, padding, and prose it wraps itself.
11
+ *
12
+ * **What it does not bind.** Atomic content — URLs, OTP codes, identifiers,
13
+ * filenames, tokens, key/value cell values. Those may exceed the budget and
14
+ * must NEVER be broken to satisfy it. A line break inserted into a one-time
15
+ * code or a URL is a correctness bug wearing a layout costume, and no layout
16
+ * invariant is worth one.
17
+ *
18
+ * **What a "column" means.** A JavaScript string character. The decorative
19
+ * geometry is ASCII, so characters and columns coincide there. Wrapped prose
20
+ * uses the same string semantics and makes NO claim to Unicode terminal-cell
21
+ * precision — a CJK glyph or an emoji occupying two cells is not modelled.
22
+ * Promising otherwise would be a false precision, and this comment is the
23
+ * promise we are willing to keep.
24
+ */
25
+
26
+ /** The assistant's avatar, drawn beside the last line of its bubble. */
27
+ export const AVATAR = "[c_S]";
28
+
29
+ /** The recipient's avatar, drawn beside the last line of their bubble. */
30
+ export const KAOMOJI = "(•̀_ರ╮)";
31
+
32
+ /** Spaces between an avatar and the box it sits beside. */
33
+ const AVATAR_GAP = 2;
34
+
35
+ /**
36
+ * Columns reserved on the RIGHT for the recipient's avatar.
37
+ *
38
+ * Both sides are reserved, exactly as the HTML reserves both `<td>`s and hides
39
+ * the one it is not using. Only one avatar shows per row, but the box must sit
40
+ * in the same columns whichever it is — an unreserved side would let a user
41
+ * bubble slide right and the conversation would read as two columns rather than
42
+ * one exchange.
43
+ *
44
+ * `KAOMOJI` is measured in string characters like everything else here. Its
45
+ * combining accent means it DRAWS about one column narrower than it measures, so
46
+ * this reserves a character more than it strictly needs. That is the safe
47
+ * direction of an error we already said we would not model.
48
+ */
49
+ const RIGHT_AVATAR = AVATAR_GAP + KAOMOJI.length;
50
+
51
+ /** `│ ` on the left and ` │` on the right of every bubble line. */
52
+ const BUBBLE_BORDERS = 4;
53
+
54
+ /** Padding columns each side of the `>> LABEL <<` line in a CTA box. */
55
+ const CTA_BOX_PAD = 3;
56
+
57
+ /** A chat message is clamped to this many lines, however long it is. */
58
+ const MAX_MESSAGE_LINES = 3;
59
+
60
+ /** Every derived measurement of one monospace layout. */
61
+ export interface AsciiGeometry {
62
+ /** The column budget generated geometry fits inside. */
63
+ readonly columns: number;
64
+ /** Left gutter reserved for the sender's avatar, so bubbles align. */
65
+ readonly indent: number;
66
+ /** Characters of message text per bubble line. */
67
+ readonly messageWidth: number;
68
+ /**
69
+ * The column a bubble's right border sits in — the edge CTAs align to and
70
+ * dots centre over. Short of `columns` by the reserved right avatar, which
71
+ * hangs outside the box.
72
+ */
73
+ readonly boxRight: number;
74
+ /** Lines a message is clamped to before it is ellipsized. */
75
+ readonly maxMessageLines: number;
76
+ /** Padding each side of a CTA label. */
77
+ readonly ctaPad: number;
78
+ }
79
+
80
+ /**
81
+ * Derive a layout from a column budget.
82
+ *
83
+ * `columns` is the WIDEST line the layer will draw — the left avatar gutter,
84
+ * both box borders, the message text, and the right avatar hanging off the end.
85
+ * Measuring the budget at the box's edge instead would be measuring the wrong
86
+ * thing: the recipient's avatar sits outside that border, and it is the part
87
+ * that pushes a phone into horizontal scrolling.
88
+ *
89
+ * @throws RangeError when the budget cannot fit both avatar gutters, both
90
+ * borders and at least one character of message. A layout that cannot draw its
91
+ * own frame is a programming error, not a narrow screen, and silently clamping
92
+ * it would produce a plausible-looking box with content missing from it.
93
+ */
94
+ export function geometryFor(columns: number): AsciiGeometry {
95
+ const indent = AVATAR.length + AVATAR_GAP;
96
+ const overhead = indent + BUBBLE_BORDERS + RIGHT_AVATAR;
97
+ const messageWidth = columns - overhead;
98
+ if (messageWidth < 1) {
99
+ throw new RangeError(
100
+ `Column budget ${columns} leaves no room for message text ` +
101
+ `(needs more than ${overhead}).`,
102
+ );
103
+ }
104
+ return {
105
+ columns,
106
+ indent,
107
+ messageWidth,
108
+ boxRight: indent + messageWidth + BUBBLE_BORDERS,
109
+ maxMessageLines: MAX_MESSAGE_LINES,
110
+ ctaPad: CTA_BOX_PAD,
111
+ };
112
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The monospace layout layer — the drawing every channel with a fixed-width
3
+ * surface shares.
4
+ *
5
+ * Not a renderer. It answers no `NotificationContent` and produces no channel's
6
+ * bytes; it draws box art and returns lines of runs, and the channels decide
7
+ * what those become. See `./README.md`.
8
+ */
9
+
10
+ export type { AsciiGeometry } from "./geometry";
11
+ export { AVATAR, geometryFor, KAOMOJI } from "./geometry";
12
+
13
+ export type { MonospaceLine, MonospaceRun } from "./runs";
14
+ export {
15
+ blockWidth,
16
+ flattenLine,
17
+ flattenLines,
18
+ indentLine,
19
+ lineLength,
20
+ padLineStart,
21
+ text,
22
+ } from "./runs";
23
+
24
+ export { clampMessage, proseLines, wrapClamped, wrapText } from "./wrap";
25
+
26
+ export { ctaBoxWidth, renderCtaAscii } from "./cta";
27
+
28
+ export { keyValueLines } from "./keyvalue";
29
+
30
+ export { fullRuleAscii, ruleAscii } from "./rule";
31
+
32
+ export { signatureLines } from "./signature";
33
+
34
+ export type { ChatAlign, ChatPart } from "./chat";
35
+ export {
36
+ planChatUnit,
37
+ renderBubbleAscii,
38
+ renderChatCtaAscii,
39
+ renderChatDotsAscii,
40
+ } from "./chat";
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `Label: value` — the one place the separator the content model drops is put
3
+ * back.
4
+ *
5
+ * `KeyValueRow` carries a label and a value and no colon, because a colon is
6
+ * presentation (see `../../kinds/README.md`). Every monospace surface puts the
7
+ * same one back, so it is written once here rather than once per channel.
8
+ *
9
+ * **A value is atomic and is never wrapped.** A device string, an IP, an expiry
10
+ * or an identifier means what it means as one run of characters; breaking one
11
+ * across lines to satisfy the column budget would corrupt the fact
12
+ * (`../layout.ts`). A long value overhangs, and that is the correct failure.
13
+ */
14
+
15
+ import type { KeyValueRow } from "../../content";
16
+
17
+ import { type MonospaceLine, text } from "./runs";
18
+
19
+ /**
20
+ * Rows as `Label: value` lines, optionally under a heading.
21
+ *
22
+ * The heading is the element's own label with a colon — `metadata`'s "Request
23
+ * details:" — and is a line of its own rather than a prefix, because it names
24
+ * the group rather than any one row.
25
+ */
26
+ export function keyValueLines(
27
+ rows: KeyValueRow[],
28
+ heading?: string,
29
+ ): MonospaceLine[] {
30
+ return [
31
+ ...(heading ? [text(`${heading}:`)] : []),
32
+ ...rows.map((row) => text(`${row.label}: ${row.value}`)),
33
+ ];
34
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * A horizontal rule, sized to what it brackets.
3
+ *
4
+ * Lived in `./chat.ts` while a conversation was the only thing that needed
5
+ * bracketing. The title needs one too, so it moved here rather than being
6
+ * imported from a module whose name would have made it look chat-specific.
7
+ *
8
+ * The HTML surface draws the same idea as an `<hr>`; this is the monospace
9
+ * character that looks like one.
10
+ */
11
+
12
+ import type { AsciiGeometry } from "./geometry";
13
+ import { blockWidth, type MonospaceLine, text } from "./runs";
14
+
15
+ /**
16
+ * `─` rather than `_`: an underscore sits on the baseline and reads as an
17
+ * underline under whatever is above it, while a box-drawing horizontal sits at
18
+ * mid-height and reads as a rule.
19
+ */
20
+ const RULE = "─";
21
+
22
+ /**
23
+ * A rule spanning the widest line it brackets, capped at the budget.
24
+ *
25
+ * Sized to the block rather than always to the budget: a short exchange draws a
26
+ * short rule, and a rule wider than what it brackets reads as a separator
27
+ * BETWEEN things rather than the frame of one.
28
+ *
29
+ * The cap is what stops an ATOMIC line dragging the layout out with it. A chat
30
+ * CTA prints its destination underneath, and a 67-character URL is allowed to
31
+ * overhang (`../layout.ts`) — but it must not recruit the rule into overhanging
32
+ * with it. The URL is content that happens to be long; the rule is geometry, and
33
+ * geometry answers to the budget.
34
+ */
35
+ export function ruleAscii(
36
+ lines: MonospaceLine[],
37
+ geometry: AsciiGeometry,
38
+ ): MonospaceLine {
39
+ return text(RULE.repeat(Math.min(blockWidth(lines), geometry.columns)));
40
+ }
41
+
42
+ /**
43
+ * A rule spanning the whole budget, whatever it sits under.
44
+ *
45
+ * For the title, where `ruleAscii`'s size-to-content behaviour is wrong: a
46
+ * title underline that stops a couple of characters short of the rules further
47
+ * down the message reads as a miscalculation rather than a choice. Spanning the
48
+ * full measure says "this is the top of the message" in a way a ragged rule
49
+ * cannot.
50
+ */
51
+ export function fullRuleAscii(geometry: AsciiGeometry): MonospaceLine {
52
+ return text(RULE.repeat(geometry.columns));
53
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The monospace line vocabulary — the ONLY thing the ascii layer returns.
3
+ *
4
+ * A run is text, or text that is a link. A line is runs. That is the whole
5
+ * model, and it is deliberately dumb.
6
+ *
7
+ * **What must never move in here.** CTA-ness, warning-ness, chat turn, author,
8
+ * role, alignment, spacing — these stay INPUTS to the functions in this
9
+ * directory. The moment a run learns what a CTA is, `../ascii` has started
10
+ * becoming a second notification model competing with `../../content`, which is
11
+ * the fusion ADR-CONTRACTS-086 spent its effort undoing. If a field feels like
12
+ * it wants to live on a run, that is the signal it belongs in a function
13
+ * signature instead.
14
+ *
15
+ * **Why runs rather than `string[]`.** A rendered line can contain a URL, and
16
+ * the channels disagree about what to do with it: email's text surface prints
17
+ * it, Slack's `rich_text_preformatted` wants it as a `RichTextLink` so it stays
18
+ * clickable. Flattening to a string here would force Slack to go looking for
19
+ * the URL inside a string this layer had already dissolved it into — the exact
20
+ * hack this shape exists to prevent.
21
+ */
22
+
23
+ /** A stretch of one line: plain text, or text that points somewhere. */
24
+ export type MonospaceRun =
25
+ { type: "text"; text: string } | { type: "link"; text: string; href: string };
26
+
27
+ /** One line of a monospace layout, left to right. */
28
+ export type MonospaceLine = MonospaceRun[];
29
+
30
+ /** A line of plain text — the common case, spelled once. */
31
+ export function text(value: string): MonospaceLine {
32
+ return [{ type: "text", text: value }];
33
+ }
34
+
35
+ /** The characters on a line, ignoring where the links are. */
36
+ export function lineLength(line: MonospaceLine): number {
37
+ return line.reduce((total, run) => total + run.text.length, 0);
38
+ }
39
+
40
+ /**
41
+ * The widest line in a block, in characters.
42
+ *
43
+ * Used to size rules that must span whatever they bracket. Empty input is 0
44
+ * rather than an error: a chat unit with no drawable items has no width.
45
+ */
46
+ export function blockWidth(lines: MonospaceLine[]): number {
47
+ return lines.reduce((widest, line) => Math.max(widest, lineLength(line)), 0);
48
+ }
49
+
50
+ /**
51
+ * Collapse a line to its characters, discarding link identity.
52
+ *
53
+ * For surfaces that have no way to carry a link inline — email's plain-text
54
+ * body is the one today. A surface that CAN carry one (Slack) must map the runs
55
+ * instead, which is the entire reason they survive this far.
56
+ */
57
+ export function flattenLine(line: MonospaceLine): string {
58
+ return line.map((run) => run.text).join("");
59
+ }
60
+
61
+ /** `flattenLine` over a block, joined with newlines. */
62
+ export function flattenLines(lines: MonospaceLine[]): string {
63
+ return lines.map(flattenLine).join("\n");
64
+ }
65
+
66
+ /** Prepend `count` spaces to a line, as a text run. */
67
+ export function indentLine(line: MonospaceLine, count: number): MonospaceLine {
68
+ if (count <= 0 || line.length === 0) return line;
69
+ return [{ type: "text", text: " ".repeat(count) }, ...line];
70
+ }
71
+
72
+ /**
73
+ * Right-align a line to `column`, as a leading text run.
74
+ *
75
+ * A line already at or past `column` is returned untouched — alignment pads,
76
+ * it never truncates. Truncation of content is `./wrap`'s business and is
77
+ * always a deliberate clamp, never a side effect of positioning.
78
+ */
79
+ export function padLineStart(
80
+ line: MonospaceLine,
81
+ column: number,
82
+ ): MonospaceLine {
83
+ return indentLine(line, column - lineLength(line));
84
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The sign-off — the end-of-message marker, the copyright line, the URL.
3
+ *
4
+ * Drawn here so both monospace surfaces sign a notification the same way. It
5
+ * takes the signer, the year and the URL as ARGUMENTS rather than reading a
6
+ * brand: this directory knows about columns and characters, and the day it
7
+ * starts knowing who we are is the day it stops being a layout layer.
8
+ *
9
+ * The URL comes back as a `link` run. Email's `text/plain` body flattens it to
10
+ * the bare address, which is all that surface can do; Slack maps it onto a
11
+ * `RichTextLink`, which `rich_text_preformatted` explicitly admits
12
+ * (`(RichTextText | RichTextLink)[]`). Whether Slack's client then makes it
13
+ * clickable inside a code block is Slack's call and is not something this
14
+ * package can verify — but the address is VISIBLE either way, so the run costs
15
+ * nothing if it renders as plain text. Same drawing, two surfaces, no guess
16
+ * baked in.
17
+ */
18
+
19
+ import { type MonospaceLine, text } from "./runs";
20
+
21
+ /**
22
+ * The end-of-message marker.
23
+ *
24
+ * Borrowed from the C comment it looks like: a notification is a message with a
25
+ * definite end, and this says so in the one typographic register the brand
26
+ * uses everywhere else.
27
+ */
28
+ const EOM = "/* EOM */";
29
+
30
+ /** The marker, the copyright line, then the URL as a link. */
31
+ export function signatureLines(
32
+ signer: string,
33
+ year: number,
34
+ url: string,
35
+ ): MonospaceLine[] {
36
+ return [
37
+ text(EOM),
38
+ text(`ⓒ ${year} • ${signer}`),
39
+ [{ type: "link", text: url, href: url }],
40
+ ];
41
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Word wrapping and the one truncation authority.
3
+ *
4
+ * Moved verbatim from `../email/chat.ts` (ADR-CONTRACTS-086 put it there; the
5
+ * ascii extraction moved it here) so that every channel drawing a monospace
6
+ * surface breaks and clamps text at exactly the same point. Two channels that
7
+ * wrap differently are two different messages, and the whole reason this
8
+ * directory exists is that they should not be.
9
+ *
10
+ * These operate on plain strings, not runs: wrapping is about where characters
11
+ * end, and a link's href plays no part in that. Callers reassemble runs around
12
+ * the result.
13
+ */
14
+
15
+ import type { AsciiGeometry } from "./geometry";
16
+ import { type MonospaceLine, text } from "./runs";
17
+
18
+ /** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
19
+ export function wrapText(text: string, width: number): string[] {
20
+ const lines: string[] = [];
21
+ let cur = "";
22
+ for (const word of text.split(/\s+/).filter(Boolean)) {
23
+ let w = word;
24
+ while (w.length > width) {
25
+ if (cur) {
26
+ lines.push(cur);
27
+ cur = "";
28
+ }
29
+ lines.push(w.slice(0, width));
30
+ w = w.slice(width);
31
+ }
32
+ if (!cur) cur = w;
33
+ else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
34
+ else {
35
+ lines.push(cur);
36
+ cur = w;
37
+ }
38
+ }
39
+ if (cur) lines.push(cur);
40
+ return lines.length ? lines : [""];
41
+ }
42
+
43
+ /** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
44
+ export function wrapClamped(
45
+ text: string,
46
+ width: number,
47
+ maxLines: number,
48
+ ): string[] {
49
+ const lines = wrapText(text, width);
50
+ if (lines.length <= maxLines) return lines;
51
+ const kept = lines.slice(0, maxLines);
52
+ const last = kept[maxLines - 1];
53
+ kept[maxLines - 1] =
54
+ (last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
55
+ "...";
56
+ return kept;
57
+ }
58
+
59
+ /**
60
+ * The one truncation authority: clamp a raw message to `maxLines` × `width`,
61
+ * ellipsized, rejoined into a single string.
62
+ *
63
+ * Every surface of a chat unit runs content through this, so they all truncate
64
+ * at exactly the same point — HTML bubbles, plain text box art, and Slack's
65
+ * preformatted block alike.
66
+ */
67
+ export function clampMessage(
68
+ text: string,
69
+ width: number,
70
+ maxLines: number,
71
+ ): string {
72
+ return wrapClamped(text, width, maxLines).join(" ");
73
+ }
74
+
75
+ /**
76
+ * Prose as monospace lines, wrapped to the budget.
77
+ *
78
+ * A `\n` in the source separates lines the author wrote; each of those is then
79
+ * wrapped independently, so an authored break survives and a long sentence still
80
+ * gets broken. This is the prose path — key/value values, URLs and CTA labels
81
+ * are atomic and never come through here (`../layout.ts`).
82
+ *
83
+ * Wrapped at the FULL budget, not at a bubble's `messageWidth`. Prose is not
84
+ * inside a box and has no avatar gutters to pay for, so narrowing it to a
85
+ * bubble's interior would wrap the same sentence differently on the two channels
86
+ * — which is the drift this whole layer exists to prevent.
87
+ */
88
+ export function proseLines(
89
+ value: string,
90
+ geometry: AsciiGeometry,
91
+ ): MonospaceLine[] {
92
+ return value
93
+ .split("\n")
94
+ .flatMap((line) => wrapText(line, geometry.columns))
95
+ .map(text);
96
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Brand facts every channel signs with.
3
+ *
4
+ * The brand NAME and the copyright year are not here — they are
5
+ * `../context`'s, read from `RenderContext.brand` so a caller can render for a
6
+ * different brand or pin a year. The URL is not yet on that record, and it sits
7
+ * here rather than inside `./email/constants.ts` because more than one channel
8
+ * signs with it now: a constant shared by two channels belongs above both.
9
+ */
10
+
11
+ /** The `companysemantics.ai` link under every signature. */
12
+ export const COMPANY_URL = "https://companysemantics.ai";