@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
@@ -134,6 +134,23 @@ the three from another.
134
134
  broken, so it gets an unread inbox row (`proactive.org_structure_review_ready`)
135
135
  and no chat or banner. It is triggered by the committed insert of a
136
136
  `validated` proposal, and its occurrence key is the proposal id.
137
+ - **The two workflow settlement kinds are one `explained` and one `notice`**
138
+ (control ADR-CTRL-540 phase 2; control ADR `affordance-system`). Both are
139
+ triggered by a ROUTED workflow settlement — the committed `deferred → routed`
140
+ transition of a settlement nobody was watching, routed per reason through
141
+ `../workflows/deferred-route` — and both key on
142
+ `workflow-settled:{workflowId}:{executionEpoch}`. `workflow.needs_attention`
143
+ is `explained`: the `workflowNeedsAttention` chat is FACT-FREE (no counts —
144
+ the outcome's facts are stripped), carries the settled outcome as
145
+ `ProactiveChatMessage.outcome` so its grounded affordances render as real
146
+ controls, and has NO chips (a chip only sends a chat prompt, which is not
147
+ grounded). Its composer frames and never narrates: `text` is the backend's
148
+ settlement narration verbatim, and its per-type title states no digit and
149
+ no next step. The inbox row is born read. `workflow.finished` is a `notice`:
150
+ an unread inbox row and nothing else, because telling it needs counts that
151
+ must re-render under the reader's authority. Their rows are
152
+ `proactive.workflow_needs_attention` and `proactive.workflow_finished`.
153
+ Neither sets `emailKind` or `actionItemKind`.
137
154
  - **The occurrence vocabulary is closed.** `ProactiveEventId` is branded so a
138
155
  raw string cannot stand in for an occurrence; `ProactiveRecipientReason` is a
139
156
  closed enum because the recipients table is the frozen audit record of who
@@ -172,9 +189,9 @@ the three from another.
172
189
  | `bornRead` | Whether a class's `chat` or `inbox` row is born read |
173
190
  | `ORG_SYSTEM_EVENT_TYPES` | Mirror of the backend `org_system_event_type` pgEnum — the banner ids |
174
191
  | `OrgSystemEventType` | Union derived from the array |
175
- | `PROACTIVE_CHAT_TEMPLATES` | Which prose composes a kind's pushed chat (8: 2 + 5 `billing*` + 1 `source*`) |
192
+ | `PROACTIVE_CHAT_TEMPLATES` | Which prose composes a kind's pushed chat (9, incl. `workflowNeedsAttention`) |
176
193
  | `ProactiveChatTemplate` | Union derived from the array |
177
- | `PROACTIVE_EVENT_KIND_IDS` | Closed kinds (3 org/brief/chat + 15 `billing.*` + 2 `sourceSync.*`) |
194
+ | `PROACTIVE_EVENT_KIND_IDS` | Closed kinds (org/brief/chat, `billing.*`, `sourceSync.*`, `workflow.*` …) |
178
195
  | `ProactiveEventKind` | Union derived from the array |
179
196
  | `ProactiveEventId` | Branded id of ONE occurrence — a raw string cannot be passed as one |
180
197
  | `PROACTIVE_RECIPIENT_REASONS` | Closed enum of WHY a recipient was addressed (the frozen audit record) |
@@ -192,7 +209,9 @@ the three from another.
192
209
  | `BillingSuspendedFacts` | `orgName` — the prose needs nothing else, and may state nothing else |
193
210
  | `BillingSubscriptionCanceledFacts` | `orgName` |
194
211
  | `SourceSyncReviewReadyFacts` | `orgName` + `checkpoint`; no count, no document name, no source name |
195
- | `ProactiveChatMessage` | What a composer returns: `title`, `text`, and 0..4 `replies` chips |
212
+ | `ExplainedWorkflowOutcomeData` | The settlement data part with an explained, fact-free outcome |
213
+ | `WorkflowNeedsAttentionFacts` | `narration` (backend settlement copy) + the fact-free `outcome` data part |
214
+ | `ProactiveChatMessage` | `title`, `text`, 0..4 `replies` chips, and an optional settled `outcome` |
196
215
  | `ProactiveChatComposer` | The composer port — `template` plus an arrow-property `compose(facts)` |
197
216
  | `PROACTIVE_CHAT_MAX_REPLIES` | At most this many chips (4); more is a menu, not a chip row |
198
217
  | `PROACTIVE_CHAT_COMPOSERS` | The total template → composer registry (see `templates/README.md`) |
@@ -203,5 +222,8 @@ Type-only imports of `UserNotificationKind` (`../user-notifications`),
203
222
  `NotificationKind` (`../notifications`) and `ActionItemKind`
204
223
  (`../action-items`) — the three vocabularies this one composes — and of
205
224
  `SuggestedReply` (`../message-parts`), the chip shape a composer's `replies`
206
- reuse rather than redeclare. No `zod`, no runtime imports; pure vocabulary
225
+ reuse rather than redeclare. Type-only imports of `WorkflowOutcomeData`
226
+ (`../message-parts`), `ExplainedWorkflowOutcome` (`../workflows`) and
227
+ `WorkflowType` for the workflow settlement chat; the dependency runs
228
+ proactive → workflows, never the reverse. No `zod`, no runtime imports; pure vocabulary
207
229
  data and pure helpers.
@@ -51,6 +51,16 @@ cannot.
51
51
  must not be told to update one), past-due separates the seat subscription from
