@company-semantics/contracts 90.0.0 → 91.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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/affordances/README.md +65 -0
  3. package/src/affordances/__tests__/README.md +30 -0
  4. package/src/affordances/__tests__/affordances.test.ts +251 -0
  5. package/src/affordances/affordance.ts +124 -0
  6. package/src/affordances/args.ts +59 -0
  7. package/src/affordances/availability.ts +78 -0
  8. package/src/affordances/ids.ts +45 -0
  9. package/src/affordances/index.ts +39 -0
  10. package/src/api/generated-spec-hash.ts +2 -2
  11. package/src/api/generated.ts +458 -2
  12. package/src/generated/openapi-routes.ts +4 -0
  13. package/src/proactive/README.md +26 -4
  14. package/src/proactive/__tests__/README.md +10 -0
  15. package/src/proactive/__tests__/chat-templates.test.ts +8 -0
  16. package/src/proactive/__tests__/vocabulary.test.ts +11 -0
  17. package/src/proactive/__tests__/workflow-attention-fixtures.ts +49 -0
  18. package/src/proactive/__tests__/workflow-attention.test.ts +146 -0
  19. package/src/proactive/composer.ts +8 -0
  20. package/src/proactive/facts.ts +36 -0
  21. package/src/proactive/index.ts +2 -0
  22. package/src/proactive/kinds.ts +29 -0
  23. package/src/proactive/registry.ts +22 -0
  24. package/src/proactive/surfaces.ts +2 -0
  25. package/src/proactive/templates/README.md +9 -1
  26. package/src/proactive/templates/index.ts +2 -0
  27. package/src/proactive/templates/workflow-needs-attention.ts +35 -0
  28. package/src/root-exports/README.md +1 -0
  29. package/src/root-exports/delivery.ts +69 -1
  30. package/src/user-notifications/README.md +8 -0
  31. package/src/user-notifications/kinds.ts +13 -0
  32. package/src/user-notifications/lifecycle-registry.ts +10 -0
  33. package/src/workflows/README.md +44 -6
  34. package/src/workflows/__tests__/README.md +21 -2
  35. package/src/workflows/__tests__/affordances-of-outcome.test.ts +96 -0
  36. package/src/workflows/__tests__/deferred-route.test.ts +167 -0
  37. package/src/workflows/__tests__/outcome.test.ts +68 -3
  38. package/src/workflows/affordances-of-outcome.ts +64 -0
  39. package/src/workflows/deferred-route.ts +242 -0
  40. package/src/workflows/index.ts +51 -11
  41. package/src/workflows/outcome-reasons.ts +244 -0
  42. package/src/workflows/outcome.ts +50 -148
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "90.0.0",
3
+ "version": "91.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -127,7 +127,7 @@
127
127
  "generate:openapi-routes": "tsx scripts/generate-openapi-routes.ts",
128
128
  "generate:openapi-routes:check": "tsx scripts/generate-openapi-routes.ts --check",
129
129
  "generate:api-types:check": "openapi-typescript openapi/backend.yaml -o /tmp/cs-api-types-check.ts && diff -q src/api/generated.ts /tmp/cs-api-types-check.ts",
130
- "generate:current": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-ci/scripts/generate-current.ts\"",
130
+ "generate:current": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-ci/scripts/generate-current.ts\" --write",
131
131
  "adr:new": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-control/scripts/adr/adr-new-cli.ts\"",
132
132
  "repo-map": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-ci/scripts/generate-repo-map.ts\" --roots src --name company-semantics-contracts --write",
133
133
  "sync:workflow-pins": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-ci/scripts/sync-workflow-pins.ts\" --write",
