@company-semantics/contracts 35.1.0 → 37.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.
@@ -2,8 +2,8 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- The Slack channel (ADR-CONTRACTS-086) — `slackRenderer: Renderer<SlackMessage>`. A
6
- **non-functional placeholder**.
5
+ The Slack channel (ADR-CONTRACTS-086, ADR-CONTRACTS-090) —
6
+ `slackRenderer: Renderer<SlackMessage>`.
7
7
 
8
8
  Where `../sms` proves the content model survives the poorest channel, this proves
9
9
  the other edge: a channel whose output is a tree of typed blocks — neither a
@@ -12,23 +12,52 @@ record, a string and a different record, which is `../../renderer.ts`'s "each
12
12
  renderer returns its natural type" held by real modules rather than asserted about
13
13
  the type itself.
14
14
 
15
- The whole channel is one `index.ts` — the Block Kit subset, the escaping, and the
16
- renderer. `../email` splits across seven modules because it carries real markup; a
17
- stub that sprawls is a stub pretending.
15
+ The whole channel is one `index.ts` — the surfaces, the escaping, and the
16
+ renderer. `../email` splits across seven modules because it carries real markup;
17
+ this one composes blocks and has no markup to own.
18
18
 
19
- ## What "placeholder" means here
19
+ ## The blocks are Slack's, not ours
20
20
 
21
- - **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
22
- renders; delivery and the decision to deliver are the backend's.
23
- - **It is low fidelity.** `SlackBlock` is a minimal, hand-written subset of Block
24
- Kit enough to carry every element the channel admits, and no more. It is not
25
- the Slack API's types and is not generated from them. The wording is invented
26
- rather than relocated: there is no golden here, unlike `../email`.
21
+ `SlackMessage.blocks` is `KnownBlock[]` from **`@slack/types`** the published
22
+ type package behind `node-slack-sdk`, so it is the same union Slack's API
23
+ validates against. This channel used to hand-write a five-block approximation;
24
+ ADR-CONTRACTS-090 retires it. A renderer whose output type is invented can drift
25
+ from the wire format silently and nothing in this package would notice the
26
+ compiler is the only thing that can, and only if the types are theirs.
27
+
28
+ `index.ts` re-exports the block types it uses, so a consumer can name them without
29
+ taking its own dependency on `@slack/types`. Widen that list rather than
30
+ redeclaring a shape.
27
31
 
28
32
  The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
29
33
  wire format and those are its names. That is deliberate, not a lapse in the
30
34
  repo's camelCase convention.
31
35
 
36
+ ## What is still non-functional
37
+
38
+ - **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
39
+ renders; delivery and the decision to deliver are the backend's —
40
+ `SlackChannelDelivery` throws `ChannelNotImplementedError` by design.
41
+ - **The copy is invented**, not relocated from a shipped Slack app. There is no
42
+ golden here the way `../email` has one. The SHAPE is legitimate Block Kit and
43
+ `__tests__/index.test.ts` pins it; the WORDING is expected to change when a kind
44
+ is actually posted to Slack.
45
+
46
+ ## Element → block
47
+
48
+ | Element | Block |
49
+ | --------------------------------- | ---------------------------------------------------------- |
50
+ | `metadata.title` | `header` (+ the `text` fallback) |
51
+ | `greeting`, `body`, `warning` | `section` (mrkdwn) |
52
+ | `keyValueTable` | `section.fields` — Slack's two-column facts, chunked at 10 |
53
+ | `callToAction` **with** `href` | `actions` + `button` |
54
+ | `callToAction` **without** `href` | `rich_text` › `rich_text_preformatted` |
55
+ | `list` | `rich_text` › `rich_text_list` (`bullet` / `ordered`) |
56
+ | `divider` | `divider` |
57
+ | `metadata`, `notice`, `signature` | `context` |
58
+ | `heroImage` | `image` |
59
+ | `chatUnit` | _declined_ |
60
+
32
61
  ## The one decline
33
62
 
34
63
  `supports` answers `false` for `chatUnit` alone. Block Kit has no vocabulary for
@@ -37,9 +66,9 @@ attribution that IS the meaning — which the content model names as the reason
37
66
  decline a whole element rather than approximate it.
38
67
 
