@company-semantics/contracts 58.0.0 → 58.2.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 (55) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/resource-keys.test.ts +30 -0
  3. package/src/api/generated-spec-hash.ts +2 -2
  4. package/src/api/generated.ts +33 -1
  5. package/src/chat/README.md +15 -4
  6. package/src/chat/__tests__/proactive-kind.test.ts +51 -0
  7. package/src/chat/index.ts +9 -0
  8. package/src/chat/proactive-kind.ts +51 -0
  9. package/src/chat/schemas.ts +92 -1
  10. package/src/chat/types.ts +19 -1
  11. package/src/index.ts +110 -0
  12. package/src/message-parts/README.md +5 -0
  13. package/src/message-parts/__tests__/suggested-replies.test.ts +52 -0
  14. package/src/message-parts/__tests__/wire.test.ts +48 -0
  15. package/src/message-parts/index.ts +8 -0
  16. package/src/message-parts/suggested-replies.ts +48 -0
  17. package/src/message-parts/types.ts +7 -1
  18. package/src/message-parts/wire.ts +26 -0
  19. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +1 -0
  20. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +1 -0
  21. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +207 -0
  22. package/src/notifications/__tests__/fixtures.ts +9 -0
  23. package/src/notifications/__tests__/org-invite.test.ts +75 -0
  24. package/src/notifications/__tests__/render-snapshot.test.ts +8 -0
  25. package/src/notifications/kinds/org-invite.ts +27 -12
  26. package/src/notifications/payloads.ts +7 -0
  27. package/src/org/README.md +38 -0
  28. package/src/org/__tests__/canonical-facts.test.ts +118 -0
  29. package/src/org/__tests__/structure-inference.test.ts +392 -0
  30. package/src/org/__tests__/structure-provenance.test.ts +187 -0
  31. package/src/org/canonical-facts.ts +94 -1
  32. package/src/org/index.ts +54 -0
  33. package/src/org/schemas.ts +23 -0
  34. package/src/org/structure-inference.ts +521 -0
  35. package/src/proactive/README.md +125 -0
  36. package/src/proactive/__tests__/README.md +56 -0
  37. package/src/proactive/__tests__/chat-templates.test.ts +167 -0
  38. package/src/proactive/__tests__/compile-fixtures.ts +110 -0
  39. package/src/proactive/__tests__/vocabulary.test.ts +279 -0
  40. package/src/proactive/classes.ts +125 -0
  41. package/src/proactive/composer.ts +104 -0
  42. package/src/proactive/facts.ts +87 -0
  43. package/src/proactive/index.ts +52 -0
  44. package/src/proactive/kinds.ts +127 -0
  45. package/src/proactive/plan.ts +79 -0
  46. package/src/proactive/registry.ts +71 -0
  47. package/src/proactive/surfaces.ts +59 -0
  48. package/src/proactive/templates/README.md +58 -0
  49. package/src/proactive/templates/index.ts +32 -0
  50. package/src/proactive/templates/morning-brief.ts +77 -0
  51. package/src/proactive/templates/org-became-shared.ts +54 -0
  52. package/src/resource-key-types.ts +9 -0
  53. package/src/resource-keys.ts +2 -0
  54. package/src/user-notifications/README.md +10 -0
  55. package/src/user-notifications/kinds.ts +28 -0
