@company-semantics/contracts 62.5.0 → 62.7.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 (39) hide show
  1. package/package.json +5 -5
  2. package/src/__tests__/iso-datetime.test.ts +71 -0
  3. package/src/admin/direct-grants.ts +4 -3
  4. package/src/api/generated-spec-hash.ts +2 -2
  5. package/src/api/generated.ts +2 -3
  6. package/src/api/primitives.ts +44 -0
  7. package/src/chat/README.md +1 -1
  8. package/src/chat/index.ts +0 -1
  9. package/src/chat/schemas.ts +20 -26
  10. package/src/execution/README.md +1 -0
  11. package/src/execution/schemas.ts +3 -2
  12. package/src/execution/sse.ts +2 -1
  13. package/src/identity/README.md +1 -0
  14. package/src/identity/identity-link.ts +3 -2
  15. package/src/impersonation/schemas.ts +7 -6
  16. package/src/index.ts +15 -0
  17. package/src/integrations/README.md +1 -1
  18. package/src/integrations/schemas.ts +2 -1
  19. package/src/meetings/schemas.ts +4 -3
  20. package/src/message-parts/README.md +5 -0
  21. package/src/message-parts/__tests__/workflow-progress.test.ts +95 -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 +29 -0
  25. package/src/message-parts/workflow-progress.ts +66 -0
  26. package/src/org/occupancy.ts +3 -2
  27. package/src/org/reasoning-review.ts +2 -1
  28. package/src/org/reconciliation.ts +4 -3
  29. package/src/org/schemas.ts +4 -4
  30. package/src/org/sync-run.ts +3 -2
  31. package/src/permissions/permission-introspection.ts +2 -1
  32. package/src/permissions/share-api.ts +2 -1
  33. package/src/security/org-secrets.ts +4 -3
  34. package/src/user-events/schemas.ts +2 -2
  35. package/src/workflows/README.md +35 -0
  36. package/src/workflows/__tests__/README.md +30 -0
  37. package/src/workflows/__tests__/types.test.ts +52 -0
  38. package/src/workflows/index.ts +12 -0
  39. package/src/workflows/types.ts +44 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "62.5.0",
3
+ "version": "62.7.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -139,8 +139,8 @@
139
139
  "node": "22.x"
140
140
  },
141
141
  "dependencies": {
142
- "@slack/types": "^3.0.0",
143
- "zod": "^4.4.3"
142
+ "@slack/types": "^3.1.0",
143
+ "zod": "^4.5.2"
144
144
  },
145
145
  "devDependencies": {
146
146
  "@eslint/js": "^10.0.1",
@@ -149,13 +149,13 @@
149
149
  "culori": "^4.0.2",
150
150
  "eslint": "^10.9.0",
151
151
  "husky": "^9.1.7",
152
- "lint-staged": "^17.3.0",
152
+ "lint-staged": "^17.4.1",
153
153
  "markdownlint-cli2": "^0.23.2",
154
154
  "openapi-typescript": "^7.13.0",
155
155
  "prettier": "^3.9.6",
156
156
  "tsx": "^4.23.12",
157
157
  "typescript": "^5.8.3",
158
- "typescript-eslint": "^8.67.0",
158
+ "typescript-eslint": "^8.68.0",
159
159
  "vite": "^8.2.2",
160
160
  "vitest": "^4.1.11",
161
161
  "yaml": "^2.9.0"
@@ -0,0 +1,71 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { z } from "zod";
3
+
4
+ import {
5
+ ISO_DATETIME_OFFSET_PATTERN,
6
+ ISO_DATETIME_PATTERN,
7
+ IsoDateTime,
8
+ IsoDateTimeWithOffset,
9
+ } from "../api/primitives";
10
+
11
+ /**
12
+ * These are drift alarms, not unit tests.
13
+ *
14
+ * `IsoDateTime` delegates to zod's built-in RFC 3339 regex, and zod is advanced
15
+ * automatically by Dependabot inside a grouped `minor`/`patch` bump. That is
16
+ * exactly how the seconds-optional -> seconds-mandatory tightening reached
17
+ * production undocumented (backend #520). If zod moves this regex again, the
18
+ * documented format of every `format: date-time` field on the public API moves
19
+ * with it — so it fails here first and gets ratified deliberately.
20
+ *
21
+ * If one of these fails: do not update the constant to make it green. Work out
22
+ * what zod changed, decide whether we want it, and record the decision.
23
+ */
24
+ describe("the RFC 3339 wire contract is pinned, not inherited", () => {
25
+ it("emits the pinned pattern for the Z-only form", () => {
26
+ expect(z.toJSONSchema(IsoDateTime, { io: "input" }).pattern).toBe(
27
+ ISO_DATETIME_PATTERN,
28
+ );
29
+ });
30
+
31
+ it("emits the pinned pattern for the offset-allowing form", () => {
32
+ expect(z.toJSONSchema(IsoDateTimeWithOffset, { io: "input" }).pattern).toBe(
33
+ ISO_DATETIME_OFFSET_PATTERN,
34
+ );
35
+ });
36
+
37
+ it("still emits format: date-time, which the pattern alone would not carry", () => {
38
+ expect(z.toJSONSchema(IsoDateTime, { io: "input" }).format).toBe(
39
+ "date-time",
40
+ );
41
+ });
42
+ });
43
+
44
+ describe("seconds are mandatory (ADR-CONTRACTS-156)", () => {
45
+ it.each([
46
+ "2026-09-01T12:00:00Z",
47
+ "2026-09-01T12:00:00.000Z",
48
+ "2024-02-29T23:59:59Z",
49
+ ])("accepts %s", (value) => {
50
+ expect(IsoDateTime.safeParse(value).success).toBe(true);
51
+ });
52
+
53
+ it.each(["2026-09-01T12:00Z", "2026-09-01T12Z", "2026-09-01"])(
54
+ "rejects %s",
55
+ (value) => {
56
+ expect(IsoDateTime.safeParse(value).success).toBe(false);
57
+ },
58
+ );
59
+
60
+ it("rejects a numeric offset on the Z-only form", () => {
61
+ expect(IsoDateTime.safeParse("2026-09-01T12:00:00+02:00").success).toBe(
62
+ false,
63
+ );
64
+ });
65
+
66
+ it("accepts a numeric offset on the offset-allowing form", () => {
67
+ expect(
68
+ IsoDateTimeWithOffset.safeParse("2026-09-01T12:00:00+02:00").success,
69
+ ).toBe(true);
70
+ });
71
+ });
@@ -7,12 +7,13 @@
7
7
  * direct-grant-audit-trail and the `direct-grant-audit` CI guard).
8
8
  */
9
9
  import { z } from "zod";
10
+ import { IsoDateTime } from "../api/primitives";
10
11
 
11
12
  export const DirectGrantCreate = z.object({
12
13
  subject_id: z.string().uuid(),
13
14
  scope_pattern: z.string(),
14
15
  resource_filter: z.record(z.string(), z.unknown()).optional(),
15
- expires_at: z.string().datetime().optional(),
16
+ expires_at: IsoDateTime.optional(),
16
17
  });
17
18
 
18
19
  export type DirectGrantCreate = z.infer<typeof DirectGrantCreate>;
@@ -24,8 +25,8 @@ export const DirectGrant = z.object({
24
25
  scope_pattern: z.string(),
25
26
  source: z.literal("direct"),
26
27
  granted_by: z.string().uuid(),
27
- granted_at: z.string().datetime(),
28
- expires_at: z.string().datetime().nullable(),
28
+ granted_at: IsoDateTime,
29
+ expires_at: IsoDateTime.nullable(),
29
30
  });
