@company-semantics/contracts 92.0.0 → 93.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 (61) hide show
  1. package/package.json +9 -9
  2. package/src/action-items/README.md +12 -4
  3. package/src/action-items/schemas.ts +10 -0
  4. package/src/affordances/README.md +52 -1
  5. package/src/affordances/__tests__/README.md +10 -1
  6. package/src/affordances/__tests__/affordances.test.ts +13 -1
  7. package/src/affordances/__tests__/catalog-wave.test.ts +237 -0
  8. package/src/affordances/affordance.ts +56 -4
  9. package/src/affordances/args.ts +44 -1
  10. package/src/affordances/available.ts +59 -0
  11. package/src/affordances/coverage.ts +51 -0
  12. package/src/affordances/ids.ts +28 -0
  13. package/src/affordances/index.ts +17 -0
  14. package/src/api/README.md +1 -0
  15. package/src/api/generated-spec-hash.ts +2 -2
  16. package/src/api/generated.ts +397 -1
  17. package/src/api/primitives.ts +13 -0
  18. package/src/billing/README.md +1 -0
  19. package/src/billing/refusals.ts +12 -0
  20. package/src/message-parts/README.md +5 -0
  21. package/src/message-parts/affordance-offer.ts +44 -0
  22. package/src/message-parts/index.ts +7 -0
  23. package/src/message-parts/types.ts +7 -1
  24. package/src/message-parts/wire.ts +18 -0
  25. package/src/notifications/README.md +17 -3
  26. package/src/notifications/__tests__/README.md +4 -0
  27. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +27 -25
  28. package/src/notifications/__tests__/fixtures.ts +7 -1
  29. package/src/notifications/__tests__/grounded-ctas.test.ts +103 -0
  30. package/src/notifications/__tests__/render-snapshot.test.ts +1 -1
  31. package/src/notifications/content.ts +9 -0
  32. package/src/notifications/kinds/billing-auto-recharge-failed.ts +4 -2
  33. package/src/notifications/kinds/billing-payment-past-due.ts +2 -0
  34. package/src/notifications/kinds/billing-suspended.ts +2 -0
  35. package/src/notifications/kinds/chat-shared.ts +10 -3
  36. package/src/notifications/kinds/company-md-access-approved.ts +13 -6
  37. package/src/notifications/kinds/share-granted.ts +1 -1
  38. package/src/notifications/kinds/unit-owner-granted.ts +9 -2
  39. package/src/notifications/payloads.ts +6 -0
  40. package/src/proactive/README.md +18 -2
  41. package/src/proactive/__tests__/README.md +11 -1
  42. package/src/proactive/__tests__/chat-templates.test.ts +7 -6
  43. package/src/proactive/__tests__/proactive-offers.test.ts +124 -0
  44. package/src/proactive/__tests__/vocabulary.test.ts +1 -1
  45. package/src/proactive/composer.ts +11 -0
  46. package/src/proactive/templates/README.md +11 -3
  47. package/src/proactive/templates/billing-auto-recharge-failed.ts +1 -0
  48. package/src/proactive/templates/billing-balance-exhausted.ts +2 -6
  49. package/src/proactive/templates/billing-payment-past-due.ts +1 -0
  50. package/src/proactive/templates/billing-subscription-canceled.ts +1 -5
  51. package/src/proactive/templates/billing-suspended.ts +1 -0
  52. package/src/proactive/templates/morning-brief.ts +1 -0
  53. package/src/proactive/templates/org-became-shared.ts +4 -3
  54. package/src/proactive/templates/source-sync-review-ready.ts +3 -8
  55. package/src/proactive/templates/workflow-needs-attention.ts +1 -0
  56. package/src/rate-limit/README.md +27 -1
  57. package/src/rate-limit/__tests__/README.md +23 -0
  58. package/src/rate-limit/__tests__/rate-limited-error.test.ts +53 -0
  59. package/src/rate-limit/index.ts +33 -0
  60. package/src/root-exports/agent-runtime.ts +14 -2
  61. package/src/root-exports/delivery.ts +15 -0
@@ -57,8 +57,10 @@ export const billingSuspendedDefinition: NotificationDefinition<"billing.suspend
57
57
  },
58
58
  {
59
59
  type: "callToAction",
60
+ // cta-grounded: billing.open
60
61
  label: "OPEN BILLING",
61
62
  href: billingUrl,
63
+ affordance: { id: "billing.open" },
62
64
  },
