@company-semantics/contracts 92.0.0 → 93.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 (68) hide show
  1. package/package.json +13 -12
  2. package/src/__tests__/resource-keys.test.ts +1 -0
  3. package/src/action-items/README.md +12 -4
  4. package/src/action-items/schemas.ts +10 -0
  5. package/src/affordances/README.md +52 -1
  6. package/src/affordances/__tests__/README.md +10 -1
  7. package/src/affordances/__tests__/affordances.test.ts +13 -1
  8. package/src/affordances/__tests__/catalog-wave.test.ts +237 -0
  9. package/src/affordances/affordance.ts +56 -4
  10. package/src/affordances/args.ts +44 -1
  11. package/src/affordances/available.ts +59 -0
  12. package/src/affordances/coverage.ts +51 -0
  13. package/src/affordances/ids.ts +28 -0
  14. package/src/affordances/index.ts +17 -0
  15. package/src/api/README.md +1 -0
  16. package/src/api/generated-spec-hash.ts +2 -2
  17. package/src/api/generated.ts +506 -1
  18. package/src/api/primitives.ts +13 -0
  19. package/src/billing/README.md +1 -0
  20. package/src/billing/refusals.ts +12 -0
  21. package/src/chat/__tests__/page-context.test.ts +22 -43
  22. package/src/chat/__tests__/runtime-profile.test.ts +20 -44
  23. package/src/generated/openapi-routes.ts +1 -0
  24. package/src/message-parts/README.md +5 -0
  25. package/src/message-parts/affordance-offer.ts +44 -0
  26. package/src/message-parts/index.ts +7 -0
  27. package/src/message-parts/types.ts +7 -1
  28. package/src/message-parts/wire.ts +18 -0
  29. package/src/notifications/README.md +17 -3
  30. package/src/notifications/__tests__/README.md +4 -0
  31. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +27 -25
  32. package/src/notifications/__tests__/fixtures.ts +7 -1
  33. package/src/notifications/__tests__/grounded-ctas.test.ts +103 -0
  34. package/src/notifications/__tests__/render-snapshot.test.ts +1 -1
  35. package/src/notifications/content.ts +9 -0
  36. package/src/notifications/kinds/billing-auto-recharge-failed.ts +4 -2
  37. package/src/notifications/kinds/billing-payment-past-due.ts +2 -0
  38. package/src/notifications/kinds/billing-suspended.ts +2 -0
  39. package/src/notifications/kinds/chat-shared.ts +10 -3
  40. package/src/notifications/kinds/company-md-access-approved.ts +13 -6
  41. package/src/notifications/kinds/share-granted.ts +1 -1
  42. package/src/notifications/kinds/unit-owner-granted.ts +9 -2
  43. package/src/notifications/payloads.ts +6 -0
  44. package/src/proactive/README.md +18 -2
  45. package/src/proactive/__tests__/README.md +11 -1
  46. package/src/proactive/__tests__/chat-templates.test.ts +7 -6
  47. package/src/proactive/__tests__/proactive-offers.test.ts +124 -0
  48. package/src/proactive/__tests__/vocabulary.test.ts +1 -1
  49. package/src/proactive/composer.ts +11 -0
  50. package/src/proactive/templates/README.md +11 -3
  51. package/src/proactive/templates/billing-auto-recharge-failed.ts +1 -0
  52. package/src/proactive/templates/billing-balance-exhausted.ts +2 -6
  53. package/src/proactive/templates/billing-payment-past-due.ts +1 -0
  54. package/src/proactive/templates/billing-subscription-canceled.ts +1 -5
  55. package/src/proactive/templates/billing-suspended.ts +1 -0
  56. package/src/proactive/templates/morning-brief.ts +1 -0
  57. package/src/proactive/templates/org-became-shared.ts +4 -3
  58. package/src/proactive/templates/source-sync-review-ready.ts +3 -8
  59. package/src/proactive/templates/workflow-needs-attention.ts +1 -0
  60. package/src/rate-limit/README.md +27 -1
  61. package/src/rate-limit/__tests__/README.md +23 -0
  62. package/src/rate-limit/__tests__/rate-limited-error.test.ts +53 -0
  63. package/src/rate-limit/index.ts +33 -0
  64. package/src/resource-key-tables.ts +1 -0
  65. package/src/resource-key-types.ts +3 -0
  66. package/src/resource-keys.ts +1 -0
  67. package/src/root-exports/agent-runtime.ts +14 -2
  68. package/src/root-exports/delivery.ts +15 -0
@@ -23,13 +23,21 @@
23
23
  * no-sibling-imports invariant intact (the same pattern as
24
24
  * runtime-profile.test.ts).
25
25
  *
