@company-semantics/contracts 35.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 (25) hide show
  1. package/package.json +1 -1
  2. package/src/notifications/README.md +21 -19
  3. package/src/notifications/__tests__/README.md +42 -47
  4. package/src/notifications/__tests__/__snapshots__/README.md +15 -8
  5. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
  6. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
  7. package/src/notifications/__tests__/context.test.ts +5 -5
  8. package/src/notifications/__tests__/definition.test.ts +2 -2
  9. package/src/notifications/__tests__/fixtures.ts +226 -0
  10. package/src/notifications/__tests__/kinds.test.ts +2 -3
  11. package/src/notifications/__tests__/registry.test.ts +27 -27
  12. package/src/notifications/__tests__/render-snapshot.test.ts +10 -6
  13. package/src/notifications/kinds/README.md +2 -2
  14. package/src/notifications/render.ts +6 -6
  15. package/src/notifications/renderers/email/README.md +10 -6
  16. package/src/notifications/renderers/email/__tests__/README.md +19 -16
  17. package/src/notifications/renderers/email/__tests__/render.test.ts +44 -36
  18. package/src/notifications/renderers/email/chat.ts +3 -2
  19. package/src/notifications/renderers/email/constants.ts +17 -0
  20. package/src/notifications/renderers/email/index.ts +4 -4
  21. package/src/notifications/renderers/email/render.ts +15 -2
  22. package/src/notifications/renderers/email/shells.ts +3 -2
  23. package/src/notifications/text.ts +2 -2
  24. package/src/notifications/__tests__/output-parity.golden.ts +0 -363
  25. package/src/notifications/__tests__/output-parity.test.ts +0 -122
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "35.0.0",
3
+ "version": "35.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,15 +9,17 @@ into `Definition → Payload → compose(payload, context) → Content → Rende
9
9
  This is the package's SOLE notification surface. `@company-semantics/contracts/email`
10
10
  and every `Email*` symbol are gone, with no shim and no alias.
11
11
 
12
- The domain holds the **output-parity baseline** a committed, byte-exact
13
- capture of what the email layer rendered at 32.0.0, plus the test that proves
14
- this layer still renders exactly that. The baseline was deliberately landed
15
- _before_ the abstraction it protects, so that every later step in the migration
16
- had a mechanical answer to "did any real email change?". Now that the old layer
17
- is deleted, the golden is also the only surviving record of those bytes.
18
-
19
- On top of it sits the **semantic model**: the channel-agnostic vocabulary the
20
- migration is heading toward.
12
+ The domain holds the **semantic model**: the channel-agnostic vocabulary an
13
+ element names, and the renderers that decide what it looks like per channel.
14
+
15
+ It used to hold an **output-parity baseline** too a byte-exact capture of what
16
+ the email layer rendered at 32.0.0, landed deliberately _before_ the abstraction
17
+ it protected, so every step of the migration had a mechanical answer to "did any
18
+ real email change?". That baseline did its job: the answer was "none", and it
19
+ shipped in 35.0.0. ADR-CONTRACTS-087 retired it, because a proof that the
20
+ migration changed nothing cannot also serve as permission to change something,
21
+ and email design is not frozen. `__tests__/render-snapshot.test.ts` is the fence
22
+ now; the retired bytes are readable in git at `v35.0.0`.
21
23
 
22
24
  - `content.ts` — `NotificationContent { metadata, sections }`, `Section`, and the