52
52
  the AI balance, and suspension and cancellation each say outright that nothing
53
53
  was deleted.
54
+ - `workflow-attention.test.ts` — the two workflow settlement kinds (control
55
+ ADR-CTRL-540 phase 2): `workflow.needs_attention` is explained (a chat and a
56
+ born-read inbox row, never a banner) and `workflow.finished` is a notice (an
57
+ unread inbox row only); the `workflowNeedsAttention` chat carries the
58
+ fact-free outcome (schema-valid, `facts: null`) and no chips, frames the
59
+ backend narration verbatim, has a title per workflow type with no digit and
60
+ no next-step wording; and both inbox rows supersede per workflow type.
61
+ - `workflow-attention-fixtures.ts` — explained, fact-free outcomes for those
62
+ tests and the registry-wide fixtures. A COMPILED module, so each fixture is
63
+ typechecked against `ExplainedWorkflowOutcome`.
54
64
 
55
65
  ## Invariants
56
66
 
@@ -6,6 +6,10 @@ import type { OrgSharedTransitionTrigger } from "../../org/shared-transition";
6
6
  import { PROACTIVE_CHAT_TEMPLATES } from "../surfaces";
7
7
  import type { ProactiveChatTemplate } from "../surfaces";
8
8
  import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
9
+ import {
10
+ DRIVE_AUTH_NARRATION,
11
+ DRIVE_AUTH_OUTCOME,
12
+ } from "./workflow-attention-fixtures";
9
13
 