26
- * It is skipped when the app is not checked out beside this repo — contracts
27
- * consumed standalone from npm, or contracts CI, which by design has no app
28
- * checkout because contracts is the root of the dependency tree. The inverted
29
- * marker test makes the reporter say so rather than showing a silent pass.
26
+ * Where the app is read from: `resolveSibling` (tests/workspace-sibling.ts), the
27
+ * workspace's sibling rule (ci ADR-CI-276): this session's app worktree when
28
+ * the session holds one, else app's committed origin/main; never a primary's
29
+ * working tree. It used to probe `<workspace>/company-semantics-app` (the
30
+ * PRIMARY) and `../company-semantics-app` from cwd for
31
+ * `src/platform/route-parser.ts`, a path app has since moved to
32
+ * `src/lib/nav/route-parser.ts`, so it skipped everywhere (ci ADR-CI-294
33
+ * follow-up). A moved file now fails the read.
34
+ *
35
+ * Inside the workspace a missing or unreadable app FAILS this file. The parity
36
+ * tests skip, and the inverted marker test says why, only in a standalone
37
+ * clone (no other company-semantics-* checkout beside it): contracts consumed
38
+ * from npm, or contracts CI, which by design has no app checkout because
39
+ * contracts is the root of the dependency tree.
30
40
  */
31
- import { existsSync, readFileSync } from "node:fs";
32
- import { resolve, sep } from "node:path";
33
41
  import { describe, it, expect } from "vitest";
