@company-semantics/contracts 89.2.0 → 91.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 (53) hide show
  1. package/package.json +4 -3
  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 +142 -2
  12. package/src/generated/openapi-routes.ts +1 -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/source-sync/README.md +95 -5
  31. package/src/source-sync/__tests__/attention-evidence.test.ts +96 -0
  32. package/src/source-sync/__tests__/barrel.test.ts +39 -0
  33. package/src/source-sync/__tests__/enumeration.test.ts +160 -0
  34. package/src/source-sync/__tests__/schemas.test.ts +223 -0
  35. package/src/source-sync/__tests__/vocabulary.test.ts +104 -5
  36. package/src/source-sync/attention-evidence.ts +93 -0
  37. package/src/source-sync/candidate-item.ts +25 -0
  38. package/src/source-sync/enumeration.ts +152 -0
  39. package/src/source-sync/index.ts +35 -1
  40. package/src/source-sync/relevance.ts +85 -11
  41. package/src/source-sync/vocabulary.ts +37 -1
  42. package/src/user-notifications/README.md +8 -0
  43. package/src/user-notifications/kinds.ts +13 -0
  44. package/src/user-notifications/lifecycle-registry.ts +10 -0
  45. package/src/workflows/README.md +38 -2
  46. package/src/workflows/__tests__/README.md +21 -2
  47. package/src/workflows/__tests__/affordances-of-outcome.test.ts +99 -0
  48. package/src/workflows/__tests__/deferred-route.test.ts +168 -0
  49. package/src/workflows/__tests__/outcome.test.ts +66 -0
  50. package/src/workflows/affordances-of-outcome.ts +65 -0
  51. package/src/workflows/deferred-route.ts +240 -0
  52. package/src/workflows/index.ts +34 -0
  53. package/src/workflows/outcome.ts +114 -1
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { AffordanceSchema } from "../../affordances/affordance";
4
+ import {
5
+ WORKFLOW_AFFORDANCES_OF_REASON,
6
+ affordancesOfOutcome,
7
+ affordancesOfReason,
8
+ } from "../affordances-of-outcome";
9
+ import {
10
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
11
+ WorkflowOutcomeSchema,
12
+ type WorkflowOutcome,
13
+ } from "../outcome";
14
+ import { ANALYSIS_WORKFLOW_TYPES } from "../types";
15
+
16
+ const review = {
17
+ kind: "review",
18
+ requestType: "source-sync/candidates",
19
+ requestId: "set-1",
20
+ } as const;
21
+
22
+ const produced: WorkflowOutcome = {
23
+ workflowType: "source-sync/discover",
24
+ executionEpoch: 0,
25
+ kind: "produced",
26
+ reason: "candidates_found",
27
+ facts: null,
28
+ disposition: review,
29
+ affordances: [],
30
+ };
31
+
32
+ const partial: WorkflowOutcome = {
33
+ ...produced,
34
+ kind: "partial",
35
+ reason: "budget_exhausted",
36
+ };
37
+
38
+ const driveAuth: WorkflowOutcome = {
39
+ ...produced,
40
+ kind: "failed",
41
+ reason: "drive_auth",
42
+ disposition: { kind: "narrate" },
43
+ affordances: [{ id: "integrations.connectGoogleDrive" }],
44
+ };
45
+
46
+ const REVIEW_OPEN = {
47
+ id: "review.open",
48
+ args: { requestType: "source-sync/candidates", requestId: "set-1" },
49
+ };
50
+
51
+ describe("affordancesOfOutcome", () => {
52
+ it("fixtures are valid outcomes", () => {
53
+ for (const outcome of [produced, partial, driveAuth]) {
54
+ expect(WorkflowOutcomeSchema.safeParse(outcome).success).toBe(true);
55
+ }
56
+ });
57
+
58
+ it("projects review.open from a review disposition, after the frozen offer", () => {
59
+ expect(affordancesOfOutcome(produced)).toEqual([REVIEW_OPEN]);
60
+ expect(affordancesOfOutcome(partial)).toEqual([
61
+ ...partial.affordances,
62
+ REVIEW_OPEN,
63
+ ]);
64
+ });
65
+
66
+ it("projects nothing extra from a narrate disposition", () => {
67
+ expect(affordancesOfOutcome(driveAuth)).toEqual([
68
+ { id: "integrations.connectGoogleDrive" },
69
+ ]);
70
+ });
71
+
72
+ it("affordancesOfReason returns the table entry for each workflow type", () => {
73
+ for (const workflowType of ANALYSIS_WORKFLOW_TYPES) {
74
+ const table: Record<string, unknown> =
75
+ WORKFLOW_AFFORDANCES_OF_REASON[workflowType];
76
+ for (const [reason, offer] of Object.entries(table)) {
77
+ expect(affordancesOfReason(workflowType, reason as never)).toEqual(
78
+ offer,
79
+ );
80
+ }
81
+ }
82
+ // Both org-structure types read the one shared table.
83
+ expect(WORKFLOW_AFFORDANCES_OF_REASON["org-structure/hris-connect"]).toBe(
84
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
85
+ );
86
+ expect(WORKFLOW_AFFORDANCES_OF_REASON["org-structure/reanalyze"]).toBe(
87
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
88
+ );
89
+ });
90
+
91
+ it("every projected list is a valid Affordance list", () => {
92
+ for (const outcome of [produced, driveAuth]) {
93
+ expect(
94
+ AffordanceSchema.array().safeParse(affordancesOfOutcome(outcome))
95
+ .success,
96
+ ).toBe(true);
97
+ }
98
+ });
99
+ });
@@ -0,0 +1,168 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ import { describe, expect, it } from "vitest";
6
+
7
+ import { PROACTIVE_EVENT_KIND_IDS } from "../../proactive/kinds";
8
+ import {
9
+ DISCOVER_DEFERRED_ROUTE_OF_REASON,
10
+ DeferredRouteSchema,
11
+ ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
12
+ SETTLEMENT_AUDIENCE_POLICIES,
13
+ SETTLEMENT_COVERING_KINDS,
14
+ SYNTHESIZE_DEFERRED_ROUTE_OF_REASON,
15
+ deferredRouteOf,
16
+ toExplainedOutcome,
17
+ type DeferredRoute,
18
+ } from "../deferred-route";
19
+ import {
20
+ DISCOVER_AFFORDANCES_OF_REASON,
21
+ DISCOVER_OUTCOME_KIND_OF_REASON,
22
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
23
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
24
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
25
+ SYNTHESIZE_OUTCOME_KIND_OF_REASON,
26
+ WorkflowOutcomeSchema,
27
+ type WorkflowOutcome,
28
+ } from "../outcome";
29
+
30
+ // (kind table, offer table, route table) per outcome family. Built here rather
31
+ // than exported so the rules stay pinned without widening the public surface.
32
+ const TABLES: ReadonlyArray<
33
+ readonly [
34
+ Record<string, string>,
35
+ Record<string, readonly unknown[]>,
36
+ Record<string, DeferredRoute>,
37
+ ]
38
+ > = [
39
+ [
40
+ DISCOVER_OUTCOME_KIND_OF_REASON,
41
+ DISCOVER_AFFORDANCES_OF_REASON,
42
+ DISCOVER_DEFERRED_ROUTE_OF_REASON,
43
+ ],
44
+ [
45
+ SYNTHESIZE_OUTCOME_KIND_OF_REASON,
46
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
47
+ SYNTHESIZE_DEFERRED_ROUTE_OF_REASON,
48
+ ],
49
+ [
50
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
51
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
52
+ ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
53
+ ],
54
+ ];
55
+
56
+ const discoverFacts = {
57
+ probesTotal: 12,
58
+ probesDone: 3,
59
+ filesSeen: 0,
60
+ filesShown: 0,
61
+ filesHidden: 0,
62
+ filesDropped: 0,
63
+ coveragePercent: 25,
64
+ };
65
+
66
+ const driveAuth = {
67
+ workflowType: "source-sync/discover",
68
+ executionEpoch: 2,
69
+ kind: "failed",
70
+ reason: "drive_auth",
71
+ facts: discoverFacts,
72
+ disposition: { kind: "narrate" },
73
+ affordances: [{ id: "integrations.connectGoogleDrive" }],
74
+ } as const satisfies WorkflowOutcome;
75
+
76
+ const nothingFound = {
77
+ workflowType: "source-sync/discover",
78
+ executionEpoch: 0,
79
+ kind: "empty",
80
+ reason: "nothing_found",
81
+ facts: discoverFacts,
82
+ disposition: { kind: "narrate" },
83
+ affordances: [],
84
+ } as const satisfies WorkflowOutcome;
85
+
86
+ describe("deferred settlement routes", () => {
87
+ it("every reason of every workflow type has a deferred route", () => {
88
+ for (const [kinds, , routes] of TABLES) {
89
+ expect(Object.keys(routes).sort()).toEqual(Object.keys(kinds).sort());
90
+ for (const route of Object.values(routes)) {
91
+ expect(DeferredRouteSchema.safeParse(route).success).toBe(true);
92
+ }
93
+ }
94
+ });
95
+
96
+ it("an explained route is a fact-free failure that offers at least one affordance", () => {
97
+ for (const [kinds, offers, routes] of TABLES) {
98
+ for (const [reason, route] of Object.entries(routes)) {
99
+ if (route.route !== "explained") continue;
100
+ expect(["failed", "abandoned"]).toContain(kinds[reason]);
101
+ expect(offers[reason]?.length ?? 0).toBeGreaterThan(0);
102
+ }
103
+ }
104
+ });
105
+
106
+ it("a produced, empty or partial ending is never explained", () => {
107
+ for (const [kinds, , routes] of TABLES) {
108
+ for (const [reason, kind] of Object.entries(kinds)) {
109
+ if (!["produced", "empty", "partial"].includes(kind)) continue;
110
+ expect(["notice", "covered"]).toContain(routes[reason]?.route);
111
+ }
112
+ }
113
+ });
114
+
115
+ it("silent is reserved for a person stopping the run", () => {
116
+ for (const [, , routes] of TABLES) {
117
+ for (const [reason, route] of Object.entries(routes)) {
118
+ expect(route.route === "silent").toBe(reason === "stopped_by_user");
119
+ }
120
+ }
121
+ });
122
+
123
+ it("covered names a real proactive kind", () => {
124
+ for (const kind of SETTLEMENT_COVERING_KINDS) {
125
+ expect(PROACTIVE_EVENT_KIND_IDS).toContain(kind);
126
+ }
127
+ });
128
+
129
+ it("a covered route falls back to a notice with an audience", () => {
130
+ let covered = 0;
131
+ for (const [, , routes] of TABLES) {
132
+ for (const route of Object.values(routes)) {
133
+ if (route.route !== "covered") continue;
134
+ covered += 1;
135
+ expect(route.fallback).toBe("notice");
136
+ expect(SETTLEMENT_AUDIENCE_POLICIES).toContain(route.audience);
137
+ }
138
+ }
139
+ expect(covered).toBeGreaterThan(0);
140
+ const by = "sourceSync.review_ready";
141
+ for (const partial of [
142
+ { route: "covered", by },
143
+ { route: "covered", by, fallback: "notice" },
144
+ { route: "covered", by, audience: "creator" },
145
+ ]) {
146
+ expect(DeferredRouteSchema.safeParse(partial).success).toBe(false);
147
+ }
148
+ });
149
+
150
+ it("toExplainedOutcome strips facts and returns null for a route that is not explained", () => {
151
+ expect(deferredRouteOf(driveAuth).route).toBe("explained");
152
+ const explained = toExplainedOutcome(driveAuth);
153
+ expect(explained).toEqual({ ...driveAuth, facts: null });
154
+ expect(explained?.affordances).toEqual(driveAuth.affordances);
155
+ expect(WorkflowOutcomeSchema.safeParse(explained).success).toBe(true);
156
+ expect(toExplainedOutcome(nothingFound)).toBeNull();
157
+ });
158
+
159
+ it("the workflow module imports nothing from proactive", () => {
160
+ const dir = dirname(dirname(fileURLToPath(import.meta.url)));
161
+ const sources = readdirSync(dir).filter((f) => f.endsWith(".ts"));
162
+ expect(sources.length).toBeGreaterThan(0);
163
+ for (const file of sources) {
164
+ const text = readFileSync(join(dir, file), "utf8");
165
+ expect(text).not.toMatch(/from\s+["']\.\.\/proactive/);
166
+ }
167
+ });
168
+ });
@@ -1,9 +1,16 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
+ import {
4
+ MAX_OFFERED_AFFORDANCES,
5
+ OfferedAffordanceSchema,
6
+ } from "../../affordances/affordance";
3
7
  import type { WorkflowType } from "../types";
4
8
  import {
9
+ DISCOVER_AFFORDANCES_OF_REASON,
5
10
  DISCOVER_OUTCOME_KIND_OF_REASON,
11
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
6
12
  ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
13
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
7
14
  SYNTHESIZE_OUTCOME_KIND_OF_REASON,
8
15
  WORKFLOW_OUTCOME_KINDS,
9
16
  WorkflowOutcomeSchema,
@@ -41,6 +48,7 @@ const empty = {
41
48
  reason: "nothing_found",
42
49
  facts: discoverFacts,
43
50
  disposition: { kind: "narrate" },
51
+ affordances: [],
44
52
  } as const;
45
53
 
46
54
  describe("WorkflowOutcome", () => {
@@ -73,6 +81,7 @@ describe("WorkflowOutcome", () => {
73
81
  kind: "abandoned",
74
82
  reason: "abandoned",
75
83
  facts: null,
84
+ affordances: [{ id: "sourceSync.findFiles" }],
76
85
  };
77
86
  expect(WorkflowOutcomeSchema.safeParse(abandoned).success).toBe(true);
78
87
  });
@@ -127,4 +136,61 @@ describe("WorkflowOutcome", () => {
127
136
  }).success,
128
137
  ).toBe(false);
129
138
  });
139
+
140
+ it("every reason has an explicit offer, and every offer names an offerable affordance", () => {
141
+ for (const [kinds, offers] of [
142
+ [DISCOVER_OUTCOME_KIND_OF_REASON, DISCOVER_AFFORDANCES_OF_REASON],
143
+ [SYNTHESIZE_OUTCOME_KIND_OF_REASON, SYNTHESIZE_AFFORDANCES_OF_REASON],
144
+ [
145
+ ORG_STRUCTURE_OUTCOME_KIND_OF_REASON,
146
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
147
+ ],
148
+ ] as const) {
149
+ expect(Object.keys(offers).sort()).toEqual(Object.keys(kinds).sort());
150
+ for (const offer of Object.values(offers)) {
151
+ for (const affordance of offer) {
152
+ expect(OfferedAffordanceSchema.safeParse(affordance).success).toBe(
153
+ true,
154
+ );
155
+ }
156
+ }
157
+ }
158
+ });
159
+
160
+ it("refuses an offer that differs from its reason's table", () => {
161
+ expect(
162
+ WorkflowOutcomeSchema.safeParse({
163
+ ...empty,
164
+ affordances: [{ id: "sourceSync.findFiles" }],
165
+ }).success,
166
+ ).toBe(false);
167
+ });
168
+
169
+ it("refuses a missing affordances key", () => {
170
+ const { affordances: _omitted, ...withoutOffer } = empty;
171
+ expect(WorkflowOutcomeSchema.safeParse(withoutOffer).success).toBe(false);
172
+ });
173
+
174
+ it("refuses review.open inside a frozen offer", () => {
175
+ expect(
176
+ WorkflowOutcomeSchema.safeParse({
177
+ ...empty,
178
+ affordances: [
179
+ {
180
+ id: "review.open",
181
+ args: { requestType: "source-sync/candidates", requestId: "s" },
182
+ },
183
+ ],
184
+ }).success,
185
+ ).toBe(false);
186
+ });
187
+
188
+ it("refuses more than MAX_OFFERED_AFFORDANCES offers", () => {
189
+ const many = Array.from({ length: MAX_OFFERED_AFFORDANCES + 1 }, () => ({
190
+ id: "billing.open",
191
+ }));
192
+ expect(
193
+ WorkflowOutcomeSchema.safeParse({ ...empty, affordances: many }).success,
194
+ ).toBe(false);
195
+ });
130
196
  });
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The workflow-settlement offer rule and its projection (control ADR
3
+ * affordance-system; control ADR-CTRL-540).
4
+ *
5
+ * `affordancesOfReason` is the ONE place a producer reads the frozen offer for
6
+ * an ending (the backend's outcome builder calls it). `affordancesOfOutcome` is
7
+ * the ONE projection every consumer renders from — the settlement message, the
8
+ * proactive chat and the agent's context alike — so the Review control and the
9
+ * offered next steps are one list built one way.
10
+ */
11
+ import type { Affordance, OfferedAffordance } from "../affordances/affordance";
12
+ import {
13
+ DISCOVER_AFFORDANCES_OF_REASON,
14
+ ORG_STRUCTURE_AFFORDANCES_OF_REASON,
15
+ SYNTHESIZE_AFFORDANCES_OF_REASON,
16
+ type WorkflowOutcome,
17
+ type WorkflowOutcomeOf,
18
+ } from "./outcome";
19
+ import type { WorkflowType } from "./types";
20
+
21
+ /** Every workflow type's offer table, total over the type union. */
22
+ export const WORKFLOW_AFFORDANCES_OF_REASON = {
23
+ "source-sync/discover": DISCOVER_AFFORDANCES_OF_REASON,
24
+ "source-sync/synthesize": SYNTHESIZE_AFFORDANCES_OF_REASON,
25
+ "org-structure/hris-connect": ORG_STRUCTURE_AFFORDANCES_OF_REASON,
26
+ "org-structure/reanalyze": ORG_STRUCTURE_AFFORDANCES_OF_REASON,
27
+ } as const satisfies {
28
+ readonly [T in WorkflowType]: Record<
29
+ WorkflowOutcomeOf<T>["reason"],
30
+ readonly OfferedAffordance[]
31
+ >;
32
+ };
33
+
34
+ /** The frozen offer an ending of one workflow type carries. */
35
+ export function affordancesOfReason<T extends WorkflowType>(
36
+ workflowType: T,
37
+ reason: WorkflowOutcomeOf<T>["reason"],
38
+ ): readonly OfferedAffordance[] {
39
+ const table = WORKFLOW_AFFORDANCES_OF_REASON[workflowType] as Record<
40
+ string,
41
+ readonly OfferedAffordance[]
42
+ >;
43
+ return table[reason as string] ?? [];
44
+ }
45
+
46
+ /**
47
+ * Everything an outcome offers, in render order: the frozen offer, then the
48
+ * `review.open` projected from a `review` disposition. Pure; never persisted.
49
+ */
50
+ export function affordancesOfOutcome(
51
+ outcome: WorkflowOutcome,
52
+ ): readonly Affordance[] {
53
+ const { disposition } = outcome;
54
+ if (disposition.kind !== "review") return [...outcome.affordances];
55
+ return [
56
+ ...outcome.affordances,
57
+ {
58
+ id: "review.open",
59
+ args: {
60
+ requestType: disposition.requestType,
61
+ requestId: disposition.requestId,
62
+ },
63
+ },
64
+ ];
65
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Where a settlement goes when there is no launching conversation to write it
3
+ * into (control ADR-CTRL-540 phase 2; control ADR affordance-system).
4
+ *
5
+ * A scheduled rediscovery, an upkeep re-synthesis or a launch nobody docked
6
+ * into a chat settles with no anchor. Its route is decided per REASON, frozen by
7
+ * the backend onto the settlement row when it defers, and never re-derived.
8
+ *
9
+ * THE DOCTRINE (control docs/proactive-delivery.md): a pushed `explained` chat
10
+ * is frozen, so it may contain only material that stays non-privileged after
11
+ * the recipient's authority changes. A settlement told WITHOUT FACTS (its
12
+ * reason only, `facts: null`) plus its affordance ids qualifies — affordances
13
+ * are re-gated per viewer at render and executed by the server. An ending whose
14
+ * telling needs authority-sensitive facts (counts) goes to a `notice` inbox row,
15
+ * which re-renders under the reader's current authority.
16
+ *
17
+ * RULES (pinned by the deferred-route test):
18
+ * - `explained` only for a `failed` or `abandoned` ending that offers at least
19
+ * one affordance. A `produced`, `empty` or `partial` ending is never explained.
20
+ * - `covered` when a domain kind normally announces that ending. Covering is
21
+ * verified, not assumed: the backend checks that the covering occurrence
22
+ * exists for this workflow and epoch at route time, and otherwise routes the
23
+ * `fallback` notice to the stated audience. Nothing is ever said twice, and
24
+ * nothing falls silent because a domain kind declined.
25
+ * - `silent` only for `stopped_by_user`: the person who stopped it acted.
26
+ * - Audiences are policies the backend resolves and freezes at emit; an empty
27
+ * resolved audience routes silent.
28
+ */
29
+ import { z } from "zod";
30
+
31
+ import type {
32
+ DiscoverOutcomeReason,
33
+ DiscoverWorkflowOutcome,
34
+ HrisConnectWorkflowOutcome,
35
+ OrgStructureOutcomeReason,
36
+ ReanalyzeWorkflowOutcome,
37
+ SynthesizeOutcomeReason,
38
+ SynthesizeWorkflowOutcome,
39
+ WorkflowOutcome,
40
+ WorkflowOutcomeOf,
41
+ } from "./outcome";
42
+ import type { WorkflowType } from "./types";
43
+
44
+ /** Who a routed settlement is addressed to; resolved and frozen by the backend. */
45
+ export const SETTLEMENT_AUDIENCE_POLICIES = [
46
+ /** The person who launched the workflow, while still an active member. */
47
+ "creator",
48
+ /** The launcher plus the org owner and admins (who manage integrations). */
49
+ "creator_and_owner_admins",
50
+ ] as const;
51
+ export type SettlementAudiencePolicy =
52
+ (typeof SETTLEMENT_AUDIENCE_POLICIES)[number];
53
+
54
+ /**
55
+ * The proactive kinds that already announce some endings. Literal strings, not
56
+ * an import: workflows never imports proactive (proactive imports workflows).
57
+ * The test pins each one as a real PROACTIVE_EVENT_KIND_IDS member.
58
+ */
59
+ export const SETTLEMENT_COVERING_KINDS = [
60
+ "sourceSync.review_ready",
61
+ "sourceSync.docs_updated",
62
+ "orgStructure.review_ready",
63
+ ] as const;
64
+ export type SettlementCoveringKind = (typeof SETTLEMENT_COVERING_KINDS)[number];
65
+
66
+ /** Why a routed settlement says nothing. Closed. */
67
+ export const SETTLEMENT_SILENT_REASONS = ["stopped_by_person"] as const;
68
+
69
+ export const DEFERRED_ROUTES = [
70
+ "explained",
71
+ "notice",
72
+ "covered",
73
+ "silent",
74
+ ] as const;
75
+ export type DeferredRouteKind = (typeof DEFERRED_ROUTES)[number];
76
+
77
+ export const DeferredRouteSchema = z.discriminatedUnion("route", [
78
+ z
79
+ .object({
80
+ route: z.literal("explained"),
81
+ audience: z.enum(SETTLEMENT_AUDIENCE_POLICIES),
82
+ })
83
+ .strict(),
84
+ z
85
+ .object({
86
+ route: z.literal("notice"),
87
+ audience: z.enum(SETTLEMENT_AUDIENCE_POLICIES),
88
+ })
89
+ .strict(),
90
+ z
91
+ .object({
92
+ route: z.literal("covered"),
93
+ by: z.enum(SETTLEMENT_COVERING_KINDS),
94
+ /**
95
+ * Covering is not literal on every path (a domain kind may decline: e.g.
96
+ * sourceSync.review_ready fires on scheduled rediscovery only when new
97
+ * candidates exist). The backend verifies the covering occurrence exists
98
+ * for this workflow/epoch at route time; otherwise it routes the notice.
99
+ */
100
+ fallback: z.literal("notice"),
101
+ /** Who the fallback notice is addressed to. */
102
+ audience: z.enum(SETTLEMENT_AUDIENCE_POLICIES),
103
+ })
104
+ .strict(),
105
+ z
106
+ .object({
107
+ route: z.literal("silent"),
108
+ why: z.enum(SETTLEMENT_SILENT_REASONS),
109
+ })
110
+ .strict(),
111
+ ]);
112
+ export type DeferredRoute = z.infer<typeof DeferredRouteSchema>;
113
+
114
+ const EXPLAINED = {
115
+ route: "explained",
116
+ audience: "creator_and_owner_admins",
117
+ } as const;
118
+ const NOTICE_CREATOR = { route: "notice", audience: "creator" } as const;
119
+ const NOTICE_ADMINS = {
120
+ route: "notice",
121
+ audience: "creator_and_owner_admins",
122
+ } as const;
123
+ const STOPPED = { route: "silent", why: "stopped_by_person" } as const;
124
+
125
+ export const DISCOVER_DEFERRED_ROUTE_OF_REASON = {
126
+ // prettier-ignore
127
+ candidates_found: { route: "covered", by: "sourceSync.review_ready", fallback: "notice", audience: "creator" },
128
+ nothing_found: NOTICE_CREATOR,
129
+ nothing_relevant: NOTICE_CREATOR,
130
+ budget_exhausted: NOTICE_CREATOR,
131
+ file_ceiling: NOTICE_CREATOR,
132
+ stopped_by_user: STOPPED,
133
+ plan_coverage_refused: NOTICE_ADMINS,
134
+ billing_refused: EXPLAINED,
135
+ drive_auth: EXPLAINED,
136
+ drive_unavailable: EXPLAINED,
137
+ no_connection: EXPLAINED,
138
+ source_sync_off: EXPLAINED,
139
+ internal_error: EXPLAINED,
140
+ abandoned: EXPLAINED,
141
+ unrecognized_terminal_state: NOTICE_ADMINS,
142
+ } as const satisfies Record<DiscoverOutcomeReason, DeferredRoute>;
143
+
144
+ export const SYNTHESIZE_DEFERRED_ROUTE_OF_REASON = {
145
+ // prettier-ignore
146
+ updates_ready: { route: "covered", by: "sourceSync.review_ready", fallback: "notice", audience: "creator" },
147
+ // prettier-ignore
148
+ updates_applied: { route: "covered", by: "sourceSync.docs_updated", fallback: "notice", audience: "creator" },
149
+ stopped_by_user: STOPPED,
150
+ paused_billing: EXPLAINED,
151
+ synthesis_incomplete: NOTICE_ADMINS,
152
+ approval_mismatch: NOTICE_ADMINS,
153
+ landing_refused: NOTICE_ADMINS,
154
+ standing_order_refused: NOTICE_ADMINS,
155
+ no_connection: EXPLAINED,
156
+ source_sync_off: EXPLAINED,
157
+ internal_error: NOTICE_ADMINS,
158
+ abandoned: NOTICE_ADMINS,
159
+ unrecognized_terminal_state: NOTICE_ADMINS,
160
+ } as const satisfies Record<SynthesizeOutcomeReason, DeferredRoute>;
161
+
162
+ export const ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON = {
163
+ // prettier-ignore
164
+ proposal_ready: { route: "covered", by: "orgStructure.review_ready", fallback: "notice", audience: "creator" },
165
+ directory_synced: NOTICE_CREATOR,
166
+ analysis_already_running: NOTICE_CREATOR,
167
+ proposal_blocked: NOTICE_ADMINS,
168
+ // `empty`, so never explained, even though it offers a directory sync.
169
+ no_sync_run: NOTICE_ADMINS,
170
+ no_persons: NOTICE_CREATOR,
171
+ reporting_not_keyed: NOTICE_CREATOR,
172
+ stopped_by_user: STOPPED,
173
+ sync_failed: EXPLAINED,
174
+ snapshot_too_large: NOTICE_ADMINS,
175
+ model_failed: EXPLAINED,
176
+ run_too_large: NOTICE_ADMINS,
177
+ placement_failed: EXPLAINED,
178
+ internal_error: EXPLAINED,
179
+ abandoned: EXPLAINED,
180
+ unrecognized_terminal_state: NOTICE_ADMINS,
181
+ } as const satisfies Record<OrgStructureOutcomeReason, DeferredRoute>;
182
+
183
+ /** Every workflow type's route table, total over the type union. */
184
+ export const WORKFLOW_DEFERRED_ROUTE_OF_REASON = {
185
+ "source-sync/discover": DISCOVER_DEFERRED_ROUTE_OF_REASON,
186
+ "source-sync/synthesize": SYNTHESIZE_DEFERRED_ROUTE_OF_REASON,
187
+ "org-structure/hris-connect": ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
188
+ "org-structure/reanalyze": ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON,
189
+ } as const satisfies {
190
+ readonly [T in WorkflowType]: Record<
191
+ WorkflowOutcomeOf<T>["reason"],
192
+ DeferredRoute
193
+ >;
194
+ };
195
+
196
+ type ExplainedReasonOf<T> = {
197
+ [R in keyof T]: T[R] extends { readonly route: "explained" } ? R : never;
198
+ }[keyof T];
199
+
200
+ export type ExplainedDiscoverReason = ExplainedReasonOf<
201
+ typeof DISCOVER_DEFERRED_ROUTE_OF_REASON
202
+ >;
203
+ export type ExplainedSynthesizeReason = ExplainedReasonOf<
204
+ typeof SYNTHESIZE_DEFERRED_ROUTE_OF_REASON
205
+ >;
206
+ export type ExplainedOrgStructureReason = ExplainedReasonOf<
207
+ typeof ORG_STRUCTURE_DEFERRED_ROUTE_OF_REASON
208
+ >;
209
+
210
+ type FactFree<O, R> = Omit<O, "reason" | "facts"> & {
211
+ readonly reason: R;
212
+ readonly facts: null;
213
+ };
214
+
215
+ /** An outcome a pushed chat may carry: an explained reason, facts stripped. */
216
+ export type ExplainedWorkflowOutcome =
217
+ | FactFree<DiscoverWorkflowOutcome, ExplainedDiscoverReason>
218
+ | FactFree<SynthesizeWorkflowOutcome, ExplainedSynthesizeReason>
219
+ | FactFree<HrisConnectWorkflowOutcome, ExplainedOrgStructureReason>
220
+ | FactFree<ReanalyzeWorkflowOutcome, ExplainedOrgStructureReason>;
221
+
222
+ /** The route an outcome takes when it has no launching conversation. */
223
+ export function deferredRouteOf(outcome: WorkflowOutcome): DeferredRoute {
224
+ const table = WORKFLOW_DEFERRED_ROUTE_OF_REASON[
225
+ outcome.workflowType
226
+ ] as Record<string, DeferredRoute>;
227
+ return table[outcome.reason] as DeferredRoute;
228
+ }
229
+
230
+ /**
231
+ * The fact-free outcome a pushed chat may carry, or null when the ending's
232
+ * route is not `explained`. Stripping facts keeps the outcome schema-valid
233
+ * (`facts` is nullable) and leaves the frozen affordances untouched.
234
+ */
235
+ export function toExplainedOutcome(
236
+ outcome: WorkflowOutcome,
237
+ ): ExplainedWorkflowOutcome | null {
238
+ if (deferredRouteOf(outcome).route !== "explained") return null;
239
+ return { ...outcome, facts: null } as ExplainedWorkflowOutcome;
240
+ }