10
14
  /**
11
15
  * One representative facts fixture per template. Total over
@@ -32,6 +36,10 @@ const FACTS: { readonly [T in ProactiveChatTemplate]: ProactiveChatFacts[T] } =
32
36
  billingSuspended: { orgName: "Acme" },
33
37
  billingSubscriptionCanceled: { orgName: "Acme" },
34
38
  sourceSyncReviewReady: { orgName: "Acme", checkpoint: "candidates" },
39
+ workflowNeedsAttention: {
40
+ narration: DRIVE_AUTH_NARRATION,
41
+ outcome: { workflowId: "wf-1", outcome: DRIVE_AUTH_OUTCOME },
42
+ },
35
43
  };
36
44
 
37
45
  /**
@@ -37,6 +37,10 @@ import {
37
37
  PLAN_FIXTURES_THE_COMPILER_REJECTS,
38
38
  REGISTRY_FIXTURES_THE_COMPILER_REJECTS,
39
39
  } from "./compile-fixtures";
40
+ import {
41
+ DRIVE_AUTH_NARRATION,
42
+ DRIVE_AUTH_OUTCOME,
43
+ } from "./workflow-attention-fixtures";
40
44
 
41
45
  /**
42
46
  * The registry, read as the union it satisfies rather than as its literal
@@ -103,6 +107,10 @@ const FRESH_FACTS: {
103
107
  orgName: "Acme",
104
108
  checkpoint: "synthesis" as const,
105
109
  }),
110
+ workflowNeedsAttention: () => ({
111
+ narration: DRIVE_AUTH_NARRATION,
112
+ outcome: { workflowId: "wf-1", outcome: DRIVE_AUTH_OUTCOME },
113
+ }),
106
114
  };
107
115
 
108
116
  /**
@@ -259,6 +267,9 @@ describe("the kind registry", () => {
259
267
  "sourceSync.review_ready": "proactive",
260
268
  // The org structure row is rendered by `src/proactive/` as well.
261
269
  "orgStructure.review_ready": "proactive",
270
+ // Both workflow settlement rows are rendered by `src/proactive/`.
271
+ "workflow.needs_attention": "proactive",
272
+ "workflow.finished": "proactive",
262
273
  };
263
274
 
264
275
  expect(Object.keys(EXPECTED_INBOX_DOMAIN).sort()).toEqual(
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Explained, fact-free workflow outcomes for the `workflowNeedsAttention`
3
+ * template's tests. A COMPILED module (not `*.test.ts`), so every fixture is
4
+ * checked against `ExplainedWorkflowOutcome` — a reason that is not explained,
5
+ * or facts left in, fails the typecheck rather than passing silently.
6
+ *
7
+ * The narrations are fixture strings standing in for the backend's settlement
8
+ * copy; contracts never owns that wording.
9
+ */
10
+ import type { ExplainedWorkflowOutcomeData } from "../facts";
11
+ import type { ExplainedWorkflowOutcome } from "../../workflows/deferred-route";
12
+
13
+ export const DRIVE_AUTH_NARRATION =
14
+ "The search of the connected drive stopped because Google Drive no longer accepts the connection's sign-in.";
15
+
16
+ export const DRIVE_AUTH_OUTCOME: ExplainedWorkflowOutcome = {
17
+ workflowType: "source-sync/discover",
18
+ executionEpoch: 0,
19
+ kind: "failed",
20
+ reason: "drive_auth",
21
+ facts: null,
22
+ disposition: { kind: "narrate" },
23
+ affordances: [{ id: "integrations.connectGoogleDrive" }],
24
+ };
25
+
26
+ export const PAUSED_BILLING_OUTCOME: ExplainedWorkflowOutcome = {
27
+ workflowType: "source-sync/synthesize",
28
+ executionEpoch: 1,
29
+ kind: "failed",
30
+ reason: "paused_billing",
31
+ facts: null,
32
+ disposition: { kind: "narrate" },
33
+ affordances: [{ id: "billing.open" }],
34
+ };
35
+
36
+ export const MODEL_FAILED_OUTCOME: ExplainedWorkflowOutcome = {
37
+ workflowType: "org-structure/reanalyze",
38
+ executionEpoch: 2,
39
+ kind: "failed",
40
+ reason: "model_failed",
41
+ facts: null,
42
+ disposition: { kind: "narrate" },
43
+ affordances: [{ id: "orgStructure.reanalyze" }],
44
+ };
45
+
46
+ /** The settlement data part for an outcome, as the backend would hand it over. */
47
+ export const outcomeDataOf = (
48
+ outcome: ExplainedWorkflowOutcome,
49
+ ): ExplainedWorkflowOutcomeData => ({ workflowId: "wf-1", outcome });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The two workflow settlement proactive kinds (control ADR-CTRL-540 phase 2;
3
+ * control ADR affordance-system): `workflow.needs_attention` is explained and
4
+ * its chat only FRAMES the backend's narration, `workflow.finished` is a notice.
5
+ */
6
+ import { describe, it, expect } from "vitest";
7
+ import { CLASS_SURFACES, bornRead } from "../classes";
8
+ import type { ProactiveChatMessage } from "../composer";
9
+ import type { WorkflowNeedsAttentionFacts } from "../facts";
10
+ import { PROACTIVE_EVENT_KINDS } from "../registry";
11
+ import { PROACTIVE_CHAT_COMPOSERS } from "../templates/index";
12
+ import { notificationKeysOf } from "../../user-notifications/condition-keys";
13
+ import { NOTIFICATION_LIFECYCLE } from "../../user-notifications/lifecycle-registry";
14
+ import { WorkflowOutcomeSchema } from "../../workflows/outcome";
15
+ import { ANALYSIS_WORKFLOW_TYPES } from "../../workflows/types";
16
+ import type { ExplainedWorkflowOutcome } from "../../workflows/deferred-route";
17
+ import {
18
+ DRIVE_AUTH_NARRATION,
19
+ DRIVE_AUTH_OUTCOME,
20
+ MODEL_FAILED_OUTCOME,
21
+ PAUSED_BILLING_OUTCOME,
22
+ outcomeDataOf,
23
+ } from "./workflow-attention-fixtures";
24
+
25
+ // Mirrors the backend settlement-copy ban (backend tests/chat/workflow-outcome-copy.test.ts).
26
+ const CALL_TO_ACTION =
27
+ /\byou can\b|\btry\b|\bagain\b|\bsettings\b|\breconnect\b|\badd funds\b|\bclick\b|\bgo to\b|\bopen\b/i;
28
+
29
+ const compose = (facts: WorkflowNeedsAttentionFacts): ProactiveChatMessage =>
30
+ PROACTIVE_CHAT_COMPOSERS.workflowNeedsAttention.compose(facts);
31
+
32
+ const DRIVE_AUTH_FACTS: WorkflowNeedsAttentionFacts = {
33
+ narration: DRIVE_AUTH_NARRATION,
34
+ outcome: outcomeDataOf(DRIVE_AUTH_OUTCOME),
35
+ };
36
+
37
+ /** The drive-auth outcome re-typed as each workflow type, for title coverage. */
38
+ const outcomeAs = (
39
+ workflowType: (typeof ANALYSIS_WORKFLOW_TYPES)[number],
40
+ ): ExplainedWorkflowOutcome =>
41
+ ({ ...DRIVE_AUTH_OUTCOME, workflowType }) as ExplainedWorkflowOutcome;
42
+
43
+ describe("workflow settlement proactive kinds", () => {
44
+ it("workflow.needs_attention is explained: a chat and a born-read inbox row, never a banner", () => {
45
+ const plan = PROACTIVE_EVENT_KINDS["workflow.needs_attention"];
46
+ expect(plan).toEqual({
47
+ class: "explained",
48
+ chatTemplate: "workflowNeedsAttention",
49
+ inboxKind: "proactive.workflow_needs_attention",
50
+ });
51
+ const surfaces = CLASS_SURFACES[plan.class];
52
+ expect(surfaces.banner).toBe(false);
53
+ expect(surfaces.chat).toBe(true);
54
+ expect(bornRead(surfaces, "inbox")).toBe(true);
55
+ });
56
+
57
+ it("workflow.finished is a notice: an unread inbox row, no banner and no chat", () => {
58
+ const plan = PROACTIVE_EVENT_KINDS["workflow.finished"];
59
+ expect(plan).toEqual({
60
+ class: "notice",
61
+ inboxKind: "proactive.workflow_finished",
62
+ });
63
+ const surfaces = CLASS_SURFACES[plan.class];
64
+ expect(surfaces.banner).toBe(false);
65
+ expect(surfaces.chat).toBe(false);
66
+ expect(bornRead(surfaces, "inbox")).toBe(false);
67
+ });
68
+
69
+ it("the attention chat carries the fact-free outcome and no chips", () => {
70
+ const msg = compose(DRIVE_AUTH_FACTS);
71
+ expect(msg.replies).toEqual([]);
72
+ expect(msg.outcome).toEqual({
73
+ workflowId: "wf-1",
74
+ outcome: DRIVE_AUTH_OUTCOME,
75
+ });
76
+ expect(msg.outcome?.outcome.facts).toBeNull();
77
+ expect(WorkflowOutcomeSchema.safeParse(msg.outcome?.outcome).success).toBe(
78
+ true,
79
+ );
80
+ });
81
+
82
+ it("the attention chat frames the backend narration verbatim", () => {
83
+ for (const outcome of [
84
+ DRIVE_AUTH_OUTCOME,
85
+ PAUSED_BILLING_OUTCOME,
86
+ MODEL_FAILED_OUTCOME,
87
+ ]) {
88
+ const narration = `Narration for ${outcome.workflowType} ${outcome.reason}.`;
89
+ const msg = compose({ narration, outcome: outcomeDataOf(outcome) });
90
+ expect(msg.text, outcome.workflowType).toBe(narration);
91
+ }
92
+ });
93
+
94
+ it("the attention chat has a title for every workflow type", () => {
95
+ for (const workflowType of ANALYSIS_WORKFLOW_TYPES) {
96
+ const msg = compose({
97
+ narration: DRIVE_AUTH_NARRATION,
98
+ outcome: outcomeDataOf(outcomeAs(workflowType)),
99
+ });
100
+ expect(typeof msg.title, workflowType).toBe("string");
101
+ expect(msg.title.trim().length, workflowType).toBeGreaterThan(0);
102
+ }
103
+ });
104
+
105
+ it("the attention chat title states no digit and offers no next step", () => {
106
+ for (const workflowType of ANALYSIS_WORKFLOW_TYPES) {
107
+ const { title } = compose({
108
+ narration: DRIVE_AUTH_NARRATION,
109
+ outcome: outcomeDataOf(outcomeAs(workflowType)),
110
+ });
111
+ expect(title, workflowType).not.toMatch(/\d/);
112
+ expect(title, workflowType).not.toMatch(CALL_TO_ACTION);
113
+ }
114
+ });
115
+
116
+ it("both workflow inbox rows supersede per workflow type", () => {
117
+ for (const kind of [
118
+ "proactive.workflow_needs_attention",
119
+ "proactive.workflow_finished",
120
+ ] as const) {
121
+ const lifecycle = NOTIFICATION_LIFECYCLE[kind];
122
+ expect(lifecycle.condition, kind).toEqual({
123
+ scope: "fields",
124
+ fields: ["workflowType"],
125
+ });
126
+ expect(lifecycle.supersedes, kind).toBe(true);
127
+ expect(lifecycle.resolution, kind).toBe("never");
128
+
129
+ const first = notificationKeysOf(kind, {
130
+ workflowType: "source-sync/discover",
131
+ workflowId: "wf-1",
132
+ });
133
+ const second = notificationKeysOf(kind, {
134
+ workflowType: "source-sync/discover",
135
+ workflowId: "wf-2",
136
+ });
137
+ const other = notificationKeysOf(kind, {
138
+ workflowType: "org-structure/reanalyze",
139
+ workflowId: "wf-3",
140
+ });
141
+ expect(first.supersessionKey, kind).not.toBeNull();
142
+ expect(second.supersessionKey, kind).toBe(first.supersessionKey);
143
+ expect(other.supersessionKey, kind).not.toBe(first.supersessionKey);
144
+ }
145
+ });
146
+ });
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  import type { SuggestedReply } from "../message-parts/suggested-replies";
38
+ import type { WorkflowOutcomeData } from "../message-parts/workflow-outcome";
38
39
  import type { ProactiveChatFacts } from "./facts";
