@company-semantics/contracts 57.0.0 → 58.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.
@@ -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
+ >;