34
42
  import {
35
43
  ChatPageContextSchema,
@@ -37,6 +45,7 @@ import {
37
45
  CHAT_PAGE_SCOPES,
38
46
  CHAT_PAGE_ENTITY_TYPES,
39
47
  } from "../schemas.js";
48
+ import { resolveSibling } from "../../../tests/workspace-sibling.js";
40
49
 
41
50
  // ---------------------------------------------------------------------------
42
51
  // Fixtures
@@ -56,52 +65,22 @@ const A_DOCUMENT_BODY = `# Q3 plan\n${"Revenue grew. ".repeat(200)}`;
56
65
  // Locating the app checkout that governs this run
57
66
  // ---------------------------------------------------------------------------
58
67
 
59
- /**
60
- * Probed in order:
61
- * 1. The app worktree mirroring this one. Ralph checks the same branch of
62
- * every repo out at the same relative path, so a contracts worktree has an
63
- * app worktree beside it carrying the branch's route grammar. The app MAIN
64
- * checkout sits on another branch entirely, and comparing against it would
65
- * report drift for a value this branch already corrected.
66
- * 2. The app main checkout in the same workspace.
67
- * 3. The plain sibling guess, for a non-worktree local dev layout.
68
- */
69
- function resolveAppRoot(): string | undefined {
70
- const candidates: string[] = [];
71
-
72
- const segments = process.cwd().split(sep);
73
- const contractsIdx = segments.lastIndexOf("company-semantics-contracts");
74
- if (contractsIdx !== -1) {
75
- const workspaceRoot = segments.slice(0, contractsIdx).join(sep);
76
- const worktreeSuffix = segments.slice(contractsIdx + 1);
77
- const appRoot = resolve(workspaceRoot, "company-semantics-app");
78
- if (worktreeSuffix.length > 0) {
79
- candidates.push(resolve(appRoot, ...worktreeSuffix));
80
- }
81
- candidates.push(appRoot);
82
- }
83
- candidates.push(resolve(process.cwd(), "..", "company-semantics-app"));
84
-
85
- return candidates.find((root) =>
86
- existsSync(resolve(root, "src", "platform", "route-parser.ts")),
87
- );
88
- }
68
+ const APP_ROUTE_PARSER = "src/lib/nav/route-parser.ts";
89
69
 
90
- const APP_ROOT = resolveAppRoot();
91
- const appAvailable = APP_ROOT !== undefined;
70
+ // Throws (failing this file loudly) inside the workspace when the app cannot
71
+ // be resolved; null only in a standalone clone.
72
+ const app = await resolveSibling("company-semantics-app");
73
+ const appAvailable = app !== null;
92
74
 
93
75
  /** The body of the app's `export type Route = ...;` union, as a string. */
94
76
  function appRouteUnionSource(): string {
95
- const source = readFileSync(
96
- resolve(APP_ROOT!, "src", "platform", "route-parser.ts"),
97
- "utf8",
98
- );
77
+ const source = app!.read(APP_ROUTE_PARSER);
99
78
  // Terminated by the blank line after the union, because `;` also ends every
100
79
  // member field inside it.
101
80
  const match = source.match(/export type Route =([\s\S]*?);\r?\n\r?\n/);
102
81
  if (!match?.[1]) {
103
82
  throw new Error(
104
- "Could not locate the `Route` union in app src/platform/route-parser.ts. " +
83
+ `Could not locate the \`Route\` union in app ${APP_ROUTE_PARSER}. ` +
105
84
  "If the declaration was renamed or restructured, update this test — do " +
106
85
  "NOT delete it, or the view vocabulary can drift out of step unseen.",
107
86
  );
@@ -18,59 +18,35 @@
18
18
  * imports, keeping the contracts no-sibling-imports invariant intact (the
19
19
  * same pattern as tests/org/reserved-slug-roundtrip.test.ts).
20
20
  *
21
- * They are skipped when the backend is not checked out beside this repo — e.g.
22
- * contracts consumed standalone from npm, or contracts CI, which by design has
23
- * no backend checkout because contracts is the root of the dependency tree.
24
- * Drift introduced on the backend side is therefore NOT gated by contracts CI;
25
- * it is caught here in a workspace checkout, and the inverted marker test below
26
- * makes the reporter say so rather than showing a silent pass.
21
+ * Where the backend is read from: `resolveSibling` (tests/workspace-sibling.ts),
22
+ * the workspace's sibling rule (ci ADR-CI-276): this session's backend worktree
23
+ * when the session holds one, else backend's committed origin/main; never a
24
+ * primary's working tree, which may hold another session's uncommitted work.
25
+ * It used to probe `<workspace>/company-semantics-backend` (the PRIMARY) and
26
+ * `../company-semantics-backend` from cwd (ci ADR-CI-294 follow-up).
27
+ *
28
+ * Inside the workspace a missing or unreadable backend FAILS this file. The
29
+ * parity tests skip, and the inverted marker test says why, only in a
30
+ * standalone clone (no other company-semantics-* checkout beside it): contracts
31
+ * consumed from npm, or contracts CI, which by design has no backend checkout
32
+ * because contracts is the root of the dependency tree. Drift introduced on the
33
+ * backend side is therefore NOT gated by contracts CI; it is caught here in a
34
+ * workspace checkout.
27
35
  */
28
- import { existsSync, readFileSync } from "node:fs";
29
- import { resolve, sep } from "node:path";
30
36
  import { describe, it, expect } from "vitest";
31
37
  import {
32
38
  CHAT_RUNTIME_PROFILES,
33
39
  DEFAULT_CHAT_RUNTIME_PROFILE,
34
40
  } from "../runtime-profile.js";
41
+ import { resolveSibling } from "../../../tests/workspace-sibling.js";
35
42
 
36
- /**
37
- * Locate the backend checkout that governs this run.
38
- *
39
- * Probed in order:
40
- * 1. The backend worktree mirroring this one. Ralph checks the same branch of
41
- * every repo out at the same relative path, so a contracts worktree has a
42
- * backend worktree beside it carrying the branch's constants. The backend
43
- * MAIN checkout sits on another branch entirely, and comparing against it
44
- * would report drift for a value this branch already corrected.
45
- * 2. The backend main checkout in the same workspace.
46
- * 3. The plain sibling guess, for a non-worktree local dev layout.
47
- */
48
- function resolveBackendRoot(): string | undefined {
49
- const candidates: string[] = [];
50
-
51
- const segments = process.cwd().split(sep);
52
- const contractsIdx = segments.lastIndexOf("company-semantics-contracts");
53
- if (contractsIdx !== -1) {
54
- const workspaceRoot = segments.slice(0, contractsIdx).join(sep);
55
- const worktreeSuffix = segments.slice(contractsIdx + 1);
56
- const backendRoot = resolve(workspaceRoot, "company-semantics-backend");
57
- if (worktreeSuffix.length > 0) {
58
- candidates.push(resolve(backendRoot, ...worktreeSuffix));
59
- }
60
- candidates.push(backendRoot);
61
- }
62
- candidates.push(resolve(process.cwd(), "..", "company-semantics-backend"));
63
-
64
- return candidates.find((root) =>
65
- existsSync(resolve(root, "src", "chat", "orchestrator", "factory.ts")),
66
- );
67
- }
68
-
69
- const BACKEND_ROOT = resolveBackendRoot();
70
- const backendAvailable = BACKEND_ROOT !== undefined;
43
+ // Throws (failing this file loudly) inside the workspace when the backend
44
+ // cannot be resolved; null only in a standalone clone.
45
+ const backend = await resolveSibling("company-semantics-backend");
46
+ const backendAvailable = backend !== null;
71
47
 
72
48
  function readBackendSource(...relative: string[]): string {
73
- return readFileSync(resolve(BACKEND_ROOT!, ...relative), "utf8");
49
+ return backend!.read(relative.join("/"));
74
50
  }
75
51
 
76
52
  /** Extract the single capture of `pattern`, or explain what moved. */
@@ -95,6 +95,7 @@ export const openApiRoutes = {
95
95
  '/api/executions/{executionId}/undo': ['POST'],
96
96
  '/api/factory/floor': ['GET'],
97
97
  '/api/factory/kpis': ['GET'],
98
+ '/api/factory/lineage': ['GET'],
98
99
  '/api/factory/quality/changes': ['POST'],
99
100
  '/api/factory/quality/corpus': ['GET'],
100
101
  '/api/factory/quality/deployments': ['POST'],
@@ -17,6 +17,7 @@ Canonical vocabulary for structured assistant message output. Defines the type s
17
17
  - `SuggestedRepliesPart` is a surface but NOT a governed one: a chip fires an ordinary user turn (no `executionId`, no `submitEndpoint`), is stateless (clicking never consumes it) and advisory (the composer stays enabled) — so it is outside the at-most-one-governed-surface-per-turn rule
18
18
  - `WorkflowProgressPart` is the second non-governed surface: a two-field ANCHOR (`workflowId`, `workflowType`) to a durable background workflow. It presents no decision, carries no result snapshot (minted at t=0; parts are never rewritten) and no `startedAt` (operational state stays off immutable content), and names no domain vocabulary — live state is hydrated from the generic workflow projection read
19
19
  - `InputRequestPart` (`data-input-request` on the wire) is the GENERIC, GOVERNED anchor for a reviewed request: `{ requestType, requestId, stepCount, withheldCount }`, with every invariant of the structure-review anchor (anchor only — no cards, no endpoints, no staleness hashes; counts against the at-most-one-governed-surface-per-turn rule; emission is orchestration-driven). `isInputRequestDataPartShape` checks `requestType` as a non-empty string, NOT as a member of `INPUT_REQUEST_TYPES`: an unknown type renders nothing but never invalidates a persisted part
20
+ - `AffordanceOfferPart` (`data-affordance-offer` on the wire) is the fourth non-governed surface: the next steps a pushed chat OFFERS, frozen with it as `OfferedAffordance` instances (control ADR `affordance-system`). Ids and args only — never labels, hrefs or a projected id (`review.open`), at most `MAX_OFFERED_AFFORDANCES`; written once with its message and never re-derived; the app renders each as a real control, re-gated per viewer. Built by `WireSurfaceBuilder.affordanceOffer`; emission is orchestration-driven (the proactive chat writer), never a model choice
20
21
  - `StructureReviewPart` (`data-structure-review`) is FROZEN, not retired: it stays in `SurfacePart` and parses forever because persisted transcripts carry it, and org-structure keeps emitting it
21
22
 
22
23
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
@@ -24,6 +25,9 @@ Canonical vocabulary for structured assistant message output. Defines the type s
24
25
  ## Public API
25
26
 
26
27
  - `AddPartResult` _(type)_ — Result of adding a part to the builder.
28
+ - `AffordanceOfferData` _(type)_ — Affordance offer data payload: the frozen, ordered offer.
29
+ - `AffordanceOfferDataPart` _(type)_ — Affordance offer data part (wire format, as persisted in `chat_messages.parts`).
30
+ - `AffordanceOfferPart` _(type)_ — Affordance offer message part (semantic type, as rendered).
27
31
  - `AssistantMessagePart` _(type)_ — All assistant message part types.
28
32
  - `CONFIRMATION_LABELS` — Human-readable confirmation dialog titles for each execution kind.
29
33
  - `CandidateSource` _(type)_ — How the frontend sources candidate values for an editable field. - `inline`: the options are shipped with the…
@@ -116,6 +120,7 @@ Canonical vocabulary for structured assistant message output. Defines the type s
116
120
 
117
121
  **Internal domains:**
118
122
 
123
+ - `affordances`
119
124
  - `chat`
120
125
  - `execution`
121
126
  - `input-requests`
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Affordance Offer Part Types
3
+ *
4
+ * The next steps a message OFFERS, frozen with it (control ADR
5
+ * affordance-system). A pushed proactive chat carries its composer's
6
+ * `affordances` here so the app renders them as real controls — re-gated per
7
+ * viewer at render, live availability read separately, executed only by the
8
+ * server — instead of as chips that merely send a prompt.
9
+ *
10
+ * AFFORDANCE OFFER INVARIANTS:
11
+ * - OFFERED INSTANCES ONLY. Ids and their args, never labels or hrefs (the app
12
+ * owns both), never a projected id (`review.open`), at most
13
+ * MAX_OFFERED_AFFORDANCES.
14
+ * - WRITTEN ONCE, with the message it belongs to; never re-derived.
15
+ * - SHARE-SAFE. Ids and opaque target ids only.
16
+ * - NON-GOVERNED. It presents no decision and consumes no surface budget.
17
+ * - Emission is orchestration-driven (the proactive chat writer), never a model
18
+ * choice.
19
+ *
20
+ * Purely additive: an app that predates this part normalizes unknown part
21
+ * types to null and drops it.
22
+ */
23
+
24
+ import type { OfferedAffordance } from "../affordances/affordance";
25
+
26
+ /** Affordance offer data payload: the frozen, ordered offer. */
27
+ export interface AffordanceOfferData {
28
+ affordances: readonly OfferedAffordance[];
29
+ }
30
+
31
+ /** Affordance offer message part (semantic type, as rendered). */
32
+ export interface AffordanceOfferPart {
33
+ type: "affordance-offer";
34
+ data: AffordanceOfferData;
35
+ }
36
+
37
+ /**
38
+ * Affordance offer data part (wire format, as persisted in
39
+ * `chat_messages.parts`). Uses AI SDK's data-{name} convention.
40
+ */
41
+ export interface AffordanceOfferDataPart {
42
+ type: "data-affordance-offer";
43
+ data: AffordanceOfferData;
44
+ }
@@ -97,6 +97,13 @@ export type {
97
97
  WorkflowOutcomeDataPart,
98
98
  } from "./workflow-outcome";
99
99
 
100
+ // Affordance offer types (non-governed, the frozen offer on a pushed chat)
101
+ export type {
102
+ AffordanceOfferData,
103
+ AffordanceOfferPart,
104
+ AffordanceOfferDataPart,
105
+ } from "./affordance-offer";
106
+
100
107
  // Execution result types
101
108
  export type {
102
109
  ExecutionArtifactStatus,
@@ -22,6 +22,7 @@ import type { InputRequestPart } from "./input-request";
22
22
  import type { SuggestedRepliesPart } from "./suggested-replies";
23
23
  import type { WorkflowProgressPart } from "./workflow-progress";
24
24
  import type { WorkflowOutcomePart } from "./workflow-outcome";
25
+ import type { AffordanceOfferPart } from "./affordance-offer";
25
26
 
26
27
  // =============================================================================
27
28
  // Narrative Parts (Streamable)
@@ -130,6 +131,10 @@ export interface TablePart {
130
131
  * workflow's execution epoch, carried on its settlement message. It presents no
131
132
  * decision; a review disposition is delivered as its own InputRequestPart.
132
133
  *
134
+ * AffordanceOfferPart is the fourth non-governed member: the next steps a pushed
135
+ * chat offers, frozen with it as affordance instances (control ADR
136
+ * affordance-system). It presents no decision and consumes no surface budget.
137
+ *
133
138
  * InputRequestPart is GOVERNED, like StructureReviewPart: it anchors a reviewed
134
139
  * request that asks the person to decide, so it counts against the
135
140
  * at-most-one-governed-surface-per-turn budget. It is the generic anchor;
@@ -148,7 +153,8 @@ export type SurfacePart =
148
153
  | InputRequestPart
149
154
  | SuggestedRepliesPart
150
155
  | WorkflowProgressPart
151
- | WorkflowOutcomePart;
156
+ | WorkflowOutcomePart
157
+ | AffordanceOfferPart;
152
158
 
153
159
  /**
154
160
  * All assistant message part types.
@@ -39,6 +39,10 @@ import type {
39
39
  WorkflowOutcomeData,
40
40
  WorkflowOutcomeDataPart,
41
41
  } from "./workflow-outcome";
42
+ import type {
43
+ AffordanceOfferData,
44
+ AffordanceOfferDataPart,
45
+ } from "./affordance-offer";
42
46
  import type {
43
47
  ExecutionResultData,
44
48
  ExecutionResultDataPart,
@@ -272,6 +276,20 @@ export const WireSurfaceBuilder = {
272
276
  };
273
277
  },
274
278
 
279
+ /**
280
+ * Build an affordance-offer data part: the frozen next steps a pushed chat
281
+ * offers (control ADR affordance-system). Non-governed; ids and args only.
282
+ *
283
+ * @param data - The offered affordance instances, in the producer's order
284
+ * @returns Wire-format affordance offer part
285
+ */
286
+ affordanceOffer(data: AffordanceOfferData): AffordanceOfferDataPart {
287
+ return {
288
+ type: "data-affordance-offer",
289
+ data,
290
+ };
291
+ },
292
+
275
293
  /**
276
294
  * Build an execution result data part for streaming.
277
295
  * Reports the outcome of an executed action.
@@ -131,9 +131,23 @@ deliberately (ADR-CONTRACTS-086):
131
131
  `chat.mention`: a kind is composable prose for a caller with an explicit
132
132
  trigger, and naming it on a plan would make the proactive pipeline send it
133
133
  autonomously — notify/'s INV-1 to grant, not this union's.
134
- - This domain depends on NOTHING outside itself. The `notifications → email`
135
- imports that existed while both layers did are gone with the directory, and
136
- nothing may reintroduce one.
134
+ - This domain depends on nothing outside itself except one **type-only** import:
135
+ `content.ts` reads `OfferedAffordance` from `../affordances/affordance` for
136
+ `CallToAction.affordance`. The `notifications → email` imports that existed
137
+ while both layers did are gone with the directory, and nothing may reintroduce
138
+ one.
139
+ - `CallToAction.affordance` names the catalog affordance a link performs
140
+ (control ADR affordance-system; contracts ADR
141
+ affordance-catalog-wave-and-grounded-ctas). It is present exactly when the
142
+ link does what a catalog id names — the three `billing.*` kinds
143
+ (`billing.open`), `chat.shared` (`chat.open` with the payload's `chatId`),
144
+ `companyMd.access_request_approved` (`companyMd.openDoc` with `docId`) and
145
+ `org.unit_owner_granted` (`team.open` with `unitId`) — and the label line
146
+ carries `// cta-grounded: <id>`. It is absent for an OTP code (no `href`) and
147
+ for a link no catalog id names (an invite acceptance, a comment). Renderers
148
+ ignore it and draw the link as before. Body prose around a call to action
149
+ states facts and never tells the reader what to do; the `cta-prose-ratchet`
150
+ guard and `__tests__/grounded-ctas.test.ts` hold that line.
137
151
  - A kind IS its definition. Every `NotificationKind` has facts in `payloads.ts`
138
152
  and a `compose` in `kinds/`, both compiler-enforced via `NotificationPayloads[K]`.
139
153
  - Capability belongs to the **renderer** (`Renderer.supports`), never to an
@@ -13,6 +13,10 @@ to.
13
13
  - `render-snapshot.test.ts` + `__snapshots__/` — those fixtures rendered through
14
14
  the email channel, as vitest snapshots. This is the fence: **a diff here means
15
15
  a real sent email changed.**
16
+ - `grounded-ctas.test.ts` — a linked call to action whose link a catalog id
17
+ names carries that affordance, targeting the fixture payload's own id; a code
18
+ to copy never carries one; and no grounded or share notification body tells
19
+ the reader what to do (control ADR affordance-system).
16
20
 
17
21
  ## The retired output-parity golden (ADR-CONTRACTS-087)
18
22
 
@@ -552,7 +552,7 @@ exports[`email render > billing.auto_recharge_failed · Default 1`] = `
552
552
  <p class="csr-p-normal">Acme Corp tried to top up its AI balance automatically and the charge was not completed.</p>
553
553
  <p class="csr-p-tight">Workspace: Acme Corp</p>
554
554
  <p class="csr-p-normal">Status: Top-up failed</p>
555
- <p class="csr-p-normal">AI work carries on until the remaining balance is spent, and then pauses. The next attempt is not immediate — the system waits rather than running the same card again — so updating the payment method is what resolves this.</p>
555
+ <p class="csr-p-normal">AI work carries on until the remaining balance is spent, and then pauses. The next attempt is not immediate — the system waits before charging the same card a second time — so updating the payment method is what resolves this.</p>
556
556
  <table cellpadding="0" cellspacing="0" border="0" class="cs-cta cs-cta-hover csr-cta-box-normal">
557
557
  <tr><td class="csr-cta-cell-linked">
558
558
  <a href="https://app.companysemantics.ai/org/billing" class="cs-link csr-cta-anchor">&gt;&gt; <span class="csr-cta-label">OPEN BILLING</span> &lt;&lt;</a>
@@ -588,7 +588,7 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
588
588
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Acme Corp tried to top up its AI balance automatically and the charge was not completed.</p>
589
589
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 4px 0;">Workspace: Acme Corp</p>
590
590
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Status: Top-up failed</p>
591
- <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">AI work carries on until the remaining balance is spent, and then pauses. The next attempt is not immediate — the system waits rather than running the same card again — so updating the payment method is what resolves this.</p>
591
+ <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">AI work carries on until the remaining balance is spent, and then pauses. The next attempt is not immediate — the system waits before charging the same card a second time — so updating the payment method is what resolves this.</p>
592
592
  <table cellpadding="0" cellspacing="0" border="0" class="cs-cta cs-cta-hover" style="display: inline-block; border: 1px solid #0047FF; border-radius: 2px; margin: 0 0 20px 0; max-width: 220px;">
593
593
  <tr><td style="text-align: center; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px;">
594
594
  <a href="https://app.companysemantics.ai/org/billing" class="cs-link" style="display: block; padding: 16px 24px; color: #0047FF; text-decoration: none;">&gt;&gt; <span style="text-decoration: underline;">OPEN BILLING</span> &lt;&lt;</a>
@@ -610,8 +610,8 @@ Status: Top-up failed
610
610
 
611
611
  AI work carries on until the remaining balance
612
612
  is spent, and then pauses. The next attempt is
613
- not immediate — the system waits rather than
614
- running the same card again — so updating the
613
+ not immediate — the system waits before charging
614
+ the same card a second time — so updating the
615
615
  payment method is what resolves this.
616
616
 
617
617
  *------------------------*
@@ -1590,7 +1590,7 @@ exports[`email render > chat.shared · Long title 1`] = `
1590
1590
  <body class="csr-body">
1591
1591
  <div class="csr-frame">
1592
1592
  <p class="csr-p-normal">Hi there,</p>
1593
- <p class="csr-p-normal">A chat was shared with you. You can take part in it.</p>
1593
+ <p class="csr-p-normal">A chat was shared with you, and you are a participant.</p>
1594
1594
  <hr class="cs-faint csr-chat-rule-top">
1595
1595
  <table cellpadding="0" cellspacing="0" border="0" width="100%" class="csr-chat-row-24">
1596
1596
  <tr>
@@ -1654,7 +1654,7 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
1654
1654
  <body style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; color: #1a1a1a; background-color: #ffffff; margin: 0; padding: 0;">
1655
1655
  <div style="max-width: 520px;">
1656
1656
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Hi there,</p>
1657
- <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">A chat was shared with you. You can take part in it.</p>
1657
+ <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">A chat was shared with you, and you are a participant.</p>
1658
1658
  <hr class="cs-faint" style="border: none; border-top: 1px solid #bbb; margin: 12px 0 24px 0;">
1659
1659
  <table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
1660
1660
  <tr>
@@ -1699,8 +1699,8 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
1699
1699
  "subject": "A chat has been shared with you",
1700
1700
  "text": "Hi there,
1701
1701
 
1702
- A chat was shared with you. You can take part in
1703
- it.
1702
+ A chat was shared with you, and you are a
1703
+ participant.
1704
1704
 
1705
1705
  ────────────────────────────────────────────────
1706
1706
 
@@ -2909,7 +2909,7 @@ exports[`email render > companyMd.access_request_approved · Editor 1`] = `
2909
2909
  <tr>
2910
2910
  <td class="csr-chat-avatar-left">[c_S]</td>
2911
2911
  <td class="csr-chat-channel-left"><table cellpadding="0" cellspacing="0" border="0" class="csr-chat-bubble-wrap">
2912
- <tr><td class="cs-bubble csr-bubble-assistant">Open to view.</td></tr>
2912
+ <tr><td class="cs-bubble csr-bubble-assistant">The document is ready for you.</td></tr>
2913
2913
  </table></td>
2914
2914
  <td class="csr-chat-avatar-right-hidden">(•̀_ರ╮)</td>
2915
2915
  </tr>
@@ -2962,7 +2962,7 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
2962
2962
  <tr>
2963
2963
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-right: 8px; vertical-align: bottom; white-space: nowrap;">[c_S]</td>
2964
2964
  <td style="width: 100%; text-align: left;"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
2965
- <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">Open to view.</td></tr>
2965
+ <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">The document is ready for you.</td></tr>
2966
2966
  </table></td>
2967
2967
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
2968
2968
  </tr>
@@ -2995,7 +2995,8 @@ Your access request was approved.
2995
2995
  ────────────────────────────────────────────────
2996
2996
 
2997
2997
  ┌──────────────────────────────┐
2998
- [c_S] │ Open to view. │
2998
+ │ The document is ready for │
2999
+ [c_S] │ you. │
2999
3000
  └──────────────────────────────┘
3000
3001
 
3001
3002
  *----------------*
@@ -3257,7 +3258,7 @@ exports[`email render > companyMd.access_request_approved · Viewer 1`] = `
3257
3258
  <tr>
3258
3259
  <td class="csr-chat-avatar-left">[c_S]</td>
3259
3260
  <td class="csr-chat-channel-left"><table cellpadding="0" cellspacing="0" border="0" class="csr-chat-bubble-wrap">
3260
- <tr><td class="cs-bubble csr-bubble-assistant">Open to view.</td></tr>
3261
+ <tr><td class="cs-bubble csr-bubble-assistant">The document is ready for you.</td></tr>
3261
3262
  </table></td>
3262
3263
  <td class="csr-chat-avatar-right-hidden">(•̀_ರ╮)</td>
3263
3264
  </tr>
@@ -3310,7 +3311,7 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
3310
3311
  <tr>
3311
3312
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-right: 8px; vertical-align: bottom; white-space: nowrap;">[c_S]</td>
3312
3313
  <td style="width: 100%; text-align: left;"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
3313
- <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">Open to view.</td></tr>
3314
+ <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">The document is ready for you.</td></tr>
3314
3315
  </table></td>
3315
3316
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
3316
3317
  </tr>
@@ -3343,7 +3344,8 @@ Your access request was approved.
3343
3344
  ────────────────────────────────────────────────
3344
3345
 
3345
3346
  ┌──────────────────────────────┐
3346
- [c_S] │ Open to view. │
3347
+ │ The document is ready for │
3348
+ [c_S] │ you. │
3347
3349
  └──────────────────────────────┘
3348
3350
 
3349
3351
  *----------------*
@@ -5059,7 +5061,7 @@ exports[`email render > org.unit_owner_granted · Delegate with expiry 1`] = `
5059
5061
  <tr>
5060
5062
  <td class="csr-chat-avatar-left">[c_S]</td>
5061
5063
  <td class="csr-chat-channel-left"><table cellpadding="0" cellspacing="0" border="0" class="csr-chat-bubble-wrap">
5062
- <tr><td class="cs-bubble csr-bubble-assistant">Open the team to get started.</td></tr>
5064
+ <tr><td class="cs-bubble csr-bubble-assistant">Platform Engineering is ready for you.</td></tr>
5063
5065
  </table></td>
5064
5066
  <td class="csr-chat-avatar-right-hidden">(•̀_ರ╮)</td>
5065
5067
  </tr>
@@ -5113,7 +5115,7 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
5113
5115
  <tr>
5114
5116
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-right: 8px; vertical-align: bottom; white-space: nowrap;">[c_S]</td>
5115
5117
  <td style="width: 100%; text-align: left;"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
5116
- <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">Open the team to get started.</td></tr>
5118
+ <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">Platform Engineering is ready for you.</td></tr>
5117
5119
  </table></td>
5118
5120
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
5119
5121
  </tr>
@@ -5150,8 +5152,8 @@ strategy, and execution against goals.
5150
5152
  ────────────────────────────────────────────────
5151
5153
 
5152
5154
  ┌──────────────────────────────┐
5153
- │ Open the team to get │
5154
- [c_S] │ started. │
5155
+ │ Platform Engineering is │
5156
+ [c_S] │ ready for you. │
5155
5157
  └──────────────────────────────┘
5156
5158
 
5157
5159
  *-----------------------*
@@ -5249,7 +5251,7 @@ exports[`email render > org.unit_owner_granted · Unit owner + message 1`] = `
5249
5251
  <tr>
5250
5252
  <td class="csr-chat-avatar-left">[c_S]</td>
5251
5253
  <td class="csr-chat-channel-left"><table cellpadding="0" cellspacing="0" border="0" class="csr-chat-bubble-wrap">
5252
- <tr><td class="cs-bubble csr-bubble-assistant">Open the team to get started.</td></tr>
5254
+ <tr><td class="cs-bubble csr-bubble-assistant">Platform Engineering is ready for you.</td></tr>
5253
5255
  </table></td>
5254
5256
  <td class="csr-chat-avatar-right-hidden">(•̀_ರ╮)</td>
5255
5257
  </tr>
@@ -5316,7 +5318,7 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
5316
5318
  <tr>
5317
5319
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-right: 8px; vertical-align: bottom; white-space: nowrap;">[c_S]</td>
5318
5320
  <td style="width: 100%; text-align: left;"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
5319
- <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">Open the team to get started.</td></tr>
5321
+ <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">Platform Engineering is ready for you.</td></tr>
5320
5322
  </table></td>
5321
5323
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
5322
5324
  </tr>
@@ -5358,8 +5360,8 @@ strategy, and execution against goals.
5358
5360
  Jordan Lee
5359
5361
 
5360
5362
  ┌──────────────────────────────┐
5361
- │ Open the team to get │
5362
- [c_S] │ started. │
5363
+ │ Platform Engineering is │
5364
+ [c_S] │ ready for you. │
5363
5365
  └──────────────────────────────┘
5364
5366
 
5365
5367
  *-----------------------*
@@ -5853,7 +5855,7 @@ exports[`email render > share.granted · Meeting · viewer · no title 1`] = `
5853
5855
  <tr>
5854
5856
  <td class="csr-chat-avatar-left">[c_S]</td>
5855
5857
  <td class="csr-chat-channel-left"><table cellpadding="0" cellspacing="0" border="0" class="csr-chat-bubble-wrap">
5856
- <tr><td class="cs-bubble csr-bubble-assistant">Open to view.</td></tr>
5858
+ <tr><td class="cs-bubble csr-bubble-assistant">It is ready for you.</td></tr>
5857
5859
  </table></td>
5858
5860
  <td class="csr-chat-avatar-right-hidden">(•̀_ರ╮)</td>
5859
5861
  </tr>
@@ -5905,7 +5907,7 @@ body { background-color: #1a1a1a !important; color: #e8e8e8 !important; }
5905
5907
  <tr>
5906
5908
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-right: 8px; vertical-align: bottom; white-space: nowrap;">[c_S]</td>
5907
5909
  <td style="width: 100%; text-align: left;"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
5908
- <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">Open to view.</td></tr>
5910
+ <tr><td class="cs-bubble" style="border-radius: 8px 8px 8px 0; padding: 10px 14px; font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; color: #ffffff; background: #666;">It is ready for you.</td></tr>
5909
5911
  </table></td>
5910
5912
  <td style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
5911
5913
  </tr>
@@ -5937,7 +5939,7 @@ A meeting was shared with you.
5937
5939
  ────────────────────────────────────────────────
5938
5940
 
5939
5941
  ┌──────────────────────────────┐
5940
- [c_S] │ Open to view. │
5942
+ [c_S] │ It is ready for you. │
5941
5943
  └──────────────────────────────┘
5942
5944
 
5943
5945
  *----------------*
@@ -102,6 +102,7 @@ add("org.unit_owner_granted", "Unit owner + message", {
102
102
  unitName: "Platform Engineering",
103
103
  roleLabel: "Unit owner",
104
104
  ctaUrl: `${APP}/org/unit/42`,
105
+ unitId: "unit_42",
105
106
  message: "Welcome aboard — glad to have you owning this team.",
106
107
  });
107
108
  add("org.unit_owner_granted", "Delegate with expiry", {
@@ -110,6 +111,7 @@ add("org.unit_owner_granted", "Delegate with expiry", {
110
111
  unitName: "Platform Engineering",
111
112
  roleLabel: "Delegate",
112
113
  ctaUrl: `${APP}/org/unit/42`,
114
+ unitId: "unit_42",
113
115
  expiresAt: "2026-06-13T00:00:00.000Z",
114
116
  });
115
117
 
@@ -144,6 +146,7 @@ add("security.alert", "Unusual login location", {
144
146
 
145
147
  const CHAT_BASE = {
146
148
  sharedByName: "Alex Rivera",
149
+ chatId: "chat_7f3a",
147
150
  access: "read",
148
151
  } as const;
149
152
  add("chat.shared", "Default", {
@@ -220,14 +223,16 @@ add("companyMd.access_request_approved", "Editor", {
220
223
  docTitle: "Engineering Handbook",
221
224
  accessLevel: "editor",
222
225
  docUrl: `${APP}/doc/handbook`,
226
+ docId: "node_42",
223
227
  });
224
- // The approver's note replaces the "Open to view." prompt rather than stacking
228
+ // The approver's note replaces the "The document is ready for you." line rather than stacking
225
229
  // on it — the branch this variant exists to pin.
226
230
  add("companyMd.access_request_approved", "Editor with message", {
227
231
  approverName: "Jordan Lee",
228
232
  docTitle: "Engineering Handbook",
229
233
  accessLevel: "editor",
230
234
  docUrl: `${APP}/doc/handbook`,
235
+ docId: "node_42",
231
236
  message: "Welcome aboard — start with the onboarding section.",
232
237
  });
233
238
  add("companyMd.access_request_approved", "Viewer", {
@@ -235,6 +240,7 @@ add("companyMd.access_request_approved", "Viewer", {
235
240
  docTitle: "Engineering Handbook",
236
241
  accessLevel: "viewer",
237
242
  docUrl: `${APP}/doc/handbook`,
243
+ docId: "node_42",
238
244
  });
239
245
 
240
246
  add("companyMd.access_request_denied", "With reason", {