39
40
  import type { ProactiveChatTemplate } from "./surfaces";
40
41
 
@@ -65,6 +66,13 @@ export interface ProactiveChatMessage {
65
66
  readonly text: string;
66
67
  /** 0..`PROACTIVE_CHAT_MAX_REPLIES` chips. */
67
68
  readonly replies: readonly SuggestedReply[];
69
+ /**
70
+ * The settled workflow a template reports, when it reports one. The chat
71
+ * writer persists it as a `data-workflow-outcome` part beside the prose, so
72
+ * the same renderer shows its affordances. Present only for a template whose
73
+ * facts carry an outcome; always fact-free here.
74
+ */
75
+ readonly outcome?: WorkflowOutcomeData;
68
76
  }
69
77
 
70
78
  // =============================================================================
@@ -23,7 +23,9 @@
23
23
  * receive different prose because they were handed different facts.
24
24
  */
25
25
 
26
+ import type { WorkflowOutcomeData } from "../message-parts/workflow-outcome";
26
27
  import type { OrgSharedTransitionTrigger } from "../org/shared-transition";
28
+ import type { ExplainedWorkflowOutcome } from "../workflows/deferred-route";
27
29
 
28
30
  // =============================================================================
29
31
  // Per-template facts
@@ -189,6 +191,39 @@ export interface SourceSyncReviewReadyFacts {
189
191
  readonly checkpoint: "candidates" | "synthesis";
190
192
  }