30
31
 
31
32
  export type DirectGrant = z.infer<typeof DirectGrant>;
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = '2933a0834a99' as const;
3
- export const SPEC_HASH_FULL = '2933a0834a99c8c5e38751cc8127ead37642d7e57bd91f5ab37aae991bfd5a3b' as const;
2
+ export const SPEC_HASH = '52d89a463432' as const;
3
+ export const SPEC_HASH_FULL = '52d89a4634328ae80200c221d6aa25d8c34c9d09208a2c460d82a523ec79144d' as const;
@@ -6795,9 +6795,8 @@ export interface components {
6795
6795
  p95ContextTokens: number;
6796
6796
  medianReadsAfterFirstEdit: number;
6797
6797
  }[];
6798
- bySource: ({
6798
+ bySource: {
6799
6799
  source: string;
6800
- } & {
6801
6800
  p95Reads?: number;
6802
6801
  p50Reads?: number;
6803
6802
  p95ContextTokens?: number;
@@ -6809,7 +6808,7 @@ export interface components {
6809
6808
  navMapUsageBySource?: {
6810
6809
  [key: string]: number;
6811
6810
  };
6812
- })[];
6811
+ }[];
6813
6812
  trend: {
6814
6813
  recent: {
6815
6814
  p95Reads?: number;
@@ -25,3 +25,47 @@ export type CursorPage<T> = {
25
25
  hasMore: boolean;
26
26
  };
27
27
  export type ErrorResponse = z.infer<typeof ErrorResponseSchema>;
28
+
29
+ // =============================================================================
30
+ // The RFC 3339 timestamp contract
31
+ // =============================================================================
32
+ //
33
+ // Every `format: date-time` field on the public API surface is built from one of
34
+ // the two schemas below, so the wire contract has exactly one definition site.
35
+ //
36
+ // The validators delegate to zod's built-in `.datetime()` rather than
37
+ // reimplementing RFC 3339 by hand: that keeps `format: date-time` in the emitted
38
+ // OpenAPI document and avoids a hand-rolled regex drifting from the runtime
39
+ // check. The regex zod produces is therefore an *input* to our published
40
+ // contract, and zod is a transitive dependency that Dependabot advances
41
+ // automatically — so the emitted pattern is pinned below and asserted in
42
+ // `src/__tests__/iso-datetime.test.ts`.
43
+ //
44
+ // That assertion is the point. A zod release that changes this regex changes the
45
+ // documented format of 52 public fields; it must fail a test and be ratified,
46
+ // not land silently inside a grouped `minor`/`patch` bump. See ADR-CONTRACTS-156.
47
+
48
+ /** Emitted `pattern` for {@link IsoDateTime}. Pinned — see the note above. */
49
+ export const ISO_DATETIME_PATTERN =
50
+ "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$";
51
+
52
+ /** Emitted `pattern` for {@link IsoDateTimeWithOffset}. Pinned — see the note above. */
53
+ export const ISO_DATETIME_OFFSET_PATTERN =
54
+ "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z|([+-](?:[01]\\d|2[0-3]):[0-5]\\d)))$";
55
+
56
+ /**
57
+ * A UTC RFC 3339 timestamp — `2026-09-01T12:00:00.000Z`.
58
+ *
59
+ * Seconds are mandatory. `2026-09-01T12:00Z` is rejected: it is not valid
60
+ * RFC 3339, and the backend has rejected it since zod 4.5.x.
61
+ */
62
+ export const IsoDateTime = z.string().datetime();
63
+
64
+ /**
65
+ * An RFC 3339 timestamp that may carry a numeric UTC offset instead of `Z` —
66
+ * `2026-09-01T12:00:00.000+02:00`.
67
+ *
68
+ * Use only where a client legitimately reports local wall-clock time with its
69
+ * offset. Prefer {@link IsoDateTime} for anything the server mints.
70
+ */
71
+ export const IsoDateTimeWithOffset = z.string().datetime({ offset: true });
@@ -89,7 +89,6 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
89
89
  - `InvalidateChatListEventSchema`
90
90
  - `InvalidationReason` _(type)_ — Reason for cache invalidation.
91
91
  - `InvalidationReasonSchema`
92
- - `IsoDateString` — ISO 8601 datetime string — the runtime safety net for date serialization.
93
92
  - `ListSharesResponse` _(type)_ — Response for GET /api/chats/:chatId/shares
94
93
  - `ListSharesResponseSchema` — Response for GET /api/chats/:chatId/shares
95
94
  - `ProactiveChatResolution` _(type)_ — Resolving an occurrence id to the CURRENT reader's chat — one of the four bounded states `ready` (with…
@@ -117,6 +116,7 @@ Shared types for chat persistence, sharing, real-time events, and runtime profil
117
116
 
118
117
  **Internal domains:**
119
118
 
119
+ - `api`
120
120
  - `proactive`
121
121
 
122
122
  **External packages:**
package/src/chat/index.ts CHANGED
@@ -61,7 +61,6 @@ export type {
61
61
  // =============================================================================
62
62
 
63
63
  export {
64
- IsoDateString,
65
64
  ChatVisibilitySchema,
66
65
  TitleSourceSchema,
67
66
  InvalidationReasonSchema,
@@ -1,11 +1,5 @@
1
1
  import { z } from "zod";
2
-
3
- // =============================================================================
4
- // Reusable Primitives
5
- // =============================================================================
6
-
7
- /** ISO 8601 datetime string — the runtime safety net for date serialization. */
8
- export const IsoDateString = z.string().datetime();
2
+ import { IsoDateTime } from "../api/primitives";
9
3
 
10
4
  // =============================================================================
11
5
  // Enums
@@ -78,15 +72,15 @@ export const ChatSummarySchema = z.object({
78
72
  id: z.string(),
79
73
  title: z.string().nullable(),
80
74
  messageCount: z.number().int(),
81
- createdAt: IsoDateString,
82
- updatedAt: IsoDateString,
75
+ createdAt: IsoDateTime,
76
+ updatedAt: IsoDateTime,
83
77
  isShared: z.boolean(),
84
78
  });
85
79
 
86
80
  export const ChatSummaryExtendedSchema = ChatSummarySchema.extend({
87
- pinnedAt: IsoDateString.nullable(),
81
+ pinnedAt: IsoDateTime.nullable(),
88
82
  titleSource: TitleSourceSchema.nullable(),
89
- titleGeneratedAt: IsoDateString.nullable(),
83
+ titleGeneratedAt: IsoDateTime.nullable(),
90
84
  ...proactiveChatFields,
91
85
  });
92
86
 
@@ -129,7 +123,7 @@ export const ChatShareInfoSchema = z.object({
129
123
  shareUrl: z.string().optional(),
130
124
  messageCountAtShare: z.number().int(),
131
125
  titleAtShare: z.string(),
132
- createdAt: IsoDateString,
126
+ createdAt: IsoDateTime,
133
127
  createdByName: z.string(),
134
128
  isRevoked: z.boolean(),
135
129
  });
@@ -137,7 +131,7 @@ export const ChatShareInfoSchema = z.object({
137
131
  export const SharedChatViewSchema = z.object({
138
132
  title: z.string(),
139
133
  messages: z.array(SharedChatMessageSchema),
140
- sharedAt: IsoDateString,
134
+ sharedAt: IsoDateTime,
141
135
  sharedByName: z.string(),
142
136
  visibility: ChatVisibilitySchema,
143
137
  });
@@ -148,7 +142,7 @@ export const SharedChatViewSchema = z.object({
148
142
 
149
143
  export const BaseEventSchema = z.object({
150
144
  v: z.literal(1),
151
- timestamp: IsoDateString,
145
+ timestamp: IsoDateTime,
152
146
  eventId: z.string().optional(),
153
147
  });
154
148
 
@@ -167,10 +161,10 @@ export const ChatCreatedEventSchema = BaseEventSchema.extend({
167
161
  interactionId: z.string().optional(),
168
162
  title: z.string(),
169
163
  titleSource: TitleSourceSchema,
170
- pinnedAt: IsoDateString.nullable(),
164
+ pinnedAt: IsoDateTime.nullable(),
171
165
  messageCount: z.number().int(),
172
- createdAt: IsoDateString,
173
- updatedAt: IsoDateString,
166
+ createdAt: IsoDateTime,
167
+ updatedAt: IsoDateTime,
174
168
  isShared: z.boolean(),
175
169
  ...proactiveChatFields,
176
170
  }),
@@ -182,11 +176,11 @@ export const ChatUpdatedEventSchema = BaseEventSchema.extend({
182
176
  chatId: z.string(),
183
177
  title: z.string(),
184
178
  titleSource: TitleSourceSchema.nullable(),
185
- titleGeneratedAt: IsoDateString.nullable(),
186
- pinnedAt: IsoDateString.nullable(),
179
+ titleGeneratedAt: IsoDateTime.nullable(),
180
+ pinnedAt: IsoDateTime.nullable(),
187
181
  messageCount: z.number().int(),
188
- createdAt: IsoDateString,
189
- updatedAt: IsoDateString,
182
+ createdAt: IsoDateTime,
183
+ updatedAt: IsoDateTime,
190
184
  isShared: z.boolean(),
191
185
  ...proactiveChatFields,
192
186
  }),
@@ -245,7 +239,7 @@ export const ChatMessageSchema = z.object({
245
239
  content: z.string(),
246
240
  parts: z.array(z.unknown()).optional(),
247
241
  sequenceNumber: z.number().int(),
248
- createdAt: IsoDateString,
242
+ createdAt: IsoDateTime,
249
243
  });
250
244
 
251
245
  // =============================================================================
@@ -325,11 +319,11 @@ export const ChatDetailSchema = z.object({
325
319
  id: z.string(),
326
320
  title: z.string(),
327
321
  interactionId: z.string(),
328
- pinnedAt: IsoDateString.nullable().optional(),
322
+ pinnedAt: IsoDateTime.nullable().optional(),
329
323
  titleSource: TitleSourceSchema.nullable().optional(),
330
- titleGeneratedAt: IsoDateString.nullable().optional(),
331
- createdAt: IsoDateString,
332
- updatedAt: IsoDateString,
324
+ titleGeneratedAt: IsoDateTime.nullable().optional(),
325
+ createdAt: IsoDateTime,
326
+ updatedAt: IsoDateTime,
333
327
  // Opening the detail is what clears `unread`; the detail carries the flag so
334
328
  // the reader can tell a first open from a return visit. `proactiveKind` is
335
329
  // a list concern (the sidebar row) and does not ride the detail.
@@ -102,6 +102,7 @@ TypeScript types and registry for execution kinds and their governance metadata.
102
102
 
103
103
  **Internal domains:**
104
104
 
105
+ - `api`
105
106
  - `message-parts`
106
107
 
107
108
  **External packages:**
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import { z } from "zod";
11
+ import { IsoDateTimeWithOffset } from "../api/primitives";
11
12
 
12
13
  import type { ExecutionResultData } from "../message-parts/execution";
13
14
  import type { ExecutionKind } from "./kinds";
@@ -22,8 +23,8 @@ export const ExecutionSummarySchema = z.object({
22
23
  executionId: z.string().uuid(),
23
24
  kind: z.string(),
24
25
  status: z.enum(["pending", "succeeded", "failed"]),
25
- decidedAt: z.string().datetime({ offset: true }),
26
- completedAt: z.string().datetime({ offset: true }).optional(),
26
+ decidedAt: IsoDateTimeWithOffset,
27
+ completedAt: IsoDateTimeWithOffset.optional(),
27
28
  target: z.object({ type: z.string() }),
28
29
  });
29
30
 
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import { z } from "zod";
18
+ import { IsoDateTimeWithOffset } from "../api/primitives";
18
19
  import { ExecutionEventTypeSchema } from "./events";
19
20
  import { ExecutionStateSchema } from "./status";
20
21
 
@@ -27,7 +28,7 @@ export const ExecutionSseEventSchema = z.object({
27
28
  /** Coarse actor class that produced the event (e.g. `user`, `system`). */
28
29
  actorType: z.string(),
29
30
  /** DB insert time of the event, ISO-8601 with offset. */
30
- createdAt: z.string().datetime({ offset: true }),
31
+ createdAt: IsoDateTimeWithOffset,
31
32
  });
32
33
 
33
34
  export type ExecutionSseEvent = z.infer<typeof ExecutionSseEventSchema>;
@@ -88,6 +88,7 @@ TypeScript types and functions for user identity and display name resolution.
88
88
 
89
89
  **Internal domains:**
90
90
 
91
+ - `api`
91
92
  - `org`
92
93
 
93
94
  **External packages:**
@@ -17,6 +17,7 @@
17
17
  * @see ADR-CONT-032 for identity vocabulary design rationale
18
18
  */
19
19
  import { z } from "zod";
20
+ import { IsoDateTime } from "../api/primitives";
20
21
 
21
22
  // ---------------------------------------------------------------------------
22
23
  // IdentityLink — external system id → Person
@@ -31,8 +32,8 @@ export const IdentityLinkSchema = z.object({
31
32
  externalId: z.string().min(1),
32
33
  // The Person this external identity resolves to (Person.id).
33
34
  personId: z.string().uuid(),
34
- createdAt: z.string().datetime(),
35
- updatedAt: z.string().datetime(),
35
+ createdAt: IsoDateTime,
36
+ updatedAt: IsoDateTime,
36
37
  });
37
38
 
38
39
  export type IdentityLink = z.infer<typeof IdentityLinkSchema>;
@@ -9,6 +9,7 @@
9
9
  */
10
10
 
11
11
  import { z } from "zod";
12
+ import { IsoDateTimeWithOffset } from "../api/primitives";
12
13
 
13
14
  export const ImpersonationSessionSchema = z.object({
14
15
  impersonationSessionId: z.string(),
@@ -16,9 +17,9 @@ export const ImpersonationSessionSchema = z.object({
16
17
  targetUserId: z.string(),
17
18
  reason: z.string(),
18
19
  reasonHash: z.string(),
19
- startedAt: z.string().datetime({ offset: true }),
20
- expiresAt: z.string().datetime({ offset: true }),
21
- endedAt: z.string().datetime({ offset: true }).nullable(),
20
+ startedAt: IsoDateTimeWithOffset,
21
+ expiresAt: IsoDateTimeWithOffset,
22
+ endedAt: IsoDateTimeWithOffset.nullable(),
22
23
  ipAddress: z.string(),
23
24
  userAgent: z.string(),
24
25
  });
@@ -48,9 +49,9 @@ export const ImpersonationSessionWireDtoSchema = z
48
49
  .object({
49
50
  id: z.string(),
50
51
  targetUserId: z.string(),
51
- startedAt: z.string().datetime({ offset: true }),
52
- expiresAt: z.string().datetime({ offset: true }),
53
- endedAt: z.string().datetime({ offset: true }).nullable(),
52
+ startedAt: IsoDateTimeWithOffset,
53
+ expiresAt: IsoDateTimeWithOffset,
54
+ endedAt: IsoDateTimeWithOffset.nullable(),
54
55
  })
55
56
  .strict();
56
57
 
package/src/index.ts CHANGED
@@ -367,6 +367,17 @@ export type {
367
367
  ProactiveChatMessage,
368
368
  } from "./proactive/index";
369
369
 
370
+ // Analysis workflows — the registered types a durable long-running analysis
371
+ // can be (PRD-00976). The workflow primitive itself is domain-agnostic; its
372
+ // TYPE is the one place a domain names itself, and that name is a public wire
373
+ // discriminator carried on the workflow row, in the data-workflow-progress
374
+ // chat part and by the generic projection read. Namespaced `<domain>/<flow>`
375
+ // so domains cannot collide, and a CLOSED const array so the discriminator is
376
+ // validated and handled exhaustively. See src/workflows/README.md.
377
+ export { ANALYSIS_WORKFLOW_TYPES, isWorkflowType } from "./workflows/index";
378
+
379
+ export type { WorkflowType } from "./workflows/index";
380
+
370
381
  // Comment wire contract — where a thread hangs (the anchor union), what a
371
382
  // thread and its comments look like on the wire, and who a comment may name.
372
383
  // The anchor is the single most load-bearing shape in this package: the backend
@@ -1032,6 +1043,10 @@ export type {
1032
1043
  SuggestedRepliesData,
1033
1044
  SuggestedRepliesPart,
1034
1045
  SuggestedRepliesDataPart,
1046
+ // Workflow progress surface types (non-governed anchor, data-workflow-progress, PRD-00976)
1047
+ WorkflowProgressData,
1048
+ WorkflowProgressPart,
1049
+ WorkflowProgressDataPart,
1035
1050
  // Execution result types (Phase 5)
1036
1051
  ExecutionArtifactStatus,
1037
1052
  ExecutionResultSummary,
@@ -47,7 +47,7 @@ alongside `Meetings`, `Comms`, and `Docs`.
47
47
 
48
48
  **Internal domains:**
49
49
 
50
- _None._
50
+ - `api`
51
51
 
52
52
  **External packages:**
53
53
 
@@ -16,6 +16,7 @@
16
16
  * stringified JSON blobs on the schema.
17
17
  */
18
18
  import { z } from "zod";
19
+ import { IsoDateTime } from "../api/primitives";
19
20
 
20
21
  // =============================================================================
21
22
  // HR connection status
@@ -87,7 +88,7 @@ export const HrConnectionStatusSchema = z
87
88
  description:
88
89
  "Departments mirrored from the HR provider; null when never synced.",
89
90
  }),
90
- lastSyncAt: z.string().datetime().nullable().meta({
91
+ lastSyncAt: IsoDateTime.nullable().meta({
91
92
  description: "ISO timestamp of the last successful sync; null if never.",
92
93
  }),
93
94
  syncHealth: z.enum(["healthy", "degraded", "error"]).nullable().meta({
@@ -21,6 +21,7 @@
21
21
  * stringified JSON blobs on the schema.
22
22
  */
23
23
  import { z } from "zod";
24
+ import { IsoDateTime } from "../api/primitives";
24
25
 
25
26
  // =============================================================================
26
27
  // Recording identity
@@ -237,7 +238,7 @@ export const TranscriptionSessionGrantSchema = z
237
238
  sessionId: RecordingIdSchema,
238
239
  connectUrl: z.string().url(),
239
240
  authHeaders: z.record(z.string(), z.string()),
240
- expiresAt: z.string().datetime(),
241
+ expiresAt: IsoDateTime,
241
242
  audioConfig: z.object({
242
243
  sampleRateHz: z.literal(16000),
243
244
  channels: z.literal(2),
@@ -279,8 +280,8 @@ export const MeetingMetadataProjectionSchema = z
279
280
  visibility: MeetingVisibilitySchema,
280
281
  status: RecordingStatusSchema,
281
282
  quality: RecordingQualitySchema,
282
- startedAt: z.string().datetime(),
283
- endedAt: z.string().datetime().nullable(),
283
+ startedAt: IsoDateTime,
284
+ endedAt: IsoDateTime.nullable(),
284
285
  durationMs: z.number().int().nonnegative(),
285
286
  participantUserIds: z.array(z.string().uuid()),
286
287
  transcriptAvailable: z.boolean(),
@@ -15,6 +15,7 @@ Canonical vocabulary for structured assistant message output. Defines the type s
15
15
  - Execution results carry `state` resolved from ExecutionState (single authority, no redundant status)
16
16
  - Undo creates append-only audit rows — original execution is never mutated
17
17
  - `SuggestedRepliesPart` is a surface but NOT a governed one: a chip fires an ordinary user turn (no `executionId`, no `submitEndpoint`), is stateless (clicking never consumes it) and advisory (the composer stays enabled) — so it is outside the at-most-one-governed-surface-per-turn rule
18
+ - `WorkflowProgressPart` is the second non-governed surface: a two-field ANCHOR (`workflowId`, `workflowType`) to a durable background workflow. It presents no decision, carries no result snapshot (minted at t=0; parts are never rewritten) and no `startedAt` (operational state stays off immutable content), and names no domain vocabulary — live state is hydrated from the generic workflow projection read
18
19
 
19
20
  <!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
20
21
 
@@ -82,6 +83,9 @@ Canonical vocabulary for structured assistant message output. Defines the type s
82
83
  - `UndoResultData` _(type)_ — Undo result data payload.
83
84
  - `UndoResultDataPart` _(type)_ — Undo result data part (wire format).
84
85
  - `WireSurfaceBuilder` — Factory for creating wire-format surface parts.
86
+ - `WorkflowProgressData` _(type)_ — Workflow progress surface data payload — the anchor's handle and its discriminator.
87
+ - `WorkflowProgressDataPart` _(type)_ — Workflow progress data part (wire format, as persisted in `chat_messages.parts`).
88
+ - `WorkflowProgressPart` _(type)_ — Workflow progress message part (semantic type, as rendered).
85
89
  - `addPart` — Add any message part to the builder.
86
90
  - `addSurface` — Add a surface part to the builder.
87
91
  - `addText` — Add a text part to the builder.
@@ -102,6 +106,7 @@ Canonical vocabulary for structured assistant message output. Defines the type s
102
106
 
103
107
  - `execution`
104
108
  - `mcp`
109
+ - `workflows`
105
110
 
106
111
  **External packages:**
107
112
 
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { WireSurfaceBuilder } from "../wire";
3
+ import { isSurfacePart, isTextPart } from "../types";
4
+ import type { AssistantMessagePart, SurfacePart } from "../types";
5
+ import { createPartBuilder, addSurface, addText } from "../builder";
6
+ import type {
7
+ WorkflowProgressData,
8
+ WorkflowProgressPart,
9
+ } from "../workflow-progress";
10
+
11
+ describe("WireSurfaceBuilder.workflowProgress", () => {
12
+ const data: WorkflowProgressData = {
13
+ workflowId: "3f1c2d4e-0000-0000-0000-000000000000",
14
+ workflowType: "org-structure/hris-connect",
15
+ };
16
+
17
+ it("returns data part with type data-workflow-progress", () => {
18
+ const result = WireSurfaceBuilder.workflowProgress(data);
19
+ expect(result.type).toBe("data-workflow-progress");
20
+ });
21
+
22
+ it("data matches input WorkflowProgressData exactly", () => {
23
+ const result = WireSurfaceBuilder.workflowProgress(data);
24
+ expect(result.data).toEqual(data);
25
+ });
26
+
27
+ // The part is an ANCHOR: a handle plus a discriminator and nothing else.
28
+ // Pinning the key set catches the two documented regressions — a `result`
29
+ // snapshot (minted at t=0, it could only ever be empty or stale) or a
30
+ // `startedAt` (operational state duplicated into immutable content) creeping
31
+ // onto a durably persisted, share-visible payload.
32
+ it("workflow-progress carries exactly the anchor keys", () => {
33
+ const result = WireSurfaceBuilder.workflowProgress(data);
34
+ expect(Object.keys(result.data).sort()).toEqual([
35
+ "workflowId",
36
+ "workflowType",
37
+ ]);
38
+ expect(result.data).not.toHaveProperty("result");
39
+ expect(result.data).not.toHaveProperty("startedAt");
40
+ });
41
+
42
+ // Non-governed: nothing on the payload would make it count against the
43
+ // at-most-one-governed-surface-per-turn budget.
44
+ it("carries no governed-surface fields (no executionId, no submitEndpoint)", () => {
45
+ const result = WireSurfaceBuilder.workflowProgress(data);
46
+ expect(result.data).not.toHaveProperty("executionId");
47
+ expect(result.data).not.toHaveProperty("submitEndpoint");
48
+ });
49
+
50
+ // Stateless: building twice from the same facts yields identical output, so
51
+ // a historical message re-rendered later cannot differ from the original.
52
+ it("is deterministic across repeated builds", () => {
53
+ expect(WireSurfaceBuilder.workflowProgress(data)).toEqual(
54
+ WireSurfaceBuilder.workflowProgress(data),
55
+ );
56
+ });
57
+ });
58
+
59
+ // The semantic part is what the app renders after normalizing the wire part.
60
+ // Typing the fixture as WorkflowProgressPart and then assigning it to
61
+ // SurfacePart is the compile-time proof that the union gained the member.
62
+ const anchor: WorkflowProgressPart = {
63
+ type: "workflow-progress",
64
+ data: {
65
+ workflowId: "3f1c2d4e-0000-0000-0000-000000000000",
66
+ workflowType: "org-structure/reanalyse",
67
+ },
68
+ };
69
+
70
+ describe("WorkflowProgressPart in the SurfacePart union", () => {
71
+ it("is assignable to SurfacePart and AssistantMessagePart", () => {
72
+ const asSurface: SurfacePart = anchor;
73
+ const asPart: AssistantMessagePart = anchor;
74
+ expect(asSurface.type).toBe("workflow-progress");
75
+ expect(asPart.type).toBe("workflow-progress");
76
+ });
77
+
78
+ it("is classified as a surface part, not a text part", () => {
79
+ expect(isSurfacePart(anchor)).toBe(true);
80
+ expect(isTextPart(anchor)).toBe(false);
81
+ });
82
+
83
+ // The builder's narrative-before-surface state machine applies to the anchor
84
+ // like any other surface: it is emitted after the prose, never streamed.
85
+ it("is accepted by addSurface after narrative text", () => {
86
+ let state = createPartBuilder();
87
+ state = addText(state, "Analysing your org structure now.").state;
88
+ const result = addSurface(state, anchor);
89
+ expect(result.accepted).toBe(true);
90
+ expect(result.state.parts).toEqual([
91
+ { type: "text", text: "Analysing your org structure now." },
92
+ anchor,
93
+ ]);
94
+ });
95
+ });
@@ -71,6 +71,13 @@ export type {
71
71
  SuggestedRepliesDataPart,
72
72
  } from "./suggested-replies";
73
73
 
74
+ // Workflow progress surface types (non-governed anchor to a durable workflow)
75
+ export type {
76
+ WorkflowProgressData,
77
+ WorkflowProgressPart,
78
+ WorkflowProgressDataPart,
79
+ } from "./workflow-progress";
80
+
74
81
  // Execution result types
75
82
  export type {
76
83
  ExecutionArtifactStatus,
@@ -19,6 +19,7 @@ import type { ConfirmationPart } from "./confirmation";
19
19
  import type { InteractiveTaskPart } from "./interactive";
20
20
  import type { StructureReviewPart } from "./structure-review";
21
21
  import type { SuggestedRepliesPart } from "./suggested-replies";
22
+ import type { WorkflowProgressPart } from "./workflow-progress";
22
23
 
23
24
  // =============================================================================
24
25
  // Narrative Parts (Streamable)
@@ -118,6 +119,10 @@ export interface TablePart {
118
119
  * SuggestedRepliesPart is a surface part but NOT a governed one: a chip fires
119
120
  * an ordinary user turn, so it is outside the preview/confirmation/interactive
120
121
  * triad and its at-most-one-governed-surface-per-turn rule.
122
+ *
123
+ * WorkflowProgressPart is the second non-governed member: it anchors a durable
124
+ * background workflow to the occurrence and presents no decision, so it must
125
+ * not consume the governed-surface budget either.
121
126
  */
122
127
  export type SurfacePart =
123
128
  | ToolListPart
@@ -128,7 +133,8 @@ export type SurfacePart =
128
133
  | PreviewPart
129
134
  | InteractiveTaskPart
130
135
  | StructureReviewPart
131
- | SuggestedRepliesPart;
136
+ | SuggestedRepliesPart
137
+ | WorkflowProgressPart;
132
138
 
133
139
  /**
134
140
  * All assistant message part types.
@@ -25,6 +25,10 @@ import type {
25
25
  SuggestedRepliesData,
26
26
  SuggestedRepliesDataPart,
27
27
  } from "./suggested-replies";
28
+ import type {
29
+ WorkflowProgressData,
30
+ WorkflowProgressDataPart,
31
+ } from "./workflow-progress";
28
32
  import type {
29
33
  ExecutionResultData,
30
34
  ExecutionResultDataPart,
@@ -164,6 +168,31 @@ export const WireSurfaceBuilder = {
164
168
  };
165
169
  },
166
170
 
171
+ /**
172
+ * Build a workflow-progress data part for streaming.
173
+ * The anchor for a durable background workflow attached to this assistant
174
+ * occurrence: a handle plus the namespaced workflow type, hydrated
175
+ * client-side from the generic workflow projection read.
176
+ *
177
+ * INVARIANTS:
178
+ * - Not a governed surface: presents no decision, no executionId, no
179
+ * submitEndpoint — so it does not count against the
180
+ * at-most-one-governed-surface-per-turn rule
181
+ * - Anchor only: no result snapshot (minted at t=0, parts are never
182
+ * rewritten) and no startedAt (operational state stays off immutable
183
+ * content)
184
+ * - Emission is orchestration-driven (a typed launch), never a model choice
185
+ *
186
+ * @param data - Workflow progress data (workflowId + workflowType)
187
+ * @returns Wire-format workflow progress part ready for stream
188
+ */
189
+ workflowProgress(data: WorkflowProgressData): WorkflowProgressDataPart {
190
+ return {
191
+ type: "data-workflow-progress",
192
+ data,
193
+ };
194
+ },
195
+
167
196
  /**
168
197
  * Build an execution result data part for streaming.
169
198
  * Reports the outcome of an executed action.
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Workflow Progress Surface Types
3
+ *
4
+ * A durable background workflow attached to an assistant occurrence. The part
5
+ * tells a renderer "there is long-running work behind this message; render its
6
+ * progress surface" — and deliberately says nothing about what that work is
7
+ * analysing. It is a SIBLING of `suggested-replies`: a surface part, but NOT a
8
+ * governed one.
9
+ *
10
+ * WORKFLOW PROGRESS INVARIANTS:
11
+ * - AN ANCHOR, NOT A PAYLOAD. Chat message parts are persisted verbatim and
12
+ * shipped verbatim to token-only share readers, so this carries a handle and
13
+ * a type and nothing else. Live and settled state come from one authorized
14
+ * read keyed on `workflowId` (the generic workflow projection), never from
15
+ * the part.
16
+ * - NO `result`. The part is minted at t=0, before the workflow has produced
17
+ * anything, and `chat_messages.parts` is written once and never rewritten.
18
+ * A result snapshot here would be permanently empty or permanently stale.
19
+ * - NO `startedAt`. It would duplicate operational state into immutable
20
+ * message content, and nothing renders it without hydrating the workflow
21
+ * anyway.
22
+ * - NON-GOVERNED. It presents no decision: no `executionId`, no
23
+ * `submitEndpoint`, no side effect of its own. So it must NOT count against
24
+ * the at-most-one-governed-surface-per-turn budget the chat bridge enforces
25
+ * over the preview/confirmation/interactive triad.
26
+ * - NO DOMAIN VOCABULARY. `workflowType` is the namespaced discriminator from
27
+ * `ANALYSIS_WORKFLOW_TYPES`; the part never names a department, proposal,
28
+ * provider or person. A domain adapter on the consuming side owns that.
29
+ * - Emission is orchestration-driven (a typed launch), never a model choice.
30
+ *
31
+ * Purely additive: an app that predates this part normalizes unknown part
32
+ * types to null, so it drops the anchor and still renders the prose.
33
+ *
34
+ * @see ../workflows/types.ts for the registered workflow types
35
+ * @see decisions/ADR-CONT-026.md for the message-parts design
36
+ */
37
+
38
+ import type { WorkflowType } from "../workflows/types";
39
+
40
+ /**
41
+ * Workflow progress surface data payload — the anchor's handle and its
42
+ * discriminator. Both fields required; nothing else is ever carried.
43
+ */
44
+ export interface WorkflowProgressData {
45
+ /** The durable workflow this occurrence is attached to. */
46
+ workflowId: string;
47
+ /** Which registered flow it is — the namespaced wire discriminator. */
48
+ workflowType: WorkflowType;
49
+ }
50
+
51
+ /**
52
+ * Workflow progress message part (semantic type, as rendered).
53
+ */
54
+ export interface WorkflowProgressPart {
55
+ type: "workflow-progress";
56
+ data: WorkflowProgressData;
57
+ }
58
+
59
+ /**
60
+ * Workflow progress data part (wire format, as persisted in
61
+ * `chat_messages.parts`). Uses AI SDK's data-{name} convention.
62
+ */
63
+ export interface WorkflowProgressDataPart {
64
+ type: "data-workflow-progress";
65
+ data: WorkflowProgressData;
66
+ }
@@ -25,6 +25,7 @@
25
25
  * account.
26
26
  */
27
27
  import { z } from "zod";
28
+ import { IsoDateTime } from "../api/primitives";
28
29
 
29
30
  import { FactProvenanceSchema } from "./structure-facts";
30
31
 
@@ -60,9 +61,9 @@ export const OccupancySchema = z.object({
60
61
  /** Lifecycle of this hold; only `active` makes the position occupied. */
61
62
  status: OccupancyStatusSchema,
62
63
  /** ISO start of the hold; `null`/absent when unknown or open-started. */
63
- startsAt: z.string().datetime().nullable().optional(),
64
+ startsAt: IsoDateTime.nullable().optional(),
64
65
  /** ISO end of the hold; `null`/absent while the hold is still open. */
65
- endsAt: z.string().datetime().nullable().optional(),
66
+ endsAt: IsoDateTime.nullable().optional(),
66
67
  /** Provenance of this occupancy fact (truth hierarchy + supersede model). */
67
68
  provenance: FactProvenanceSchema,
68
69
  });
@@ -20,6 +20,7 @@
20
20
  * `inferenceType` and is not part of the stable contract surface.
21
21
  */
22
22
  import { z } from "zod";
23
+ import { IsoDateTime } from "../api/primitives";
23
24
 
24
25
  // ---------------------------------------------------------------------------
25
26
  // OrgStructureReviewInference — one pending ('review') structure inference
@@ -43,7 +44,7 @@ export const OrgStructureReviewInferenceSchema = z.object({
43
44
  /** Human-meaningful WHY the engine proposed this change. */
44
45
  reason: z.string(),
45
46
  /** ISO timestamp the inference was created. */
46
- createdAt: z.string().datetime(),
47
+ createdAt: IsoDateTime,
47
48
  });
48
49
 
49
50
  export type OrgStructureReviewInference = z.infer<
@@ -39,6 +39,7 @@
39
39
  * remove an entry is the two sides agreeing.
40
40
  */
41
41
  import { z } from "zod";
42
+ import { IsoDateTime } from "../api/primitives";
42
43
 
43
44
  import { FactSourceTierSchema } from "./structure-facts";
44
45
 
@@ -92,7 +93,7 @@ export const OrgDivergenceSchema = z.object({
92
93
  */
93
94
  reason: z.string().max(2000).nullable(),
94
95
  /** ISO timestamp the workspace's current word on this field was recorded. */
95
- recordedAt: z.string().datetime(),
96
+ recordedAt: IsoDateTime,
96
97
  /**
97
98
  * ISO timestamp someone in this workspace ACKNOWLEDGED this entry, or `null`.
98
99
  *
@@ -102,7 +103,7 @@ export const OrgDivergenceSchema = z.object({
102
103
  * the entry — arrives unacknowledged, which is the right default for a
103
104
  * statement nobody has read yet.
104
105
  */
105
- acknowledgedAt: z.string().datetime().nullable(),
106
+ acknowledgedAt: IsoDateTime.nullable(),
106
107
  /** Who acknowledged it. `null` when nobody has, or when that user is gone. */
107
108
  acknowledgedByUserId: z.string().uuid().nullable(),
108
109
  });
@@ -146,7 +147,7 @@ export const ResolveOrgDivergenceResultSchema = z.object({
146
147
  /** How it was resolved. Local decisions only — see the module docblock. */
147
148
  resolution: OrgDivergenceResolutionSchema,
148
149
  /** ISO timestamp the decision was recorded in this workspace. */
149
- resolvedAt: z.string().datetime(),
150
+ resolvedAt: IsoDateTime,
150
151
  /**
151
152
  * Whether anything was written back to the source system. Pinned to the
152
153
  * literal `false`: the HRIS client is read-only and no write scope is
@@ -11,7 +11,7 @@
11
11
  * - scope-check.ts (GET /api/scope/check, POST /api/scope/check-batch)
12
12
  */
13
13
  import { z } from "zod";
14
- import { CursorPageSchema } from "../api/primitives";
14
+ import { CursorPageSchema, IsoDateTime } from "../api/primitives";
15
15
  import { OrgChartRoleSchema } from "../permissions/orgchart-roles";
16
16
  import { PositionRefSchema } from "../identity/position-ref";
17
17
 
@@ -1757,7 +1757,7 @@ export const OwnerAuthoritySchema = z.object({
1757
1757
  * the projection read filters `expires_at > now()` — so a present value is
1758
1758
  * always in the future.
1759
1759
  */
1760
- expiresAt: z.string().datetime().nullable().optional(),
1760
+ expiresAt: IsoDateTime.nullable().optional(),
1761
1761
  });
1762
1762
  export type OwnerAuthority = z.infer<typeof OwnerAuthoritySchema>;
1763
1763
 
@@ -1817,7 +1817,7 @@ export const CreateDelegationRequestSchema = z.object({
1817
1817
  * Optional ISO-8601 expiry. Omitted or `null` ⇒ a permanent delegation. The
1818
1818
  * UI captures a date and sends end-of-day in the user's timezone.
1819
1819
  */
1820
- expiresAt: z.string().datetime().nullable().optional(),
1820
+ expiresAt: IsoDateTime.nullable().optional(),
1821
1821
  /**
1822
1822
  * When true, the granted user is emailed a notification (carrying `note` as
1823
1823
  * the message). Not persisted — it only gates the side-effect email. Omitted
@@ -1836,7 +1836,7 @@ export type CreateDelegationRequest = z.infer<
1836
1836
  * on an existing delegate row.
1837
1837
  */
1838
1838
  export const UpdateDelegationRequestSchema = z.object({
1839
- expiresAt: z.string().datetime().nullable(),
1839
+ expiresAt: IsoDateTime.nullable(),
1840
1840
  });
1841
1841
  export type UpdateDelegationRequest = z.infer<
1842
1842
  typeof UpdateDelegationRequestSchema
@@ -26,6 +26,7 @@
26
26
  * nullable string rather than wrapped in a page envelope.
27
27
  */
28
28
  import { z } from "zod";
29
+ import { IsoDateTime } from "../api/primitives";
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // SyncRunSummary — the outcome record of one adapter sync run
@@ -35,9 +36,9 @@ export const SyncRunSummarySchema = z.object({
35
36
  /** Unique identifier for this sync run within the org graph. */
36
37
  id: z.string().uuid(),
37
38
  /** ISO start of the run. Always set once a run has begun. */
38
- startedAt: z.string().datetime(),
39
+ startedAt: IsoDateTime,
39
40
  /** ISO end of the run; `null`/absent while the run is still in progress. */
40
- endedAt: z.string().datetime().nullable().optional(),
41
+ endedAt: IsoDateTime.nullable().optional(),
41
42
  /** Count of facts examined by the run (the denominator). */
42
43
  processed: z.number().int().nonnegative(),
43
44
  /** Count of facts that resulted in a real mutation (insert/supersede). */
@@ -18,6 +18,7 @@
18
18
  * permission_decisions; the UI never re-derives.
19
19
  */
20
20
  import { z } from "zod";
21
+ import { IsoDateTime } from "../api/primitives";
21
22
  import { AccessLevelSchema } from "./access-levels";
22
23
  import { AccessSourceSchema } from "./access-source";
23
24
  import { PrincipalSchema } from "./share-api";
@@ -51,7 +52,7 @@ export const RecentDecisionSchema = z.object({
51
52
  allow: z.boolean(),
52
53
  reason: z.string(),
53
54
  source_chain: z.array(AccessSourceSchema),
54
- decided_at: z.string().datetime(),
55
+ decided_at: IsoDateTime,
55
56
  });
56
57
  export type RecentDecision = z.infer<typeof RecentDecisionSchema>;
57
58
 
@@ -21,6 +21,7 @@
21
21
  * changing (ADR-CONTRACTS-093).
22
22
  */
23
23
  import { z } from "zod";
24
+ import { IsoDateTime } from "../api/primitives";
24
25
  import { AccessLevelSchema } from "./access-levels";
25
26
  import { AccessSourceSchema } from "./access-source";
26
27
 
@@ -77,7 +78,7 @@ export const AclGrantResponseSchema = z.object({
77
78
  principal: PrincipalSchema,
78
79
  access_level: GrantableAccessLevelSchema,
79
80
  granted_by_user_id: z.string().uuid(),
80
- granted_at: z.string().datetime(),
81
+ granted_at: IsoDateTime,
81
82
  });
82
83
  export type AclGrantResponse = z.infer<typeof AclGrantResponseSchema>;
83
84
 
@@ -13,6 +13,7 @@
13
13
  * `./secret.ts`) before storage / encryption.
14
14
  */
15
15
  import { z } from "zod";
16
+ import { IsoDateTime } from "../api/primitives";
16
17
 
17
18
  /**
18
19
  * Usage class of an org secret. Drives which resolver layer is allowed to read
@@ -75,10 +76,10 @@ export const OrgSecretSummarySchema = z.object({
75
76
  maskedPrefix: z.string().min(1).max(64),
76
77
  version: z.number().int().positive(),
77
78
  enabled: z.boolean(),
78
- lastUsedAt: z.string().datetime().nullable(),
79
+ lastUsedAt: IsoDateTime.nullable(),
79
80
  usageCount: z.string(),
80
- rotatedAt: z.string().datetime().nullable(),
81
- createdAt: z.string().datetime(),
81
+ rotatedAt: IsoDateTime.nullable(),
82
+ createdAt: IsoDateTime,
82
83
  });
83
84
  export type OrgSecretSummary = z.infer<typeof OrgSecretSummarySchema>;
84
85
 
@@ -29,6 +29,7 @@
29
29
  * stream without first giving it a real commit-ordered log.
30
30
  */
31
31
  import { z } from "zod";
32
+ import { IsoDateTime } from "../api/primitives";
32
33
  import type { ResourceKey } from "../resource-keys";
33
34
  import { isResourceKeyShape } from "../resource-keys";
34
35
 
@@ -36,7 +37,6 @@ import { isResourceKeyShape } from "../resource-keys";
36
37
  * Local, not chat's. `chat/schemas.ts` owns a structurally identical helper;
37
38
  * importing it would make a chat frame change a user-events breaking change.
38
39
  */
39
- const IsoDateString = z.string().datetime();
40
40
 
41
41
  /**
42
42
  * Fields every frame carries.
@@ -47,7 +47,7 @@ const IsoDateString = z.string().datetime();
47
47
  */
48
48
  export const UserEventBaseSchema = z.object({
49
49
  v: z.literal(1),
50
- timestamp: IsoDateString,
50
+ timestamp: IsoDateTime,
51
51
  });
52
52
 
53
53
  // =============================================================================
@@ -0,0 +1,35 @@
1
+ # workflows/
2
+
3
+ The registry of analysis-workflow types — the one place a domain names the
4
+ long-running work it hands to the durable workflow primitive.
5
+
6
+ ## Purpose
7
+
8
+ An analysis workflow is a durable, domain-agnostic record of long-running AI
9
+ work (the backend's `analysis_workflows` table). Its lifecycle
10
+ (`pending | running | draining | stopped | complete | failed`) knows nothing
11
+ about departments, proposals or providers. The workflow's TYPE is where a
12
+ domain names itself, and that name travels on the wire: it is stored on the
13
+ workflow row, carried by the `data-workflow-progress` chat message part, and
14
+ answered by the generic projection read. This directory holds that vocabulary
15
+ so every producer, validator and renderer agrees on it.
16
+
17
+ ## Invariants
18
+
19
+ - **Namespaced as `<domain>/<flow>`.** `org-structure/hris-connect` and a
20
+ future `org-deck/ingest` cannot collide, and neither learns the other's
21
+ vocabulary.
22
+ - **Centrally registered.** A second consumer adds one member to
23
+ `ANALYSIS_WORKFLOW_TYPES` and a progress adapter on its own side; it touches
24
+ no generic file. These are public wire discriminators, so the coupling is the
25
+ point — a closed const array gives wire validation and exhaustive handling.
26
+ - **Closed and append-only.** A member is persisted on durable rows and inside
27
+ immutable chat parts; removing one orphans every record that carries it.
28
+ - **Activity-free.** A type says WHICH flow, never what the flow is currently
29
+ doing. Activity is domain progress, carried opaquely by the domain.
30
+ - **`isWorkflowType` accepts exact members only.** A bare `hris-connect`
31
+ without its namespace is rejected.
32
+
33
+ ## Dependencies
34
+
35
+ None. Pure vocabulary data and one pure guard; no `zod`, no runtime imports.
@@ -0,0 +1,30 @@
1
+ # workflows/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Locks the registry of analysis-workflow types and the guard that validates a
6
+ wire value against it.
7
+
8
+ - `types.test.ts` — pins the exact membership of `ANALYSIS_WORKFLOW_TYPES`, so
9
+ an append or removal is a deliberate diff here rather than a silent change
10
+ to a public wire discriminator. Asserts every member is namespaced as
11
+ `<domain>/<flow>` with exactly one separator, which is the collision guard
12
+ between domains. For `isWorkflowType`, the load-bearing cases are the
13
+ rejections: a bare `hris-connect` without its namespace, the namespace on
14
+ its own, a trailing separator, an unknown flow, and non-string inputs.
15
+
16
+ ## Invariants
17
+
18
+ - These assert VOCABULARY and SHAPE, never behaviour. What a workflow of a
19
+ given type does belongs in the owning domain's suites in backend.
20
+ - The membership test is intentionally exact. Loosening it to "contains" would
21
+ let a removed member (which orphans persisted rows and immutable chat parts)
22
+ pass unnoticed.
23
+
24
+ ## Public API
25
+
26
+ None — test-only.
27
+
28
+ ## Dependencies
29
+
30
+ `vitest` and `../types`.
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { ANALYSIS_WORKFLOW_TYPES, isWorkflowType } from "../types";
3
+
4
+ describe("ANALYSIS_WORKFLOW_TYPES", () => {
5
+ it("contains exactly the two registered org-structure flows", () => {
6
+ expect([...ANALYSIS_WORKFLOW_TYPES]).toEqual([
7
+ "org-structure/hris-connect",
8
+ "org-structure/reanalyse",
9
+ ]);
10
+ });
11
+
12
+ it("has no duplicates", () => {
13
+ expect(new Set(ANALYSIS_WORKFLOW_TYPES).size).toBe(
14
+ ANALYSIS_WORKFLOW_TYPES.length,
15
+ );
16
+ });
17
+
18
+ // Namespacing is the collision guard between domains: every member must be
19
+ // `<domain>/<flow>`, with exactly one separator and no empty half.
20
+ it("every member is namespaced as <domain>/<flow>", () => {
21
+ for (const type of ANALYSIS_WORKFLOW_TYPES) {
22
+ expect(type).toMatch(/^[a-z0-9-]+\/[a-z0-9-]+$/);
23
+ }
24
+ });
25
+ });
26
+
27
+ describe("isWorkflowType", () => {
28
+ it("returns true for every member of ANALYSIS_WORKFLOW_TYPES", () => {
29
+ for (const type of ANALYSIS_WORKFLOW_TYPES) {
30
+ expect(isWorkflowType(type)).toBe(true);
31
+ }
32
+ });
33
+
34
+ it("rejects a bare flow name without its namespace", () => {
35
+ expect(isWorkflowType("hris-connect")).toBe(false);
36
+ expect(isWorkflowType("reanalyse")).toBe(false);
37
+ });
38
+
39
+ it("rejects the namespace on its own and unknown flows", () => {
40
+ expect(isWorkflowType("org-structure")).toBe(false);
41
+ expect(isWorkflowType("org-structure/")).toBe(false);
42
+ expect(isWorkflowType("org-structure/unknown")).toBe(false);
43
+ });
44
+
45
+ it("rejects non-string and empty values", () => {
46
+ expect(isWorkflowType("")).toBe(false);
47
+ expect(isWorkflowType(undefined)).toBe(false);
48
+ expect(isWorkflowType(null)).toBe(false);
49
+ expect(isWorkflowType(42)).toBe(false);
50
+ expect(isWorkflowType({ type: "org-structure/hris-connect" })).toBe(false);
51
+ });
52
+ });
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Analysis-workflow vocabulary barrel — the registered types a durable
3
+ * long-running analysis can be.
4
+ *
5
+ * Import from '@company-semantics/contracts' (root).
6
+ *
7
+ * @see ./types.ts for why the list is namespaced, central and closed
8
+ */
9
+
10
+ export type { WorkflowType } from "./types";
11
+
12
+ export { ANALYSIS_WORKFLOW_TYPES, isWorkflowType } from "./types";
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Registered analysis-workflow types.
3
+ *
4
+ * An analysis workflow is a durable record of long-running AI work
5
+ * (`analysis_workflows` in the backend). Its lifecycle is domain-agnostic;
6
+ * its TYPE is the one place a domain names itself, and that name is a public
7
+ * wire discriminator: it is stored on the workflow row, carried by the
8
+ * `data-workflow-progress` chat message part, and returned by the generic
9
+ * projection read. Public wire values are validated and handled exhaustively,
10
+ * which is why the list is a closed const array rather than an open string.
11
+ *
12
+ * INVARIANTS:
13
+ * - NAMESPACED as `<domain>/<flow>`. Two domains cannot collide on a bare
14
+ * name: `org-structure/hris-connect` and a future `org-deck/ingest` can both
15
+ * exist without either learning the other's vocabulary.
16
+ * - CENTRALLY REGISTERED. A new consumer adds one member here and a domain
17
+ * progress adapter on its own side; it touches nothing generic. Central
18
+ * registration is the intended coupling, not an accident — the discriminator
19
+ * must be knowable to every renderer and validator at once.
20
+ * - CLOSED and append-only. A member is a persisted value on durable rows and
21
+ * in immutable chat parts; removing one orphans every record that carries it.
22
+ * - Activity-free. A type says WHICH flow; what the flow is currently doing is
23
+ * domain progress and never lives in this vocabulary.
24
+ */
25
+ export const ANALYSIS_WORKFLOW_TYPES = [
26
+ /** Analyse an org structure freshly connected from an HRIS provider. */
27
+ "org-structure/hris-connect",
28
+ /** Re-run the org-structure analysis against an already-connected source. */
29
+ "org-structure/reanalyse",
30
+ ] as const;
31
+
32
+ /** One registered analysis-workflow type — the wire discriminator. */
33
+ export type WorkflowType = (typeof ANALYSIS_WORKFLOW_TYPES)[number];
34
+
35
+ /**
36
+ * Runtime guard for an untrusted value claiming to be a {@link WorkflowType}.
37
+ * Accepts only exact, namespaced members; a bare flow name is rejected.
38
+ */
39
+ export function isWorkflowType(value: unknown): value is WorkflowType {
40
+ return (
41
+ typeof value === "string" &&
42
+ (ANALYSIS_WORKFLOW_TYPES as readonly string[]).includes(value)
43
+ );
44
+ }