@company-semantics/contracts 34.0.0 → 35.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 (76) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +17 -11
  3. package/src/notifications/README.md +140 -0
  4. package/src/notifications/__tests__/README.md +54 -0
  5. package/src/notifications/__tests__/__snapshots__/README.md +32 -0
  6. package/src/notifications/__tests__/content.test.ts +186 -0
  7. package/src/notifications/__tests__/context.test.ts +72 -0
  8. package/src/notifications/__tests__/definition.test.ts +222 -0
  9. package/src/notifications/__tests__/kinds.test.ts +81 -0
  10. package/src/notifications/__tests__/output-parity.golden.ts +363 -0
  11. package/src/notifications/__tests__/output-parity.test.ts +122 -0
  12. package/src/notifications/__tests__/registry.test.ts +184 -0
  13. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
  14. package/src/notifications/__tests__/renderer.test.ts +181 -0
  15. package/src/notifications/content.ts +249 -0
  16. package/src/notifications/context.ts +70 -0
  17. package/src/notifications/definition.ts +82 -0
  18. package/src/notifications/index.ts +104 -0
  19. package/src/notifications/kinds/README.md +57 -0
  20. package/src/notifications/kinds/auth-otp.ts +99 -0
  21. package/src/notifications/kinds/chat-shared.ts +56 -0
  22. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  23. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  24. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  25. package/src/notifications/kinds/index.ts +19 -0
  26. package/src/notifications/kinds/org-invite.ts +62 -0
  27. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  28. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  29. package/src/notifications/kinds/security-alert.ts +78 -0
  30. package/src/notifications/kinds/share-granted.ts +74 -0
  31. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  32. package/src/notifications/kinds.ts +68 -0
  33. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  34. package/src/notifications/registry.ts +107 -0
  35. package/src/notifications/render.ts +106 -0
  36. package/src/notifications/renderer.ts +50 -0
  37. package/src/notifications/renderers/README.md +50 -0
  38. package/src/notifications/renderers/email/README.md +74 -0
  39. package/src/notifications/renderers/email/__tests__/README.md +29 -0
  40. package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
  41. package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
  42. package/src/notifications/renderers/email/constants.ts +47 -0
  43. package/src/notifications/renderers/email/cta.ts +63 -0
  44. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  45. package/src/notifications/renderers/email/index.ts +73 -0
  46. package/src/notifications/renderers/email/render.ts +225 -0
  47. package/src/notifications/renderers/email/shells.ts +60 -0
  48. package/src/notifications/renderers/slack/README.md +73 -0
  49. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  50. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  51. package/src/notifications/renderers/slack/index.ts +261 -0
  52. package/src/notifications/renderers/sms/README.md +69 -0
  53. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  54. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  55. package/src/notifications/renderers/sms/index.ts +131 -0
  56. package/src/notifications/text.ts +52 -0
  57. package/src/email/README.md +0 -51
  58. package/src/email/__tests__/registry.test.ts +0 -161
  59. package/src/email/index.ts +0 -36
  60. package/src/email/registry.ts +0 -155
  61. package/src/email/render/auth-otp.ts +0 -71
  62. package/src/email/render/blocks.ts +0 -281
  63. package/src/email/render/chat-shared.ts +0 -35
  64. package/src/email/render/company-md-access-approved.ts +0 -42
  65. package/src/email/render/company-md-access-denied.ts +0 -43
  66. package/src/email/render/company-md-access-requested.ts +0 -46
  67. package/src/email/render/constants.ts +0 -18
  68. package/src/email/render/index.ts +0 -58
  69. package/src/email/render/org-invite.ts +0 -40
  70. package/src/email/render/ownership-transfer-completed.ts +0 -41
  71. package/src/email/render/ownership-transfer.ts +0 -42
  72. package/src/email/render/render-email.ts +0 -194
  73. package/src/email/render/security-alert.ts +0 -61
  74. package/src/email/render/share-granted.ts +0 -52
  75. package/src/email/render/unit-owner-granted.ts +0 -60
  76. /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Unit-owner-granted notification (added to a team's unit ownership).
3
+ *
4
+ * THREE ROLE STRINGS, DELIBERATELY DIFFERENT — do not unify them:
5
+ * - `roleWord` ("owner" / "delegate owner") — the title's short form. Derived
6
+ * here because the payload does not carry it; this is `subjectData`'s only job
7
+ * in `renderEmail` today, and it folds into `compose` with it.
8
+ * - `roleNoun` ("unit owner" / "delegate owner") — the body's prose form. Note it
9
+ * disagrees with `roleWord` for a formal owner, on purpose: the title is terse,
10
+ * the body is explanatory.
11
+ * - `titleCase(roleLabel)` ("Unit Owner" / "Delegate") — the Role row's display
12
+ * form, which is the payload's own label.
13
+ */
14
+
15
+ import type { ChatUnitItem, KeyValueRow } from "../content";
16
+ import type { NotificationDefinition } from "../definition";
17
+
18
+ import { formatExpiry, NOTICE, titleCase } from "../text";
19
+
20
+ export const unitOwnerGrantedDefinition: NotificationDefinition<"org.unit_owner_granted"> =
21
+ {
22
+ kind: "org.unit_owner_granted",
23
+ compose: (payload, context) => {
24
+ const {
25
+ granterName,
26
+ recipientName,
27
+ orgName,
28
+ unitName,
29
+ roleLabel,
30
+ ctaUrl,
31
+ message,
32
+ expiresAt,
33
+ } = payload;
34
+
35
+ const roleWord = roleLabel === "Delegate" ? "delegate owner" : "owner";
36
+ const roleNoun =
37
+ roleLabel === "Delegate" ? "delegate owner" : "unit owner";
38
+
39
+ const items: ChatUnitItem[] = [
40
+ ...(message
41
+ ? [
42
+ {
43
+ type: "message" as const,
44
+ role: "user" as const,
45
+ text: message,
46
+ from: granterName,
47
+ },
48
+ ]
49
+ : []),
50
+ {
51
+ type: "message",
52
+ role: "assistant",
53
+ text: "Open the team to get started.",
54
+ },
55
+ { type: "callToAction", label: "MANAGE TEAM", href: ctaUrl },
56
+ ];
57
+
58
+ const rows: KeyValueRow[] = [
59
+ { label: "From", value: granterName },
60
+ { label: "Team", value: unitName },
61
+ { label: "Role", value: titleCase(roleLabel) },
62
+ ...(expiresAt != null
63
+ ? [{ label: "Expires", value: formatExpiry(expiresAt) }]
64
+ : []),
65
+ ];
66
+
67
+ return {
68
+ metadata: {
69
+ kind: "org.unit_owner_granted",
70
+ title: `You've been added as ${roleWord} to ${unitName} in ${orgName}`,
71
+ },
72
+ sections: [
73
+ {
74
+ elements: [
75
+ { type: "greeting", ...(recipientName ? { recipientName } : {}) },
76
+ {
77
+ type: "body",
78
+ text: `You are now a ${roleNoun} of ${unitName}. ${roleNoun[0].toUpperCase()}${roleNoun.slice(1)}s hold authority over the team: managing team membership, doc access, strategy, and execution against goals.`,
79
+ },
80
+ { type: "chatUnit", items },
81
+ { type: "keyValueTable", rows },
82
+ {
83
+ type: "notice",
84
+ lines: [
85
+ `This notification was sent via ${context.brand.name}.`,
86
+ NOTICE,
87
+ ],
88
+ },
89
+ { type: "signature" },
90
+ ],
91
+ },
92
+ ],
93
+ };
94
+ },
95
+ };
@@ -0,0 +1,68 @@
1
+ /**
2
+ * What notifications exist (ADR-CONTRACTS-085).
3
+ *
4
+ * `NotificationKind` is the old `EmailKind` renamed and narrowed to the kinds
5
+ * that can actually be composed. `EmailKind` derived from this union while both
6
+ * layers existed; it was deleted with `src/email/` (ADR-CONTRACTS-086), so this
7
+ * is now the only union of its kind in the package.
8
+ *
9
+ * NOTE ON MODULE RESOLUTION: this file is `kinds.ts` and its neighbour is the
10
+ * `kinds/` directory. Under `moduleResolution: "bundler"` the specifier
11
+ * `./kinds` resolves to THIS FILE, never to `kinds/index.ts` — import the
12
+ * definition barrel explicitly as `./kinds/index`. The split is deliberate: this
13
+ * file is the vocabulary (which kinds exist), the directory is one definition
14
+ * per kind (what each one says).
15
+ *
16
+ * INVARIANTS:
17
+ * - Every member has a payload in `./payloads` and a definition in `./kinds/`.
18
+ * Both are enforced structurally rather than by comment — see `./definition`
19
+ * and `./registry`.
20
+ * - `{domain}.{type}` dot notation, matching the ids the email layer shipped on
21
+ * the wire. These strings are persisted by consumers; renaming one is a
22
+ * breaking change, not a tidy-up.
23
+ */
24
+
25
+ // =============================================================================
26
+ // NotificationKind Union
27
+ // =============================================================================
28
+
29
+ /**
30
+ * NotificationKind identifies the type of transactional notification.
31
+ *
32
+ * Naming convention: `{domain}.{type}`
33
+ * - domain: auth, org, security, chat, share, companyMd
34
+ * - type: specific notification variant
35
+ *
36
+ * This union is exactly the kinds with a `compose` implementation — the 11 the
37
+ * email layer could render, and deliberately NOT the 12 its `EmailKind` had.
38
+ * `auth.magic_link` was registered with a subject but never had a template, so
39
+ * the old `renderEmail("auth.magic_link", …)` type-checked and then threw at
40
+ * runtime. Excluding it closes that hole by construction: a kind IS its
41
+ * definition, `NOTIFICATION_DEFINITIONS` is total, and
42
+ * `getNotificationKindDefinition` cannot fail. A kind earns membership by having
43
+ * something to say, not by having a subject line reserved for it.
44
+ *
45
+ * That kind and its reserved subject left the package with `src/email/` and have
46
+ * no replacement here. Magic-link login is not a thing this layer can say; if it
47
+ * returns, it returns as a definition like any other.
48
+ *
49
+ * New kinds MUST be added to:
50
+ * 1. This union
51
+ * 2. `NotificationPayloads` in `./payloads`
52
+ * 3. A definition module under `./kinds/`, exported from `./kinds/index`
53
+ * 4. `NOTIFICATION_DEFINITIONS` in `./registry`
54
+ *
55
+ * Steps 2 and 4 are compiler-enforced; the union will not type-check without them.
56
+ */
57
+ export type NotificationKind =
58
+ | "auth.otp"
59
+ | "org.invite"
60
+ | "org.unit_owner_granted"
61
+ | "org.ownership_transfer"
62
+ | "org.ownership_transfer_completed"
63
+ | "security.alert"
64
+ | "chat.shared"
65
+ | "share.granted"
66
+ | "companyMd.access_requested"
67
+ | "companyMd.access_request_approved"
68
+ | "companyMd.access_request_denied";
@@ -1,61 +1,47 @@
1
1
  /**
2
- * Email Domain Types
2
+ * The business facts each notification kind carries (ADR-CONTRACTS-085).
3
3
  *
4
- * Shared types for transactional email across Company Semantics codebases.
5
- * Types only - no runtime code, no business logic.
4
+ * This is the old `EmailPayloads` renamed, not redesigned: the same per-kind
5
+ * fields, in the same shapes, moved to the layer that actually owns them. A
6
+ * payload was never an email concept — "who invited you, to which workspace,
7
+ * until when" is true of the notification regardless of whether it arrives as an
8
+ * email, a chat message or a push. `EmailPayloads` aliased this type while both
9
+ * layers existed, and was deleted with `src/email/` (ADR-CONTRACTS-086).
6
10
  *
7
- * @see ADR-CONT-034 for design rationale
8
- */
9
-
10
- // =============================================================================
11
- // EmailKind Union
12
- // =============================================================================
13
-
14
- /**
15
- * EmailKind identifies the type of transactional email.
11
+ * A payload carries FACTS, never copy. "Jun 13, 2026" and "You've been added as
12
+ * owner" are `compose`'s output, not a caller's input — that split is what lets
13
+ * one payload render to every channel.
16
14
  *
17
- * Naming convention: `{domain}.{type}`
18
- * - domain: auth, org, security
19
- * - type: specific email variant
20
- *
21
- * New kinds MUST be added to:
22
- * 1. This union type
23
- * 2. EMAIL_KINDS registry in registry.ts
24
- * 3. EmailPayloads interface (if kind has payload)
15
+ * INVARIANTS:
16
+ * - Types only. No markup, no copy, no formatted values — an `expiresAt` is an
17
+ * ISO timestamp here and becomes a date string in `compose`.
18
+ * - Keys are exactly `NotificationKind`. `../definition` enforces this
19
+ * structurally: `NotificationPayloads[K]` fails to compile for a kind with no
20
+ * entry here, so a kind cannot exist without its facts.
25
21
  */
26
- export type EmailKind =
27
- | "auth.otp"
28
- | "auth.magic_link" // future
29
- | "org.invite" // future
30
- | "org.unit_owner_granted"
31
- | "org.ownership_transfer"
32
- | "org.ownership_transfer_completed"
33
- | "security.alert" // future
34
- | "chat.shared"
35
- | "share.granted"
36
- | "companyMd.access_requested"
37
- | "companyMd.access_request_approved"
38
- | "companyMd.access_request_denied";
39
22
 
40
23
  // =============================================================================
41
- // Email Payloads
24
+ // Notification Payloads
42
25
  // =============================================================================
43
26
 
44
27
  /**
45
- * Type-safe payload mapping for each email kind.
28
+ * Type-safe payload mapping for each notification kind.
46
29
  *
47
- * Each key is an EmailKind, and the value is the required payload shape.
48
- * Kinds without entries here have empty payloads.
30
+ * Each key is a `NotificationKind`, and the value is the required payload shape.
49
31
  */
50
- export interface EmailPayloads {
32
+ export interface NotificationPayloads {
51
33
  "auth.otp": {
52
34
  /** The 6-digit OTP code */
53
35
  otp: string;
54
36
  /** How long until the code expires */
55
37
  expiresInMinutes: number;
56
- /** IP address of the request (for security context) */
38
+ /**
39
+ * IP address of the request (for security context). PII under GDPR: the
40
+ * caller decides whether to supply it at all, and `compose` shows request
41
+ * details only when it is present. See ADR-CONTRACTS-085.
42
+ */
57
43
  requestIp?: string;
58
- /** User agent of the request (for security context) */
44
+ /** User agent of the request (for security context). PII-gated upstream, as `requestIp`. */
59
45
  userAgent?: string;
60
46
  };
61
47
  "org.invite": {
@@ -71,7 +57,7 @@ export interface EmailPayloads {
71
57
  granterName: string;
72
58
  /** Display name of the recipient (optional; falls back to a neutral greeting) */
73
59
  recipientName?: string;
74
- /** Name of the organization / workspace (rendered in the subject) */
60
+ /** Name of the organization / workspace (rendered in the title) */
75
61
  orgName: string;
76
62
  /** Name of the org unit / team the access applies to */
77
63
  unitName: string;
@@ -79,13 +65,13 @@ export interface EmailPayloads {
79
65
  roleLabel: "Unit owner" | "Delegate";
80
66
  /** Full URL to view the org unit in the app */
81
67
  ctaUrl: string;
82
- /** Optional message from the granter, shown in the email and recorded with the grant */
68
+ /** Optional message from the granter, shown in the notification and recorded with the grant */
83
69
  message?: string;
84
70
  /** ISO timestamp when the grant expires. Absent = permanent (formal unit owners; delegations with no expiry). Rendered as the last key/value row ("Expires: Jun 13, 2026") when present. */
85
71
  expiresAt?: string;
86
72
  };
87
73
  "org.ownership_transfer": {
88
- /** Name of the organization being transferred (subject is static; org name renders in the body) */
74
+ /** Name of the organization being transferred (title is static; org name renders in the body) */
89
75
  orgName: string;
90
76
  /** Full URL to accept the transfer (token-bearing) */
91
77
  acceptUrl: string;
@@ -119,7 +105,7 @@ export interface EmailPayloads {
119
105
  shareUrl: string;
120
106
  /** Share visibility mode */
121
107
  visibility: "private" | "public";
122
- /** First ~200 chars of chat content for email preview. Generated server-side from snapshot. */
108
+ /** First ~200 chars of chat content for preview. Generated server-side from snapshot. */
123
109
  previewText?: string;
124
110
  };
125
111
  "share.granted": {
@@ -135,7 +121,7 @@ export interface EmailPayloads {
135
121
  accessLevel: "editor" | "commenter" | "viewer";
136
122
  /** Full URL to view the shared entity in the app */
137
123
  ctaUrl: string;
138
- /** Optional message from the granter, included in the email only (not persisted) */
124
+ /** Optional message from the granter, included in the notification only (not persisted) */
139
125
  message?: string;
140
126
  };
141
127
  /** Sent to the doc owner(s) when an actor requests access (ADR-BE-338). */
@@ -144,7 +130,7 @@ export interface EmailPayloads {
144
130
  requesterName: string;
145
131
  /** Title of the company.md node access is requested for */
146
132
  docTitle: string;
147
- /** Optional message from the requester (email only) */
133
+ /** Optional message from the requester (notification only) */
148
134
  message?: string;
149
135
  /** Deep-link that opens the doc's ShareDialog scrolled to the pending request */
150
136
  reviewUrl: string;
@@ -166,28 +152,7 @@ export interface EmailPayloads {
166
152
  approverName: string;
167
153
  /** Title of the company.md node */
168
154
  docTitle: string;
169
- /** Optional reason from the owner (email only) */
155
+ /** Optional reason from the owner (notification only) */
170
156
  reason?: string;
171
157
  };
172
158
  }
173
-
174
- // =============================================================================
175
- // Send Email Input
176
- // =============================================================================
177
-
178
- /**
179
- * Type-safe input for sending emails.
180
- *
181
- * The payload type is inferred from the kind.
182
- * If the kind is in EmailPayloads, that payload is required.
183
- */
184
- export interface SendEmailInput<K extends EmailKind> {
185
- /** The type of email to send */
186
- kind: K;
187
- /** Recipient email address (will be normalized) */
188
- to: string;
189
- /** Type-safe payload for this email kind */
190
- payload: K extends keyof EmailPayloads ? EmailPayloads[K] : never;
191
- /** Idempotency key to prevent duplicate sends */
192
- idempotencyKey: string;
193
- }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * The notification definition registry (ADR-CONTRACTS-085).
3
+ *
4
+ * `NOTIFICATION_DEFINITIONS` is the single source of truth for what each kind
5
+ * says, and replaced `EMAIL_KINDS` as the place a caller starts from. What it
6
+ * does not hold is as important as what it does: no subject (that is
7
+ * `compose`'s `metadata.title`), and no `plainTextRequired` / `htmlSupported`
8
+ * (that is `Renderer.supports`). What remains is the kind and how to say it.
9
+ *
10
+ * INVARIANTS:
11
+ * - Exhaustive over `NotificationKind`, by construction — `NotificationDefinitions`
12
+ * is a mapped type, so a kind with no entry is a compile error rather than an
13
+ * `undefined` at runtime.
14
+ * - Registry keys MUST match `definition.kind`. Enforced by the mapped type: the
15
+ * value at key `K` is a `NotificationDefinition<K>`, whose `kind` is `K`.
16
+ * - Total: `getNotificationKindDefinition` cannot fail for a `NotificationKind`.
17
+ * This is the improvement on `EMAIL_KINDS`, where `auth.magic_link` type-checked
18
+ * and then threw — see `./kinds` for why the union excludes it.
19
+ */
20
+
21
+ import type { NotificationDefinition } from "./definition";
22
+ import type { NotificationKind } from "./kinds";
23
+
24
+ import {
25
+ accessApprovedDefinition,
26
+ accessDeniedDefinition,
27
+ accessRequestedDefinition,
28
+ authOtpDefinition,
29
+ chatSharedDefinition,
30
+ orgInviteDefinition,
31
+ ownershipTransferCompletedDefinition,
32
+ ownershipTransferDefinition,
33
+ securityAlertDefinition,
34
+ shareGrantedDefinition,
35
+ unitOwnerGrantedDefinition,
36
+ } from "./kinds/index";
37
+
38
+ // =============================================================================
39
+ // Registry
40
+ // =============================================================================
41
+
42
+ /**
43
+ * The registry's shape: `Record<NotificationKind, NotificationDefinition>` made
44
+ * per-kind precise.
45
+ *
46
+ * A plain `Record` would widen every `compose` to accept a union of all eleven
47
+ * payloads, and — since `compose` is an arrow property, checked contravariantly
48
+ * — no real definition would be assignable to it. The mapped type keeps each
49
+ * kind bound to its own payload, which is what a caller wants anyway.
50
+ */
51
+ export type NotificationDefinitions = {
52
+ [K in NotificationKind]: NotificationDefinition<K>;
53
+ };
54
+
55
+ /**
56
+ * Every notification kind and its definition.
57
+ *
58
+ * To add a kind:
59
+ * 1. Add it to `NotificationKind` in `./kinds`
60
+ * 2. Add its facts to `NotificationPayloads` in `./payloads`
61
+ * 3. Write `./kinds/{kind}.ts` and export it from `./kinds/index`
62
+ * 4. Add it here
63
+ *
64
+ * Steps 2–4 are compiler-enforced; step 1 alone will not type-check.
65
+ */
66
+ export const NOTIFICATION_DEFINITIONS: NotificationDefinitions = {
67
+ "auth.otp": authOtpDefinition,
68
+ "org.invite": orgInviteDefinition,
69
+ "org.unit_owner_granted": unitOwnerGrantedDefinition,
70
+ "org.ownership_transfer": ownershipTransferDefinition,
71
+ "org.ownership_transfer_completed": ownershipTransferCompletedDefinition,
72
+ "security.alert": securityAlertDefinition,
73
+ "chat.shared": chatSharedDefinition,
74
+ "share.granted": shareGrantedDefinition,
75
+ "companyMd.access_requested": accessRequestedDefinition,
76
+ "companyMd.access_request_approved": accessApprovedDefinition,
77
+ "companyMd.access_request_denied": accessDeniedDefinition,
78
+ };
79
+
80
+ // =============================================================================
81
+ // Registry Helpers
82
+ // =============================================================================
83
+
84
+ /**
85
+ * Type-safe registry lookup. Generic in `K` so a caller with a literal kind gets
86
+ * that kind's definition — and so its `compose` takes that kind's payload —
87
+ * rather than a union it would have to narrow again.
88
+ */
89
+ export function getNotificationKindDefinition<K extends NotificationKind>(
90
+ kind: K,
91
+ ): NotificationDefinitions[K] {
92
+ return NOTIFICATION_DEFINITIONS[kind];
93
+ }
94
+
95
+ /**
96
+ * Check if a string is a valid `NotificationKind`.
97
+ * Use at API boundaries to reject unknown kinds.
98
+ *
99
+ * `Object.hasOwn`, not `in`: this is fed untrusted strings by definition, and
100
+ * `in` walks the prototype chain — the old `isValidEmailKind("toString")`
101
+ * returned true. The registry's OWN keys are the vocabulary; `Object`'s are not.
102
+ */
103
+ export function isValidNotificationKind(
104
+ kind: string,
105
+ ): kind is NotificationKind {
106
+ return Object.hasOwn(NOTIFICATION_DEFINITIONS, kind);
107
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * The entry point (ADR-CONTRACTS-086).
3
+ *
4
+ * `renderToChannel` is the whole pipeline in one call: look the kind up in
5
+ * `NOTIFICATION_DEFINITIONS`, `compose` its payload into channel-agnostic
6
+ * `NotificationContent`, hand that to a `Renderer`. Every piece already existed
7
+ * and is already proven byte-for-byte against the output-parity golden — this
8
+ * module is the wiring, not a rendering change.
9
+ *
10
+ * What it deliberately does NOT do is dispatch. The old `renderEmail` was a
11
+ * twelve-arm `switch` that threw on the one arm it did not have; here the
12
+ * registry is a mapped type over `NotificationKind`, so the lookup cannot miss
13
+ * and there is no `default:` to fall into. Adding a channel adds a `Renderer`
14
+ * and touches nothing here; adding a kind adds a definition and touches nothing
15
+ * here. That is the point of the seam.
16
+ *
17
+ * `renderEmail` is a convenience over it, kept because email is the channel
18
+ * every caller has today and `renderToChannel(kind, payload, emailRenderer,
19
+ * createRenderContext())` is a lot of ceremony for "send this email". It REPLACES
20
+ * the old `email/render/render-email`, which was deleted along with the `./email`
21
+ * subpath (ADR-CONTRACTS-086) — same name, different third argument: a
22
+ * render-context concern rather than `RenderEmailOptions`.
23
+ * `includeRequestMetadata` has no equivalent and needs none — `auth.otp`'s
24
+ * `compose` gates request details on whether the payload carries them, so the
25
+ * decision stays with the caller that holds the policy instead of being handed
26
+ * PII and told to ignore it.
27
+ *
28
+ * INVARIANTS:
29
+ * - `renderToChannel` is PURE when given a `RenderContext`. It reads the clock
30
+ * only to build one when the caller supplies none, and only via
31
+ * `createRenderContext` — the single sanctioned clock read in this domain.
32
+ * Pass a context to get reproducible bytes.
33
+ * - Output MUST equal what the old email layer emitted, byte for byte, for every
34
+ * fixture in `__tests__/output-parity.golden.ts`. That is what
35
+ * `__tests__/output-parity.test.ts` asserts, and it is the only reason this
36
+ * rewiring is safe. The golden is now the sole record of those bytes.
37
+ * - A kind's `defaults` layer UNDER the caller's wishes, never over them. A full
38
+ * `RenderContext` has no gaps left to fill, so it wins outright; partial
39
+ * overrides are filled from `defaults` first and the package default last.
40
+ * - Depends only on this domain. The cycle back into `../email` that this layer
41
+ * existed to unwind is gone with the directory; nothing may reintroduce one.
42
+ */
43
+
44
+ import type { RenderContext, RenderContextOverrides } from "./context";
45
+ import { createRenderContext } from "./context";
46
+ import type { NotificationKind } from "./kinds";
47
+ import type { NotificationPayloads } from "./payloads";
48
+ import { NOTIFICATION_DEFINITIONS } from "./registry";
49
+ import type { Renderer } from "./renderer";
50
+ import type { RenderedEmail } from "./renderers/email";
51
+ import { emailRenderer } from "./renderers/email";
52
+
53
+ /**
54
+ * Layer a kind's `defaults` under a caller's overrides, field by field.
55
+ *
56
+ * Shallow-merging `brand` rather than replacing it is the difference between
57
+ * "the caller pinned the year" and "the caller pinned the year and silently lost
58
+ * the kind's signer".
59
+ */
60
+ function layerOverrides(
61
+ defaults: RenderContextOverrides | undefined,
62
+ overrides: RenderContextOverrides | undefined,
63
+ ): RenderContextOverrides {
64
+ return { brand: { ...defaults?.brand, ...overrides?.brand } };
65
+ }
66
+
67
+ /**
68
+ * Render a notification to one channel: compose the kind's content, then let the
69
+ * renderer say it.
70
+ *
71
+ * Generic in `K` so a caller with a literal kind is held to that kind's payload,
72
+ * and in `Out` so the renderer's natural type comes back — `RenderedEmail` for
73
+ * email, whatever the next channel returns for the next channel. Forcing a
74
+ * common output shape would recreate the fusion this layer unpicks.
75
+ *
76
+ * @param context Pin it for reproducible bytes. Omitted, it is built from the
77
+ * kind's `defaults` and today's date — the one clock read.
78
+ */
79
+ export function renderToChannel<K extends NotificationKind, Out>(
80
+ kind: K,
81
+ payload: NotificationPayloads[K],
82
+ renderer: Renderer<Out>,
83
+ context?: RenderContext,
84
+ ): Out {
85
+ const definition = NOTIFICATION_DEFINITIONS[kind];
86
+ const resolved = context ?? createRenderContext(definition.defaults);
87
+ return renderer.render(definition.compose(payload, resolved), resolved);
88
+ }
89
+
90
+ /**
91
+ * Render a notification as an email.
92
+ *
93
+ * The third argument is `RenderContextOverrides`, not options: there is nothing
94
+ * left to opt into. The subject is `compose`'s `metadata.title`, and request
95
+ * metadata is decided by what the payload carries.
96
+ */
97
+ export function renderEmail<K extends NotificationKind>(
98
+ kind: K,
99
+ payload: NotificationPayloads[K],
100
+ overrides?: RenderContextOverrides,
101
+ ): RenderedEmail {
102
+ const context = createRenderContext(
103
+ layerOverrides(NOTIFICATION_DEFINITIONS[kind].defaults, overrides),
104
+ );
105
+ return renderToChannel(kind, payload, emailRenderer, context);
106
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The channel seam (ADR-CONTRACTS-086).
3
+ *
4
+ * A `Renderer` turns channel-agnostic `NotificationContent` into one channel's
5
+ * bytes. Email becomes one renderer rather than the shape of the whole layer.
6
+ *
7
+ * Two things are deliberate here.
8
+ *
9
+ * **Capability lives on the renderer, not the content.** `supports` asks a
10
+ * renderer what it can depict. The alternative — tagging elements with the
11
+ * channels they apply to — makes every new channel a rewrite of the content, and
12
+ * puts knowledge of the channel back inside the thing that is supposed to be
13
+ * channel-agnostic. This also superseded the `plainTextRequired` /
14
+ * `htmlSupported` flags on the old `EmailKindDefinition`, which put capability on
15
+ * the kind: the kind is not what can or cannot draw an image.
16
+ *
17
+ * **Each renderer returns its natural type.** `Out` is `{ subject, text, html }`
18
+ * for email and something else entirely for the next channel. Forcing a common
19
+ * output shape would recreate the fusion this layer unpicked — the old `Block`,
20
+ * which carried `html` and `text` together and would have carried a third
21
+ * surface bolted on.
22
+ *
23
+ * Delivery and policy stay OUT of contracts: a renderer renders, it does not
24
+ * send, and nothing here decides whether to.
25
+ *
26
+ * INVARIANTS:
27
+ * - Pure — `render` is a function of `(content, context)` and nothing else. No
28
+ * clock, no environment, no I/O. Ambient state arrives via `RenderContext`.
29
+ * - `supports` is total over `NotificationElementType` and must agree with
30
+ * `render`: if it answers `false`, `render` MUST omit that element rather than
31
+ * throw or approximate it.
32
+ */
33
+
34
+ import type { NotificationContent, NotificationElementType } from "./content";
35
+ import type { RenderContext } from "./context";
36
+
37
+ /**
38
+ * A channel's renderer. `Out` is whatever that channel's bytes look like.
39
+ *
40
+ * Members are arrow properties, not method shorthand: the vocabulary-guard reads
41
+ * method signatures as behaviour smuggled into the vocabulary.
42
+ */
43
+ export interface Renderer<Out> {
44
+ /** Stable channel identity, e.g. "email". */
45
+ id: string;
46
+ /** Whether this channel can depict the given element. Must be total. */
47
+ supports: (elementType: NotificationElementType) => boolean;
48
+ /** Render the whole notification. Pure. */
49
+ render: (content: NotificationContent, context: RenderContext) => Out;
50
+ }
@@ -0,0 +1,50 @@
1
+ # notifications/renderers/
2
+
3
+ ## Purpose
4
+
5
+ One directory per channel (ADR-CONTRACTS-086). A renderer turns
6
+ channel-agnostic `NotificationContent` into one channel's bytes, and is the
7
+ only place in this package that knows what a sentence looks like.
8
+
9
+ - `email/` — `Renderer<RenderedEmail>`, the first and richest channel. It is the
10
+ old `src/email/render` layer relocated behind the seam, and — now that the
11
+ original is deleted — what replaced it: email demoted from "the shape of the
12
+ whole layer" to one channel among several.
13
+ - `sms/` — `Renderer<string>`, a non-functional placeholder. The poorest channel
14
+ the vocabulary will meet: no markup, no images, no layout.
15
+ - `slack/` — `Renderer<SlackMessage>`, a non-functional placeholder. A minimal
16
+ Block Kit tree.
17
+
18
+ A channel is added by writing a directory here — not by editing `../content.ts`,
19
+ `../definition.ts` or any kind. That is the property the seam exists for, and the
20
+ measure of whether it holds.
21
+
22
+ `sms/` and `slack/` are that measure, cashed. Email could satisfy `Renderer` by
23
+ construction — the seam was drawn around it, so it proves nothing on its own.
24
+ Adding two channels that are nothing like it, and adding them **without touching a
25
+ single element, kind or definition**, is what turns the claim above from an
26
+ intention into a fact. They are placeholders on purpose: they do not send, and
27
+ their copy is invented rather than relocated. Their SHAPE is not provisional.
28
+
29
+ Three channels now answer `supports` differently over one content that carries no
30
+ channel tags — email declines nothing, slack declines `chatUnit`, sms also
31
+ declines `heroImage` and `divider` — and `Out` is a record, then a string, then a
32
+ different record. Those are this layer's two design claims, stated by real modules
33
+ rather than by test doubles.
34
+
35
+ ## Invariants
36
+
37
+ - `render` is PURE — a function of `(content, context)`. No clock (the year comes
38
+ from `RenderContext`), no environment, no I/O. `../context.ts` owns the only
39
+ clock read in the domain.
40
+ - `supports` is total over `NotificationElementType` and MUST agree with
41
+ `render`: an element it declines is OMITTED, never approximated and never
42
+ thrown on.
43
+ - Each renderer returns its NATURAL type. There is no `ChannelOutput` supertype —
44
+ forcing a common shape would recreate the `html`+`text` fusion this layer is
45
+ unpicking, with a third surface bolted on.
46
+ - A renderer renders. It does not send, and it does not decide whether to —
47
+ delivery and policy are the backend's.
48
+ - Markup, styling and spacing live HERE and nowhere else. If a renderer needs a
49
+ fact the content does not carry, the fix is upstream in `compose`; the moment a
50
+ renderer starts special-casing a `metadata.kind`, the model has lost.