39
68
  Everything else Slack genuinely depicts, **including the `heroImage` that `../sms`
40
- has no surface for**. That difference is the point of shipping two stubs rather
41
- than one: same `NotificationContent`, no channel tags on it, three channels of
42
- differing capability each keeping what they can show.
69
+ has no surface for**. That difference is the point of shipping three channels
70
+ rather than one: same `NotificationContent`, no channel tags on it, three channels
71
+ of differing capability each keeping what they can show.
43
72
 
44
73
  `metadata` and `notice` become `context` blocks — Slack's own device for
45
74
  de-emphasised small print, which is exactly what `MetadataElement` exists to be.
@@ -52,20 +81,32 @@ de-emphasised small print, which is exactly what `MetadataElement` exists to be.
52
81
  nowhere to hide.
53
82
  - `supports` is total over `NotificationElementType` and AGREES with `render`:
54
83
  `chatUnit` is omitted, never approximated and never thrown on.
55
- - Every user-controlled string reaching an mrkdwn surface passes through
56
- `escapeMrkdwn` first — the same rule `../email` applies with `escapeHtml`. This
57
- is the one thing in a low-fidelity stub that would be a real defect rather than
58
- a fidelity gap, so `__tests__/index.test.ts` pins it.
84
+ - **Escaping follows the SURFACE, not the string.** Every user-controlled string
85
+ reaching an **mrkdwn** surface passes through `escapeMrkdwn` — the same rule
86
+ `../email` applies with `escapeHtml`. But `plain_text` (header, button label)
87
+ and `rich_text` (the OTP code, list items) are **literal**: escaping them is a
88
+ DEFECT, because those surfaces do not parse the entities and the reader sees
89
+ `Acme &amp; Co`. The old subset had no literal surface beyond a button label, so
90
+ the distinction only became load-bearing with `header` and `rich_text`. The
91
+ compiler cannot help — both surfaces are `string` — so
92
+ `__tests__/index.test.ts` pins both halves.
93
+ - Slack's documented LIMITS are enforced here, not left to the API to reject: a
94
+ header truncates at 150 characters, `section.fields` chunks at 10. These are
95
+ 400s from `chat.postMessage`, not fidelity gaps.
59
96
  - `renderElement` switches over all TWELVE element types, not the eleven
60
97
  `supports` admits. The declined arm is unreachable; writing it out is what makes
61
98
  the compiler prove the two lists agree. Do not collapse it into a `default` —
62
99
  that would silently swallow a thirteenth member of the union.
63
- - A `callToAction` with no `href` becomes text, never a button. `href` absent
64
- means the label IS the payload (an OTP code); a button with no destination is a
65
- lie about what the notification is asking for.
66
- - `SlackMessage.text` is `metadata.title` the fallback Slack shows where blocks
67
- cannot render. The same content-level fact email spends as its subject, which is
68
- why it lives on the content rather than in a channel's registry.
100
+ - A `callToAction` with no `href` becomes a code block, never a button. `href`
101
+ absent means the label IS the payload (an OTP code); a button with no
102
+ destination is a lie about what the notification is asking for.
103
+ - `metadata.title` is spent TWICE and neither use replaces the other:
104
+ `SlackMessage.text` is the fallback Slack shows where blocks cannot render (a
105
+ push notification, a screen reader), and the `header` block is what the message
106
+ looks like. It is the same content-level fact email spends as its subject —
107
+ three channel uses of one fact, which is why it lives on the content rather than
108
+ in a channel's registry. Only the block carries the 150-char limit; the fallback
109
+ is untruncated.
69
110
  - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
70
111
  lost the model; the fix for a missing fact is upstream in `compose`.
71
112
  - This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
@@ -1,12 +1,21 @@
1
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.
2
+ * What this pins, now that the blocks are Slack's own vocabulary rather than a
3
+ * hand-written approximation (ADR-CONTRACTS-090):
7
4
  *
