@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.
- package/package.json +4 -4
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +12 -0
- package/src/index.ts +52 -0
- package/src/integrations/README.md +4 -0
- package/src/integrations/schemas.ts +26 -0
- package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +1 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +1 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +207 -0
- package/src/notifications/__tests__/fixtures.ts +9 -0
- package/src/notifications/__tests__/org-invite.test.ts +75 -0
- package/src/notifications/__tests__/render-snapshot.test.ts +8 -0
- package/src/notifications/kinds/org-invite.ts +27 -12
- package/src/notifications/payloads.ts +7 -0
- package/src/org/README.md +38 -0
- package/src/org/__tests__/canonical-facts.test.ts +118 -0
- package/src/org/__tests__/structure-inference.test.ts +392 -0
- package/src/org/__tests__/structure-provenance.test.ts +187 -0
- package/src/org/canonical-facts.ts +94 -1
- package/src/org/index.ts +54 -0
- package/src/org/schemas.ts +23 -0
- package/src/org/structure-inference.ts +521 -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
|
+
>;
|