@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
@@ -12,66 +12,102 @@ 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 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.
15
+ | module | what it holds |
16
+ | ------------ | ---------------------------------------------------------------- |
17
+ | `index.ts` | `slackRenderer`, the element switch, the title |
18
+ | `message.ts` | `SlackMessage`, the channel's natural type |
19
+ | `blocks.ts` | Slack's surfaces, and `../ascii`'s runs bridged onto them |
20
+ | `cta.ts` | the button / the drawn box, and why a CTA is one or the other |
21
+ | `chat.ts` | `chatUnit` → a drawn conversation, cut where a button breaks out |
18
22
 
19
- ## The blocks are Slack's, not ours
23
+ ## The voice is monospace, and it is REAL
20
24
 
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.
25
+ The narrative content is drawn by `../ascii` — the same functions email's
26
+ `text/plain` surface uses, so a conversation cannot come out a column apart on the
27
+ two channels and set in `rich_text_preformatted`, which Slack renders in a
28
+ monospace face itself.
27
29
 
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.
30
+ That last clause is the whole point. The brand voice reaches a real workspace
31
+ here, not just the Ladle gallery. Nothing in the app applies a stylesheet to make
32
+ this look monospace; it IS monospace, because of the block type.
31
33
 
32
- The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
33
- wire format and those are its names. That is deliberate, not a lapse in the
34
- repo's camelCase convention.
34
+ ## Where the voice stops, and why that is not a compromise
35
35
 
36
- ## What is still non-functional
36
+ Controls, structure and images use Slack's own primitives:
37
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.
38
+ - a linked `callToAction` is a native `actions` button
39
+ - `divider` is a `divider`
40
+ - `heroImage` is an `image`
41
+
42
+ Slack's button beats anything we could draw inside a code block a large touch
43
+ target, an unambiguous affordance, keyboard and screen-reader semantics supplied
44
+ by the client, and forward compatibility with whatever Slack's button becomes.
45
+ Sharing a VOCABULARY across channels is the goal; sharing a control
46
+ REPRESENTATION is not. The drift worth preventing is semantic drift, not a
47
+ requirement that a button and a plaintext email match byte for byte.
48
+
49
+ ## There is no author-set colour or font — do not go looking again
50
+
51
+ The entire Block Kit schema contains two colour-ish fields and neither helps:
52
+
53
+ | Candidate | What it actually is |
54
+ | ------------------ | ---------------------------------------------------------- |
55
+ | `Button.style` | `'primary' \| 'danger'` — and Slack's `primary` is GREEN |
56
+ | `RichTextColor` | renders a colour SWATCH from a hex, not coloured text |
57
+ | `AlertBlock.level` | `'default' \| 'info' \| 'warning' \| 'error' \| 'success'` |
58
+ | `CardBlock` | images, title, subtitle, body, actions — no colour |
59
+ | attachment `color` | legacy; the documentation page 404s |
60
+
61
+ No font or size field exists anywhere, on any block. A `header` block is
62
+ `plain_text` and Slack owns its typography completely — which is why the title is
63
+ NOT a header block here (see below). Any future attempt to brand this channel with
64
+ CSS belongs in a gallery, not in a renderer, and a gallery that does it is lying
65
+ about what ships.
66
+
67
+ ## The title is body text, not a `header` block
68
+
69
+ `HeaderBlock` is `{ type, text }`. Slack picks the size, weight, face and colour,
70
+ so a header would be the one line of the notification that could never be set in
71
+ the brand's face. Putting the title in the monospace body with a rule under it
72
+ makes it monospace in a real workspace.
73
+
74
+ What that costs is Slack's large headline treatment. What it does not cost is the
75
+ push notification or the screen reader: those read `SlackMessage.text`, which is
76
+ `metadata.title` and never came from the header block.
45
77
 
46
78
  ## Element → block
47
79
 
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
-
61
- ## The one decline
62
-
63
- `supports` answers `false` for `chatUnit` alone. Block Kit has no vocabulary for
64
- turn-taking, and flattening a conversation into a run of sections drops the
65
- attribution that IS the meaning — which the content model names as the reason to
66
- decline a whole element rather than approximate it.
67
-
68
- Everything else Slack genuinely depicts, **including the `heroImage` that `../sms`
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.
72
-
73
- `metadata` and `notice` become `context` blocks Slack's own device for
74
- de-emphasised small print, which is exactly what `MetadataElement` exists to be.
80
+ | Element | Block |
81
+ | ---------------------------------------------------- | -------------------------------------------- |
82
+ | `metadata.title` | monospace lines + a full-width rule |
83
+ | `greeting`, `body`, `notice`, `warning`, `signature` | `rich_text` › `rich_text_preformatted` |
84
+ | `keyValueTable`, `metadata` | `Label: value` lines, in the same flow |
85
+ | `callToAction` **with** `href` | `actions` + `button` |
86
+ | `callToAction` **without** `href` | the drawn `>> LABEL <<` box, in the flow |
87
+ | `chatUnit` | a drawn conversation, cut for any linked CTA |
88
+ | `list` | `rich_text` › `rich_text_list` |
89
+ | `divider` | `divider` |
90
+ | `heroImage` | `image` |
91
+
92
+ Contiguous monospace COALESCES into one block. Slack draws every
93
+ `rich_text_preformatted` as its own bordered box, so one block per element stacks
94
+ a grey rectangle per element; email's `text/plain` body is one continuous surface
95
+ and so is this. The run breaks only at a native primitive.
96
+
97
+ ## Nothing is declined any more
98
+
99
+ `supports` answers `true` for every element.
100
+
101
+ `chatUnit` was the one decline, on the grounds that Block Kit has no vocabulary
102
+ for turn-taking. That was sound about BLOCKS and wrong about the message: both
103
+ `auth.otp` and `org.invite` nest their payload inside a `chatUnit`, so declining
104
+ the element meant the one-time code and the accept button never reached Slack at
105
+ all. That is semantic loss, not a presentation limitation, and no amount of
106
+ correctness about turn-taking justifies posting a login notification with no login
107
+ code in it.
108
+
109
+ A monospace drawing depicts the conversation AND keeps the attribution the decline
110
+ was protecting, so there is nothing left to decline.
75
111
 