191
193
 
194
+ // =============================================================================
195
+ // Workflow settlement facts (control ADR-CTRL-540 phase 2)
196
+ // =============================================================================
197
+
198
+ /**
199
+ * The settlement data part a pushed chat may carry: an explained, fact-free
200
+ * outcome.
201
+ */
202
+ export type ExplainedWorkflowOutcomeData = Omit<
203
+ WorkflowOutcomeData,
204
+ "outcome"
205
+ > & {
206
+ readonly outcome: ExplainedWorkflowOutcome;
207
+ };
208
+
209
+ /**
210
+ * A workflow ended in a failure a person can act on, and nobody was watching.
211
+ *
212
+ * FACT-FREE (INV-PROACTIVE-CONTENT). `narration` is the BACKEND's deterministic
213
+ * settlement copy (src/chat/workflow-outcome-copy.ts), rendered from the
214
+ * fact-stripped outcome (`facts: null`) — the same sentence the launching chat
215
+ * would have received, so one module owns settlement wording and it already
216
+ * carries the call-to-action ban. `outcome` is the settlement data part with
217
+ * the frozen outcome's facts stripped: reason and affordance ids only, each
218
+ * affordance re-gated per viewer at render and executed by the server.
219
+ */
220
+ export interface WorkflowNeedsAttentionFacts {
221
+ /** The backend settlement narration for this outcome, told with `facts: null`. */
222
+ readonly narration: string;
223
+ /** `{ workflowId, outcome }` — explained route only, `facts: null`. */
224
+ readonly outcome: ExplainedWorkflowOutcomeData;
225
+ }
226
+
192
227
  // =============================================================================
