@company-semantics/contracts 91.1.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 (68) hide show
  1. package/package.json +11 -10
  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 +401 -11
  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
  62. package/src/source-sync/README.md +2 -1
  63. package/src/source-sync/__tests__/approval-schemas.test.ts +2 -13
  64. package/src/source-sync/__tests__/current-synthesis.test.ts +1 -0
  65. package/src/source-sync/__tests__/schemas.test.ts +5 -32
  66. package/src/source-sync/schemas.ts +6 -15
  67. package/src/workflows/deferred-route.ts +2 -0
  68. package/src/workflows/outcome-reasons.ts +4 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "91.1.0",
3
+ "version": "93.0.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -142,27 +142,27 @@
142
142
  "node": "22.x"
143
143
  },
144
144
  "dependencies": {
145
- "@slack/types": "^3.1.0",
145
+ "@slack/types": "^3.2.0",
146
146
  "zod": "^4.6.5"
147
147
  },
148
148
  "devDependencies": {
149
149
  "@eslint/js": "^10.0.1",
150
- "@types/node": "^22.20.4",
150
+ "@types/node": "^22.20.5",
151
151
  "amphtml-validator": "^1.0.38",
152
152
  "culori": "^4.0.2",
153
- "dependency-cruiser": "18.4.0",
154
- "eslint": "^10.11.0",
153
+ "dependency-cruiser": "18.5.0",
154
+ "eslint": "^10.12.0",
155
155
  "husky": "^9.1.7",
156
- "knip": "6.38.0",
156
+ "knip": "6.39.0",
157
157
  "lint-staged": "^17.6.0",
158
158
  "markdownlint-cli2": "^0.23.3",
159
159
  "openapi-typescript": "^7.13.0",
160
160
  "prettier": "^3.9.9",
161
161
  "tsx": "^4.23.15",
162
162
  "typescript": "^5.8.3",
163
- "typescript-eslint": "^8.70.1",
164
- "vite": "^8.3.1",
165
- "vitest": "^5.0.2",
163
+ "typescript-eslint": "^8.71.0",
164
+ "vite": "^8.3.2",
165
+ "vitest": "^5.0.3",
166
166
  "yaml": "^2.9.1"
167
167
  },
168
168
  "pnpm": {
@@ -179,6 +179,7 @@
179
179
  "esbuild@<0.28.1": ">=0.28.1",
180
180
  "linkify-it@<5.0.2": ">=5.0.2",
181
181
  "postcss@<8.5.23": ">=8.5.23",
182
+ "source-map-js@<1.2.2": ">=1.2.2",
182
183
  "markdown-it@<14.2.0": ">=14.2.0",
183
184
  "@humanfs/node@<0.16.8": ">=0.16.8 <0.17.0"
184
185
  }
@@ -188,5 +189,5 @@
188
189
  "*.md": "markdownlint-cli2",
189
190
  "package.json": "node -e \"JSON.parse(require('fs').readFileSync('package.json'))\""
190
191
  },
191
- "securityRequirementsVersion": "8700b9fe36f8c948f12970ec54128e4f1955986dd9b634114f7501fdb1884267"
192
+ "securityRequirementsVersion": "4b88755e9a43a82b2f5eb55b953581526f1982de362da77bc579888d1f6a1bb0"
192
193
  }
@@ -42,6 +42,13 @@ absent from the next read. There is no clearing code to forget to write.
42
42
  - **`href` must RESOLVE the item, not merely show it.** An item you can only
43
43
  look at is a notification. This is what keeps the layer honest about being
44
44
  actionable-only.
45
+ - **`affordance` is optional; `href` is the fallback.** When a catalog
46
+ affordance resolves the item (control ADR `affordance-system`), the provider
47
+ sets it and a surface that can render a real control renders that —
48
+ re-gated per viewer, executed by the server. When it is absent, the surface
49
+ links to `href`. Any instance is allowed, including the projected
50
+ `review.open`: an item is derived on read and never stored, so nothing is
51
+ stored twice.
45
52
  - **`id` is `${kind}:${sourceRowId}`** — a function of the source row, stable
46
53
  across reads. It is not an id of its own, because there is no row of its own.