23
25
  flat `NotificationElement` union (greeting, body, keyValueTable, callToAction,
@@ -91,16 +93,16 @@ deliberately (ADR-CONTRACTS-086):
91
93
 
92
94
  ## Invariants
93
95
 
94
- - The golden in `__tests__/output-parity.golden.ts` records what the email layer
95
- rendered at **32.0.0**, and is now the only record of it — there is no longer a
96
- layer to re-capture it from. It is a regression baseline, not a specification:
97
- when it and this render layer disagree, the GOLDEN is right and the renderer
98
- must be fixed, never the golden regenerated to match.
99
- - Every kind in `NOTIFICATION_DEFINITIONS` needs at least one parity fixture,
100
- enforced by the test. The registry is the source of that list — it is total, so
101
- "every kind the entry point accepts" and "every kind with a fixture" are the
102
- same claim.
103
- - Rendering for parity MUST pin the year to `OUTPUT_PARITY_CLOCK`'s — by passing
96
+ - `__tests__/render-snapshot.test.ts` locks what this layer renders. A diff there
97
+ means a real sent email changed: review it, then regenerate. It is a regression
98
+ fence, not a specification but since ADR-CONTRACTS-087 retired the parity
99
+ golden that used to outrank it, reading the diff is the ONLY control left. A
100
+ snapshot refreshed on sight proves nothing.
101
+ - Every kind in `NOTIFICATION_DEFINITIONS` needs at least one fixture in
102
+ `__tests__/fixtures.ts`, enforced by the tests. The registry is the source of
103
+ that list — it is total, so "every kind the entry point accepts" and "every
104
+ kind with a fixture" are the same claim.
105
+ - Rendering MUST pin the year to `FIXTURE_CLOCK`'s — by passing
104
106
  `copyrightYear` in the `RenderContext`, NOT by freezing the clock. `signature`
105
107
  used to read `new Date().getFullYear()` mid-render, which is why the fixtures
106
108
  once needed fake timers; the year is a value now, and no test in this domain
@@ -2,53 +2,48 @@
2
2
 
3
3
  ## Purpose
4
4
 
5
- The output-parity proof for the multi-channel notification render layer
6
- (ADR-CONTRACTS-086), and the snapshot suite that documents the markup.
7
-
8
- - `output-parity.golden.ts` — the fixtures (kind + payload) and
9
- `OUTPUT_PARITY_GOLDEN`, the exact `{ subject, text, html }` each one rendered
10
- to through the email layer **as it stood before any of this existed**.
11
- - `output-parity.test.ts` renders every fixture through `renderToChannel`, the
12
- new entry point, and asserts byte equality against that golden.
13
- - `render-snapshot.test.ts` + `__snapshots__/` — the same fixtures as vitest
14
- snapshots, moved here from the old `src/email/render/__tests__/` with the
15
- markup they document.
16
-
17
- The parity test used to pin the old `renderEmail` against itself, which was the
18
- point: the bytes were captured _before_ the layer they describe was touched. That
19
- baseline has now done its job the same fixtures render through
20
- `compose Content Renderer` and produce the same bytes, so the generalisation
21
- is proven to have changed no email. `src/email/` was deleted on that proof.
22
-
23
- Prefer the golden when the two suites disagree. `vitest -u` can regenerate a
24
- snapshot in a keystroke, and a snapshot regenerated under refactor pressure is
25
- worth nothing; the golden is a committed module, so making it pass is a
26
- deliberate, reviewable act.
5
+ The shared fixture corpus for the multi-channel notification render layer
6
+ (ADR-CONTRACTS-086), and the snapshot suite that documents the markup it renders
7
+ to.
8
+
9
+ - `fixtures.ts` every implemented kind × representative payload, plus
10
+ `FIXTURE_CLOCK` and `fixtureKey`. Shared by every suite here and by
11
+ `../renderers/email/__tests__/render.test.ts`, so a kind added to the registry
12
+ is covered everywhere by being added once.
13
+ - `render-snapshot.test.ts` + `__snapshots__/` — those fixtures rendered through
14
+ the email channel, as vitest snapshots. This is the fence: **a diff here means
15
+ a real sent email changed.**
16
+
17
+ ## The retired output-parity golden (ADR-CONTRACTS-087)
18
+
19
+ `fixtures.ts` was `output-parity.golden.ts`, and it used to carry
20
+ `OUTPUT_PARITY_GOLDEN` the exact `{ subject, text, html }` the email layer
21
+ emitted at 32.0.0, before any of this existed asserted byte-for-byte by
22
+ `output-parity.test.ts` at the entry point and by the email renderer's own suite
23
+ at the renderer. Those two tests were the "no output change" contract for the
24
+ generalisation: `src/email/` was deleted on that proof.
25
+
26
+ **The proof passed and shipped in 35.0.0, which discharged it.** It was a claim
27
+ about a migration, not a freeze on email design — and the moment email design is
28
+ deliberately exercised, byte-equality to a deleted layer fails by construction
29
+ and keeps failing.
30
+
31
+ It was **retired rather than regenerated**, and the distinction is the point:
32
+ rewriting those bytes to match a redesign would have made the file assert
33
+ something false about a layer that no longer exists to contradict it. The bytes
34
+ are readable in git at `v35.0.0`; nothing in the tree claims to be them.
27
35
 
28
36
  ## Invariants
29
37
 
30
- - The year MUST be pinned to `OUTPUT_PARITY_CLOCK`'s via the `RenderContext`, not
31
- by freezing the clock. It is baked into every case by `signature`, so an
32
- unpinned render fails on the next New Year's Day for calendar reasons alone.
33
- Fake timers are no longer needed anywhere here, and that is the improvement
34
- this layer was built to deliver do not reintroduce them.
35
- - The captured year is **not** redacted or normalised. It is part of the output
36
- surface this proof exists to protect.
37
- - A failure here means a **real sent email changed**. Review the diff before
38
- regenerating; the golden is not a snapshot to refresh on sight. `src/email/` is
39
- deleted, so these bytes cannot be re-captured — the golden is the only record
40
- of what that layer emitted, and regenerating it destroys the proof rather than
41
- updating it.
42
- - The golden and the fixtures MUST stay in exact correspondence — no missing and
43
- no orphaned entries. The test enforces both directions.
44
- - Every kind in `NOTIFICATION_DEFINITIONS` MUST have a fixture, enforced by both
45
- suites.
46
- - The golden's BYTES are frozen; its TYPES were re-anchored onto this domain when
47
- the `Email*` originals were deleted. `OutputParityFixture.options` went with
48
- them — it was declared as `RenderEmailOptions` and carried the old layer's
49
- `includeRequestMetadata`, which `compose` replaced with "does the payload carry
50
- request details?". It had been left in place as frozen evidence, but its type
51
- no longer exists and no test ever read it; the one fixture that set it supplies
52
- both request fields anyway, so the gate opens regardless. The byte equality is
53
- what proves that — which is exactly why dropping the field was safe and
54
- re-recording a byte would not be.
38
+ - A snapshot diff means **a real sent email changed**. Review it, then
39
+ `vitest -u` never the other way round. Regenerating on sight is the whole
40
+ hazard; the review is the control, and it is now the only one.
41
+ - The year MUST be pinned via `FIXTURE_CLOCK` on the `RenderContext`, never by
42
+ freezing the clock. `signature` bakes it into every case, so an unpinned render
43
+ fails on the next New Year's Day for calendar reasons alone. Fake timers are
44
+ not needed anywhere here that is the impurity ADR-CONTRACTS-086 retired, so
45
+ do not reintroduce them.
46
+ - The pinned year is **not** redacted or normalised. It is part of the output
47
+ surface these suites exist to protect.
48
+ - Every kind in `NOTIFICATION_DEFINITIONS` MUST have a fixture, enforced by
49
+ `./definition.test.ts` and `./registry.test.ts`.
@@ -13,6 +13,11 @@ stayed `{kind} · {name}`, so every snapshot key still resolves against the same
13
13
  file. That is the claim the move is making — the keys matched, so the bytes
14
14
  matched.
15
15
 
16
+ The `security.alert` cases have since moved: the `warning` banner is drawn in the
17
+ product's destructive colour (ADR-CONTRACTS-087). That is the first deliberate
18
+ change to this markup since the move, and the reason the parity golden that used
19
+ to guard it is gone.
20
+
16
21
  This directory exists because vitest resolves snapshots adjacent to the test
17
22
  file, so the `.snap` had to travel with it.
18
23
 
@@ -20,13 +25,15 @@ file, so the `.snap` had to travel with it.
20
25
 
21
26
  - This is **production-parity email markup**. A diff here means a real sent email
22
27
  changed. Review it, then `vitest -u` — never the other way round.
23
- - These snapshots are **not** the migration's proof. That is
24
- `../output-parity.golden.ts`, a committed module `vitest -u` cannot touch. When
25
- the two disagree, the golden is right. Regenerating this file to make a
26
- refactor pass is the exact failure ADR-CONTRACTS-086 exists to prevent, and it
27
- is one keystroke away which is why the golden is not a snapshot.
28
- - The copyright year is pinned to `OUTPUT_PARITY_CLOCK`'s, never the wall clock.
29
- It is baked into every case via `signature`, so an unpinned capture would fail
30
- on the next New Year's Day for calendar reasons alone.
28
+ - These snapshots are now the **only** automated record of that markup. Until
29
+ ADR-CONTRACTS-087 the migration's proof sat beside them in
30
+ `../output-parity.golden.ts`, a committed module `vitest -u` could not touch,
31
+ and it was the tie-breaker when the two disagreed. That proof was discharged in
32
+ 35.0.0 and retired, because it asserted byte-equality with a deleted layer and
33
+ email design is allowed to change. Nothing now catches a snapshot regenerated
34
+ under refactor pressure except reading the diff so read it.
35
+ - The copyright year is pinned to `FIXTURE_CLOCK`'s, never the wall clock. It is
36
+ baked into every case via `signature`, so an unpinned capture would fail on the
37
+ next New Year's Day for calendar reasons alone.
31
38
  - The year is **not** redacted or normalised. It is part of the output surface
32
39
  these snapshots exist to protect.
@@ -0,0 +1,31 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`NOTIFICATION_DEFINITIONS > titles every kind 1`] = `
4
+ {
5
+ "auth.otp · Default": "Your login code for Company Semantics",
6
+ "auth.otp · Short expiry": "Your login code for Company Semantics",
7
+ "auth.otp · With request metadata": "Your login code for Company Semantics",
8
+ "chat.shared · Default": "A chat has been shared with you",
9
+ "chat.shared · Long preview": "A chat has been shared with you",
10
+ "chat.shared · Long title": "A chat has been shared with you",
11
+ "chat.shared · No preview": "A chat has been shared with you",
12
+ "chat.shared · Short": "A chat has been shared with you",
13
+ "companyMd.access_request_approved · Editor": "Your access request was approved",
14
+ "companyMd.access_request_approved · Viewer": "Your access request was approved",
15
+ "companyMd.access_request_denied · No reason": "Your access request was reviewed",
16
+ "companyMd.access_request_denied · With reason": "Your access request was reviewed",
17
+ "companyMd.access_requested · No message": "Someone requested access to a document",
18
+ "companyMd.access_requested · With message": "Someone requested access to a document",
19
+ "org.invite · Admin": "You've been invited to join Acme Corp on Company Semantics",
20
+ "org.invite · Member": "You've been invited to join Acme Corp on Company Semantics",
21
+ "org.ownership_transfer · No note (admin)": "You've been invited to become a workspace owner",
22
+ "org.ownership_transfer · With note + from": "You've been invited to become a workspace owner",
23
+ "org.ownership_transfer_completed · Default": "Workspace ownership has been transferred",
24
+ "org.unit_owner_granted · Delegate with expiry": "You've been added as delegate owner to Platform Engineering in Acme Corp",
25
+ "org.unit_owner_granted · Unit owner + message": "You've been added as owner to Platform Engineering in Acme Corp",
26
+ "security.alert · Excessive OTP requests": "Security alert for your account",
27
+ "security.alert · Unusual login location": "Security alert for your account",
28
+ "share.granted · Document · editor · message": "Something has been shared with you",
29
+ "share.granted · Meeting · viewer · no title": "Something has been shared with you",
30
+ }
31
+ `;
@@ -1547,7 +1547,7 @@ exports[`email render > security.alert · Excessive OTP requests 1`] = `
1547
1547
  <head><meta charset="UTF-8"></head>
1548
1548
  <body style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; color: #1a1a1a; margin: 0; padding: 0;">
1549
1549
  <div style="max-width: 520px; margin: 0 auto;">
1550
- <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">🆆🅰🆁🅽🅸🅽🅶</p>
1550
+ <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;"><span style="color: #e7000b;">🆆🅰🆁🅽🅸🅽🅶</span></p>
1551
1551
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Unusual login-code activity detected.</p>
1552
1552
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Details: 12 login codes requested in 5 minutes</p>
1553
1553
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 4px 0;">If this was you, no action is needed.</p>
@@ -1587,7 +1587,7 @@ exports[`email render > security.alert · Unusual login location 1`] = `
1587
1587
  <head><meta charset="UTF-8"></head>