193
228
  // ProactiveChatFacts
194
229
  // =============================================================================
@@ -207,4 +242,5 @@ export interface ProactiveChatFacts {
207
242
  readonly billingSuspended: BillingSuspendedFacts;
208
243
  readonly billingSubscriptionCanceled: BillingSubscriptionCanceledFacts;
209
244
  readonly sourceSyncReviewReady: SourceSyncReviewReadyFacts;
245
+ readonly workflowNeedsAttention: WorkflowNeedsAttentionFacts;
210
246
  }
@@ -51,6 +51,8 @@ export type {
51
51
  BillingSuspendedFacts,
52
52
  BillingSubscriptionCanceledFacts,
53
53
  SourceSyncReviewReadyFacts,
54
+ ExplainedWorkflowOutcomeData,
55
+ WorkflowNeedsAttentionFacts,
54
56
  ProactiveChatFacts,
55
57
  } from "./facts";
56
58
  export { PROACTIVE_CHAT_MAX_REPLIES } from "./composer";
@@ -302,6 +302,35 @@ export const PROACTIVE_EVENT_KIND_IDS = [
302
302
  * times its job is redelivered.
303
303
  */
304
304
  "orgStructure.review_ready",
305
+
306
+ // ===========================================================================
307
+ // Workflow settlements (control ADR-CTRL-540 phase 2; control ADR
308
+ // affordance-system)
309
+ // ===========================================================================
310
+ //
311
+ // A workflow that settles with no launching conversation (a scheduled
312
+ // rediscovery, an upkeep re-synthesis, a launch nobody docked into a chat)
313
+ // is ROUTED here by the backend, per reason, through the frozen
314
+ // `*_DEFERRED_ROUTE_OF_REASON` tables in ../workflows/deferred-route.
315
+ //
316
+ // TRIGGER: the committed `deferred → routed` transition of the settlement
317
+ // row (INV-PROACTIVE-TRIGGER). OCCURRENCE KEY:
318
+ // `workflow-settled:{workflowId}:{executionEpoch}` — one settlement announces
319
+ // once, however often the sweep runs.
320
+ /**
321
+ * A workflow ended in a failure a person can act on. `explained` — a pushed
322
+ * chat that says what happened, FACT-FREE (reason only), carrying the frozen
323
+ * outcome so its grounded affordances render as controls; plus a born-read
324
+ * inbox row. No banner: one background run is not the workspace broken.
325
+ */
326
+ "workflow.needs_attention",
327
+ /**
328
+ * A workflow finished (or failed with nothing to offer) while nobody was
329
+ * watching. A `notice` — an unread inbox row and nothing else: telling it
330
+ * needs counts, which are authority-sensitive and must re-render under the
331
+ * reader's current authority, never freeze into a chat.
332
+ */
333
+ "workflow.finished",
305
334
  ] as const;