47
54
  - **`unitPath` is a roll-up ANCHOR, not a materialized ancestor path.** It is
@@ -54,8 +61,9 @@ absent from the next read. There is no clearing code to forget to write.
54
61
  - **Authority is the provider's, never this layer's.** These are shapes; nothing
55
62
  here filters. A provider that returns an item the viewer may not act on is a
56
63
  provider bug, and its test is the one that must catch it.
57
- - Types and pure vocabulary only — no classes, no side effects, no imports
58
- beyond `zod` (vocabulary-guard enforced).
64
+ - Types and pure vocabulary only — no classes, no side effects, no runtime
65
+ imports beyond `zod` and sibling contracts modules (vocabulary-guard
66
+ enforced).
59
67
 
60
68
  ## Kinds
61
69
 
@@ -80,5 +88,5 @@ absent from the next read. There is no clearing code to forget to write.
80
88
 
81
89
  ## Dependencies
82
90
 
83
- `zod`, and `./kinds` from `./schemas`. Nothing else — this domain depends on
84
- nothing outside itself, including `notifications/`.
91
+ `zod`, `./kinds` from `./schemas`, and `../affordances/affordance` for the
92
+ optional `affordance`. Nothing else — in particular not `notifications/`.
@@ -7,6 +7,7 @@
7
7
  * Zod-canonical: schema is the source of truth, the type is inferred.
8
8
  */
9
9
  import { z } from "zod";
10
+ import { AffordanceSchema } from "../affordances/affordance";
10
11
  import { ACTION_ITEM_KINDS } from "./kinds";
11
12
 
12
13
  /**
@@ -91,6 +92,15 @@ export const ActionItemSchema = z.object({
91
92
  * an action item — see `./kinds`.
92
93
  */
93
94
  href: z.string(),
95
+ /**
96
+ * The catalog affordance that resolves it, when one does (control ADR
97
+ * affordance-system). A surface that can render a real control renders this
98
+ * — re-gated per viewer, executed by the server — and falls back to `href`
99
+ * when it is absent. Any instance, including a projected one (`review.open`):
100
+ * an action item is derived on read and never stored, so nothing is stored
101
+ * twice.
102
+ */
103
+ affordance: AffordanceSchema.optional(),
94
104
  createdAt: z.string(),
95
105
  });
96
106
  export type ActionItem = z.infer<typeof ActionItemSchema>;
@@ -43,12 +43,59 @@ only the binding KIND.
43
43
  whether an action executes.
44
44
  - **`MAX_OFFERED_AFFORDANCES` is a sanity bound**, never a presentation cap.
45
45
  How many a surface shows is that surface's decision.
