@company-semantics/contracts 34.0.0 → 35.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.
Files changed (76) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +17 -11
  3. package/src/notifications/README.md +142 -0
  4. package/src/notifications/__tests__/README.md +49 -0
  5. package/src/notifications/__tests__/__snapshots__/README.md +39 -0
  6. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
  7. package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
  8. package/src/notifications/__tests__/content.test.ts +186 -0
  9. package/src/notifications/__tests__/context.test.ts +72 -0
  10. package/src/notifications/__tests__/definition.test.ts +222 -0
  11. package/src/notifications/__tests__/fixtures.ts +226 -0
  12. package/src/notifications/__tests__/kinds.test.ts +80 -0
  13. package/src/notifications/__tests__/registry.test.ts +184 -0
  14. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -29
  15. package/src/notifications/__tests__/renderer.test.ts +181 -0
  16. package/src/notifications/content.ts +249 -0
  17. package/src/notifications/context.ts +70 -0
  18. package/src/notifications/definition.ts +82 -0
  19. package/src/notifications/index.ts +104 -0
  20. package/src/notifications/kinds/README.md +57 -0
  21. package/src/notifications/kinds/auth-otp.ts +99 -0
  22. package/src/notifications/kinds/chat-shared.ts +56 -0
  23. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  24. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  25. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  26. package/src/notifications/kinds/index.ts +19 -0
  27. package/src/notifications/kinds/org-invite.ts +62 -0
  28. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  29. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  30. package/src/notifications/kinds/security-alert.ts +78 -0
  31. package/src/notifications/kinds/share-granted.ts +74 -0
  32. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  33. package/src/notifications/kinds.ts +68 -0
  34. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  35. package/src/notifications/registry.ts +107 -0
  36. package/src/notifications/render.ts +106 -0
  37. package/src/notifications/renderer.ts +50 -0
  38. package/src/notifications/renderers/README.md +50 -0
  39. package/src/notifications/renderers/email/README.md +78 -0
  40. package/src/notifications/renderers/email/__tests__/README.md +32 -0
  41. package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
  42. package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
  43. package/src/notifications/renderers/email/constants.ts +64 -0
  44. package/src/notifications/renderers/email/cta.ts +63 -0
  45. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  46. package/src/notifications/renderers/email/index.ts +73 -0
  47. package/src/notifications/renderers/email/render.ts +238 -0
  48. package/src/notifications/renderers/email/shells.ts +61 -0
  49. package/src/notifications/renderers/slack/README.md +73 -0
  50. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  51. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  52. package/src/notifications/renderers/slack/index.ts +261 -0
  53. package/src/notifications/renderers/sms/README.md +69 -0
  54. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  55. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  56. package/src/notifications/renderers/sms/index.ts +131 -0
  57. package/src/notifications/text.ts +52 -0
  58. package/src/email/README.md +0 -51
  59. package/src/email/__tests__/registry.test.ts +0 -161
  60. package/src/email/index.ts +0 -36
  61. package/src/email/registry.ts +0 -155
  62. package/src/email/render/auth-otp.ts +0 -71
  63. package/src/email/render/blocks.ts +0 -281
  64. package/src/email/render/chat-shared.ts +0 -35
  65. package/src/email/render/company-md-access-approved.ts +0 -42
  66. package/src/email/render/company-md-access-denied.ts +0 -43
  67. package/src/email/render/company-md-access-requested.ts +0 -46
  68. package/src/email/render/constants.ts +0 -18
  69. package/src/email/render/index.ts +0 -58
  70. package/src/email/render/org-invite.ts +0 -40
  71. package/src/email/render/ownership-transfer-completed.ts +0 -41
  72. package/src/email/render/ownership-transfer.ts +0 -42
  73. package/src/email/render/render-email.ts +0 -194
  74. package/src/email/render/security-alert.ts +0 -61
  75. package/src/email/render/share-granted.ts +0 -52
  76. package/src/email/render/unit-owner-granted.ts +0 -60