76
112
  ## Invariants
77
113
 
@@ -79,36 +115,56 @@ de-emphasised small print, which is exactly what `MetadataElement` exists to be.
79
115
  environment, no I/O. The brand and the copyright year come from `context.brand`.
80
116
  `signature` is the element that used to hide a `new Date()`; here there is
81
117
  nowhere to hide.
82
- - `supports` is total over `NotificationElementType` and AGREES with `render`:
83
- `chatUnit` is omitted, never approximated and never thrown on.
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.
96
- - `renderElement` switches over all TWELVE element types, not the eleven
97
- `supports` admits. The declined arm is unreachable; writing it out is what makes
98
- the compiler prove the two lists agree. Do not collapse it into a `default`
99
- that would silently swallow a thirteenth member of the union.
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.
118
+ - **`assertNever`, not `supports`, is what protects the next element.** With
119
+ `supports` total-true it can no longer be the guard: an unmapped thirteenth
120
+ element would fall through and post a message with a hole in it — the `chatUnit`
121
+ bug under a new name, and harder to see, because there would be no `supports`
122
+ returning false to point at. `renderElement` therefore has NO `default` arm and
123
+ ends in `assertNever`, so adding a variant fails compilation until someone
124
+ chooses how Slack says it.
125
+ - **There is no mrkdwn surface here, and that is a safety property.** Escaping
126
+ used to be a per-call-site rule: `mrkdwn` parses `&`/`<`/`>` and had to be
127
+ escaped, `plain_text` and `rich_text` are literal and had to NOT be. Both
128
+ mistakes were possible and neither was a type error, because both surfaces are
129
+ `string`. Moving the body to `rich_text_preformatted` retired the last mrkdwn
130
+ emitter, so `escapeMrkdwn`, `mrkdwn`, `section` and `context` were DELETED
131
+ rather than kept in case — a helper that exists is one someone will reach for,
132
+ and reaching for that one is now always the bug.
133
+ - **No empty `rich_text_preformatted`, ever.** Slack draws one as a visible empty
134
+ rectangle, so a break-out at the head or tail of a chat unit must not leave a
135
+ zero-line box beside the button. `./chat.ts` merges segments and then filters.
136
+ - **Adjacent linked CTAs coalesce** into one `actions` block. Two buttons in one
137
+ block sit side by side the way a reader expects a choice; two blocks stack them
138
+ and read as two unrelated prompts. Slack caps a block at 25 elements.
139
+ - A `callToAction` with no `href` is drawn, never made a button. `href` absent
140
+ means the label IS the payload (an OTP code); a button with no destination is a
141
+ lie about what the notification is asking for.
142
+ - Slack's documented limits are enforced here, not left to the API: an `actions`
143
+ block splits at 25 elements. The header's 150-char cap left with the header
144
+ block.
110
145
  - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
111
146
  lost the model; the fix for a missing fact is upstream in `compose`.
112
147
  - This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
113
148
  no parity fixture. It cannot change a sent email, and it must not acquire the
114
149
  ability to.
150
+
151
+ ## The blocks are Slack's, not ours
152
+
153
+ `SlackMessage.blocks` is `KnownBlock[]` from **`@slack/types`** — the published
154
+ type package behind `node-slack-sdk`, so it is the same union Slack's API
155
+ validates against. `index.ts` re-exports the block types it uses, so a consumer can
156
+ name them without taking its own dependency on `@slack/types`. Widen that list
157
+ rather than redeclaring a shape.
158
+
159
+ The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
160
+ wire format and those are its names. That is deliberate, not a lapse in the repo's
161
+ camelCase convention.
162
+
163
+ ## What is still non-functional
164
+
165
+ - **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
166
+ renders; delivery and the decision to deliver are the backend's —
167
+ `SlackChannelDelivery` throws `ChannelNotImplementedError` by design.
168
+ - **The copy is invented**, not relocated from a shipped Slack app. The SHAPE is
169
+ legitimate Block Kit and `__tests__/index.test.ts` pins it; the WORDING is
170
+ expected to change when a kind is actually posted to Slack.
@@ -10,8 +10,9 @@ not pin the Block Kit wording. They pin the contract a placeholder still owes:
10
10
  read from `RenderContext`, and a content carrying all twelve element types
11
11
  rendering without throwing.
12
12
 
13
- One exception: the mrkdwn escaping IS pinned exactly. Everything else here is a
14
- fidelity gap that a real Slack kind will close; unescaped user input would be a
13
+ One exception: the ABSENCE of an mrkdwn surface is pinned exactly. Everything
14
+ else here is a fidelity gap that a real Slack kind will close; escaped user input
15
+ reaching a literal surface would be a
15
16
  real defect shipped in a published package.
16
17
 
17
18
  ## Invariants