63
65
  {
64
66
  type: "notice",
@@ -15,7 +15,8 @@ import { NOTICE } from "../text";
15
15
  export const chatSharedDefinition: NotificationDefinition<"chat.shared"> = {
16
16
  kind: "chat.shared",
17
17
  compose: (payload, context) => {
18
- const { chatTitle, chatUrl, access, previewText, sharedByName } = payload;
18
+ const { chatTitle, chatUrl, chatId, access, previewText, sharedByName } =
19
+ payload;
19
20
 
20
21
  const items: ChatUnitItem[] = [
21
22
  { type: "message", role: "user", text: chatTitle, from: sharedByName },
@@ -29,7 +30,13 @@ export const chatSharedDefinition: NotificationDefinition<"chat.shared"> = {
29
30
  ]
30
31
  : []),
31
32
  { type: "continuation" },
32
- { type: "callToAction", label: "OPEN CHAT", href: chatUrl },
33
+ {
34
+ type: "callToAction",
35
+ // cta-grounded: chat.open
36
+ label: "OPEN CHAT",
37
+ href: chatUrl,
38
+ affordance: { id: "chat.open", args: { chatId } },
39
+ },
33
40
  ];
34
41
 
35
42
  return {
@@ -45,7 +52,7 @@ export const chatSharedDefinition: NotificationDefinition<"chat.shared"> = {
45
52
  type: "body",
46
53
  text:
47
54
  access === "collaborate"
48
- ? "A chat was shared with you. You can take part in it."
55
+ ? "A chat was shared with you, and you are a participant."
49
56
  : "A chat was shared with you to read.",
50
57
  },
51
58
  { type: "chatUnit", items },
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Company.md access-approved notification (sent to the requester).
3
3
  *
4
- * The chat unit carries EITHER the approver's own words or the bare "Open to
5
- * view." prompt — never both. An approver who wrote a note has already said what
6
- * the prompt would say, and stacking the two reads as a form letter with a
4
+ * The chat unit carries EITHER the approver's own words or the bare "The
5
+ * document is ready for you." line — never both. An approver who wrote a note
6
+ * has already said what the line would say, and stacking the two reads as a form letter with a
7
7
  * comment bolted on. Same shape as `share.granted` (ADR-CONTRACTS-106); the call
8
8
  * to action stays in place either way, since approval always resolves to a doc
9
9
  * the requester can now open.
@@ -17,7 +17,8 @@ export const accessApprovedDefinition: NotificationDefinition<"companyMd.access_
17
17
  {
18
18
  kind: "companyMd.access_request_approved",
19
19
  compose: (payload, context) => {
20
- const { approverName, docTitle, accessLevel, docUrl, message } = payload;
20
+ const { approverName, docTitle, accessLevel, docUrl, docId, message } =
21
+ payload;
21
22
 
22
23
  return {
23
24
  metadata: {
@@ -42,9 +43,15 @@ export const accessApprovedDefinition: NotificationDefinition<"companyMd.access_
42
43
  : {
43
44
  type: "message",
44
45
  role: "assistant",
45
- text: "Open to view.",
46
+ text: "The document is ready for you.",
46
47
  },
47
- { type: "callToAction", label: "OPEN", href: docUrl },
48
+ {
49
+ type: "callToAction",
50
+ // cta-grounded: companyMd.openDoc
51
+ label: "OPEN",
52
+ href: docUrl,
53
+ affordance: { id: "companyMd.openDoc", args: { docId } },
54
+ },
48
55
  ],
49
56
  },
50
57
  {
@@ -52,7 +52,7 @@ export const shareGrantedDefinition: NotificationDefinition<"share.granted"> = {
52
52
  : {
53
53
  type: "message",
54
54
  role: "assistant",
55
- text: "Open to view.",
55
+ text: "It is ready for you.",
56
56
  },
57
57
  { type: "callToAction", label: "OPEN", href: ctaUrl },
58
58
  ],
@@ -28,6 +28,7 @@ export const unitOwnerGrantedDefinition: NotificationDefinition<"org.unit_owner_
28
28
  unitName,
29
29
  roleLabel,
30
30
  ctaUrl,
31
+ unitId,
31
32
  message,
32
33
  expiresAt,
33
34
  } = payload;
@@ -50,9 +51,15 @@ export const unitOwnerGrantedDefinition: NotificationDefinition<"org.unit_owner_
50
51
  {
51
52
  type: "message",
52
53
  role: "assistant",
53
- text: "Open the team to get started.",
54
+ text: `${unitName} is ready for you.`,
55
+ },
56
+ {
57
+ type: "callToAction",
58
+ // cta-grounded: team.open
59
+ label: "MANAGE TEAM",
60
+ href: ctaUrl,
61
+ affordance: { id: "team.open", args: { unitId } },
54
62
  },
55
- { type: "callToAction", label: "MANAGE TEAM", href: ctaUrl },
56
63
  ];
57
64
 
58
65
  const rows: KeyValueRow[] = [
@@ -72,6 +72,8 @@ export interface NotificationPayloads {
72
72
  roleLabel: "Unit owner" | "Delegate";
73
73
  /** Full URL to view the org unit in the app */
74
74
  ctaUrl: string;
75
+ /** The org unit's id — the `team.open` affordance the link performs. */
76
+ unitId: string;
75
77
  /** Optional message from the granter, shown in the notification and recorded with the grant */
76
78
  message?: string;
77
79
  /** 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. */
@@ -110,6 +112,8 @@ export interface NotificationPayloads {
110
112
  chatTitle: string;
111
113
  /** Full URL of the chat itself — the link that opens it in the recipient's app */
112
114
  chatUrl: string;
115
+ /** The chat's id — the `chat.open` affordance the link performs. */
116
+ chatId: string;
113
117
  /** What the share granted: Read only or Collaborate */
114
118
  access: "read" | "collaborate";
115
119
  /** First ~200 chars of chat content for preview. Generated server-side. */
@@ -158,6 +162,8 @@ export interface NotificationPayloads {
158
162
  accessLevel: "editor" | "commenter" | "viewer";
159
163
  /** Full URL to view the node */
160
164
  docUrl: string;
165
+ /** The company.md node's id — the `companyMd.openDoc` affordance the link performs. */
166
+ docId: string;
161
167
  /**
162
168
  * Optional note from the approver, included in the notification only (not
163
169
  * persisted) — the approval counterpart of the deny kind's `reason`
@@ -176,6 +176,21 @@ the three from another.
176
176
  time. The deliberate opposite of the durable-inbox rule, and both are
177
177
  correct: a transcript that changed under the reader would be a lie about the
178
178
  conversation.
179
+ - **A pushed chat offers its next steps as affordance instances, never as
180
+ chips or prose** (control ADR `affordance-system`; contracts ADR
181
+ `affordance-catalog-wave-and-grounded-ctas`). `ProactiveChatMessage.affordances`
182
+ is REQUIRED on every composer — an empty list is an explicit decision — and
183
+ holds `OfferedAffordance` instances (ids and args, never labels or hrefs),
184
+ frozen with the message. The backend chat writer persists them as a
185
+ `data-affordance-offer` part (`../message-parts/affordance-offer.ts`) and the
186
+ app renders them as real controls, re-gated per viewer. A chip only sends a
187
+ chat prompt, so a next step is never a chip, and the prose states facts
188
+ rather than telling the reader where to go. A projected id (`review.open`)
189
+ is never offered: the five billing chats offer `billing.open`,
190
+ `orgBecameShared` offers `workspace.openOrgSettings`, and `morningBrief`,
191
+ `sourceSyncReviewReady` (opening its review is the projected `review.open`,
192
+ and its facts carry no request id) and
193
+ `workflowNeedsAttention` (its outcome carries its own offer) offer nothing.
179
194
 
180
195
  ## Public API
181
196
 
@@ -211,7 +226,7 @@ the three from another.
211
226
  | `SourceSyncReviewReadyFacts` | `orgName` + `checkpoint`; no count, no document name, no source name |
212
227
  | `ExplainedWorkflowOutcomeData` | The settlement data part with an explained, fact-free outcome |
213
228
  | `WorkflowNeedsAttentionFacts` | `narration` (backend settlement copy) + the fact-free `outcome` data part |
214
- | `ProactiveChatMessage` | `title`, `text`, 0..4 `replies` chips, and an optional settled `outcome` |
229
+ | `ProactiveChatMessage` | `title`, `text`, 0..4 chips, the offered `affordances`, an optional `outcome` |
215
230
  | `ProactiveChatComposer` | The composer port — `template` plus an arrow-property `compose(facts)` |
216
231
  | `PROACTIVE_CHAT_MAX_REPLIES` | At most this many chips (4); more is a menu, not a chip row |
217
232
  | `PROACTIVE_CHAT_COMPOSERS` | The total template → composer registry (see `templates/README.md`) |
@@ -222,7 +237,8 @@ Type-only imports of `UserNotificationKind` (`../user-notifications`),
222
237
  `NotificationKind` (`../notifications`) and `ActionItemKind`
223
238
  (`../action-items`) — the three vocabularies this one composes — and of
224
239
  `SuggestedReply` (`../message-parts`), the chip shape a composer's `replies`
225
- reuse rather than redeclare. Type-only imports of `WorkflowOutcomeData`
240
+ reuse rather than redeclare, and of `OfferedAffordance` (`../affordances`),
241
+ the instance shape a composer's `affordances` offer. Type-only imports of `WorkflowOutcomeData`
226
242
  (`../message-parts`), `ExplainedWorkflowOutcome` (`../workflows`) and
227
243
  `WorkflowType` for the workflow settlement chat; the dependency runs
228
244
  proactive → workflows, never the reverse. No `zod`, no runtime imports; pure vocabulary
@@ -42,7 +42,8 @@ cannot.
42
42
  every template in `PROACTIVE_CHAT_TEMPLATES` has a composer whose `template`
43
43
  equals its key (the type proves totality; this proves no entry is an
44
44
  accidental `undefined`), the org-became-shared prose NAMES THE RELOCATION and
45
- every one of the five sections that leave My settings for Org settings, the
45
+ every one of the five sections that moved to the workspace (offering
46
+ `workspace.openOrgSettings` as the destination), the
46
47
  morning brief PERSONALIZES THROUGH ITS FACTS (two recipients of one
47
48
  occurrence get different prose from one composer), chips stay within
48
49
  `PROACTIVE_CHAT_MAX_REPLIES`, and composers emit content — no markup, no
@@ -58,6 +59,15 @@ cannot.
58
59
  fact-free outcome (schema-valid, `facts: null`) and no chips, frames the
59
60
  backend narration verbatim, has a title per workflow type with no digit and
60
61
  no next-step wording; and both inbox rows supersede per workflow type.
62
+ - `proactive-offers.test.ts` — pushed chats offer next steps as affordance
63
+ instances, never as chips or prose (control ADR `affordance-system`): every
64
+ composer returns an explicit `affordances` list, the billing chats offer
65
+ `billing.open` and carry no add-funds or resubscribe chip, org-became-shared
66
+ offers `workspace.openOrgSettings`, no composer offers a projected id, no
67
+ title, text, chip label or chip prompt carries call-to-action wording (the
68
+ cta-prose-ratchet regex; `workflowNeedsAttention` excepted, since it frames
69
+ the backend's narration verbatim), and `WireSurfaceBuilder.affordanceOffer`
70
+ carries the offered instances verbatim.
61
71
  - `workflow-attention-fixtures.ts` — explained, fact-free outcomes for those
62
72
  tests and the registry-wide fixtures. A COMPILED module, so each fixture is
63
73
  typechecked against `ExplainedWorkflowOutcome`.
@@ -82,7 +82,7 @@ describe("proactive chat templates", () => {
82
82
  orgName: "Acme",
83
83
  joinerDisplayName: "Ada",
84
84
  });
85
- expect(msg.text).toContain("Org settings");
85
+ expect(msg.affordances).toEqual([{ id: "workspace.openOrgSettings" }]);
86
86
  expect(msg.text).toContain("relocation");
87
87
  expect(msg.replies.length).toBeLessThanOrEqual(PROACTIVE_CHAT_MAX_REPLIES);
88
88
  });
@@ -102,7 +102,6 @@ describe("proactive chat templates", () => {
102
102
  "AI usage",
103
103
  "Spending controls",
104
104
  "Billing",
105
- "My settings",
106
105
  ]) {
107
106
  expect(msg.text, section).toContain(section);
108
107
  }
@@ -131,7 +130,9 @@ describe("proactive chat templates", () => {
131
130
  expect(msg.text, trigger).not.toContain("accepted your invite");
132
131
  // The half that does not vary: the relocation is the same either way.
133
132
  expect(msg.text, trigger).toContain("relocation, not an addition");
134
- expect(msg.text, trigger).toContain("Org settings");
133
+ expect(msg.affordances, trigger).toEqual([
134
+ { id: "workspace.openOrgSettings" },
135
+ ]);
135
136
  }
136
137
  });
137
138
 
@@ -149,9 +150,9 @@ describe("proactive chat templates", () => {
149
150
  "Ada accepted your invite, so Acme moved from a personal workspace to a " +
150
151
  "shared one.\n\n" +
151
152
  "This is a relocation, not an addition: Integrations, Invite team " +
152
- "members, AI usage, Spending controls and Billing have moved out of My " +
153
- "settings and into Org settings. Nothing was removed — the five sections now " +
154
- "belong to the workspace rather than to you.",
153
+ "members, AI usage, Spending controls and Billing have moved from your " +
154
+ "own area to the workspace's. Nothing was removed — the five sections " +
155
+ "now belong to the workspace rather than to you.",
155
156
  );
156
157
  });
157
158
 
@@ -0,0 +1,124 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { PROJECTED_AFFORDANCE_IDS } from "../../affordances/affordance";
4
+ import { WireSurfaceBuilder } from "../../message-parts/wire";
5
+ import type { ProactiveChatMessage } from "../composer";
6
+ import type { ProactiveChatFacts } from "../facts";
7
+ import {
8
+ PROACTIVE_CHAT_TEMPLATES,
9
+ type ProactiveChatTemplate,
10
+ } from "../surfaces";
11
+ import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
12
+ import {
13
+ DRIVE_AUTH_NARRATION,
14
+ DRIVE_AUTH_OUTCOME,
15
+ } from "./workflow-attention-fixtures";
16
+
17
+ /**
18
+ * Pushed chats offer next steps as affordance instances, never as chips or
19
+ * prose (control ADR affordance-system; PRD-01137).
20
+ */
21
+ const CALL_TO_ACTION =
22
+ /\byou can\b|\btry\b|\bagain\b|\bsettings\b|\breconnect\b|\badd funds\b|\bclick\b|\bgo to\b|\bopen\b/i;
23
+
24
+ const FACTS: { readonly [T in ProactiveChatTemplate]: ProactiveChatFacts[T] } =
25
+ {
26
+ orgBecameShared: {
27
+ trigger: "member_added",
28
+ orgName: "Acme",
29
+ joinerDisplayName: "Ada",
30
+ },
31
+ morningBrief: {
32
+ recipientDisplayName: "Ada",
33
+ orgName: "Acme",
34
+ orgLocalDateLabel: "Tuesday 25 August",
35
+ pendingActionItemCount: 2,
36
+ unreadNotificationCount: 5,
37
+ },
38
+ billingBalanceExhausted: { orgName: "Acme", autoRechargeEnabled: false },
39
+ billingAutoRechargeFailed: { orgName: "Acme", paymentMethodPresent: true },
40
+ billingPaymentPastDue: { orgName: "Acme", paymentMethodPresent: false },
41
+ billingSuspended: { orgName: "Acme" },
42
+ billingSubscriptionCanceled: { orgName: "Acme" },
43
+ sourceSyncReviewReady: { orgName: "Acme", checkpoint: "synthesis" },
44
+ workflowNeedsAttention: {
45
+ narration: DRIVE_AUTH_NARRATION,
46
+ outcome: { workflowId: "wf-1", outcome: DRIVE_AUTH_OUTCOME },
47
+ },
48
+ };
49
+
50
+ function compose(template: ProactiveChatTemplate): ProactiveChatMessage {
51
+ const fn = PROACTIVE_CHAT_COMPOSERS[template].compose as (
52
+ facts: ProactiveChatFacts[ProactiveChatTemplate],
53
+ ) => ProactiveChatMessage;
54
+ return fn(FACTS[template]);
55
+ }
56
+
57
+ const BILLING = [
58
+ "billingBalanceExhausted",
59
+ "billingAutoRechargeFailed",
60
+ "billingPaymentPastDue",
61
+ "billingSuspended",
62
+ "billingSubscriptionCanceled",
63
+ ] as const;
64
+
65
+ describe("proactive chat offers", () => {
66
+ it("every composer returns an explicit affordance offer", () => {
67
+ for (const template of PROACTIVE_CHAT_TEMPLATES) {
68
+ expect(Array.isArray(compose(template).affordances), template).toBe(true);
69
+ }
70
+ });
71
+
72
+ it("billing chats offer billing.open and no add-funds chip", () => {
73
+ for (const template of BILLING) {
74
+ const msg = compose(template);
75
+ expect(msg.affordances, template).toEqual([{ id: "billing.open" }]);
76
+ expect(
77
+ msg.replies.map((r) => r.id),
78
+ template,
79
+ ).not.toContain("add-funds");
80
+ expect(
81
+ msg.replies.map((r) => r.id),
82
+ template,
83
+ ).not.toContain("resubscribe");
84
+ }
85
+ });
86
+
87
+ it("org became shared offers the org settings", () => {
88
+ expect(compose("orgBecameShared").affordances).toEqual([
89
+ { id: "workspace.openOrgSettings" },
90
+ ]);
91
+ });
92
+
93
+ it("no composer offers a projected affordance", () => {
94
+ for (const template of PROACTIVE_CHAT_TEMPLATES) {
95
+ for (const affordance of compose(template).affordances) {
96
+ expect(
97
+ PROJECTED_AFFORDANCE_IDS as readonly string[],
98
+ template,
99
+ ).not.toContain(affordance.id);
100
+ }
101
+ }
102
+ });
103
+
104
+ it("no composer text, chip label or chip prompt carries call-to-action wording", () => {
105
+ for (const template of PROACTIVE_CHAT_TEMPLATES) {
106
+ if (template === "workflowNeedsAttention") continue; // frames the backend's narration verbatim
107
+ const msg = compose(template);
108
+ expect(msg.title, template).not.toMatch(CALL_TO_ACTION);
109
+ expect(msg.text, template).not.toMatch(CALL_TO_ACTION);
110
+ for (const reply of msg.replies) {
111
+ expect(reply.label, template).not.toMatch(CALL_TO_ACTION);
112
+ expect(reply.prompt ?? "", template).not.toMatch(CALL_TO_ACTION);
113
+ }
114
+ }
115
+ });
116
+
117
+ it("the affordance offer part carries the offered instances verbatim", () => {
118
+ const affordances = compose("billingSuspended").affordances;
119
+ expect(WireSurfaceBuilder.affordanceOffer({ affordances })).toEqual({
120
+ type: "data-affordance-offer",
121
+ data: { affordances: [{ id: "billing.open" }] },
122
+ });
123
+ });
124
+ });
@@ -490,7 +490,7 @@ describe("the kind registry", () => {
490
490
  expect(reply.label, checkpoint).not.toMatch(/[0-9]/);
491
491
  expect(reply.prompt ?? "", checkpoint).not.toMatch(/[0-9]/);
492
492
  }
493
- expect(msg.replies, checkpoint).toHaveLength(3);
493
+ expect(msg.replies, checkpoint).toHaveLength(2);
494
494
  }
495
495
  });
496
496
 
@@ -34,6 +34,7 @@
34
34
  * reader would be a lie about the conversation.
35
35
  */
36
36
 
37
+ import type { OfferedAffordance } from "../affordances/affordance";
37
38
  import type { SuggestedReply } from "../message-parts/suggested-replies";
38
39
  import type { WorkflowOutcomeData } from "../message-parts/workflow-outcome";
39
40
  import type { ProactiveChatFacts } from "./facts";
@@ -66,6 +67,16 @@ export interface ProactiveChatMessage {
66
67
  readonly text: string;
67
68
  /** 0..`PROACTIVE_CHAT_MAX_REPLIES` chips. */
68
69
  readonly replies: readonly SuggestedReply[];
70
+ /**
71
+ * The next steps this chat OFFERS, frozen with it (control ADR
72
+ * affordance-system). A chip only sends a chat prompt, so a next step is
73
+ * never a chip: it is an affordance instance the chat writer persists as a
74
+ * `data-affordance-offer` part and the app renders as a control, re-gated per
75
+ * viewer. REQUIRED — an empty list is an explicit decision. A projected id
76
+ * (`review.open`) is never offered here. A template that reports a workflow
77
+ * outcome leaves this empty: the outcome carries its own offer.
78
+ */
79
+ readonly affordances: readonly OfferedAffordance[];
69
80
  /**
70
81
  * The settled workflow a template reports, when it reports one. The chat
71
82
  * writer persists it as a `data-workflow-outcome` part beside the prose, so
@@ -31,9 +31,17 @@ chat. That is the precedent notify/'s README states for outbound notifications
31
31
  - At most `PROACTIVE_CHAT_MAX_REPLIES` chips. More is a menu, not a chip row.
32
32
  - A file per template, named after it in kebab-case. Templates do not share a
33
33
  module — the point is that a template is self-contained.
34
- - `org-became-shared.ts` NAMES THE RELOCATION: the five sections that leave My
35
- settings for Org settings. Unannounced, that reads as things going missing
36
- (the app ADR with slug `nav-posture-opens-on-invite-send`).
34
+ - `org-became-shared.ts` NAMES THE RELOCATION: the five sections that moved
35
+ from the person's own area to the workspace's. Unannounced, that reads as
36
+ things going missing (the app ADR with slug
37
+ `nav-posture-opens-on-invite-send`). The destination is the
38
+ `workspace.openOrgSettings` affordance it offers, not prose.
39
+ - **Every composer returns an explicit `affordances` offer** (control ADR
40
+ `affordance-system`): the five billing templates `[{ id: "billing.open" }]`,
41
+ `org-became-shared.ts` `[{ id: "workspace.openOrgSettings" }]`, the rest
42
+ `[]`. A next step is never a chip (a chip only sends a prompt) and never
43
+ prose telling the reader where to go; a projected id is never offered.
44
+ `../__tests__/proactive-offers.test.ts` pins all of it.
37
45
  - `morning-brief.ts` PERSONALIZES THROUGH THE FACTS, never the occurrence. One
38
46
  `brief.morning` occurrence per org per org-local day is shared by every
39
47
  recipient; two people's briefs differ only because the `MorningBriefFacts`
@@ -49,5 +49,6 @@ export const billingAutoRechargeFailedComposer: ProactiveChatComposer<"billingAu
49
49
  prompt: "Can I add AI funds manually instead of waiting for a retry?",
50
50
  },
51
51
  ],
52
+ affordances: [{ id: "billing.open" }],
52
53
  }),
53
54
  };
@@ -29,7 +29,7 @@ export const billingBalanceExhaustedComposer: ProactiveChatComposer<"billingBala
29
29
  text:
30
30
  `${facts.orgName} has run out of AI balance, so AI work is paused. ` +
31
31
  `Nothing was lost — every chat, document and work item is exactly ` +
32
- `where you left it, and the assistant starts answering again as soon ` +
32
+ `where you left it, and the assistant resumes answering as soon ` +
33
33
  `as the balance is topped up.\n\n` +
34
34
  (facts.autoRechargeEnabled
35
35
  ? `Automatic top-up is switched on, so this usually clears itself ` +
@@ -41,11 +41,6 @@ export const billingBalanceExhaustedComposer: ProactiveChatComposer<"billingBala
41
41
  `\n\nThe current balance is on the billing page, which is the only ` +
42
42
  `place it is guaranteed to be up to date.`,
43
43
  replies: [
44
- {
45
- id: "add-funds",
46
- label: "How do I add funds?",
47
- prompt: "How do I add AI funds to this workspace?",
48
- },
49
44
  {
50
45
  id: "auto-top-up",
51
46
  label: "Explain automatic top-up",
@@ -59,5 +54,6 @@ export const billingBalanceExhaustedComposer: ProactiveChatComposer<"billingBala
59
54
  "What exactly stops working while AI is paused, and what keeps working?",
60
55
  },
61
56
  ],
57
+ affordances: [{ id: "billing.open" }],
62
58
  }),
63
59
  };
@@ -53,5 +53,6 @@ export const billingPaymentPastDueComposer: ProactiveChatComposer<"billingPaymen
53
53
  "What happens to my workspace if this payment stays unresolved?",
54
54
  },
55
55
  ],
56
+ affordances: [{ id: "billing.open" }],
56
57
  }),
57
58
  };
@@ -47,11 +47,7 @@ export const billingSubscriptionCanceledComposer: ProactiveChatComposer<"billing
47
47
  prompt:
48
48
  "What happens to the AI funds already in my wallet after cancellation?",
49
49
  },
50
- {
51
- id: "resubscribe",
52
- label: "How do I resubscribe?",
53
- prompt: "How do I start the seat subscription again?",
54
- },
55
50
  ],
51
+ affordances: [{ id: "billing.open" }],
56
52
  }),
57
53
  };
@@ -49,5 +49,6 @@ export const billingSuspendedComposer: ProactiveChatComposer<"billingSuspended">
49
49
  "Is any of my workspace data at risk while this is suspended?",
50
50
  },
51
51
  ],
52
+ affordances: [{ id: "billing.open" }],
52
53
  }),
53
54
  };
@@ -73,5 +73,6 @@ export const morningBriefComposer: ProactiveChatComposer<"morningBrief"> = {
73
73
  prompt: "What needs a decision from me today?",
74
74
  },
75
75
  ],
76
+ affordances: [],
76
77
  }),
77
78
  };
@@ -74,9 +74,9 @@ export const orgBecameSharedComposer: ProactiveChatComposer<"orgBecameShared"> =
74
74
  text:
75
75
  `${openingFor(facts)}\n\n` +
76
76
  `This is a relocation, not an addition: Integrations, Invite team ` +
77
- `members, AI usage, Spending controls and Billing have moved out of My ` +
78
- `settings and into Org settings. Nothing was removed — the five sections now ` +
79
- `belong to the workspace rather than to you.`,
77
+ `members, AI usage, Spending controls and Billing have moved from your ` +
78
+ `own area to the workspace's. Nothing was removed — the five sections ` +
79
+ `now belong to the workspace rather than to you.`,
80
80
  replies: [
81
81
  {
82
82
  id: "plain-english",
@@ -96,5 +96,6 @@ export const orgBecameSharedComposer: ProactiveChatComposer<"orgBecameShared"> =
96
96
  prompt: "Who can see what in my workspace now that it is shared?",
97
97
  },
98
98
  ],
99
+ affordances: [{ id: "workspace.openOrgSettings" }],
99
100
  }),
100
101
  };
@@ -36,17 +36,11 @@ export const sourceSyncReviewReadyComposer: ProactiveChatComposer<"sourceSyncRev
36
36
  `strategy documents and have proposed changes for you to ` +
37
37
  `review.\n\n` +
38
38
  `Nothing has been changed. Each document is updated only when ` +
39
- `you say so, as an ordinary version you can restore, and ` +
39
+ `you say so, as an ordinary version that can be restored, and ` +
40
40
  `anything a person has edited is left alone unless you choose ` +
41
41
  `otherwise.`) +
42
- `\n\nOpen the review when you have a few minutes. If you leave it, ` +
43
- `nothing happens.`,
42
+ `\n\nThe review waits for you. If you leave it, nothing happens.`,
44
43
  replies: [
45
- {
46
- id: "open-review",
47
- label: "Open the review",
48
- prompt: "Open the source review.",
49
- },
50
44
  {
51
45
  id: "what-was-found",
52
46
  label: "What did you find?",
@@ -60,5 +54,6 @@ export const sourceSyncReviewReadyComposer: ProactiveChatComposer<"sourceSyncRev
60
54
  "If I attach a source to a document, who will be able to see it?",
61
55
  },
62
56
  ],
57
+ affordances: [],
63
58
  }),
64
59
  };
@@ -30,6 +30,7 @@ export const workflowNeedsAttentionComposer: ProactiveChatComposer<"workflowNeed
30
30
  title: TITLE_OF_WORKFLOW_TYPE[facts.outcome.outcome.workflowType],
31
31
  text: facts.narration,
32
32
  replies: [],
33
+ affordances: [],
33
34
  outcome: facts.outcome,
34
35
  }),
35
36
  };
@@ -35,6 +35,29 @@ interface RateLimitResult {
35
35
  }
36
36
  ```
37
37
 
38
+ ### RateLimitedErrorResponseSchema
39
+
40
+ The 429 body every rate-limited response carries — exactly what backend's
41
+ shared limiter sends, plus the reset fact:
42
+
43
+ ```typescript
44
+ {
45
+ error: "RATE_LIMIT_EXCEEDED"; // RATE_LIMITED_ERROR_CODE
46
+ scope?: "email" | "ip" | "global";
47
+ message: string;
48
+ retryAfterSeconds: number | null; // whole seconds until the window resets
49
+ }
50
+ ```
51
+
52
+ The object is strict and `retryAfterSeconds` is REQUIRED: it mirrors the
53
+ `Retry-After` header, or is null when the limiter cannot say.
54
+
55
+ **Facts only.** `retryAfterSeconds` is a fact, so the message states it
56
+ ("Too many requests. Available in 30s.") and never tells the reader to retry.
57
+ A retry control, if a surface has one, is that surface's own control label
58
+ (control ADR affordance-system). Distinct from `ShedResponse` in
59
+ `src/pressure.ts`, which is the load-shed body only.
60
+
38
61
  ## Semantic Convention
39
62
 
40
63
  This domain uses `allowed: boolean` where `true` means the request is permitted.
@@ -54,9 +77,12 @@ This is the canonical semantic across all Company Semantics repos.
54
77
 
55
78
  ## Public API
56
79
 
80
+ - `RATE_LIMITED_ERROR_CODE`
57
81
  - `RateLimitConfig` _(type)_
58
82
  - `RateLimitResult` _(type)_
59
83
  - `RateLimitTier`
84
+ - `RateLimitedErrorResponse` _(type)_
85
+ - `RateLimitedErrorResponseSchema`
60
86
 
61
87
  <!-- END GENERATED: readme-public-api -->
62
88
 
@@ -70,6 +96,6 @@ _None._
70
96
 
71
97
  **External packages:**
72
98
 
73
- _None._
99
+ - `zod`
74
100
 
75
101
  <!-- END GENERATED: readme-dependencies -->
@@ -0,0 +1,23 @@
1
+ # rate-limit/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Locks the shape of the rate-limited error envelope.
6
+
7
+ - `rate-limited-error.test.ts` — proves a rate-limited body carries
8
+ `retryAfterSeconds` as whole non-negative seconds or null and is refused
9
+ without it, refuses any code other than `RATE_LIMIT_EXCEEDED`, and that the
10
+ schema and code are reachable from the package root as the same binding.
11
+
12
+ ## Invariants
13
+
14
+ - These assert VOCABULARY and SHAPE, never limiter behavior. Window sizes and
15
+ which routes are limited belong to backend.
16
+
17
+ ## Public API
18
+
19
+ None — test-only.
20
+
21
+ ## Dependencies
22
+
23
+ `vitest`, `../index` and the package root.