306
335
  export type ProactiveEventKind = (typeof PROACTIVE_EVENT_KIND_IDS)[number];
307
336
 
@@ -262,4 +262,26 @@ export const PROACTIVE_EVENT_KINDS = {
262
262
  class: "notice",
263
263
  inboxKind: "proactive.org_structure_review_ready",
264
264
  },
265
+
266
+ // ===========================================================================
267
+ // Workflow settlements (control ADR-CTRL-540 phase 2)
268
+ // ===========================================================================
269
+ //
270
+ // Routed per reason by the backend through ../workflows/deferred-route. NO
271
+ // PLAN SETS `emailKind` or `actionItemKind`: a background run's ending is
272
+ // announced, not standing work.
273
+ /**
274
+ * A failure a person can act on. EXPLAINED: the fact-free chat carries the
275
+ * badge and the frozen outcome; the inbox row is born read.
276
+ */
277
+ "workflow.needs_attention": {
278
+ class: "explained",
279
+ chatTemplate: "workflowNeedsAttention",
280
+ inboxKind: "proactive.workflow_needs_attention",
281
+ },
282
+ /** A finished run nobody watched. A NOTICE: the inbox row is the attention. */
283
+ "workflow.finished": {
284
+ class: "notice",
285
+ inboxKind: "proactive.workflow_finished",
286
+ },
265
287
  } as const satisfies Record<ProactiveEventKind, ProactiveEventDefinition>;
@@ -91,5 +91,7 @@ export const PROACTIVE_CHAT_TEMPLATES = [
91
91
  "billingSubscriptionCanceled",
92
92
  /** "A source review is ready — here is what it is and what happens if you leave it." */
93
93
  "sourceSyncReviewReady",
94
+ /** "A background run did not finish — here is what happened." Fact-free. */
95
+ "workflowNeedsAttention",
94
96
  ] as const;
95
97
  export type ProactiveChatTemplate = (typeof PROACTIVE_CHAT_TEMPLATES)[number];
@@ -66,6 +66,13 @@ chat. That is the precedent notify/'s README states for outbound notifications
66
66
  nothing. It names no count, no document and no source: all three are
67
67
  scope-gated, and a frozen sentence cannot be taken back.
68
68
  `../__tests__/vocabulary.test.ts` enforces "no digit" for both checkpoints.
69
+ - **`workflow-needs-attention.ts` FRAMES; IT DOES NOT NARRATE.** Its `text` is
70
+ the backend's settlement narration verbatim (one owner of settlement wording),
71
+ its title is per workflow type with no digit and no next step, it has no
72
+ chips, and it carries the fact-free outcome as `outcome` so the outcome's
73
+ grounded affordances render as real controls (control ADR
74
+ `affordance-system`). `../__tests__/workflow-attention.test.ts` pins all of
75
+ it.
69
76
 
70
77
  ## Public API
71
78
 
@@ -80,10 +87,11 @@ chat. That is the precedent notify/'s README states for outbound notifications
80
87
  | `billingSuspendedComposer` | The grace period elapsed; leads with "nothing was deleted" |