@@ -0,0 +1,521 @@
1
+ /**
2
+ * Org-structure inference vocabulary — the snapshot in, the proposal out.
3
+ *
4
+ * HRIS gives us a department STRING and a reporting graph. Neither one IS the
5
+ * org-unit tree. This module is the frozen vocabulary for the engine that
6
+ * reconciles them: a {@link StructureInferenceSnapshotSchema} of the persisted
7
+ * canonical graph goes in, and a {@link StructureProposalSchema} — a proposed
8
+ * DESIRED STATE, never a list of database commands — comes out.
9
+ *
10
+ * The four terms of the surrounding model are distinct and must stay distinct
11
+ * here, in the tables and in UI copy:
12
+ *
13
+ * - SOURCE FACT — what the HRIS said ({@link CanonicalFactsSchema}).
14
+ * - INFERENCE PROPOSAL — what the reasoning system thinks it means (this file).
15
+ * - ACCEPTED ORGANIZATIONAL FACT — what the product considers the org to be.
16
+ * - DRIFT FINDING — evidence that a later sync no longer agrees.
17
+ *
18
+ * Four decisions are load-bearing and are encoded in the types, not in prose:
19
+ *
20
+ * 1. Evidence is TYPED ({@link StructureEvidenceSchema}). A validator cannot
21
+ * enforce "this unit is not supported by reporting topology alone" against
22
+ * the free-text string "several employees have similar roles"; it can
23
+ * enforce it against a discriminated union plus
24
+ * {@link TOPOLOGY_ONLY_EVIDENCE_KINDS}.
25
+ * 2. A proposed unit carries `targetUnitId`. Present means "preserve that
26
+ * durable identity"; null means "create". Without it the very first run
27
+ * that renames `Human Resources` to `People` creates a SECOND unit beside
28
+ * the first and orphans a doc-owning one.
29
+ * 3. Every person in the snapshot gets EXACTLY ONE outcome — placed,
30
+ * needs_review or excluded. "Zero or one placement" would accept a proposal
31
+ * that silently forgets three people, which is the same silent-omission
32
+ * failure class this whole effort exists because of.
33
+ * 4. People and units are referenced ONLY by the opaque ids the snapshot
34
+ * supplied. Names are display-only and never identifiers — two records with
35
+ * no characters in common can be the same person (a legal name and a
36
+ * preferred name), so no fuzzy name match can be trusted.
37
+ *
38
+ * WHY THIS IS NOT `structure-facts.ts` PROVENANCE. Every other org fact carries
39
+ * a {@link FactProvenanceSchema} envelope (tier / source / confidence / locked /
40
+ * supersedesFactId). A proposal deliberately carries none: it is a CANDIDATE,
41
+ * not a recorded fact, and it has not entered the truth hierarchy at all.
42
+ * Provenance attaches when a human accepts it.
43
+ *
44
+ * WHY {@link StructureUnitAuthoritySchema} IS NOT {@link FactSourceTierSchema}.
45
+ * The tier axis (`user` > `sync` > `import` > `inferred`) answers "which writer
46
+ * wins when two facts collide". The authority axis here answers a different
47
+ * question the model must be told the answer to: "may you reorganize this unit?"
48
+ * `human_confirmed` is an ANCHOR the model must leave alone and disagree with
49
+ * out loud; `hris_source` is imported evidence it may reorganize. Collapsing the
50
+ * two axes would make `inferred` mean both "loses a write race" and "safe to
51
+ * rearrange", which are not the same claim.
52
+ *
53
+ * PROVENANCE IS THREE AXES, NOT ONE LADDER. When a proposal is ACCEPTED it
54
+ * records {@link StructureProvenanceSchema}: `proposalOrigin` (how it came to be
55
+ * proposed), `acceptedBy` (who signed off) and `authority` (what it may now
56
+ * overwrite). Only the third drives precedence, via
57
+ * {@link mayOverrideAuthority}. Clicking Apply on an AI proposal makes it
58
+ * `human_confirmed` without making its origin `manual` — collapsing those is
59
+ * what makes an AI guess indistinguishable from a human decision today.
60
+ *
61
+ * NOT TO BE CONFUSED WITH `reasoning-review.ts`. That module
62
+ * (`OrgStructureReviewInference`, `OrgStructureReviewQueue`, ADR-CONT-095 /
63
+ * ADR-BE-345) is the OLDER reasoning-lens review queue, backed by
64
+ * `org_structure_inferences`, which can express only two mutations: re-parent an
65
+ * existing unit and rename a seat title. This vocabulary is a SEPARATE seam
66
+ * backed by `org_structure_proposals`, and it describes a whole desired state.
67
+ * Neither replaces the other in this release.
68
+ */
69
+ import { z } from "zod";
70
+
71
+ import { PositionReportingRelationshipTypeSchema } from "./position-reporting";
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // StructureEvidence — typed support for a boundary, a name or a placement
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * TYPED evidence. Prose evidence cannot be validated; a discriminated union can.
79
+ * The validator reads these `kind`s directly to enforce "a proposed unit must
80
+ * have at least one boundary signal that is not reporting topology" and "a name
81
+ * may not carry specificity absent from its `nameBasis`".
82
+ */
83
+ export const StructureEvidenceSchema = z.discriminatedUnion("kind", [
84
+ /** The source stated a department outright for these people. */
85
+ z.object({
86
+ kind: z.literal("explicit_department"),
87
+ personIds: z.array(z.string()),
88
+ value: z.string(),
89
+ }),
90
+ /** The source stated a division outright for these people. */
91
+ z.object({
92
+ kind: z.literal("explicit_division"),
93
+ personIds: z.array(z.string()),
94
+ value: z.string(),
95
+ }),
96
+ /** A title cluster. Role families cut ACROSS reporting lines on purpose. */
97
+ z.object({
98
+ kind: z.literal("role_family"),
99
+ family: z.string(),
100
+ personIds: z.array(z.string()),
101
+ count: z.number().int(),
102
+ }),
103
+ /** A C-level / VP seat that anchors a function. */
104
+ z.object({
105
+ kind: z.literal("functional_leader"),
106
+ personId: z.string(),
107
+ function: z.string(),
108
+ }),
109
+ /** Manager topology. Alone this is NEVER enough — see the const below. */
110
+ z.object({
111
+ kind: z.literal("reporting_cluster"),
112
+ managerPersonId: z.string(),
113
+ personIds: z.array(z.string()),
114
+ }),
115
+ /**
116
+ * A manager whose reports span many functions — an administrative supervisor
117
+ * rather than a unit boundary. Recorded so a proposal can explain why it did
118
+ * NOT draw a unit around that seat.
119
+ */
120
+ z.object({
121
+ kind: z.literal("administrative_pivot"),
122
+ personId: z.string(),
123
+ departmentEntropy: z.number(),
124
+ }),
125
+ /** A concept from a controlled ontology rather than from this org's data. */
126
+ z.object({
127
+ kind: z.literal("controlled_ontology"),
128
+ concept: z.string(),
129
+ }),
130
+ ]);
131
+ export type StructureEvidence = z.infer<typeof StructureEvidenceSchema>;
132
+
133
+ /**
134
+ * Evidence kinds that alone are NOT sufficient to justify a unit boundary.
135
+ *
136
+ * On the reference org, 17 of 19 cross-department placements route through two
137
+ * `Sr. HR Administrator` seats whose reports span seven departments each.
138
+ * Deriving units from reporting topology puts the entire company inside one
139
+ * unit — so a unit whose only support is `reporting_cluster` is rejected.
140
+ */
141
+ export const TOPOLOGY_ONLY_EVIDENCE_KINDS = [
142
+ "reporting_cluster",
143
+ ] as const satisfies ReadonlyArray<StructureEvidence["kind"]>;
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // StructureUnitAuthority — may the model reorganize this existing unit?
147
+ // ---------------------------------------------------------------------------
148
+
149
+ /**
150
+ * How much license the engine has over an EXISTING unit or placement. See the
151
+ * file header for why this is a separate axis from {@link FactSourceTierSchema}.
152
+ *
153
+ * This is the authority axis of {@link StructureProvenanceSchema} read from the
154
+ * proposal side; {@link StructureAuthoritySchema} is the SAME schema object read
155
+ * from the apply side. One ladder, and exactly one meaning per rung:
156
+ *
157
+ * - `human_confirmed` — a PERSON signed off, whether by editing structure
158
+ * directly or by accepting an AI proposal. An anchor: never renamed,
159
+ * reparented or replaced by inference; a disagreement is surfaced for review
160
+ * instead.
161
+ * - `ai_inferred` — accepted with nobody in the loop, i.e. `acceptedBy` is
162
+ * `system_policy` (calibrated auto-accept); may be revisited. An AI proposal
163
+ * a human clicked Apply on is NOT this — it is `human_confirmed`, and the
164
+ * fact that an AI proposed it survives in `proposalOrigin`, not here. Writing
165
+ * a human-accepted fact as `ai_inferred` would make it reorganizable and
166
+ * overwritable by the next AI guess, which is the defect this vocabulary
167
+ * exists to close.
168
+ * - `hris_source` — imported source evidence the feed accepted for itself; may
169
+ * be reorganized when stronger functional evidence supports it.
170
+ */
171
+ export const StructureUnitAuthoritySchema = z.enum([
172
+ "human_confirmed",
173
+ "ai_inferred",
174
+ "hris_source",
175
+ ]);
176
+ export type StructureUnitAuthority = z.infer<
177
+ typeof StructureUnitAuthoritySchema
178
+ >;
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // StructureProvenance — three orthogonal axes: origin, acceptor, authority
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /**
185
+ * How a structural fact came to be PROPOSED. Orthogonal to who accepted it and
186
+ * to how much authority it now carries.
187
+ *
188
+ * Accepting an AI proposal must not rewrite its origin as `manual`. The moment
189
+ * it does, an AI guess and a human decision become indistinguishable in the
190
+ * record — which is exactly the defect being fixed: today both land as
191
+ * `source_authority.system_of_record = 'user_override'`, nothing downstream can
192
+ * tell them apart, and nothing ever deletes such a row, so the guess is
193
+ * permanent AND wears the costume of intent. Nothing is recorded as
194
+ * `user_override` again.
195
+ */
196
+ export const StructureProposalOriginSchema = z.enum([
197
+ "ai_inference",
198
+ "hris_source",
199
+ "manual",
200
+ ]);
201
+ export type StructureProposalOrigin = z.infer<
202
+ typeof StructureProposalOriginSchema
203
+ >;
204
+
205
+ /**
206
+ * WHO accepted the fact. A discriminated union rather than a nullable `userId`,
207
+ * because "nobody" and "a policy decided" are different claims, and only the
208
+ * second one is an answer when someone asks who signed off. A null would let
209
+ * an unattributed write and an automated one share a representation.
210
+ */
211
+ export const StructureAcceptedBySchema = z.discriminatedUnion("kind", [
212
+ z.object({ kind: z.literal("user"), userId: z.string().uuid() }),
213
+ z.object({ kind: z.literal("system_policy") }),
214
+ ]);
215
+ export type StructureAcceptedBy = z.infer<typeof StructureAcceptedBySchema>;
216
+
217
+ /**
218
+ * The authority axis — the ONLY axis precedence runs on:
219
+ * `human_confirmed` > `ai_inferred` > `hris_source`.
220
+ *
221
+ * Deliberately the same vocabulary as {@link StructureUnitAuthoritySchema}, and
222
+ * deliberately the same schema object rather than a second identically-valued
223
+ * enum beside it. "May the model reorganize this unit?" and "may this write
224
+ * overwrite that fact?" are two readings of ONE ladder; two copies of it would
225
+ * drift, and a proposal-time answer that disagreed with the apply-time answer is
226
+ * precisely the class of bug this vocabulary exists to make impossible.
227
+ */
228
+ export const StructureAuthoritySchema = StructureUnitAuthoritySchema;
229
+ export type StructureAuthority = StructureUnitAuthority;
230
+
231
+ /**
232
+ * Highest wins. Private on purpose: callers ask {@link mayOverrideAuthority}
233
+ * instead of comparing numbers, so the ordering has exactly one definition and
234
+ * adding a member to the enum is a compile error here rather than a silent tie.
235
+ */
236
+ const AUTHORITY_RANK: Record<StructureAuthority, number> = {
237
+ human_confirmed: 3,
238
+ ai_inferred: 2,
239
+ hris_source: 1,
240
+ };
241
+
242
+ /**
243
+ * True when `incoming` may overwrite a fact currently held at `existing`.
244
+ *
245
+ * Equal authority overwrites: a later HRIS sync must be able to correct an
246
+ * earlier one, and a second human decision must be able to revise the first.
247
+ * A strict `>` would freeze the first write of every field forever.
248
+ */
249
+ export function mayOverrideAuthority(
250
+ incoming: StructureAuthority,
251
+ existing: StructureAuthority,
252
+ ): boolean {
253
+ return AUTHORITY_RANK[incoming] >= AUTHORITY_RANK[existing];
254
+ }
255
+
256
+ /**
257
+ * The three axes together, as one accepted structural fact records them.
258
+ *
259
+ * - v1 — AI proposes, an admin clicks Apply:
260
+ * `ai_inference` / `user` / `human_confirmed`.
261
+ * - Later — calibrated auto-accept:
262
+ * `ai_inference` / `system_policy` / `ai_inferred`.
263
+ * - An admin edits structure directly:
264
+ * `manual` / `user` / `human_confirmed`.
265
+ * - The feed said so:
266
+ * `hris_source` / `system_policy` / `hris_source`.
267
+ *
268
+ * Read the `authority` column alone and you have precedence. Read a row across
269
+ * and acceptance has not erased how the fact originated — which is the whole
270
+ * reason these are three fields and not one.
271
+ */
272
+ export const StructureProvenanceSchema = z.object({
273
+ proposalOrigin: StructureProposalOriginSchema,
274
+ acceptedBy: StructureAcceptedBySchema,
275
+ authority: StructureAuthoritySchema,
276
+ });
277
+ export type StructureProvenance = z.infer<typeof StructureProvenanceSchema>;
278
+
279
+ // ---------------------------------------------------------------------------
280
+ // ProposedOrgUnit — one node of the proposed desired state
281
+ // ---------------------------------------------------------------------------
282
+
283
+ /**
284
+ * A unit the proposal wants the org to have. `tempId` is proposal-scoped
285
+ * plumbing; `targetUnitId` is the only durable identity in this record.
286
+ */
287
+ export const ProposedOrgUnitSchema = z.object({
288
+ /** Proposal-scoped handle. NOT the durable identity. */
289
+ tempId: z.string().min(1),
290
+ /**
291
+ * Present => this proposal IS an existing durable unit; preserve its id, its
292
+ * Company.md doc, its history and its permissions. Null => create a new
293
+ * durable unit on acceptance.
294
+ *
295
+ * Without this, renaming `Human Resources` to `People` creates a second unit
296
+ * and orphans the first. A unit's durable identity is never derived from its
297
+ * name and never from its leader — when a team lead leaves it is still the
298
+ * same team.
299
+ */
300
+ targetUnitId: z.string().uuid().nullable(),
301
+ /** Certainty in [0,1] that `targetUnitId` really is this unit. */
302
+ identityConfidence: z.number().min(0).max(1),
303
+ /** Typed support for the identity claim above. */
304
+ identityEvidence: z.array(StructureEvidenceSchema),
305
+ /** Human-facing unit name. Display only; never an identifier. */
306
+ name: z.string().min(1),
307
+ /** Typed support for the NAME specifically — the anti-invention check. */
308
+ nameBasis: z.array(StructureEvidenceSchema),
309
+ /** `tempId` of the parent unit; `null` for the proposed root. */
310
+ parentTempId: z.string().nullable(),
311
+ /** Free-text level label (e.g. `Department`, `Team`); advisory only. */
312
+ suggestedTypeLabel: z.string(),
313
+ /** Opaque person id of the unit's head; `null` when unknown. */
314
+ headPersonId: z.string().nullable(),
315
+ /** Certainty in [0,1] that the NAME is right. */
316
+ nameConfidence: z.number().min(0).max(1),
317
+ /** Certainty in [0,1] that the BOUNDARY is right. */
318
+ structureConfidence: z.number().min(0).max(1),
319
+ /** A leader-derived placeholder label the user is expected to rename. */
320
+ provisional: z.boolean(),
321
+ /** Typed support for the BOUNDARY — what the topology-only rule reads. */
322
+ evidence: z.array(StructureEvidenceSchema),
323
+ });
324
+ export type ProposedOrgUnit = z.infer<typeof ProposedOrgUnitSchema>;
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // PersonStructureOutcome — exactly one per person in the snapshot
328
+ // ---------------------------------------------------------------------------
329
+
330
+ /**
331
+ * EXACTLY ONE outcome per person in the snapshot.
332
+ *
333
+ * "Zero or one placement" would accept a proposal that simply forgets three
334
+ * people — the same silent-omission failure that dropped people out of the
335
+ * reporting tree while the sync reported a clean run. A person the engine
336
+ * cannot place returns `needs_review` with a reason; never a guess, and never
337
+ * an absence.
338
+ */
339
+ export const PersonStructureOutcomeSchema = z.discriminatedUnion("kind", [
340
+ z.object({
341
+ kind: z.literal("placed"),
342
+ personId: z.string(),
343
+ /** `tempId` of the unit this person is placed in. */
344
+ unitTempId: z.string(),
345
+ confidence: z.number().min(0).max(1),
346
+ evidence: z.array(StructureEvidenceSchema),
347
+ /** Signals that disagreed with this placement, stated not suppressed. */
348
+ conflicts: z.array(z.string()),
349
+ }),
350
+ z.object({
351
+ kind: z.literal("needs_review"),
352
+ personId: z.string(),
353
+ reason: z.enum([
354
+ "insufficient_evidence",
355
+ "conflicting_evidence",
356
+ "ambiguous_boundary",
357
+ ]),
358
+ }),
359
+ z.object({
360
+ kind: z.literal("excluded"),
361
+ personId: z.string(),
362
+ reason: z.enum(["service_account", "not_workforce", "inactive"]),
363
+ }),
364
+ ]);
365
+ export type PersonStructureOutcome = z.infer<
366
+ typeof PersonStructureOutcomeSchema
367
+ >;
368
+
369
+ // ---------------------------------------------------------------------------
370
+ // StructureReviewItem — a decision boundary, not merely a low number
371
+ // ---------------------------------------------------------------------------
372
+
373
+ /**
374
+ * A question the engine is asking a human, with the competing signals attached.
375
+ *
376
+ * A low confidence number is not a review item. A review item is a genuine
377
+ * decision boundary: an engine that confidently places an ambiguous person and
378
+ * never asks scores well on placement accuracy while being WORSE for the
379
+ * product than one that reports three signals disagreeing.
380
+ */
381
+ export const StructureReviewItemSchema = z.object({
382
+ kind: z.enum([
383
+ "placement_conflict",
384
+ "unit_boundary",
385
+ "unit_name",
386
+ "unit_head",
387
+ ]),
388
+ /** Opaque person ids this question is about. */
389
+ personIds: z.array(z.string()),
390
+ /** Proposal-scoped unit handles this question is about. */
391
+ unitTempIds: z.array(z.string()),
392
+ /** The question, phrased for a human reviewer. */
393
+ question: z.string(),
394
+ /** What the engine would do absent an answer. */
395
+ recommended: z.string(),
396
+ /** The other defensible answers. */
397
+ alternatives: z.array(z.string()),
398
+ /** The competing signals, so a reviewer can see WHY it is ambiguous. */
399
+ signals: z.record(z.string(), z.string()),
400
+ confidence: z.number().min(0).max(1),
401
+ evidence: z.array(StructureEvidenceSchema),
402
+ });
403
+ export type StructureReviewItem = z.infer<typeof StructureReviewItemSchema>;
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // StructureProposal — the proposed desired state
407
+ // ---------------------------------------------------------------------------
408
+
409
+ /**
410
+ * A proposed desired state for one org. Not database commands, not an applied
411
+ * change: acceptance is a separate, human step.
412
+ */
413
+ export const StructureProposalSchema = z.object({
414
+ orgId: z.string().uuid(),
415
+ /** Lineage only. Staleness is decided by `inputHash`, not by this. */
416
+ sourceSnapshotId: z.string(),
417
+ /** The org-graph revision this proposal reasoned about. */
418
+ structureRevision: z.string(),
419
+ /** Hash of the NORMALIZED inference inputs — the staleness key. */
420
+ inputHash: z.string(),
421
+ /** Registry version of the prompt that produced this proposal. */
422
+ promptVersion: z.string(),
423
+ orgUnits: z.array(ProposedOrgUnitSchema),
424
+ personOutcomes: z.array(PersonStructureOutcomeSchema),
425
+ /** Things the engine noticed but could not express as a review item. */
426
+ structureWarnings: z.array(z.string()),
427
+ reviewItems: z.array(StructureReviewItemSchema),
428
+ });
429
+ export type StructureProposal = z.infer<typeof StructureProposalSchema>;
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // StructureInferenceSnapshot — the inference INPUT, taken from the graph
433
+ // ---------------------------------------------------------------------------
434
+
435
+ /**
436
+ * A person as the inference engine sees them. `personId` is the opaque internal
437
+ * id and the ONLY handle a proposal may return; `sourcePersonId` is retained
438
+ * for diagnostics and is stripped before the snapshot reaches the model, because
439
+ * keeping provider identity out of the reasoning layer is the point.
440
+ */
441
+ export const StructurePersonFactSchema = z.object({
442
+ /** Opaque internal id. The only handle a proposal may reference. */
443
+ personId: z.string().min(1),
444
+ /** Diagnostics only — stripped from the model-facing snapshot. */
445
+ sourcePersonId: z.string().min(1).nullable().optional(),
446
+ /** Display only. Never an identifier, never fuzzy-matched. */
447
+ displayName: z.string(),
448
+ title: z.string().nullable(),
449
+ department: z.string().nullable(),
450
+ division: z.string().nullable(),
451
+ location: z.string().nullable(),
452
+ managerPersonId: z.string().nullable(),
453
+ directReportPersonIds: z.array(z.string()),
454
+ });
455
+ export type StructurePersonFact = z.infer<typeof StructurePersonFactSchema>;
456
+
457
+ /**
458
+ * One reporting edge, at person granularity. Reporting is a SEPARATE axis: a
459
+ * proposal must never rewrite one, and the validator proves it did not by
460
+ * comparing against these.
461
+ */
462
+ export const StructureReportingFactSchema = z.object({
463
+ reportPersonId: z.string().min(1),
464
+ managerPersonId: z.string().min(1),
465
+ relationshipType: PositionReportingRelationshipTypeSchema,
466
+ });
467
+ export type StructureReportingFact = z.infer<
468
+ typeof StructureReportingFactSchema
469
+ >;
470
+
471
+ /** An org unit that already exists, with the license the engine has over it. */
472
+ export const ExistingUnitFactSchema = z.object({
473
+ unitId: z.string().uuid(),
474
+ name: z.string(),
475
+ parentUnitId: z.string().uuid().nullable(),
476
+ depth: z.number().int(),
477
+ /**
478
+ * Anchor vs reorganizable. This is the `authority` axis of the accepted
479
+ * fact's {@link StructureProvenanceSchema}, copied through unchanged — a unit
480
+ * a human accepted is `human_confirmed` here even when an AI proposed it.
481
+ */
482
+ authority: StructureUnitAuthoritySchema,
483
+ });
484
+ export type ExistingUnitFact = z.infer<typeof ExistingUnitFactSchema>;
485
+
486
+ /** Where a person already sits, and on whose authority they sit there. */
487
+ export const ExistingPlacementFactSchema = z.object({
488
+ personId: z.string().min(1),
489
+ unitId: z.string().uuid(),
490
+ /** The accepted fact's `authority` axis — see {@link ExistingUnitFactSchema}. */
491
+ authority: StructureUnitAuthoritySchema,
492
+ });
493
+ export type ExistingPlacementFact = z.infer<typeof ExistingPlacementFactSchema>;
494
+
495
+ /**
496
+ * The inference INPUT: a point-in-time view of the PERSISTED canonical graph.
497
+ *
498
+ * Never a {@link CanonicalFactsSchema} batch. Internal ids are already resolved
499
+ * here, duplicates already reconciled, provenance already attached — and a delta
500
+ * batch is a partial workforce, on which inference is meaningless.
501
+ *
502
+ * Derived heuristic signals (role families, functional leaders, administrative
503
+ * pivot candidates, department outliers) are deliberately NOT part of this
504
+ * contract. They are an engine-local enrichment layered on top by the extractor;
505
+ * they are tuning knobs that will change without a coordinated release, so they
506
+ * fail the promotion rule that governs this package. What is frozen here is the
507
+ * FACTS the engine reasons over and the ids a proposal may reference.
508
+ */
509
+ export const StructureInferenceSnapshotSchema = z.object({
510
+ orgId: z.string().uuid(),
511
+ /** Lineage only. Staleness is decided by the hash of these inputs. */
512
+ sourceSnapshotId: z.string(),
513
+ structureRevision: z.string(),
514
+ persons: z.array(StructurePersonFactSchema),
515
+ reporting: z.array(StructureReportingFactSchema),
516
+ existingUnits: z.array(ExistingUnitFactSchema),
517
+ existingPlacements: z.array(ExistingPlacementFactSchema),
518
+ });
519
+ export type StructureInferenceSnapshot = z.infer<
520
+ typeof StructureInferenceSnapshotSchema
521
+ >;
@@ -0,0 +1,125 @@
1
+ # proactive/
2
+
3
+ ## Purpose
4
+
5
+ The vocabulary of **proactive delivery** — how the system speaks first. It
6
+ encodes the doctrine in the control ADR with slug
7
+ `proactive-delivery-occurrence-audience-projection` (ADR-CTRL-408) as a
8
+ compile-checked vocabulary: a **presentation class** is a TOTAL FUNCTION from
9
+ class to surfaces, and an **event kind** carries only the IDS each surface
10
+ needs. See ADR-CONTRACTS-142.
11
+
12
+ This vocabulary COMPOSES the three notification vocabularies beside it — it
13
+ adds no member to `NotificationKind` or `ActionItemKind`, and it derives none of
14
+ the three from another.
15
+
16
+ ## Invariants
17
+
18
+ - **`CLASS_SURFACES` is total in both directions.** Checked by
19
+ `as const satisfies Record<ProactivePresentationClass, ClassSurfacePlan>`: a
20
+ class with no entry fails to compile, and an entry for a non-class fails too.
21
+ - **Exactly one badge carrier per class.** `ClassSurfacePlan` is a UNION, not a
22
+ flat interface, so a badge naming a surface the class does not have is a
23
+ compile error.
24
+ - **Every class writes a durable inbox row.** `inbox` is literal `true` in every
25
+ union member. What varies is whether the row is born read.
26
+ - **The born-read rule is stated once.** `bornRead(surfaces, surface)` is the
27
+ only place "born read whenever a louder surface owns the badge" lives. A
28
+ backend that re-derives it per call site is how the two halves drift.
29
+ - **Loudness, not severity.** `error|warning|info` exists in this package scoped
30
+ to org-transformation findings and is not overloaded here.
31
+ - **Kinds carry ids, never surface booleans.** A kind names its `class` and the
32
+ identifiers each surface needs; the surfaces themselves are derived from the
33
+ class table. Restating them would leave the compiler having to prove two
34
+ declarations agree.
35
+ - **`PROACTIVE_EVENT_KINDS` is total in both directions.** Same
36
+ `as const satisfies Record<ProactiveEventKind, ProactiveEventDefinition>`
37
+ idiom: a kind with no plan fails to compile, and a plan for a non-kind fails.
38
+ - **The plan union types the ids a class lacks as `never`.** An `announcement`
39
+ MUST name a `bannerType` and a `chatTemplate`; `explained` and `briefing`
40
+ cannot name a banner; a `notice` can name neither. Every member requires an
41
+ `inboxKind`. The one consistency the compiler cannot express — a plan's ids
42
+ agreeing with `CLASS_SURFACES[plan.class]` — is pinned by the vocabulary
43
+ test.
44
+ - **Email and action items are orthogonal axes.** `emailKind` is off-platform
45
+ delivery governed by notify/'s policy; `actionItemKind` is a work-state
46
+ projection, not a loudness tier. No kind sets `emailKind` in this wave.
47
+ - **The kind string and the inbox kind string are distinct.**
48
+ `org.became_shared` names its row `proactive.org_became_shared` and
49
+ `brief.morning` names its row `proactive.brief_morning`; identical names
50
+ across two unions is how one quietly becomes derived from the other.
51
+ - **A `briefing` needs no new machinery.** `brief.morning` is the proof: its
52
+ class already types `bannerType` as `never`, so it cannot name a banner, and
53
+ the plan/surface test covers it for free. ONE occurrence per org per
54
+ org-local day, with N recipients — personalisation lives in the per-recipient
55
+ `MorningBriefFacts`, never in occurrence identity, or the system would have
56
+ one event row per person per day and two shapes of occurrence to reason
57
+ about.
58
+ - **`ORG_SYSTEM_EVENT_TYPES` mirrors the backend pgEnum value for value.**
59
+ `org.became_shared` names the EXISTING `first_member_joined` banner rather
60
+ than minting a third value — it fires at exactly the personal-to-shared flip.
61
+ - **The occurrence vocabulary is closed.** `ProactiveEventId` is branded so a
62
+ raw string cannot stand in for an occurrence; `ProactiveRecipientReason` is a
63
+ closed enum because the recipients table is the frozen audit record of who
64
+ was addressed and why; `ProactiveProjection` is deliberately broader than the
65
+ three surfaces because an occurrence has zero or more PROJECTIONS.
66
+ - **Pushed-chat copy lives here, not in the backend.** The precedent notify/'s
67
+ README states for outbound notifications — copy belongs to contracts'
68
+ `compose` — applied to the third surface. The backend hands a composer facts
69
+ and writes what comes back; it never owns a sentence.
70
+ - **The composer is pure and content-only.** `(facts)` in, message out; no
71
+ clock, no env, no I/O; no markup, no channel names, no hrefs the app owns.
72
+ Members are arrow properties, never method shorthand, because the vocabulary
73
+ guard reads a method signature as behaviour.
74
+ - **`PROACTIVE_CHAT_COMPOSERS` is total in both directions.** Same
75
+ `as const satisfies Record<ProactiveChatTemplate, ProactiveChatComposer>`
76
+ idiom: a template with no composer fails to compile. `ProactiveChatComposer`
77
+ is a DISTRIBUTIVE type so the bare name is a union of per-template composers
78
+ — an arrow `compose` is checked contravariantly, and a composer taking one
79
+ template's facts is not assignable to one taking the union of all facts.
80
+ - **The pushed message is FROZEN AT WRITE TIME (INV-PROACTIVE-CONTENT).** It
81
+ is written once into `chat_messages.content` and never re-renders, so a
82
+ composer may only interpolate facts the recipient was entitled to at compose
83
+ time. The deliberate opposite of the durable-inbox rule, and both are
84
+ correct: a transcript that changed under the reader would be a lie about the
85
+ conversation.
86
+
87
+ ## Public API
88
+
89
+ | Export | Description |
90
+ | -------------------------------- | ----------------------------------------------------------------------------- |
91
+ | `PROACTIVE_PRESENTATION_CLASSES` | The closed class vocabulary (announcement, explained, briefing, notice) |
92
+ | `ProactivePresentationClass` | Union derived from the array |
93
+ | `BadgeCarrier` | `banner` \| `chat` \| `inbox` — exactly one per class |
94
+ | `ClassSurfacePlan` | Union of the surface sets a badge carrier presupposes |
95
+ | `CLASS_SURFACES` | The total class → surfaces table |
96
+ | `bornRead` | Whether a class's `chat` or `inbox` row is born read |
97
+ | `ORG_SYSTEM_EVENT_TYPES` | Mirror of the backend `org_system_event_type` pgEnum — the banner ids |
98
+ | `OrgSystemEventType` | Union derived from the array |
99
+ | `PROACTIVE_CHAT_TEMPLATES` | Which prose composes a kind's pushed chat (`orgBecameShared`, `morningBrief`) |
100
+ | `ProactiveChatTemplate` | Union derived from the array |
101
+ | `PROACTIVE_EVENT_KIND_IDS` | The closed kind vocabulary (`org.became_shared`, `brief.morning`) |
102
+ | `ProactiveEventKind` | Union derived from the array |
103
+ | `ProactiveEventId` | Branded id of ONE occurrence — a raw string cannot be passed as one |
104
+ | `PROACTIVE_RECIPIENT_REASONS` | Closed enum of WHY a recipient was addressed (the frozen audit record) |
105
+ | `ProactiveRecipientReason` | Union derived from the array |
106
+ | `PROACTIVE_PROJECTIONS` | What was materialized for one recipient — broader than the three surfaces |
107
+ | `ProactiveProjection` | Union derived from the array |
108
+ | `ProactiveEventDefinition` | A kind's plan: discriminated union on `class`, ids the class lacks as `never` |
109
+ | `PROACTIVE_EVENT_KINDS` | The total kind → plan registry |
110
+ | `ProactiveChatFacts` | The facts each template composes from, keyed by template |
111
+ | `OrgBecameSharedFacts` | Facts for the personal-to-shared flip: `orgName`, `joinerDisplayName` |
112
+ | `MorningBriefFacts` | Per-recipient facts for the morning brief — where personalisation lives |
113
+ | `ProactiveChatMessage` | What a composer returns: `title`, `text`, and 0..4 `replies` chips |
114
+ | `ProactiveChatComposer` | The composer port — `template` plus an arrow-property `compose(facts)` |
115
+ | `PROACTIVE_CHAT_MAX_REPLIES` | At most this many chips (4); more is a menu, not a chip row |
116
+ | `PROACTIVE_CHAT_COMPOSERS` | The total template → composer registry (see `templates/README.md`) |
117
+
118
+ ## Dependencies
119
+
120
+ Type-only imports of `UserNotificationKind` (`../user-notifications`),
121
+ `NotificationKind` (`../notifications`) and `ActionItemKind`
122
+ (`../action-items`) — the three vocabularies this one composes — and of
123
+ `SuggestedReply` (`../message-parts`), the chip shape a composer's `replies`
124
+ reuse rather than redeclare. No `zod`, no runtime imports; pure vocabulary
125
+ data and pure helpers.