8
- * The mrkdwn escaping IS pinned. It is the one thing here that would be a real
9
- * defect rather than a fidelity gap.
5
+ * - the contract every renderer owes `supports` total and in agreement with
6
+ * `render`, `render` pure and reading the brand and year from the context,
7
+ * every element type rendering without throwing;
8
+ * - the ESCAPING RULE, both halves. mrkdwn escapes; `plain_text` and `rich_text`
9
+ * must not. This is the defect the new literal surfaces made possible, and the
10
+ * compiler cannot catch it — both surfaces are `string`.
11
+ * - the SHAPE — that each element reaches the Block Kit primitive it claims to.
12
+ * `@slack/types` proves a block is well-formed; only a test proves a `list`
13
+ * became a `rich_text_list` rather than a section full of bullet characters.
14
+ * - Slack's documented LIMITS, which are rejections at the API rather than
15
+ * fidelity gaps: header ≤150, section fields ≤10.
16
+ *
17
+ * Still NOT pinned: the wording. It is invented rather than relocated from a
18
+ * shipped Slack app, and it is expected to change when a kind is actually posted.
10
19
  */
11
20
 
12
21
  import { describe, expect, it } from "vitest";
@@ -67,6 +76,17 @@ const ALL_TYPES: NotificationElementType[] = [
67
76
  "heroImage",
68
77
  ];
69
78
 