1588
1588
  <body style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; color: #1a1a1a; margin: 0; padding: 0;">
1589
1589
  <div style="max-width: 520px; margin: 0 auto;">
1590
- <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">🆆🅰🆁🅽🅸🅽🅶</p>
1590
+ <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;"><span style="color: #e7000b;">🆆🅰🆁🅽🅸🅽🅶</span></p>
1591
1591
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">A login from an unusual location was detected.</p>
1592
1592
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Details: Login from Berlin, DE</p>
1593
1593
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 4px 0;">If this was you, no action is needed.</p>
@@ -12,13 +12,13 @@
12
12
  import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
13
13
 
14
14
  import { COMPANY_NAME, createRenderContext } from "../context";
15
- import { OUTPUT_PARITY_CLOCK } from "./output-parity.golden";
15
+ import { FIXTURE_CLOCK } from "./fixtures";
16
16
 
17
- const FROZEN_YEAR = new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear();
17
+ const FROZEN_YEAR = new Date(FIXTURE_CLOCK).getUTCFullYear();
18
18
 
19
19
  beforeAll(() => {
20
20
  vi.useFakeTimers();
21
- vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
21
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
22
22
  });
23
23
 
24
24
  afterAll(() => {
@@ -38,7 +38,7 @@ describe("createRenderContext", () => {
38
38
  it("defaults the year from the clock", () => {
39
39
  vi.setSystemTime(new Date("2030-03-04T05:06:07.000Z"));
40
40
  expect(createRenderContext().brand.copyrightYear).toBe(2030);
41
- vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
41
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
42
42
  });
43
43
 
44
44
  it("overrides each brand field independently", () => {
@@ -55,7 +55,7 @@ describe("createRenderContext", () => {
55
55
  const before = createRenderContext(pinned);
56
56
  vi.setSystemTime(new Date("2031-01-01T00:00:00.000Z"));
57
57
  expect(createRenderContext(pinned)).toEqual(before);
58
- vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
58
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
59
59
  });
60
60
 
61
61
  it("returns a fresh context per call — callers cannot alias each other", () => {
@@ -31,7 +31,7 @@ import type { NotificationContent, NotificationElement } from "../content";
31
31
  import { createRenderContext } from "../context";
32
32
  import type { NotificationKind } from "../kinds";
33
33
  import { NOTIFICATION_DEFINITIONS } from "../registry";
34
- import { OUTPUT_PARITY_FIXTURES } from "./output-parity.golden";
34
+ import { NOTIFICATION_FIXTURES } from "./fixtures";
35
35
 
36
36
  const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
37
37
 
@@ -40,7 +40,7 @@ function compose(
40
40
  kind: NotificationKind,
41
41
  context = CONTEXT,
42
42
  ): NotificationContent {
43
- const fixture = OUTPUT_PARITY_FIXTURES.find((f) => f.kind === kind);
43
+ const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
44
44
  if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
45
45
  const fn = NOTIFICATION_DEFINITIONS[kind].compose as (
46
46
  p: unknown,
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The notification fixture corpus: every implemented kind × representative
3
+ * payload. Shared by `./render-snapshot.test.ts`, `./definition.test.ts`,
4
+ * `./registry.test.ts` and `../renderers/email/__tests__/render.test.ts`, so a
5
+ * kind added to the registry is covered everywhere by being added once here.
6
+ *
7
+ * WAS `output-parity.golden.ts` (ADR-CONTRACTS-087). These same inputs used to
8
+ * carry a frozen `OUTPUT_PARITY_GOLDEN` of the `{ subject, text, html }` the
9
+ * email layer emitted at 32.0.0, asserted byte-for-byte to prove the
10
+ * multi-channel generalisation (ADR-CONTRACTS-086) changed no output. That proof
11
+ * passed and shipped in 35.0.0, which discharged it: it was a claim about a
12
+ * migration, not a freeze on email design. The bytes were retired rather than
13
+ * rewritten — editing them to match a deliberate redesign would have made the
14
+ * file assert something false about a layer that no longer exists. They remain
15
+ * readable in git at `v35.0.0`.
16
+ *
17
+ * `./render-snapshot.test.ts` is now the fence: it locks the same fixtures'
18
+ * rendered output, and unlike the golden it is regenerable on purpose, because
19
+ * changing how email looks is now an ordinary reviewed diff.
20
+ *
21
+ * Capture-time options are not recorded. The old `renderEmail` took a
22
+ * `RenderEmailOptions`, and one fixture set `includeRequestMetadata: true`; the
23
+ * entry point has no equivalent because `auth.otp`'s `compose` gates request
24
+ * details on whether the payload carries them — and that fixture's payload
25
+ * carries both, so the gate opens anyway.
26
+ */
27
+
28
+ import type { NotificationKind } from "../kinds";
29
+ import type { NotificationPayloads } from "../payloads";
30
+
31
+ /**
32
+ * The instant every fixture renders under.
33
+ *
34
+ * A pinned year, not a frozen clock. The copyright year is a value on
35
+ * `RenderContext`, so a test states this instant instead of stubbing time —
36
+ * which is the impurity ADR-CONTRACTS-086 retired. Pinned rather than left to
37
+ * the wall clock so no snapshot is due to fail on New Year's Day.
38
+ */
39
+ export const FIXTURE_CLOCK = "2026-07-15T00:00:00.000Z";
40
+
41
+ /** One case: a kind rendered with a representative payload. */
42
+ export interface NotificationFixture {
43
+ readonly kind: NotificationKind;
44
+ /** Variant label, unique within a kind. */
45
+ readonly name: string;
46
+ readonly payload: NotificationPayloads[NotificationKind];
47
+ }
48
+
49
+ /** Stable label for a fixture — also its snapshot key. */
50
+ export function fixtureKey(kind: NotificationKind, name: string): string {
51
+ return `${kind} · ${name}`;
52
+ }
53
+
54
+ const fixtures: NotificationFixture[] = [];
55
+ function add<K extends NotificationKind>(
56
+ kind: K,
57
+ name: string,
58
+ payload: NotificationPayloads[K],
59
+ ): void {
60
+ fixtures.push({ kind, name, payload });
61
+ }
62
+
63
+ const APP = "https://app.companysemantics.ai";
64
+
65
+ add("auth.otp", "Default", { otp: "123456", expiresInMinutes: 10 });
66
+ add("auth.otp", "Short expiry", { otp: "902413", expiresInMinutes: 1 });
67
+ add("auth.otp", "With request metadata", {
68
+ otp: "246810",
69
+ expiresInMinutes: 5,
70
+ requestIp: "203.0.113.4",
71
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
72
+ });
73
+
74
+ add("org.invite", "Admin", {
75
+ inviterName: "Alex Rivera",
76
+ orgName: "Acme Corp",
77
+ role: "admin",
78
+ acceptUrl: `${APP}/invite/abc123`,
79
+ expiresAt: "2026-06-13T00:00:00.000Z",
80
+ });
81
+ add("org.invite", "Member", {
82
+ inviterName: "Alex Rivera",
83
+ orgName: "Acme Corp",
84
+ role: "member",
85
+ acceptUrl: `${APP}/invite/def456`,
86
+ expiresAt: "2026-06-13T00:00:00.000Z",
87
+ });
88
+
89
+ add("org.unit_owner_granted", "Unit owner + message", {
90
+ granterName: "Jordan Lee",
91
+ recipientName: "Sam Chen",
92
+ orgName: "Acme Corp",
93
+ unitName: "Platform Engineering",
94
+ roleLabel: "Unit owner",
95
+ ctaUrl: `${APP}/org/unit/42`,
96
+ message: "Welcome aboard — glad to have you owning this team.",
97
+ });
98
+ add("org.unit_owner_granted", "Delegate with expiry", {
99
+ granterName: "Jordan Lee",
100
+ orgName: "Acme Corp",
101
+ unitName: "Platform Engineering",
102
+ roleLabel: "Delegate",
103
+ ctaUrl: `${APP}/org/unit/42`,
104
+ expiresAt: "2026-06-13T00:00:00.000Z",
105
+ });
106
+
107
+ add("org.ownership_transfer", "With note + from", {
108
+ orgName: "Acme Corp",
109
+ acceptUrl: `${APP}/owner-transfer/accept/tok123`,
110
+ expiresAt: "2026-06-13T00:00:00.000Z",
111
+ note: "Handing this over as I move to an advisory role.",
112
+ fromName: "Jordan Lee",
113
+ });
114
+ add("org.ownership_transfer", "No note (admin)", {
115
+ orgName: "Acme Corp",
116
+ acceptUrl: `${APP}/owner-transfer/accept/tok456`,
117
+ expiresAt: "2026-06-13T00:00:00.000Z",
118
+ });
119
+
120
+ add("org.ownership_transfer_completed", "Default", {
121
+ orgName: "Acme Corp",
122
+ newOwnerEmail: "new.owner@acme.com",
123
+ });
124
+
125
+ add("security.alert", "Excessive OTP requests", {
126
+ alertType: "excessive_otp_requests",
127
+ details: "12 login codes requested in 5 minutes",
128
+ timestamp: "2026-07-12T14:30:00Z",
129
+ });
130
+ add("security.alert", "Unusual login location", {
131
+ alertType: "unusual_login_location",
132
+ details: "Login from Berlin, DE",
133
+ timestamp: "2026-07-12T14:30:00Z",
134
+ });
135
+
136
+ const CHAT_BASE = {
137
+ sharedByName: "Alex Rivera",
138
+ visibility: "private",
139
+ } as const;
140
+ add("chat.shared", "Default", {
141
+ ...CHAT_BASE,
142
+ chatTitle: "API design discussion",
143
+ shareUrl: `${APP}/share/abc123`,
144
+ previewText:
145
+ "Here are my thoughts on the REST API design for the new authentication endpoints.",
146
+ });
147
+ add("chat.shared", "No preview", {
148
+ ...CHAT_BASE,
149
+ chatTitle: "Quick sync notes",
150
+ shareUrl: `${APP}/share/xyz789`,
151
+ });
152
+ add("chat.shared", "Long title", {
153
+ ...CHAT_BASE,
154
+ visibility: "public",
155
+ chatTitle:
156
+ "This is a very long chat title that should be truncated to fit the layout properly",
157
+ shareUrl: `${APP}/share/long456`,
158
+ previewText: "The assistant response preview text.",
159
+ });
160
+ add("chat.shared", "Long preview", {
161
+ ...CHAT_BASE,
162
+ chatTitle: "Code review feedback",
163
+ shareUrl: `${APP}/share/preview789`,
164
+ previewText:
165
+ "I reviewed the pull request and found several areas that need improvement. The authentication logic needs better error handling, and the database queries could be optimized.",
166
+ });
167
+ add("chat.shared", "Short", {
168
+ ...CHAT_BASE,
169
+ chatTitle: "Hi",
170
+ shareUrl: `${APP}/share/short123`,
171
+ previewText: "Hello!",
172
+ });
173
+
174
+ add("share.granted", "Document · editor · message", {
175
+ granterName: "Jordan Lee",
176
+ recipientName: "Sam Chen",
177
+ entityLabel: "document",
178
+ entityTitle: "Q3 Roadmap",
179
+ accessLevel: "editor",
180
+ ctaUrl: `${APP}/doc/roadmap`,
181
+ message: "Take a look before Thursday's planning.",
182
+ });
183
+ add("share.granted", "Meeting · viewer · no title", {
184
+ granterName: "Jordan Lee",
185
+ entityLabel: "meeting",
186
+ accessLevel: "viewer",
187
+ ctaUrl: `${APP}/meeting/8842`,
188
+ });
189
+
190
+ add("companyMd.access_requested", "With message", {
191
+ requesterName: "Sam Chen",
192
+ docTitle: "Engineering Handbook",
193
+ message: "I'd like to contribute the onboarding section.",
194
+ reviewUrl: `${APP}/doc/handbook?request=req_123`,
195
+ });
196
+ add("companyMd.access_requested", "No message", {
197
+ requesterName: "Sam Chen",
198
+ docTitle: "Engineering Handbook",
199
+ reviewUrl: `${APP}/doc/handbook?request=req_456`,
200
+ });
201
+
202
+ add("companyMd.access_request_approved", "Editor", {
203
+ approverName: "Jordan Lee",
204
+ docTitle: "Engineering Handbook",
205
+ accessLevel: "editor",
206
+ docUrl: `${APP}/doc/handbook`,
207
+ });
208
+ add("companyMd.access_request_approved", "Viewer", {
209
+ approverName: "Jordan Lee",
210
+ docTitle: "Engineering Handbook",
211
+ accessLevel: "viewer",
212
+ docUrl: `${APP}/doc/handbook`,
213
+ });
214
+
215
+ add("companyMd.access_request_denied", "With reason", {
216
+ approverName: "Jordan Lee",
217
+ docTitle: "Engineering Handbook",
218
+ reason: "This doc is limited to the platform team for now.",
219
+ });
220
+ add("companyMd.access_request_denied", "No reason", {
221
+ approverName: "Jordan Lee",
222
+ docTitle: "Engineering Handbook",
223
+ });
224
+
225
+ /** The corpus — every implemented kind × representative variants. */
226
+ export const NOTIFICATION_FIXTURES: readonly NotificationFixture[] = fixtures;
@@ -17,9 +17,8 @@
17
17
  * union member is added without being named here;
18
18
  * - the frozen list in the first test, which fails at runtime if one is removed;
19
19
  * - `./registry.test.ts`, which pins a definition for every member;
20
- * - `./output-parity.test.ts`, which pins golden bytes captured from the old
21
- * layer — for every member. That is what now carries "these are exactly the
22
- * kinds email could render": every kind here has bytes that layer produced.
20
+ * - `./render-snapshot.test.ts`, which pins rendered output for every member.
21
+ * That is what carries "these are exactly the kinds email can render".
23
22
  *
24
23
  * The DIVERGENCE is the easiest thing in this change to mistake for an omission,
25
24
  * so it keeps a test of its own. `auth.magic_link` was a registered subject with
@@ -28,11 +28,7 @@ import {
28
28
  isValidNotificationKind,
29
29
  NOTIFICATION_DEFINITIONS,
30
30
  } from "../registry";
31
- import {
32
- fixtureKey,
33
- OUTPUT_PARITY_FIXTURES,
34
- OUTPUT_PARITY_GOLDEN,
35
- } from "./output-parity.golden";
31
+ import { fixtureKey, NOTIFICATION_FIXTURES } from "./fixtures";
36
32
 
37
33
  const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
38
34
 
@@ -45,7 +41,7 @@ const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
45
41
  * so a second set of inputs would be a second thing to keep true.
46
42
  */
47
43
  function payloadFor(kind: NotificationKind): unknown {
48
- const fixture = OUTPUT_PARITY_FIXTURES.find((f) => f.kind === kind);
44
+ const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
49
45
  if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
50
46
  return fixture.payload;
51
47
  }
@@ -112,28 +108,32 @@ describe("NOTIFICATION_DEFINITIONS", () => {
112
108
  }
113
109
  });
114
110
 
115
- it("titles every kind exactly as email subjected it at 32.0.0", () => {
116
- // The subject MOVED from EMAIL_KINDS into compose it was not rewritten.
117
- // Asserted against the frozen golden's subjects, across every parity
118
- // fixture, so the interpolation the old `resolveSubject`/`subjectData` did
119
- // (the `{orgName}` fill, and unit_owner_granted's derived `roleWord`) is
120
- // proven to survive as a template literal rather than assumed to.
111
+ it("titles every kind", () => {
112
+ // The title is what the email channel turns into a subject, and the
113
+ // interpolation `compose` does (the `{orgName}` fill, and
114
+ // unit_owner_granted's derived `roleWord`) is easy to break silently — a
115
+ // template literal that stops filling yields a plausible-looking title with
116
+ // a hole in it. Snapshotting every fixture's title makes that a reviewable
117
+ // diff, and isolates the claim at COMPOSE: `./render-snapshot.test.ts`
118
+ // covers the same titles once the renderer has had them.
121
119
  //
122
- // The oracle used to be the old `renderEmail`'s own output. The golden is
123
- // what replaced it when that layer was deleted, and it is the stronger of
124
- // the two: it is the bytes 32.0.0 actually shipped, not live code that could
125
- // have drifted alongside this registry. This isolates the claim at COMPOSE —
126
- // `./output-parity.test.ts` proves the same subjects survive the renderer.
127
- for (const fixture of OUTPUT_PARITY_FIXTURES) {
128
- const compose = NOTIFICATION_DEFINITIONS[fixture.kind].compose as (
129
- p: unknown,
130
- c: typeof CONTEXT,
131
- ) => { metadata: { title: string } };
132
-
133
- expect(compose(fixture.payload, CONTEXT).metadata.title).toBe(
134
- OUTPUT_PARITY_GOLDEN[fixtureKey(fixture.kind, fixture.name)].subject,
135
- );
136
- }
120
+ // These titles were asserted against the frozen 32.0.0 golden's subjects
121
+ // until ADR-CONTRACTS-087 retired it the values below are those subjects,
122
+ // recaptured here as an ordinary snapshot. The migration proof they served
123
+ // was discharged in 35.0.0.
124
+ const titles = Object.fromEntries(
125
+ NOTIFICATION_FIXTURES.map((fixture) => {
126
+ const compose = NOTIFICATION_DEFINITIONS[fixture.kind].compose as (
127
+ p: unknown,
128
+ c: typeof CONTEXT,
129
+ ) => { metadata: { title: string } };
130
+ return [
131
+ fixtureKey(fixture.kind, fixture.name),
132
+ compose(fixture.payload, CONTEXT).metadata.title,
133
+ ];
134
+ }),
135
+ );
136
+ expect(titles).toMatchSnapshot();
137
137
  });
138
138
  });
139
139