46
+ - **The burn-down wave is appended, never inserted** (PRD-01137). Twelve ids
47
+ follow `review.open`: `integrations.open`, `hris.connect`,
48
+ `account.editProfile`, `workspace.openOrgSettings`, `sourceSync.addFile`,
49
+ `sourceSync.openUpdate`, `orgStructure.openReview`, `chat.open`,
50
+ `companyMd.openDoc`, `companyMd.openHistory`, `team.open`, `execution.open`
51
+ (arguments and binding kinds in [the burn-down wave](#the-burn-down-wave)
52
+ below). An action on one thing names it by its id, never by a URL — the
53
+ binding owns the route. None reads a live availability condition yet; adding
54
+ one is an append.
55
+ - **Reverse coverage is exactly one of two things.** Every capability a person
56
+ can act on declares `{ affordance }` or `{ noAffordance }` with a reason from
57
+ the closed `NO_AFFORDANCE_REASONS` (`readOnly`, `inlineControl`,
58
+ `internalAdmin`, `systemOnly`, `authFlow`), so "nobody decided" is not
59
+ representable.
60
+ - **"What can I do here" is a result shape, not a catalog listing.**
61
+ `AvailableAffordancesSchema` holds the policy-allowed instances the context's
62
+ offer rules produced, each with its live availability — bounded by
63
+ `MAX_OFFERED_AFFORDANCES`, no instance twice, and no policy field, so a
64
+ blocked one cannot leak. Nothing here enumerates the catalog.
65
+
66
+ ## The burn-down wave
67
+
68
+ The twelve ids appended after `review.open` (PRD-01137), in catalog order.
69
+ Arguments come from `AFFORDANCE_ARGS` / `NO_ARG_AFFORDANCE_IDS` (`args.ts`); an
70
+ id with no arguments has no `args` key. The binding kind comes from
71
+ `AFFORDANCE_BINDING_KIND_OF` (`affordance.ts`); none of the twelve is a
72
+ `command`. All twelve read no live availability condition (`null` in
73
+ `AFFORDANCE_AVAILABILITY_OF`).
74
+
75
+ | Id | Args (schema) | Binding kind |
76
+ | --------------------------- | ----------------------------------------------------- | ------------ |
77
+ | `integrations.open` | none | `navigate` |
78
+ | `hris.connect` | none | `surface` |
79
+ | `account.editProfile` | none | `navigate` |
80
+ | `workspace.openOrgSettings` | none | `navigate` |
81
+ | `sourceSync.addFile` | none | `surface` |
82
+ | `sourceSync.openUpdate` | `{ proposalId }` (`SourceSyncOpenUpdateArgsSchema`) | `surface` |
83
+ | `orgStructure.openReview` | `{ proposalId }` (`OrgStructureOpenReviewArgsSchema`) | `surface` |
84
+ | `chat.open` | `{ chatId }` (`ChatOpenArgsSchema`) | `navigate` |
85
+ | `companyMd.openDoc` | `{ docId }` (`CompanyMdDocArgsSchema`) | `navigate` |
86
+ | `companyMd.openHistory` | `{ docId }` (`CompanyMdDocArgsSchema`) | `navigate` |
87
+ | `team.open` | `{ unitId }` (`TeamOpenArgsSchema`) | `navigate` |
88
+ | `execution.open` | `{ executionId }` (`ExecutionOpenArgsSchema`) | `navigate` |
89
+
90
+ Every argument is a non-empty string and every args object is strict.
46
91
 
47
92
  ## Public API
48
93
 
49
94
  - `AFFORDANCE_IDS`, `AffordanceId`, `AffordanceIdSchema` (`ids.ts`).
50
95
  - `AFFORDANCE_ARGS`, `NO_ARG_AFFORDANCE_IDS`, `ReviewOpenArgsSchema`,
51
- `AffordanceArgs` (`args.ts`).
96
+ `SourceSyncOpenUpdateArgsSchema`, `OrgStructureOpenReviewArgsSchema`,
97
+ `ChatOpenArgsSchema`, `CompanyMdDocArgsSchema`, `TeamOpenArgsSchema`,
98
+ `ExecutionOpenArgsSchema`, `AffordanceArgs` (`args.ts`).
52
99
  - `AFFORDANCE_BINDING_KINDS`, `AFFORDANCE_AVAILABILITY_IDS`,
53
100
  `AffordanceAvailabilityIdSchema`, `AFFORDANCE_UNAVAILABLE_REASONS`,
54
101
  `AffordanceAvailabilitySchema` and their types (`availability.ts`).
@@ -56,6 +103,10 @@ only the binding KIND.
56
103
  `AffordanceSchema`, `OfferedAffordanceSchema`, `PROJECTED_AFFORDANCE_IDS`,
57
104
  `AFFORDANCE_BINDING_KIND_OF`, `AFFORDANCE_AVAILABILITY_OF`,
58
105
  `MAX_OFFERED_AFFORDANCES`, `sameAffordances` (`affordance.ts`).
106
+ - `NO_AFFORDANCE_REASONS`, `NoAffordanceReason`, `NoAffordanceReasonSchema`,
107
+ `AffordanceCoverage`, `AffordanceCoverageSchema` (`coverage.ts`).
108
+ - `AvailableAffordance`, `AvailableAffordanceSchema`, `AvailableAffordances`,
109
+ `AvailableAffordancesSchema` (`available.ts`).
59
110
 
60
111
  Published from the package root through `src/root-exports/delivery.ts`.
61
112
 
@@ -13,6 +13,14 @@ Locks the affordance vocabulary and the shape of an affordance instance.
13
13
  canonical args, every schema converts to JSON Schema the way the backend's
14
14
  OpenAPI generation converts it, and every name is reachable from the package
15
15
  root as the same binding.
16
+ - `catalog-wave.test.ts` — pins the twelve burn-down ids (PRD-01137) appended
17
+ after the original seven, their binding kinds and null availability; proves a
18
+ targeted id requires exactly its one non-empty id arg, the no-affordance
19
+ reasons are closed, a coverage declaration is an affordance or a reason and
20
+ never both, an available-affordances result is bounded, distinct and carries
21
+ no policy field, the error envelope and billing refusal part carry
22
+ affordances only when non-empty, and every new name is reachable from the
23
+ package root.
16
24
 
17
25
  ## Invariants
18
26
 
@@ -27,4 +35,5 @@ None — test-only.
27
35
 
28
36
  ## Dependencies
29
37
 
30
- `vitest`, `zod`, `../index`, `../../workflows/outcome` and the package root.
38
+ `vitest`, `zod`, `../index`, `../../workflows/outcome`, `../../api/primitives`,
39
+ `../../billing/refusals` and the package root.
@@ -32,7 +32,7 @@ const schemaMatchesType: Equals<
32
32
  > = true;
33
33
 
34
34
  describe("affordance vocabulary", () => {
35
- it("names the seven affordances, in order", () => {
35
+ it("names the nineteen affordances, in order", () => {
36
36
  expect(AFFORDANCE_IDS).toEqual([
37
37
  "sourceSync.findFiles",
38
38
  "integrations.connectGoogleDrive",
@@ -41,6 +41,18 @@ describe("affordance vocabulary", () => {
41
41
  "orgStructure.reanalyze",
42
42
  "hris.resyncDirectory",
43
43
  "review.open",
44
+ "integrations.open",
45
+ "hris.connect",
46
+ "account.editProfile",
47
+ "workspace.openOrgSettings",
48
+ "sourceSync.addFile",
49
+ "sourceSync.openUpdate",
50
+ "orgStructure.openReview",
51
+ "chat.open",
52
+ "companyMd.openDoc",
53
+ "companyMd.openHistory",
54
+ "team.open",
55
+ "execution.open",
44
56
  ]);
45
57
  expect(schemaMatchesType).toBe(true);
46
58
  });
@@ -0,0 +1,237 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ import * as root from "../../index";
5
+ import * as barrel from "../index";
6
+ import {
7
+ AFFORDANCE_AVAILABILITY_OF,
8
+ AFFORDANCE_BINDING_KIND_OF,
9
+ AFFORDANCE_IDS,
10
+ AffordanceCoverageSchema,
11
+ AffordanceSchema,
12
+ AvailableAffordancesSchema,
13
+ NO_AFFORDANCE_REASONS,
14
+ OfferedAffordanceSchema,
15
+ } from "../index";
16
+ import { ErrorResponseSchema } from "../../api/primitives";
17
+ import {
18
+ BILLING_REFUSAL_CODES,
19
+ BillingRefusalPartSchema,
20
+ } from "../../billing/refusals";
21
+
22
+ // The burn-down wave (PRD-01137): exactly these ids, appended, with these kinds.
23
+ const WAVE = {
24
+ "integrations.open": "navigate",
25
+ "hris.connect": "surface",
26
+ "account.editProfile": "navigate",
27
+ "workspace.openOrgSettings": "navigate",
28
+ "sourceSync.addFile": "surface",
29
+ "sourceSync.openUpdate": "surface",
30
+ "orgStructure.openReview": "surface",
31
+ "chat.open": "navigate",
32
+ "companyMd.openDoc": "navigate",
33
+ "companyMd.openHistory": "navigate",
34
+ "team.open": "navigate",
35
+ "execution.open": "navigate",
36
+ } as const;
37
+
38
+ const TARGET_ARG = {
39
+ "sourceSync.openUpdate": "proposalId",
40
+ "orgStructure.openReview": "proposalId",
41
+ "chat.open": "chatId",
42
+ "companyMd.openDoc": "docId",
43
+ "companyMd.openHistory": "docId",
44
+ "team.open": "unitId",
45
+ "execution.open": "executionId",
46
+ } as const;
47
+
48
+ describe("affordance catalog wave", () => {
49
+ it("appends the twelve burn-down ids after the original seven, in order", () => {
50
+ expect(AFFORDANCE_IDS.slice(7)).toEqual(Object.keys(WAVE));
51
+ expect(AFFORDANCE_IDS).toHaveLength(19);
52
+ });
53
+
54
+ it("every new id has the locked binding kind", () => {
55
+ for (const [id, kind] of Object.entries(WAVE)) {
56
+ expect(AFFORDANCE_BINDING_KIND_OF[id as keyof typeof WAVE], id).toBe(
57
+ kind,
58
+ );
59
+ }
60
+ });
61
+
62
+ it("the burn-down ids read no live availability", () => {
63
+ for (const id of Object.keys(WAVE)) {
64
+ expect(
65
+ AFFORDANCE_AVAILABILITY_OF[id as keyof typeof WAVE],
66
+ id,
67
+ ).toBeNull();
68
+ }
69
+ });
70
+
71
+ it("an id that targets one thing requires its id arg and refuses any other key", () => {
72
+ for (const [id, key] of Object.entries(TARGET_ARG)) {
73
+ expect(
74
+ OfferedAffordanceSchema.safeParse({ id, args: { [key]: "x1" } })
75
+ .success,
76
+ id,
77
+ ).toBe(true);
78
+ expect(OfferedAffordanceSchema.safeParse({ id }).success, id).toBe(false);
79
+ expect(
80
+ OfferedAffordanceSchema.safeParse({ id, args: { [key]: "" } }).success,
81
+ id,
82
+ ).toBe(false);
83
+ expect(
84
+ OfferedAffordanceSchema.safeParse({
85
+ id,
86
+ args: { [key]: "x1", url: "/x" },
87
+ }).success,
88
+ id,
89
+ ).toBe(false);
90
+ }
91
+ for (const id of [
92
+ "integrations.open",
93
+ "hris.connect",
94
+ "account.editProfile",
95
+ "workspace.openOrgSettings",
96
+ "sourceSync.addFile",
97
+ ]) {
98
+ expect(OfferedAffordanceSchema.safeParse({ id }).success, id).toBe(true);
99
+ expect(
100
+ OfferedAffordanceSchema.safeParse({ id, args: {} }).success,
101
+ id,
102
+ ).toBe(false);
103
+ }
104
+ });
105
+
106
+ it("the new instance schemas convert to JSON Schema", () => {
107
+ expect(() =>
108
+ z.toJSONSchema(AffordanceSchema, { io: "input" }),
109
+ ).not.toThrow();
110
+ expect(() =>
111
+ z.toJSONSchema(AvailableAffordancesSchema, { io: "input" }),
112
+ ).not.toThrow();
113
+ expect(() =>
114
+ z.toJSONSchema(AffordanceCoverageSchema, { io: "input" }),
115
+ ).not.toThrow();
116
+ });
117
+
118
+ it("the no-affordance reasons are closed, in order", () => {
119
+ expect(NO_AFFORDANCE_REASONS).toEqual([
120
+ "readOnly",
121
+ "inlineControl",
122
+ "internalAdmin",
123
+ "systemOnly",
124
+ "authFlow",
125
+ ]);
126
+ });
127
+
128
+ it("a coverage declaration names an affordance or a closed reason, never both", () => {
129
+ expect(
130
+ AffordanceCoverageSchema.safeParse({ affordance: "billing.open" })
131
+ .success,
132
+ ).toBe(true);
133
+ expect(
134
+ AffordanceCoverageSchema.safeParse({ noAffordance: "authFlow" }).success,
135
+ ).toBe(true);
136
+ expect(
137
+ AffordanceCoverageSchema.safeParse({
138
+ affordance: "billing.open",
139
+ noAffordance: "authFlow",
140
+ }).success,
141
+ ).toBe(false);
142
+ expect(
143
+ AffordanceCoverageSchema.safeParse({ noAffordance: "because" }).success,
144
+ ).toBe(false);
145
+ expect(AffordanceCoverageSchema.safeParse({}).success).toBe(false);
146
+ });
147
+
148
+ it("an available-affordances result is bounded, distinct and policy-filtered", () => {
149
+ const entry = {
150
+ affordance: { id: "billing.open" },
151
+ availability: { state: "available" },
152
+ };
153
+ expect(
154
+ AvailableAffordancesSchema.safeParse({ affordances: [] }).success,
155
+ ).toBe(true);
156
+ expect(
157
+ AvailableAffordancesSchema.safeParse({ affordances: [entry] }).success,
158
+ ).toBe(true);
159
+ expect(
160
+ AvailableAffordancesSchema.safeParse({ affordances: [entry, entry] })
161
+ .success,
162
+ ).toBe(false);
163
+ expect(
164
+ AvailableAffordancesSchema.safeParse({
165
+ affordances: [{ ...entry, policy: "allowed" }],
166
+ }).success,
167
+ ).toBe(false);
168
+ const many = Array.from({ length: 17 }, (_, i) => ({
169
+ affordance: { id: "chat.open", args: { chatId: `c${i}` } },
170
+ availability: { state: "available" },
171
+ }));
172
+ expect(
173
+ AvailableAffordancesSchema.safeParse({ affordances: many }).success,
174
+ ).toBe(false);
175
+ });
176
+
177
+ it("the error envelope and the billing refusal part offer affordances only when there are some", () => {
178
+ const err = (extra: Record<string, unknown>) => ({
179
+ error: {
180
+ code: "FORBIDDEN",
181
+ message: "Billing is paused.",
182
+ retryable: false,
183
+ ...extra,
184
+ },
185
+ });
186
+ expect(ErrorResponseSchema.safeParse(err({})).success).toBe(true);
187
+ expect(
188
+ ErrorResponseSchema.safeParse(
189
+ err({ affordances: [{ id: "billing.open" }] }),
190
+ ).success,
191
+ ).toBe(true);
192
+ expect(
193
+ ErrorResponseSchema.safeParse(err({ affordances: [] })).success,
194
+ ).toBe(false);
195
+ expect(
196
+ ErrorResponseSchema.safeParse(
197
+ err({ affordances: [{ id: "billing.nope" }] }),
198
+ ).success,
199
+ ).toBe(false);
200
+ const part = {
201
+ code: BILLING_REFUSAL_CODES[0],
202
+ orgId: "org-1",
203
+ retryable: false,
204
+ };
205
+ expect(BillingRefusalPartSchema.safeParse(part).success).toBe(true);
206
+ expect(
207
+ BillingRefusalPartSchema.safeParse({
208
+ ...part,
209
+ affordances: [{ id: "billing.open" }],
210
+ }).success,
211
+ ).toBe(true);
212
+ expect(
213
+ BillingRefusalPartSchema.safeParse({ ...part, affordances: [] }).success,
214
+ ).toBe(false);
215
+ });
216
+
217
+ it("every new name is reachable from the package root as the same binding", () => {
218
+ for (const name of [
219
+ "AffordanceCoverageSchema",
220
+ "AvailableAffordanceSchema",
221
+ "AvailableAffordancesSchema",
222
+ "ChatOpenArgsSchema",
223
+ "CompanyMdDocArgsSchema",
224
+ "ExecutionOpenArgsSchema",
225
+ "NO_AFFORDANCE_REASONS",
226
+ "NoAffordanceReasonSchema",
227
+ "OrgStructureOpenReviewArgsSchema",
228
+ "SourceSyncOpenUpdateArgsSchema",
229
+ "TeamOpenArgsSchema",
230
+ ] as const) {
231
+ expect((root as Record<string, unknown>)[name], name).toBeDefined();
232
+ expect((root as Record<string, unknown>)[name], name).toBe(
233
+ (barrel as Record<string, unknown>)[name],
234
+ );
235
+ }
236
+ });
237
+ });
@@ -14,7 +14,16 @@
14
14
  */
15
15
  import { z } from "zod";
16
16
 
17
- import { ReviewOpenArgsSchema, type AffordanceArgs } from "./args";
17
+ import {
18
+ ChatOpenArgsSchema,
19
+ CompanyMdDocArgsSchema,
20
+ ExecutionOpenArgsSchema,
21
+ OrgStructureOpenReviewArgsSchema,
22
+ ReviewOpenArgsSchema,
23
+ SourceSyncOpenUpdateArgsSchema,
24
+ TeamOpenArgsSchema,
25
+ type AffordanceArgs,
26
+ } from "./args";
18
27
  import type {
19
28
  AffordanceAvailabilityId,
20
29
  AffordanceBindingKind,
@@ -44,6 +53,11 @@ export type OfferedAffordance = Exclude<
44
53
  const noArgs = <K extends AffordanceId>(id: K) =>
45
54
  z.object({ id: z.literal(id) }).strict();
46
55
 
56
+ const withArgs = <K extends AffordanceId, A extends z.ZodTypeAny>(
57
+ id: K,
58
+ args: A,
59
+ ) => z.object({ id: z.literal(id), args }).strict();
60
+
47
61
  const OFFERED_MEMBERS = [
48
62
  noArgs("sourceSync.findFiles"),
49
63
  noArgs("integrations.connectGoogleDrive"),
@@ -51,14 +65,24 @@ const OFFERED_MEMBERS = [
51
65
  noArgs("billing.open"),
52
66
  noArgs("orgStructure.reanalyze"),
53
67
  noArgs("hris.resyncDirectory"),
68
+ noArgs("integrations.open"),
69
+ noArgs("hris.connect"),
70
+ noArgs("account.editProfile"),
71
+ noArgs("workspace.openOrgSettings"),
72
+ noArgs("sourceSync.addFile"),
73
+ withArgs("sourceSync.openUpdate", SourceSyncOpenUpdateArgsSchema),
74
+ withArgs("orgStructure.openReview", OrgStructureOpenReviewArgsSchema),
75
+ withArgs("chat.open", ChatOpenArgsSchema),
76
+ withArgs("companyMd.openDoc", CompanyMdDocArgsSchema),
77
+ withArgs("companyMd.openHistory", CompanyMdDocArgsSchema),
78
+ withArgs("team.open", TeamOpenArgsSchema),
79
+ withArgs("execution.open", ExecutionOpenArgsSchema),
54
80
  ] as const;
55
81
 
56
82
  /** Every affordance instance, validated per id. */
57
83
  export const AffordanceSchema = z.discriminatedUnion("id", [
58
84
  ...OFFERED_MEMBERS,
59
- z
60
- .object({ id: z.literal("review.open"), args: ReviewOpenArgsSchema })
61
- .strict(),
85
+ withArgs("review.open", ReviewOpenArgsSchema),
62
86
  ]);
63
87
 
64
88
  /** What may be persisted inside an offer: every member except a projection. */
@@ -76,6 +100,18 @@ export const AFFORDANCE_BINDING_KIND_OF = {
76
100
  "orgStructure.reanalyze": "command",
77
101
  "hris.resyncDirectory": "command",
78
102
  "review.open": "surface",
103
+ "integrations.open": "navigate",
104
+ "hris.connect": "surface",
105
+ "account.editProfile": "navigate",
106
+ "workspace.openOrgSettings": "navigate",
107
+ "sourceSync.addFile": "surface",
108
+ "sourceSync.openUpdate": "surface",
109
+ "orgStructure.openReview": "surface",
110
+ "chat.open": "navigate",
111
+ "companyMd.openDoc": "navigate",
112
+ "companyMd.openHistory": "navigate",
113
+ "team.open": "navigate",
114
+ "execution.open": "navigate",
79
115
  } as const satisfies Record<AffordanceId, AffordanceBindingKind>;
80
116
 
81
117
  /** The live condition each affordance's availability is read from, if any. */
@@ -88,6 +124,22 @@ export const AFFORDANCE_AVAILABILITY_OF = {
88
124
  "orgStructure.reanalyze": null,
89
125
  "hris.resyncDirectory": "hris.connected",
90
126
  "review.open": null,
127
+ // The burn-down wave reads no live condition: a navigate target is always
128
+ // reachable (its page renders its own state), and the two surfaces open a
129
+ // dialog that shows whether the HR system is connected / which files are
130
+ // already synced. A later availability id is an append, never a change.
131
+ "integrations.open": null,
132
+ "hris.connect": null,
133
+ "account.editProfile": null,
134
+ "workspace.openOrgSettings": null,
135
+ "sourceSync.addFile": null,
136
+ "sourceSync.openUpdate": null,
137
+ "orgStructure.openReview": null,
138
+ "chat.open": null,
139
+ "companyMd.openDoc": null,
140
+ "companyMd.openHistory": null,
141
+ "team.open": null,
142
+ "execution.open": null,
91
143
  } as const satisfies Record<AffordanceId, AffordanceAvailabilityId | null>;
92
144
 
93
145
  /**
@@ -2,7 +2,8 @@
2
2
  * The arguments each affordance carries. An affordance INSTANCE is
3
3
  * `{ id, args }`, never a bare id, so an affordance that one day needs a
4
4
  * target (a document, an import, a finding) is a new member here, not a new
5
- * persisted shape. Every v1 org-scoped action takes no arguments.
5
+ * persisted shape. An org-scoped action takes no arguments; an action on one
6
+ * thing names it by its id, never by a URL (the binding owns the route).
6
7
  */
7
8
  import { z } from "zod";
8
9
 
@@ -17,6 +18,36 @@ export const ReviewOpenArgsSchema = z
17
18
  })
18
19
  .strict();
19
20
 
21
+ /** `sourceSync.openUpdate`: which Source Sync update (proposal). */
22
+ export const SourceSyncOpenUpdateArgsSchema = z
23
+ .object({ proposalId: z.string().min(1) })
24
+ .strict();
25
+
26
+ /** `orgStructure.openReview`: which org-structure proposal's chat review. */
27
+ export const OrgStructureOpenReviewArgsSchema = z
28
+ .object({ proposalId: z.string().min(1) })
29
+ .strict();
30
+
31
+ /** `chat.open`: which chat. */
32
+ export const ChatOpenArgsSchema = z
33
+ .object({ chatId: z.string().min(1) })
34
+ .strict();
35
+
36
+ /** `companyMd.openDoc` / `companyMd.openHistory`: which document. */
37
+ export const CompanyMdDocArgsSchema = z
38
+ .object({ docId: z.string().min(1) })
39
+ .strict();
40
+
41
+ /** `team.open`: which team (org unit). */
42
+ export const TeamOpenArgsSchema = z
43
+ .object({ unitId: z.string().min(1) })
44
+ .strict();
45
+
46
+ /** `execution.open`: which execution. */
47
+ export const ExecutionOpenArgsSchema = z
48
+ .object({ executionId: z.string().min(1) })
49
+ .strict();
50
+
20
51
  /**
21
52
  * Argument schemas, ONLY for ids that take arguments. An id that takes none is
22
53
  * listed in NO_ARG_AFFORDANCE_IDS and its instance OMITS `args` entirely: there
@@ -27,6 +58,13 @@ export const ReviewOpenArgsSchema = z
27
58
  */
28
59
  export const AFFORDANCE_ARGS = {
29
60
  "review.open": ReviewOpenArgsSchema,
61
+ "sourceSync.openUpdate": SourceSyncOpenUpdateArgsSchema,
62
+ "orgStructure.openReview": OrgStructureOpenReviewArgsSchema,
63
+ "chat.open": ChatOpenArgsSchema,
64
+ "companyMd.openDoc": CompanyMdDocArgsSchema,
65
+ "companyMd.openHistory": CompanyMdDocArgsSchema,
66
+ "team.open": TeamOpenArgsSchema,
67
+ "execution.open": ExecutionOpenArgsSchema,
30
68
  } as const;
31
69
 
32
70
  /** The ids whose instances carry no `args` key. */
@@ -37,6 +75,11 @@ export const NO_ARG_AFFORDANCE_IDS = [
37
75
  "billing.open",
38
76
  "orgStructure.reanalyze",
39
77
  "hris.resyncDirectory",
78
+ "integrations.open",
79
+ "hris.connect",
80
+ "account.editProfile",
81
+ "workspace.openOrgSettings",
82
+ "sourceSync.addFile",
40
83
  ] as const satisfies readonly AffordanceId[];
41
84
 
42
85
  type ArgsId = keyof typeof AFFORDANCE_ARGS;