79
+ /** One content carrying one element — the shape assertions read better alone. */
80
+ function only(
81
+ element: NotificationContent["sections"][number]["elements"][number],
82
+ title = "Untitled",
83
+ ): NotificationContent {
84
+ return {
85
+ metadata: { kind: "org.invite", title },
86
+ sections: [{ elements: [element] }],
87
+ };
88
+ }
89
+
70
90
  describe("slackRenderer", () => {
71
91
  it("declares a stable channel id", () => {
72
92
  expect(slackRenderer.id).toBe("slack");
@@ -92,22 +112,22 @@ describe("slackRenderer", () => {
92
112
  const rendered = slackRenderer.render(CONTENT, CONTEXT);
93
113
 
94
114
  expect(JSON.stringify(rendered)).not.toContain("Hello?");
95
- // Eleven admitted elements, each yielding exactly one block here.
96
- expect(rendered.blocks).toHaveLength(11);
97
115
  });
98
116
 
99
117
  it("returns its natural type — a Block Kit record, not a string", () => {
100
118
  const rendered = slackRenderer.render(CONTENT, CONTEXT);
101
119
 
102
120
  // `text` is the fallback Slack shows where blocks cannot render. It is
103
- // metadata.title — the same field email spends as its subject.
121
+ // metadata.title — the same field email spends as its subject, and this
122
+ // channel ALSO leads with as a header.
104
123
  expect(rendered.text).toBe("Join Acme");
105
124
  expect(rendered.blocks.map((block) => block.type)).toEqual([
125
+ "header",
106
126
  "image",
107
127
  "section",
108
128
  "section",
109
129
  "section",
110
- "section",
130
+ "rich_text",
111
131
  "divider",
112
132
  "section",
113
133
  "context",
@@ -117,8 +137,127 @@ describe("slackRenderer", () => {
117
137
  ]);
118
138
  });
119
139
 
140
+ // ===========================================================================
141
+ // The header — metadata.title's third spend
142
+ // ===========================================================================
143
+
144
+ it("leads with a header block carrying the title", () => {
145
+ expect(slackRenderer.render(CONTENT, CONTEXT).blocks[0]).toEqual({
146
+ type: "header",
147
+ text: { type: "plain_text", text: "Join Acme" },
148
+ });
149
+ });
150
+
151
+ it("spends the title on BOTH the header and the fallback", () => {
152
+ // Neither use replaces the other: `text` is what a push notification and a
153
+ // screen reader get, the header is what the message looks like.
154
+ const rendered = slackRenderer.render(CONTENT, CONTEXT);
155
+
156
+ expect(rendered.text).toBe("Join Acme");
157
+ expect(JSON.stringify(rendered.blocks[0])).toContain("Join Acme");
158
+ });
159
+
160
+ it("truncates a header at Slack's documented 150 characters", () => {
161
+ const rendered = slackRenderer.render(
162
+ only({ type: "body", text: "x" }, "A".repeat(200)),
163
+ CONTEXT,
164
+ );
165
+ const header = rendered.blocks[0];
166
+
167
+ expect(header.type).toBe("header");
168
+ expect(header).toMatchObject({
169
+ text: { text: `${"A".repeat(149)}…` },
170
+ });
171
+ // The FALLBACK is not truncated — only the block has the limit.
172
+ expect(rendered.text).toHaveLength(200);
173
+ });
174
+
175
+ it("emits no header when the title is empty — not an empty block", () => {
176
+ const rendered = slackRenderer.render(
177
+ only({ type: "body", text: "x" }, " "),
178
+ CONTEXT,
179
+ );
180
+
181
+ expect(rendered.blocks.map((block) => block.type)).toEqual(["section"]);
182
+ });
183
+
184
+ // ===========================================================================
185
+ // Shape — each element reaches its real Block Kit primitive
186
+ // ===========================================================================
187
+
188
+ it("renders a keyValueTable as section fields, Slack's two-column device", () => {
189
+ const rendered = slackRenderer.render(
190
+ only({
191
+ type: "keyValueTable",
192
+ rows: [
193
+ { label: "Status", value: "Valid" },
194
+ { label: "Expires in", value: "10 minutes" },
195
+ ],
196
+ }),
197
+ CONTEXT,
198
+ );
199
+
200
+ expect(rendered.blocks[1]).toEqual({
201
+ type: "section",
202
+ fields: [
203
+ { type: "mrkdwn", text: "*Status*\nValid" },
204
+ { type: "mrkdwn", text: "*Expires in*\n10 minutes" },
205
+ ],
206
+ });
207
+ });
208
+
209
+ it("chunks a keyValueTable at ten fields — Slack rejects an eleventh", () => {
210
+ const rows = Array.from({ length: 23 }, (_, index) => ({
211
+ label: `L${index}`,
212
+ value: `V${index}`,
213
+ }));
214
+ const blocks = slackRenderer
215
+ .render(only({ type: "keyValueTable", rows }), CONTEXT)
216
+ .blocks.filter((block) => block.type === "section");
217
+
218
+ expect(blocks.map((block) => block.fields?.length)).toEqual([10, 10, 3]);
219
+ });
220
+
221
+ it("renders a list as a real rich_text_list, not bullet characters", () => {
222
+ const rendered = slackRenderer.render(
223
+ only({ type: "list", items: ["Read", "Write"] }),
224
+ CONTEXT,
225
+ );
226
+
227
+ expect(rendered.blocks[1]).toEqual({
228
+ type: "rich_text",
229
+ elements: [
230
+ {
231
+ type: "rich_text_list",
232
+ style: "bullet",
233
+ elements: [
234
+ {
235
+ type: "rich_text_section",
236
+ elements: [{ type: "text", text: "Read" }],
237
+ },
238
+ {
239
+ type: "rich_text_section",
240
+ elements: [{ type: "text", text: "Write" }],
241
+ },
242
+ ],
243
+ },
244
+ ],
245
+ });
246
+ });
247
+
248
+ it("maps an ordered list onto Slack's own ordered style", () => {
249
+ const rendered = slackRenderer.render(
250
+ only({ type: "list", items: ["First"], ordered: true }),
251
+ CONTEXT,
252
+ );
253
+
254
+ expect(rendered.blocks[1]).toMatchObject({
255
+ elements: [{ type: "rich_text_list", style: "ordered" }],
256
+ });
257
+ });
258
+
120
259
  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
260
+ // The channels exist to differ: same content, no channel tags on it, and
122
261
  // each channel keeps what it can depict.
123
262
  expect(slackRenderer.render(CONTENT, CONTEXT).blocks).toContainEqual({
124
263
  type: "image",
@@ -140,36 +279,106 @@ describe("slackRenderer", () => {
140
279
  });
141
280
  });
142
281
 
143
- it("renders a call to action without an href as text, not a button", () => {
282
+ it("renders a call to action without an href as a code block, not a button", () => {
144
283
  // `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.
284
+ // no destination would be a lie about what the notification is asking for;
285
+ // `rich_text_preformatted` is Slack's own device for a selectable code.
146
286
  const otp: NotificationContent = {
147
287
  metadata: { kind: "auth.otp", title: "Your code" },
148
288
  sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
149
289
  };
150
- expect(slackRenderer.render(otp, CONTEXT).blocks).toEqual([
151
- { type: "section", text: { type: "mrkdwn", text: "*123456*" } },
152
- ]);
290
+
291
+ expect(slackRenderer.render(otp, CONTEXT).blocks[1]).toEqual({
292
+ type: "rich_text",
293
+ elements: [
294
+ {
295
+ type: "rich_text_preformatted",
296
+ elements: [{ type: "text", text: "123456" }],
297
+ },
298
+ ],
299
+ });
300
+ });
301
+
302
+ // ===========================================================================
303
+ // Escaping — the rule follows the SURFACE, not the string
304
+ // ===========================================================================
305
+
306
+ it("escapes Slack's reserved characters on an mrkdwn surface", () => {
307
+ const rendered = slackRenderer.render(
308
+ only({ type: "body", text: "a & b <c> <!channel>" }),
309
+ CONTEXT,
310
+ );
311
+
312
+ expect(rendered.blocks[1]).toEqual({
313
+ type: "section",
314
+ text: { type: "mrkdwn", text: "a &amp; b &lt;c&gt; &lt;!channel&gt;" },
315
+ });
316
+ });
317
+
318
+ it("does NOT escape a plain_text surface — a header is literal", () => {
319
+ // Escaping here is a real defect, not belt-and-braces: `plain_text` does not
320
+ // parse the entities, so the reader would see `Acme &amp; Co`.
321
+ const rendered = slackRenderer.render(
322
+ only({ type: "body", text: "x" }, "Acme & Co <the org>"),
323
+ CONTEXT,
324
+ );
325
+
326
+ expect(rendered.blocks[0]).toEqual({
327
+ type: "header",
328
+ text: { type: "plain_text", text: "Acme & Co <the org>" },
329
+ });
330
+ });
331
+
332
+ it("does NOT escape a plain_text surface — a button label is literal", () => {
333
+ const rendered = slackRenderer.render(
334
+ only({
335
+ type: "callToAction",
336
+ label: "Join Acme & Co",
337
+ href: "https://e.test",
338
+ }),
339
+ CONTEXT,
340
+ );
341
+
342
+ expect(rendered.blocks[1]).toMatchObject({
343
+ elements: [{ text: { type: "plain_text", text: "Join Acme & Co" } }],
344
+ });
153
345
  });
154
346
 
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>" }] },
347
+ it("does NOT escape a rich_text surface — the OTP is literal", () => {
348
+ const rendered = slackRenderer.render(
349
+ only({ type: "callToAction", label: "a&b<c>" }),
350
+ CONTEXT,
351
+ );
352
+
353
+ expect(rendered.blocks[1]).toMatchObject({
354
+ elements: [
355
+ {
356
+ type: "rich_text_preformatted",
357
+ elements: [{ type: "text", text: "a&b<c>" }],
358
+ },
160
359
  ],
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;",
360
+ });
361
+ });
362
+
363
+ it("does NOT escape a rich_text surface — list items are literal", () => {
364
+ const rendered = slackRenderer.render(
365
+ only({ type: "list", items: ["a & b"] }),
366
+ CONTEXT,
367
+ );
368
+
369
+ expect(rendered.blocks[1]).toMatchObject({
370
+ elements: [
371
+ {
372
+ elements: [{ elements: [{ type: "text", text: "a & b" }] }],
168
373
  },
169
- },
170
- ]);
374
+ ],
375
+ });
171
376
  });
172
377
 
378
+ // ===========================================================================
379
+ // The seam properties
380
+ // ===========================================================================
381
+
173
382
  it("reads the brand and year from context, never from a clock", () => {
174
383
  // No fake timer in this file: swapping the context moves the copyright line.
175
384
  const pinned: RenderContext = {
@@ -181,16 +390,15 @@ describe("slackRenderer", () => {
181
390
  });
182
391
 
183
392
  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
- ]);
393
+ const rendered = slackRenderer.render(
394
+ only({ type: "signature", signer: "Grace" }),
395
+ CONTEXT,
396
+ );
397
+
398
+ expect(rendered.blocks[1]).toEqual({
399
+ type: "context",
400
+ elements: [{ type: "mrkdwn", text: "© 2026 Grace" }],
401
+ });
194
402
  });
195
403
 
196
404
  it("is pure — same inputs, same output", () => {