81
88
  | `billingSubscriptionCanceledComposer` | Seats ended; AI funds are not forfeit |
82
89
  | `sourceSyncReviewReadyComposer` | A source review waits; nothing has happened yet; no count |
90
+ | `workflowNeedsAttentionComposer` | A background run failed; frames the backend narration, no chips |
83
91
 
84
92
  ## Dependencies
85
93
 
86
94
  Type-only imports of `ProactiveChatComposer` (`../composer`),
87
95
  `ProactiveChatTemplate` (`../surfaces`) and the per-template facts
88
- (`../facts`). No `zod`, no runtime imports beyond the sibling template
96
+ (`../facts`), and of `WorkflowType` (`../../workflows`). No `zod`, no runtime imports beyond the sibling template
89
97
  modules.
@@ -31,6 +31,7 @@ import { billingSuspendedComposer } from "./billing-suspended";
31
31
  import { morningBriefComposer } from "./morning-brief";
32
32
  import { orgBecameSharedComposer } from "./org-became-shared";
33
33
  import { sourceSyncReviewReadyComposer } from "./source-sync-review-ready";
34
+ import { workflowNeedsAttentionComposer } from "./workflow-needs-attention";
34
35
 
35
36
  export const PROACTIVE_CHAT_COMPOSERS = {
36
37
  orgBecameShared: orgBecameSharedComposer,
@@ -41,4 +42,5 @@ export const PROACTIVE_CHAT_COMPOSERS = {
41
42
  billingSuspended: billingSuspendedComposer,
42
43
  billingSubscriptionCanceled: billingSubscriptionCanceledComposer,
43
44
  sourceSyncReviewReady: sourceSyncReviewReadyComposer,
45
+ workflowNeedsAttention: workflowNeedsAttentionComposer,
44
46
  } as const satisfies Record<ProactiveChatTemplate, ProactiveChatComposer>;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The workflow-needs-attention pushed chat — a background run ended in a
3
+ * failure a person can act on, while nobody was watching.
4
+ *
5
+ * THIS COMPOSER FRAMES; IT DOES NOT NARRATE. The sentence is the backend's
6
+ * deterministic settlement narration (one owner of settlement wording, already
7
+ * under the call-to-action ban), passed in as `narration`. The composer adds a
8
+ * title per workflow type and carries the fact-free outcome so its grounded
9
+ * affordances render as real controls (control ADR affordance-system). It
10
+ * offers no next step in words and no chips: a chip only sends a chat prompt,
11
+ * which is not grounded in a control.
12
+ *
13
+ * NO COUNTS (INV-PROACTIVE-CONTENT): the outcome's facts are stripped and the
14
+ * narration is told from the stripped outcome.
15
+ */
16
+ import type { WorkflowType } from "../../workflows/types";
17
+ import type { ProactiveChatComposer } from "../composer";
18
+
19
+ const TITLE_OF_WORKFLOW_TYPE = {
20
+ "source-sync/discover": "The drive search did not finish",
21
+ "source-sync/synthesize": "The document update did not finish",
22
+ "org-structure/hris-connect": "The org structure analysis did not finish",
23
+ "org-structure/reanalyze": "The org structure analysis did not finish",
24
+ } as const satisfies Record<WorkflowType, string>;
25
+
26
+ export const workflowNeedsAttentionComposer: ProactiveChatComposer<"workflowNeedsAttention"> =
27
+ {
28
+ template: "workflowNeedsAttention",
29
+ compose: (facts) => ({
30
+ title: TITLE_OF_WORKFLOW_TYPE[facts.outcome.outcome.workflowType],
31
+ text: facts.narration,
32
+ replies: [],
33
+ outcome: facts.outcome,
34
+ }),
35
+ };
@@ -48,6 +48,7 @@ documented in its owning domain (`src/org/`, `src/chat/`, `src/comments/`, …).
48
48
  **Internal domains:**
49
49
 
50
50
  - `action-items`
51
+ - `affordances`
51
52
  - `api`
52
53
  - `autotune`
53
54
  - `chat`