@@ -0,0 +1,65 @@
1
+ # affordances/
2
+
3
+ The closed vocabulary of next steps the product may offer a person or the
4
+ agent (control ADR `affordance-system`).
5
+
6
+ ## Purpose
7
+
8
+ An affordance names an action the product can actually perform: find files
9
+ again, connect Google Drive, turn Source Sync on, open billing, re-run the
10
+ org-structure analysis, resync the HR directory, open a review. This directory
11
+ holds that vocabulary — the ids, the arguments each id carries, the kind of
12
+ capability each one is bound to and the live condition its availability is
13
+ read from — so every producer, validator and renderer agrees on it.
14
+
15
+ Contracts carries no copy, label, route, operationId or policy key for an
16
+ affordance. Bindings are proven in the repo that owns them; contracts carries
17
+ only the binding KIND.
18
+
19
+ ## Invariants
20
+
21
+ - **Offered only by a rule, never because it exists** (INV-AFFORD-RELEVANT).
22
+ An affordance is offered only by a domain-owned, deterministic offer rule
23
+ that relates a domain state to it (for workflows, the per-reason tables in
24
+ `src/workflows/`). Being in this vocabulary, or being permitted, never offers
25
+ it. Neither a model nor a renderer invents, infers or searches for one.
26
+ - **Instances, not bare ids.** An affordance is `{ id, args? }`. An id that
27
+ takes arguments (`review.open`) requires them; an id that takes none has NO
28
+ `args` key at all (strict), so persisted JSON round-trips unchanged. There is
29
+ no undefined-valued schema anywhere here: Zod 4's JSON-schema conversion
30
+ refuses one, and the backend's OpenAPI generation converts every route
31
+ schema that carries an outcome.
32
+ - **Closed and append-only.** Ids are persisted inside immutable workflow
33
+ outcomes and chat parts; removing one orphans every record that carries it.
34
+ - **Grounded** (INV-AFFORD-GROUNDED). Every id declares a binding kind
35
+ (`command`, `navigate`, `surface`) and the owning repo proves the binding
36
+ exists with an architecture test.
37
+ - **`review.open` is a projection.** It is derived from authoritative state
38
+ already persisted elsewhere (a workflow outcome's `review` disposition) and
39
+ is never persisted inside an offer — `OfferedAffordanceSchema` refuses it.
40
+ - **Availability is live and non-authoritative** (INV-AFFORD-FOUR-STATES).
41
+ `available | already_satisfied | unavailable` (with a closed reason) is read
42
+ at render time; it never mutates a frozen offer, and only the server decides
43
+ whether an action executes.
44
+ - **`MAX_OFFERED_AFFORDANCES` is a sanity bound**, never a presentation cap.
45
+ How many a surface shows is that surface's decision.
46
+
47
+ ## Public API
48
+
49
+ - `AFFORDANCE_IDS`, `AffordanceId`, `AffordanceIdSchema` (`ids.ts`).
50
+ - `AFFORDANCE_ARGS`, `NO_ARG_AFFORDANCE_IDS`, `ReviewOpenArgsSchema`,
51
+ `AffordanceArgs` (`args.ts`).
52
+ - `AFFORDANCE_BINDING_KINDS`, `AFFORDANCE_AVAILABILITY_IDS`,
53
+ `AffordanceAvailabilityIdSchema`, `AFFORDANCE_UNAVAILABLE_REASONS`,
54
+ `AffordanceAvailabilitySchema` and their types (`availability.ts`).
55
+ - `Affordance`, `OfferedAffordance`, `ProjectedAffordanceId`,
56
+ `AffordanceSchema`, `OfferedAffordanceSchema`, `PROJECTED_AFFORDANCE_IDS`,
57
+ `AFFORDANCE_BINDING_KIND_OF`, `AFFORDANCE_AVAILABILITY_OF`,
58
+ `MAX_OFFERED_AFFORDANCES`, `sameAffordances` (`affordance.ts`).
59
+
60
+ Published from the package root through `src/root-exports/delivery.ts`.
61
+
62
+ ## Dependencies
63
+
64
+ `zod` and `../input-requests/types` (the request types `review.open` addresses).
65
+ Otherwise pure vocabulary data and one pure comparison helper.
@@ -0,0 +1,30 @@
1
+ # affordances/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Locks the affordance vocabulary and the shape of an affordance instance.
6
+
7
+ - `affordances.test.ts` — pins the exact membership and order of the ids,
8
+ availability ids, unavailable reasons and binding kinds; proves an id with no
9
+ args parses with no `args` key and refuses one, `review.open` requires a
10
+ registered request type and a non-empty id, a projected id is never an
11
+ offered one, every id declares a binding kind and an availability id (or
12
+ null), unavailable carries a closed reason, offers compare by id, order and
13
+ canonical args, every schema converts to JSON Schema the way the backend's
14
+ OpenAPI generation converts it, and every name is reachable from the package
15
+ root as the same binding.
16
+
17
+ ## Invariants
18
+
19
+ - These assert VOCABULARY and SHAPE, never behavior. Which affordance a domain
20
+ offers belongs to that domain's offer rules and their tests.
21
+ - Membership pins are intentionally exact: a removed id orphans every persisted
22
+ record that carries it.
23
+
24
+ ## Public API
25
+
26
+ None — test-only.
27
+
28
+ ## Dependencies
29
+
30
+ `vitest`, `zod`, `../index`, `../../workflows/outcome` and the package root.
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ import { WorkflowOutcomeSchema } from "../../workflows/outcome";
5
+ import * as root from "../../index";
6
+ import * as barrel from "../index";
7
+ import {
8
+ AFFORDANCE_AVAILABILITY_IDS,
9
+ AFFORDANCE_AVAILABILITY_OF,
10
+ AFFORDANCE_BINDING_KINDS,
11
+ AFFORDANCE_BINDING_KIND_OF,
12
+ AFFORDANCE_IDS,
13
+ AFFORDANCE_ARGS,
14
+ AFFORDANCE_UNAVAILABLE_REASONS,
15
+ AffordanceAvailabilitySchema,
16
+ AffordanceSchema,
17
+ NO_ARG_AFFORDANCE_IDS,
18
+ OfferedAffordanceSchema,
19
+ PROJECTED_AFFORDANCE_IDS,
20
+ sameAffordances,
21
+ type Affordance,
22
+ } from "../index";
23
+
24
+ // Compile-time: the schema's output is exactly the Affordance type.
25
+ type Equals<A, B> =
26
+ (<T>() => T extends A ? 1 : 2) extends <T>() => T extends B ? 1 : 2
27
+ ? true
28
+ : false;
29
+ const schemaMatchesType: Equals<
30
+ Readonly<z.infer<typeof AffordanceSchema>>,
31
+ Affordance
32
+ > = true;
33
+
34
+ describe("affordance vocabulary", () => {
35
+ it("names the seven affordances, in order", () => {
36
+ expect(AFFORDANCE_IDS).toEqual([
37
+ "sourceSync.findFiles",
38
+ "integrations.connectGoogleDrive",
39
+ "sourceSync.enable",
40
+ "billing.open",
41
+ "orgStructure.reanalyze",
42
+ "hris.resyncDirectory",
43
+ "review.open",
44
+ ]);
45
+ expect(schemaMatchesType).toBe(true);
46
+ });
47
+
48
+ it("names the four availability ids and the four unavailable reasons, in order", () => {
49
+ expect(AFFORDANCE_AVAILABILITY_IDS).toEqual([
50
+ "sourceSync.discoveryIdle",
51
+ "integrations.googleDriveDisconnected",
52
+ "sourceSync.disabled",
53
+ "hris.connected",
54
+ ]);
55
+ expect(AFFORDANCE_UNAVAILABLE_REASONS).toEqual([
56
+ "conflicting_operation",
57
+ "missing_dependency",
58
+ "resource_gone",
59
+ "temporarily_disabled",
60
+ ]);
61
+ expect(AFFORDANCE_BINDING_KINDS).toEqual([
62
+ "command",
63
+ "navigate",
64
+ "surface",
65
+ ]);
66
+ });
67
+
68
+ it("an affordance without args parses with no args key", () => {
69
+ expect(AffordanceSchema.safeParse({ id: "billing.open" }).success).toBe(
70
+ true,
71
+ );
72
+ expect(
73
+ AffordanceSchema.safeParse({ id: "billing.open", args: {} }).success,
74
+ ).toBe(false);
75
+ });
76
+
77
+ it("the affordance schema converts to JSON Schema", () => {
78
+ // The backend's scripts/generate-openapi.ts converts route schemas with
79
+ // z.toJSONSchema(schema, { io: "input" }); Zod 4 throws on an
80
+ // undefined-valued schema. Mirror that call on everything a route carries.
81
+ for (const schema of [
82
+ AffordanceSchema,
83
+ OfferedAffordanceSchema,
84
+ AffordanceAvailabilitySchema,
85
+ WorkflowOutcomeSchema,
86
+ ]) {
87
+ expect(() => z.toJSONSchema(schema, { io: "input" })).not.toThrow();
88
+ }
89
+ });
90
+
91
+ it("every id either takes args or is listed as taking none, never both", () => {
92
+ const withArgs = Object.keys(AFFORDANCE_ARGS);
93
+ expect([...withArgs, ...NO_ARG_AFFORDANCE_IDS].sort()).toEqual(
94
+ [...AFFORDANCE_IDS].sort(),
95
+ );
96
+ for (const id of NO_ARG_AFFORDANCE_IDS) expect(withArgs).not.toContain(id);
97
+ });
98
+
99
+ it("refuses an args key on an affordance that takes none", () => {
100
+ expect(
101
+ AffordanceSchema.safeParse({ id: "sourceSync.findFiles", args: null })
102
+ .success,
103
+ ).toBe(false);
104
+ });
105
+
106
+ it("review.open requires a registered request type and an id", () => {
107
+ const ok = {
108
+ id: "review.open",
109
+ args: { requestType: "source-sync/candidates", requestId: "set-1" },
110
+ };
111
+ expect(AffordanceSchema.safeParse(ok).success).toBe(true);
112
+ expect(AffordanceSchema.safeParse({ id: "review.open" }).success).toBe(
113
+ false,
114
+ );
115
+ expect(
116
+ AffordanceSchema.safeParse({
117
+ id: "review.open",
118
+ args: { requestType: "candidates", requestId: "set-1" },
119
+ }).success,
120
+ ).toBe(false);
121
+ expect(
122
+ AffordanceSchema.safeParse({
123
+ id: "review.open",
124
+ args: { requestType: "source-sync/candidates", requestId: "" },
125
+ }).success,
126
+ ).toBe(false);
127
+ });
128
+
129
+ it("refuses an unknown affordance id", () => {
130
+ expect(AffordanceSchema.safeParse({ id: "billing.addFunds" }).success).toBe(
131
+ false,
132
+ );
133
+ });
134
+
135
+ it("a projected affordance is never an offered one", () => {
136
+ for (const id of PROJECTED_AFFORDANCE_IDS) {
137
+ expect(
138
+ OfferedAffordanceSchema.safeParse({
139
+ id,
140
+ args: { requestType: "org-structure/review", requestId: "p-1" },
141
+ }).success,
142
+ ).toBe(false);
143
+ }
144
+ });
145
+
146
+ it("every affordance declares a binding kind and an availability id or null", () => {
147
+ expect(Object.keys(AFFORDANCE_BINDING_KIND_OF).sort()).toEqual(
148
+ [...AFFORDANCE_IDS].sort(),
149
+ );
150
+ expect(Object.keys(AFFORDANCE_AVAILABILITY_OF).sort()).toEqual(
151
+ [...AFFORDANCE_IDS].sort(),
152
+ );
153
+ for (const id of AFFORDANCE_IDS) {
154
+ expect(AFFORDANCE_BINDING_KINDS).toContain(
155
+ AFFORDANCE_BINDING_KIND_OF[id],
156
+ );
157
+ const availability = AFFORDANCE_AVAILABILITY_OF[id];
158
+ if (availability !== null) {
159
+ expect(AFFORDANCE_AVAILABILITY_IDS).toContain(availability);
160
+ }
161
+ }
162
+ });
163
+
164
+ it("unavailable carries a closed reason; available and already_satisfied carry none", () => {
165
+ expect(
166
+ AffordanceAvailabilitySchema.safeParse({ state: "available" }).success,
167
+ ).toBe(true);
168
+ expect(
169
+ AffordanceAvailabilitySchema.safeParse({ state: "already_satisfied" })
170
+ .success,
171
+ ).toBe(true);
172
+ expect(
173
+ AffordanceAvailabilitySchema.safeParse({
174
+ state: "unavailable",
175
+ reason: "conflicting_operation",
176
+ }).success,
177
+ ).toBe(true);
178
+ expect(
179
+ AffordanceAvailabilitySchema.safeParse({ state: "unavailable" }).success,
180
+ ).toBe(false);
181
+ expect(
182
+ AffordanceAvailabilitySchema.safeParse({
183
+ state: "unavailable",
184
+ reason: "Drive is busy",
185
+ }).success,
186
+ ).toBe(false);
187
+ expect(
188
+ AffordanceAvailabilitySchema.safeParse({
189
+ state: "available",
190
+ reason: "conflicting_operation",
191
+ }).success,
192
+ ).toBe(false);
193
+ });
194
+
195
+ it("compares offers by id, order and canonical args", () => {
196
+ const a: Affordance[] = [
197
+ { id: "billing.open" },
198
+ { id: "sourceSync.enable" },
199
+ ];
200
+ expect(
201
+ sameAffordances(a, [{ id: "billing.open" }, { id: "sourceSync.enable" }]),
202
+ ).toBe(true);
203
+ expect(
204
+ sameAffordances(a, [{ id: "sourceSync.enable" }, { id: "billing.open" }]),
205
+ ).toBe(false);
206
+ expect(sameAffordances(a, [{ id: "billing.open" }])).toBe(false);
207
+ expect(
208
+ sameAffordances(
209
+ [
210
+ {
211
+ id: "review.open",
212
+ args: { requestType: "org-structure/review", requestId: "p" },
213
+ },
214
+ ],
215
+ [
216
+ {
217
+ id: "review.open",
218
+ args: { requestId: "p", requestType: "org-structure/review" },
219
+ },
220
+ ],
221
+ ),
222
+ ).toBe(true);
223
+ });
224
+
225
+ it("every affordance name is reachable from the package root as the same binding", () => {
226
+ for (const name of [
227
+ "AFFORDANCE_IDS",
228
+ "AFFORDANCE_ARGS",
229
+ "AFFORDANCE_AVAILABILITY_IDS",
230
+ "AFFORDANCE_AVAILABILITY_OF",
231
+ "AFFORDANCE_BINDING_KINDS",
232
+ "AFFORDANCE_BINDING_KIND_OF",
233
+ "AFFORDANCE_UNAVAILABLE_REASONS",
234
+ "AffordanceIdSchema",
235
+ "AffordanceSchema",
236
+ "AffordanceAvailabilityIdSchema",
237
+ "AffordanceAvailabilitySchema",
238
+ "MAX_OFFERED_AFFORDANCES",
239
+ "NO_ARG_AFFORDANCE_IDS",
240
+ "OfferedAffordanceSchema",
241
+ "PROJECTED_AFFORDANCE_IDS",
242
+ "ReviewOpenArgsSchema",
243
+ "sameAffordances",
244
+ ] as const) {
245
+ expect((root as Record<string, unknown>)[name], name).toBeDefined();
246
+ expect((root as Record<string, unknown>)[name], name).toBe(
247
+ (barrel as Record<string, unknown>)[name],
248
+ );
249
+ }
250
+ });
251
+ });
@@ -0,0 +1,124 @@
1
+ /**
2
+ * An affordance INSTANCE (`{ id, args? }`) and the catalog facts every
3
+ * consumer must agree on: its binding kind and the live condition its
4
+ * availability is read from (control ADR affordance-system).
5
+ *
6
+ * INVARIANTS:
7
+ * - An instance carries `args` exactly when its id takes them; an id that takes
8
+ * none has NO `args` key (strict), so persisted JSON round-trips unchanged.
9
+ * - A PROJECTED id (`review.open`) is derived from other authoritative persisted
10
+ * state and is never persisted inside an offer (`OfferedAffordanceSchema`
11
+ * refuses it): one fact, one stored place.
12
+ * - `MAX_OFFERED_AFFORDANCES` is a SANITY bound against a pathological
13
+ * producer. How many a surface shows is that surface's presentation cap.
14
+ */
15
+ import { z } from "zod";
16
+
17
+ import { ReviewOpenArgsSchema, type AffordanceArgs } from "./args";
18
+ import type {
19
+ AffordanceAvailabilityId,
20
+ AffordanceBindingKind,
21
+ } from "./availability";
22
+ import type { AffordanceId } from "./ids";
23
+
24
+ /** Sanity bound on one offer. Never a presentation cap. */
25
+ export const MAX_OFFERED_AFFORDANCES = 16;
26
+
27
+ /** One affordance instance: an id and, when the id takes them, its args. */
28
+ export type Affordance = {
29
+ [K in AffordanceId]: AffordanceArgs[K] extends undefined
30
+ ? { readonly id: K }
31
+ : { readonly id: K; readonly args: AffordanceArgs[K] };
32
+ }[AffordanceId];
33
+
34
+ /** Ids derived from other persisted state, never stored in an offer. */
35
+ export const PROJECTED_AFFORDANCE_IDS = ["review.open"] as const;
36
+ export type ProjectedAffordanceId = (typeof PROJECTED_AFFORDANCE_IDS)[number];
37
+
38
+ /** An affordance a domain offer rule may produce and persist. */
39
+ export type OfferedAffordance = Exclude<
40
+ Affordance,
41
+ { readonly id: ProjectedAffordanceId }
42
+ >;
43
+
44
+ const noArgs = <K extends AffordanceId>(id: K) =>
45
+ z.object({ id: z.literal(id) }).strict();
46
+
47
+ const OFFERED_MEMBERS = [
48
+ noArgs("sourceSync.findFiles"),
49
+ noArgs("integrations.connectGoogleDrive"),
50
+ noArgs("sourceSync.enable"),
51
+ noArgs("billing.open"),
52
+ noArgs("orgStructure.reanalyze"),
53
+ noArgs("hris.resyncDirectory"),
54
+ ] as const;
55
+
56
+ /** Every affordance instance, validated per id. */
57
+ export const AffordanceSchema = z.discriminatedUnion("id", [
58
+ ...OFFERED_MEMBERS,
59
+ z
60
+ .object({ id: z.literal("review.open"), args: ReviewOpenArgsSchema })
61
+ .strict(),
62
+ ]);
63
+
64
+ /** What may be persisted inside an offer: every member except a projection. */
65
+ export const OfferedAffordanceSchema = z.discriminatedUnion(
66
+ "id",
67
+ OFFERED_MEMBERS,
68
+ );
69
+
70
+ /** Which kind of capability each affordance is bound to. */
71
+ export const AFFORDANCE_BINDING_KIND_OF = {
72
+ "sourceSync.findFiles": "command",
73
+ "integrations.connectGoogleDrive": "command",
74
+ "sourceSync.enable": "command",
75
+ "billing.open": "navigate",
76
+ "orgStructure.reanalyze": "command",
77
+ "hris.resyncDirectory": "command",
78
+ "review.open": "surface",
79
+ } as const satisfies Record<AffordanceId, AffordanceBindingKind>;
80
+
81
+ /** The live condition each affordance's availability is read from, if any. */
82
+ export const AFFORDANCE_AVAILABILITY_OF = {
83
+ "sourceSync.findFiles": "sourceSync.discoveryIdle",
84
+ "integrations.connectGoogleDrive": "integrations.googleDriveDisconnected",
85
+ "sourceSync.enable": "sourceSync.disabled",
86
+ "billing.open": null,
87
+ // The route joins an active run instead of refusing, so nothing to read.
88
+ "orgStructure.reanalyze": null,
89
+ "hris.resyncDirectory": "hris.connected",
90
+ "review.open": null,
91
+ } as const satisfies Record<AffordanceId, AffordanceAvailabilityId | null>;
92
+
93
+ /**
94
+ * Whether two offers are the same: same length, same ids in the same order,
95
+ * and canonically equal args (keys sorted). Order is part of an offer — the
96
+ * producing rule owns it.
97
+ */
98
+ export function sameAffordances(
99
+ a: readonly Affordance[],
100
+ b: readonly Affordance[],
101
+ ): boolean {
102
+ if (a.length !== b.length) return false;
103
+ return a.every((left, i) => {
104
+ const right = b[i];
105
+ return (
106
+ right !== undefined &&
107
+ left.id === right.id &&
108
+ canonical(argsOf(left)) === canonical(argsOf(right))
109
+ );
110
+ });
111
+ }
112
+
113
+ function argsOf(affordance: Affordance): unknown {
114
+ return "args" in affordance ? affordance.args : undefined;
115
+ }
116
+
117
+ function canonical(value: unknown): string {
118
+ if (value === undefined) return "";
119
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
120
+ const entries = Object.entries(value as Record<string, unknown>).sort(
121
+ ([x], [y]) => (x < y ? -1 : x > y ? 1 : 0),
122
+ );
123
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
124
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * The arguments each affordance carries. An affordance INSTANCE is
3
+ * `{ id, args }`, never a bare id, so an affordance that one day needs a
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.
6
+ */
7
+ import { z } from "zod";
8
+
9
+ import { INPUT_REQUEST_TYPES } from "../input-requests/types";
10
+ import type { AffordanceId } from "./ids";
11
+
12
+ /** `review.open`: which review, by its input-request type and id. */
13
+ export const ReviewOpenArgsSchema = z
14
+ .object({
15
+ requestType: z.enum(INPUT_REQUEST_TYPES),
16
+ requestId: z.string().min(1),
17
+ })
18
+ .strict();
19
+
20
+ /**
21
+ * Argument schemas, ONLY for ids that take arguments. An id that takes none is
22
+ * listed in NO_ARG_AFFORDANCE_IDS and its instance OMITS `args` entirely: there
23
+ * is deliberately no way to write an undefined-valued args schema here, because
24
+ * Zod 4's JSON-schema conversion refuses one ("Undefined cannot be represented
25
+ * in JSON Schema") and the backend's OpenAPI generation converts every route
26
+ * schema that carries an outcome.
27
+ */
28
+ export const AFFORDANCE_ARGS = {
29
+ "review.open": ReviewOpenArgsSchema,
30
+ } as const;
31
+
32
+ /** The ids whose instances carry no `args` key. */
33
+ export const NO_ARG_AFFORDANCE_IDS = [
34
+ "sourceSync.findFiles",
35
+ "integrations.connectGoogleDrive",
36
+ "sourceSync.enable",
37
+ "billing.open",
38
+ "orgStructure.reanalyze",
39
+ "hris.resyncDirectory",
40
+ ] as const satisfies readonly AffordanceId[];
41
+
42
+ type ArgsId = keyof typeof AFFORDANCE_ARGS;
43
+ type NoArgId = (typeof NO_ARG_AFFORDANCE_IDS)[number];
44
+
45
+ // Type-level totality: every id is EITHER in AFFORDANCE_ARGS or in
46
+ // NO_ARG_AFFORDANCE_IDS, never both, never neither.
47
+ type Missing = Exclude<AffordanceId, ArgsId | NoArgId>;
48
+ type Both = Extract<ArgsId, NoArgId>;
49
+ const argsCoverEveryIdOnce: [Missing, Both] extends [never, never]
50
+ ? true
51
+ : never = true;
52
+ void argsCoverEveryIdOnce;
53
+
54
+ /** The arguments of each affordance; `undefined` for one that takes none. */
55
+ export type AffordanceArgs = {
56
+ readonly [K in AffordanceId]: K extends ArgsId
57
+ ? z.infer<(typeof AFFORDANCE_ARGS)[K]>
58
+ : undefined;
59
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * How an offered affordance relates to the world right now, and how it is
3
+ * bound to the thing that performs it.
4
+ *
5
+ * Availability is LIVE and NON-AUTHORITATIVE (INV-AFFORD-FOUR-STATES): it never
6
+ * mutates a frozen offer, and only the server decides whether an action
7
+ * executes. It exists so an old offer can read "already connected" instead of
8
+ * a stale button, and so a disabled control says why in a closed reason rather
9
+ * than prose.
10
+ */
11
+ import { z } from "zod";
12
+
13
+ /** What an affordance is bound to. The owning repo proves each binding exists. */
14
+ export const AFFORDANCE_BINDING_KINDS = [
15
+ /** A mounted backend route (by operationId). */
16
+ "command",
17
+ /** An app route (`Route["type"]`). */
18
+ "navigate",
19
+ /** An app launch surface (a `LaunchAnchor` kind). */
20
+ "surface",
21
+ ] as const;
22
+ export type AffordanceBindingKind = (typeof AFFORDANCE_BINDING_KINDS)[number];
23
+
24
+ /** The live conditions an affordance's availability is read from. */
25
+ export const AFFORDANCE_AVAILABILITY_IDS = [
26
+ /**
27
+ * Whether a new drive search could start now: `unavailable`
28
+ * (`conflicting_operation`) while one runs, `unavailable`
29
+ * (`missing_dependency`) when Drive is not connected or Source Sync is off.
30
+ */
31
+ "sourceSync.discoveryIdle",
32
+ /**
33
+ * Whether Google Drive still needs connecting: `already_satisfied` when it is
34
+ * connected and its sign-in works.
35
+ */
36
+ "integrations.googleDriveDisconnected",
37
+ /** Whether Source Sync is still off: `already_satisfied` when it is on. */
38
+ "sourceSync.disabled",
39
+ /**
40
+ * Whether the HR directory can be synced: `unavailable`
41
+ * (`missing_dependency`) when no HR directory is connected, `unavailable`
42
+ * (`conflicting_operation`) while a sync runs.
43
+ */
44
+ "hris.connected",
45
+ ] as const;
46
+ export type AffordanceAvailabilityId =
47
+ (typeof AFFORDANCE_AVAILABILITY_IDS)[number];
48
+ export const AffordanceAvailabilityIdSchema = z.enum(
49
+ AFFORDANCE_AVAILABILITY_IDS,
50
+ );
51
+
52
+ /** Why an offered affordance cannot be taken right now. Closed: never prose. */
53
+ export const AFFORDANCE_UNAVAILABLE_REASONS = [
54
+ /** Another operation that conflicts with this one is running. */
55
+ "conflicting_operation",
56
+ /** Something this action depends on is missing (a connection, a setting). */
57
+ "missing_dependency",
58
+ /** The thing the action addresses no longer exists. */
59
+ "resource_gone",
60
+ /** The action is switched off for now. */
61
+ "temporarily_disabled",
62
+ ] as const;
63
+ export type AffordanceUnavailableReason =
64
+ (typeof AFFORDANCE_UNAVAILABLE_REASONS)[number];
65
+
66
+ export const AffordanceAvailabilitySchema = z.discriminatedUnion("state", [
67
+ z.object({ state: z.literal("available") }).strict(),
68
+ z.object({ state: z.literal("already_satisfied") }).strict(),
69
+ z
70
+ .object({
71
+ state: z.literal("unavailable"),
72
+ reason: z.enum(AFFORDANCE_UNAVAILABLE_REASONS),
73
+ })
74
+ .strict(),
75
+ ]);
76
+ export type AffordanceAvailability = z.infer<
77
+ typeof AffordanceAvailabilitySchema
78
+ >;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The affordance vocabulary: every next step the product may OFFER a person
3
+ * or the agent (control ADR affordance-system).
4
+ *
5
+ * An affordance names an action the product can actually perform. Existing
6
+ * here is never a reason to offer it: an affordance is offered only by a
7
+ * domain-owned, deterministic offer rule that relates a domain state to it
8
+ * (INV-AFFORD-RELEVANT). Neither a model nor a renderer invents, infers or
9
+ * searches for one.
10
+ *
11
+ * INVARIANTS:
12
+ * - `{domain}.{action}` names, camelCase after the dot. A member is persisted
13
+ * inside immutable workflow outcomes and chat parts, so the set is CLOSED and
14
+ * append-only: removing one orphans every record that carries it.
15
+ * - GROUNDED. Every member has a binding kind (`AFFORDANCE_BINDING_KIND_OF` in
16
+ * ./affordance) and the repo that owns the binding proves it exists with an
17
+ * architecture test: a mounted backend route for `command`, an app `Route`
18
+ * for `navigate`, an app launch surface for `surface` (INV-AFFORD-GROUNDED).
19
+ */
20
+ import { z } from "zod";
21
+
22
+ export const AFFORDANCE_IDS = [
23
+ /** Start a new search of the connected drive (Source Sync discovery). */
24
+ "sourceSync.findFiles",
25
+ /** Connect Google Drive, or reconnect it when its sign-in stopped working. */
26
+ "integrations.connectGoogleDrive",
27
+ /** Turn Source Sync on for the org. */
28
+ "sourceSync.enable",
29
+ /** Open the org's billing page. */
30
+ "billing.open",
31
+ /** Run the org-structure analysis again. */
32
+ "orgStructure.reanalyze",
33
+ /** Sync the HR directory again. */
34
+ "hris.resyncDirectory",
35
+ /**
36
+ * Open a review (an input request) in chat. PROJECTED, never persisted in an
37
+ * offer: it is derived from authoritative state already stored elsewhere (a
38
+ * workflow outcome's `review` disposition). See PROJECTED_AFFORDANCE_IDS.
39
+ */
40
+ "review.open",
41
+ ] as const;
42
+
43
+ export type AffordanceId = (typeof AFFORDANCE_IDS)[number];
44
+
45
+ export const AffordanceIdSchema = z.enum(AFFORDANCE_IDS);