@@ -0,0 +1,201 @@
1
+ /**
2
+ * The Slack channel is a placeholder, so what is pinned here is the contract a
3
+ * placeholder still owes — `supports` total and in agreement with `render`,
4
+ * `render` pure and reading the brand and year from the context, every element
5
+ * type rendering without throwing — and NOT the Block Kit wording, which is
6
+ * invented and expected to change when a kind is actually posted to Slack.
7
+ *
8
+ * The mrkdwn escaping IS pinned. It is the one thing here that would be a real
9
+ * defect rather than a fidelity gap.
10
+ */
11
+
12
+ import { describe, expect, it } from "vitest";
13
+
14
+ import type {
15
+ NotificationContent,
16
+ NotificationElementType,
17
+ } from "../../../content";
18
+ import type { RenderContext } from "../../../context";
19
+ import { slackRenderer } from "../index";
20
+
21
+ const CONTEXT: RenderContext = {
22
+ brand: { name: "Company Semantics", copyrightYear: 2026 },
23
+ };
24
+
25
+ /** Every element type, so `render` is exercised over the whole union. */
26
+ const CONTENT: NotificationContent = {
27
+ metadata: { kind: "org.invite", title: "Join Acme" },
28
+ sections: [
29
+ {
30
+ elements: [
31
+ { type: "heroImage", src: "https://example.test/h.png", alt: "Acme" },
32
+ { type: "greeting", recipientName: "Ada" },
33
+ { type: "body", text: "Acme uses Company Semantics." },
34
+ { type: "keyValueTable", rows: [{ label: "Org", value: "Acme" }] },
35
+ { type: "list", items: ["Read", "Write"] },
36
+ { type: "divider" },
37
+ { type: "warning" },
38
+ {
39
+ type: "chatUnit",
40
+ items: [{ type: "message", role: "user", text: "Hello?" }],
41
+ },
42
+ {
43
+ type: "metadata",
44
+ label: "Request details",
45
+ rows: [{ label: "IP", value: "203.0.113.7" }],
46
+ },
47
+ { type: "notice", lines: ["Not expecting this?"] },
48
+ { type: "callToAction", label: "JOIN", href: "https://example.test/j" },
49
+ { type: "signature" },
50
+ ],
51
+ },
52
+ ],
53
+ };
54
+
55
+ const ALL_TYPES: NotificationElementType[] = [
56
+ "greeting",
57
+ "body",
58
+ "keyValueTable",
59
+ "callToAction",
60
+ "list",
61
+ "divider",
62
+ "warning",
63
+ "metadata",
64
+ "notice",
65
+ "chatUnit",
66
+ "signature",
67
+ "heroImage",
68
+ ];
69
+
70
+ describe("slackRenderer", () => {
71
+ it("declares a stable channel id", () => {
72
+ expect(slackRenderer.id).toBe("slack");
73
+ });
74
+
75
+ it("answers supports() for every element type — the predicate is total", () => {
76
+ for (const type of ALL_TYPES) {
77
+ expect(typeof slackRenderer.supports(type), type).toBe("boolean");
78
+ }
79
+ });
80
+
81
+ it("declines only chatUnit — Block Kit cannot depict turn-taking", () => {
82
+ for (const type of ALL_TYPES) {
83
+ expect(slackRenderer.supports(type), type).toBe(type !== "chatUnit");
84
+ }
85
+ });
86
+
87
+ it("renders a content carrying every element type without throwing", () => {
88
+ expect(() => slackRenderer.render(CONTENT, CONTEXT)).not.toThrow();
89
+ });
90
+
91
+ it("omits the declined element rather than flattening it", () => {
92
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
93
+
94
+ expect(JSON.stringify(rendered)).not.toContain("Hello?");
95
+ // Eleven admitted elements, each yielding exactly one block here.
96
+ expect(rendered.blocks).toHaveLength(11);
97
+ });
98
+
99
+ it("returns its natural type — a Block Kit record, not a string", () => {
100
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
101
+
102
+ // `text` is the fallback Slack shows where blocks cannot render. It is
103
+ // metadata.title — the same field email spends as its subject.
104
+ expect(rendered.text).toBe("Join Acme");
105
+ expect(rendered.blocks.map((block) => block.type)).toEqual([
106
+ "image",
107
+ "section",
108
+ "section",
109
+ "section",
110
+ "section",
111
+ "divider",
112
+ "section",
113
+ "context",
114
+ "context",
115
+ "actions",
116
+ "context",
117
+ ]);
118
+ });
119
+
120
+ it("takes the hero image the SMS channel has no surface for", () => {
121
+ // The two stubs exist to differ: same content, no channel tags on it, and
122
+ // each channel keeps what it can depict.
123
+ expect(slackRenderer.render(CONTENT, CONTEXT).blocks).toContainEqual({
124
+ type: "image",
125
+ image_url: "https://example.test/h.png",
126
+ alt_text: "Acme",
127
+ });
128
+ });
129
+
130
+ it("renders a call to action with an href as a button", () => {
131
+ expect(slackRenderer.render(CONTENT, CONTEXT).blocks).toContainEqual({
132
+ type: "actions",
133
+ elements: [
134
+ {
135
+ type: "button",
136
+ text: { type: "plain_text", text: "JOIN" },
137
+ url: "https://example.test/j",
138
+ },
139
+ ],
140
+ });
141
+ });
142
+
143
+ it("renders a call to action without an href as text, not a button", () => {
144
+ // `href` absent means the label IS the payload (an OTP code). A button with
145
+ // no destination would be a lie about what the notification is asking for.
146
+ const otp: NotificationContent = {
147
+ metadata: { kind: "auth.otp", title: "Your code" },
148
+ sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
149
+ };
150
+ expect(slackRenderer.render(otp, CONTEXT).blocks).toEqual([
151
+ { type: "section", text: { type: "mrkdwn", text: "*123456*" } },
152
+ ]);
153
+ });
154
+
155
+ it("escapes Slack's reserved characters in user-controlled fields", () => {
156
+ const hostile: NotificationContent = {
157
+ metadata: { kind: "org.invite", title: "Join Acme" },
158
+ sections: [
159
+ { elements: [{ type: "body", text: "a & b <c> <!channel>" }] },
160
+ ],
161
+ };
162
+ expect(slackRenderer.render(hostile, CONTEXT).blocks).toEqual([
163
+ {
164
+ type: "section",
165
+ text: {
166
+ type: "mrkdwn",
167
+ text: "a &amp; b &lt;c&gt; &lt;!channel&gt;",
168
+ },
169
+ },
170
+ ]);
171
+ });
172
+
173
+ it("reads the brand and year from context, never from a clock", () => {
174
+ // No fake timer in this file: swapping the context moves the copyright line.
175
+ const pinned: RenderContext = {
176
+ brand: { name: "Acme", copyrightYear: 1999 },
177
+ };
178
+ expect(JSON.stringify(slackRenderer.render(CONTENT, pinned))).toContain(
179
+ "© 1999 Acme",
180
+ );
181
+ });
182
+
183
+ it("lets an element's signer override the context brand", () => {
184
+ const onBehalf: NotificationContent = {
185
+ metadata: { kind: "org.invite", title: "Join Acme" },
186
+ sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
187
+ };
188
+ expect(slackRenderer.render(onBehalf, CONTEXT).blocks).toEqual([
189
+ {
190
+ type: "context",
191
+ elements: [{ type: "mrkdwn", text: "© 2026 Grace" }],
192
+ },
193
+ ]);
194
+ });
195
+
196
+ it("is pure — same inputs, same output", () => {
197
+ expect(slackRenderer.render(CONTENT, CONTEXT)).toEqual(
198
+ slackRenderer.render(CONTENT, CONTEXT),
199
+ );
200
+ });
201
+ });
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The Slack channel (ADR-CONTRACTS-086) — a NON-FUNCTIONAL placeholder.
3
+ *
4
+ * Where `../sms` proves the content model survives the poorest channel, this one
5
+ * proves the other edge: a channel whose output is neither a string nor email's
6
+ * `{ subject, text, html }`, but a tree of typed blocks. Between the three, `Out`
7
+ * has been a record, a string and a different record — which is the seam property
8
+ * `../../renderer.ts` states in prose ("each renderer returns its natural type",
9
+ * no `ChannelOutput` supertype) held by real modules rather than asserted.
10
+ *
11
+ * It is a placeholder in two specific senses:
12
+ *
13
+ * - **It does not send.** No token, no channel id, no `chat.postMessage`. A
14
+ * renderer renders; delivery and the decision to deliver are the backend's.
15
+ * - **It is low fidelity.** This is a minimal, hand-written subset of Block Kit —
16
+ * enough to carry every element the channel admits, and no more. It is not the
17
+ * Slack API's types, it is not generated from them, and it is invented rather
18
+ * than relocated: there is no golden here, unlike `../email`.
19
+ *
20
+ * INVARIANTS:
21
+ * - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
22
+ * The brand and the copyright year come from `context.brand`.
23
+ * - `supports` is total, and agrees with `render`: `chatUnit` is omitted, never
24
+ * approximated and never thrown on.
25
+ * - Every user-controlled string reaching an mrkdwn surface passes through
26
+ * `escapeMrkdwn` first — the same rule `../email` applies with `escapeHtml`.
27
+ * - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
28
+ * lost the model — the fix for a missing fact is upstream in `compose`.
29
+ */
30
+
31
+ import type { NotificationContent, NotificationElement } from "../../content";
32
+ import type { RenderContext } from "../../context";
33
+ import type { Renderer } from "../../renderer";
34
+
35
+ // =============================================================================
36
+ // The Block Kit subset
37
+ // =============================================================================
38
+
39
+ /** Formatted text. `mrkdwn` is Slack's dialect, not Markdown. */
40
+ export interface SlackMrkdwnText {
41
+ type: "mrkdwn";
42
+ text: string;
43
+ }
44
+
45
+ /** Literal text — Slack forbids `mrkdwn` inside a button label. */
46
+ export interface SlackPlainText {
47
+ type: "plain_text";
48
+ text: string;
49
+ }
50
+
51
+ /** A paragraph. The workhorse block: most elements land here. */
52
+ export interface SlackSectionBlock {
53
+ type: "section";
54
+ text: SlackMrkdwnText;
55
+ }
56
+
57
+ /** A rule. The one element with a native counterpart. */
58
+ export interface SlackDividerBlock {
59
+ type: "divider";
60
+ }
61
+
62
+ /** An image. Field names are Slack's wire format, hence the snake_case. */
63
+ export interface SlackImageBlock {
64
+ type: "image";
65
+ image_url: string;
66
+ alt_text: string;
67
+ }
68
+
69
+ /** A button. `url` absent makes it inert — see `renderElement`'s `callToAction`. */
70
+ export interface SlackButtonElement {
71
+ type: "button";
72
+ text: SlackPlainText;
73
+ url?: string;
74
+ }
75
+
76
+ /** A row of buttons. */
77
+ export interface SlackActionsBlock {
78
+ type: "actions";
79
+ elements: SlackButtonElement[];
80
+ }
81
+
82
+ /** De-emphasised small print. Slack's own device for secondary detail. */
83
+ export interface SlackContextBlock {
84
+ type: "context";
85
+ elements: SlackMrkdwnText[];
86
+ }
87
+
88
+ /** Every block this channel can emit. */
89
+ export type SlackBlock =
90
+ | SlackSectionBlock
91
+ | SlackDividerBlock
92
+ | SlackImageBlock
93
+ | SlackActionsBlock
94
+ | SlackContextBlock;
95
+
96
+ /**
97
+ * One Slack message — this channel's natural output type.
98
+ *
99
+ * `text` is the notification-and-fallback string Slack shows where blocks cannot
100
+ * render (a push notification, a screen reader). It is `metadata.title`, which is
101
+ * the same field email spends as its subject — one content-level fact, two
102
+ * channel-level uses, which is why it lives on the content rather than in a
103
+ * channel's registry.
104
+ */
105
+ export interface SlackMessage {
106
+ text: string;
107
+ blocks: SlackBlock[];
108
+ }
109
+
110
+ // =============================================================================
111
+ // Rendering
112
+ // =============================================================================
113
+
114
+ /**
115
+ * Slack's three reserved characters. Everything else is literal, so — unlike
116
+ * HTML — this is the whole of the escaping rule.
117
+ */
118
+ function escapeMrkdwn(value: string): string {
119
+ return value
120
+ .replace(/&/g, "&amp;")
121
+ .replace(/</g, "&lt;")
122
+ .replace(/>/g, "&gt;");
123
+ }
124
+
125
+ /** A section carrying pre-escaped mrkdwn. */
126
+ function section(text: string): SlackSectionBlock {
127
+ return { type: "section", text: { type: "mrkdwn", text } };
128
+ }
129
+
130
+ /** A context block carrying pre-escaped mrkdwn. */
131
+ function context(text: string): SlackContextBlock {
132
+ return { type: "context", elements: [{ type: "mrkdwn", text }] };
133
+ }
134
+
135
+ /**
136
+ * One element as zero or more blocks.
137
+ *
138
+ * Zero and many are both real here — `chatUnit` yields nothing, and an element
139
+ * is free to become several blocks — which is why this returns an array where
140
+ * `../sms`'s equivalent returns a string. The switch is total over all twelve
141
+ * types rather than the eleven `supports` admits: the declined arm is unreachable
142
+ * (`render` filters on `supports` first), but writing it out is what makes the
143
+ * compiler prove the two lists agree. A `default` arm would silently swallow a
144
+ * thirteenth member of the union.
145
+ */
146
+ function renderElement(
147
+ element: NotificationElement,
148
+ renderContext: RenderContext,
149
+ ): SlackBlock[] {
150
+ switch (element.type) {
151
+ case "greeting":
152
+ return [section(`Hi ${escapeMrkdwn(element.recipientName ?? "there")},`)];
153
+ case "body":
154
+ return [section(escapeMrkdwn(element.text))];
155
+ case "keyValueTable":
156
+ // The `:` is presentation and lives in no element (see ../../kinds/README.md).
157
+ return [
158
+ section(
159
+ element.rows
160
+ .map(
161
+ (row) =>
162
+ `*${escapeMrkdwn(row.label)}:* ${escapeMrkdwn(row.value)}`,
163
+ )
164
+ .join("\n"),
165
+ ),
166
+ ];
167
+ case "callToAction":
168
+ // `href` absent means the label IS the payload (an OTP code), so it becomes
169
+ // text rather than a button with a fabricated destination.
170
+ return element.href
171
+ ? [
172
+ {
173
+ type: "actions",
174
+ elements: [
175
+ {
176
+ type: "button",
177
+ text: { type: "plain_text", text: element.label },
178
+ url: element.href,
179
+ },
180
+ ],
181
+ },
182
+ ]
183
+ : [section(`*${escapeMrkdwn(element.label)}*`)];
184
+ case "list":
185
+ return [
186
+ section(
187
+ element.items
188
+ .map((item, index) =>
189
+ element.ordered
190
+ ? `${index + 1}. ${escapeMrkdwn(item)}`
191
+ : `• ${escapeMrkdwn(item)}`,
192
+ )
193
+ .join("\n"),
194
+ ),
195
+ ];
196
+ case "divider":
197
+ return [{ type: "divider" }];
198
+ case "warning":
199
+ // Fieldless by design: the banner IS the content and its prominence is the
200
+ // channel's to choose. Slack's answer is an emoji and bold text.
201
+ return [section(":warning: *Security notice*")];
202
+ case "metadata":
203
+ // A context block, because this element exists to BE de-emphasisable —
204
+ // diagnostic detail about the request, distinct from the facts themselves.
205
+ return [
206
+ context(
207
+ [
208
+ element.label ? `*${escapeMrkdwn(element.label)}*` : "",
209
+ ...element.rows.map(
210
+ (row) => `${escapeMrkdwn(row.label)}: ${escapeMrkdwn(row.value)}`,
211
+ ),
212
+ ]
213
+ .filter(Boolean)
214
+ .join(" · "),
215
+ ),
216
+ ];
217
+ case "notice":
218
+ return [context(element.lines.map(escapeMrkdwn).join("\n"))];
219
+ case "signature":
220
+ // Both the signer fallback and the year come from the context. This is the
221
+ // element that used to hide a `new Date()`; here there is nowhere to hide.
222
+ return [
223
+ context(
224
+ `© ${renderContext.brand.copyrightYear} ${escapeMrkdwn(
225
+ element.signer ?? renderContext.brand.name,
226
+ )}`,
227
+ ),
228
+ ];
229
+ case "heroImage":
230
+ return [{ type: "image", image_url: element.src, alt_text: element.alt }];
231
+ case "chatUnit":
232
+ return [];
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Slack. Sections are one message — a channel MAY split on them (three messages
238
+ * in a thread is a real Slack shape) but choosing to is a delivery decision, and
239
+ * delivery is not this package's.
240
+ */
241
+ export const slackRenderer: Renderer<SlackMessage> = {
242
+ id: "slack",
243
+ /**
244
+ * One decline: `chatUnit`.
245
+ *
246
+ * Block Kit has no vocabulary for turn-taking. Flattening a conversation into a
247
+ * run of sections would drop the attribution that IS the meaning, which the
248
+ * content model names as the reason to decline the whole element rather than
249
+ * approximate it. Everything else Slack can genuinely depict — including the
250
+ * hero image `../sms` has no surface for, which is the point of having two
251
+ * stubs rather than one.
252
+ */
253
+ supports: (elementType) => elementType !== "chatUnit",
254
+ render: (content: NotificationContent, renderContext: RenderContext) => ({
255
+ text: content.metadata.title,
256
+ blocks: content.sections
257
+ .flatMap((section_) => section_.elements)
258
+ .filter((element) => slackRenderer.supports(element.type))
259
+ .flatMap((element) => renderElement(element, renderContext)),
260
+ }),
261
+ };
@@ -0,0 +1,69 @@
1
+ # notifications/renderers/sms/
2
+
3
+ ## Purpose
4
+
5
+ The SMS channel (ADR-CONTRACTS-086) — `smsRenderer: Renderer<string>`. A
6
+ **non-functional placeholder**.
7
+
8
+ `../email` could satisfy `Renderer` by construction: the seam was drawn around it,
9
+ so it proves nothing about whether the seam generalises. SMS is the test. It is
10
+ the poorest channel this vocabulary will ever meet — no markup, no images, no
11
+ layout, one string — and if `NotificationContent` survives being projected onto it
12
+ without a new element, a new field or a channel tag, then the content model is
13
+ channel-agnostic rather than email-shaped with a `Renderer` label on it.
14
+
15
+ The whole channel is one `index.ts`. `../email` splits across seven modules
16
+ because it carries real markup; a stub that sprawls is a stub pretending.
17
+
18
+ ## What "placeholder" means here
19
+
20
+ Two senses, neither of them a TODO to be closed by tightening this file:
21
+
22
+ - **It does not send.** No delivery, no segmentation into 160-character parts, no
23
+ phone number. A renderer renders; the rest is the backend's, and the moment it
24
+ appears here the seam has leaked.
25
+ - **It is low fidelity.** The wording is invented, not relocated. Unlike
26
+ `../email` there are no bytes to reproduce and no golden to answer to, so the
27
+ first kind actually sent over SMS should expect to author its copy properly
28
+ rather than trust these strings.
29
+
30
+ What is NOT provisional is the shape: `Out = string`, `supports` total, `render`
31
+ pure. Those are the seam's claims, and this module is one of the two places they
32
+ are stated by real code instead of a test double.
33
+
34
+ ## The three declines
35
+
36
+ `supports` answers `false` for `heroImage`, `chatUnit` and `divider`. Each is a
37
+ fact about the channel, never an edit of the notification:
38
+
39
+ | element | why |
40
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
41
+ | `heroImage` | There is no image surface. Substituting `alt` would approximate the element, which answering `false` forbids. |
42
+ | `chatUnit` | The turn-taking IS the meaning; a flat run of text cannot attribute a turn. The model says decline rather than flatten. |
43
+ | `divider` | A thematic break is a visual device and carries no content by design. SMS has no visual vocabulary to draw one with. |
44
+
45
+ Everything else is the universal subset — what survives on the poorest channel.
46
+ Note that `metadata` and `notice` are NOT declined: they are depictable, and
47
+ dropping them to save characters would be an editorial choice smuggled in as a
48
+ capability claim. Capability is "can this channel depict it", not "should it".
49
+
50
+ ## Invariants
51
+
52
+ - PURE. `render` is a function of `(content, context)` — no clock, no
53
+ environment, no I/O. The brand comes from `context.brand`.
54
+ - `supports` is total over `NotificationElementType` and AGREES with `render`: a
55
+ declined element is omitted, never approximated and never thrown on.
56
+ `__tests__/index.test.ts` asserts the whole rendered string for exactly this
57
+ reason — a decline is only visible in what is absent.
58
+ - `renderElement` switches over all TWELVE element types, not the nine `supports`
59
+ admits. The declined arms are unreachable; writing them out is what makes the
60
+ compiler prove the two lists agree. Do not collapse them into a `default` —
61
+ that would silently accept a thirteenth member of the union.
62
+ - The signature carries no copyright year. A copyright line is a footer device and
63
+ an SMS has no footer — a fidelity choice, not a purity hole. The brand still
64
+ comes from the context.
65
+ - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
66
+ lost the model; the fix for a missing fact is upstream in `compose`.
67
+ - This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
68
+ no parity fixture. It cannot change a sent email, and it must not acquire the
69
+ ability to.
@@ -0,0 +1,31 @@
1
+ # notifications/renderers/sms/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Tests for the SMS channel (ADR-CONTRACTS-086).
6
+
7
+ The channel is a placeholder, which changes what these tests are for. There is no
8
+ golden and no bytes to reproduce, so `index.test.ts` does not pin the wording —
9
+ that is invented and expected to change the first time a kind is really sent over
10
+ SMS. It pins the contract a placeholder still owes: `supports` total and in
11
+ agreement with `render`, `render` pure, the brand read from `RenderContext`, and a
12
+ content carrying all twelve element types going through without throwing.
13
+
14
+ ## Invariants
15
+
16
+ - The wording is NOT the specification. When a real SMS kind lands and rewrites
17
+ these strings, updating this file is expected and cheap. When `supports` and
18
+ `render` stop agreeing, that is a defect — the two kinds of failure must stay
19
+ easy to tell apart, so keep assertions about SHAPE separate from assertions
20
+ about copy.
21
+ - The full-string assertion in "omits declined elements" is deliberate and must
22
+ stay full-string. A decline is only observable in what is ABSENT — a partial
23
+ match cannot see an approximated hero image or a flattened chat turn.
24
+ - NO fake timers. The year is a field on `RenderContext`, and keeping every test
25
+ in this domain clock-free is the improvement ADR-CONTRACTS-086 was built to
26
+ deliver — do not reintroduce them (see `../../../__tests__/README.md`).
27
+ - `ALL_TYPES` is duplicated from `../../../__tests__/renderer.test.ts` and
28
+ `../../email/__tests__/render.test.ts` rather than shared. The list is the
29
+ totality claim itself; importing it from one place would let a new element type
30
+ be added to the union and to the shared list in one move, with no channel
31
+ noticing.