@company-semantics/contracts 58.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/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
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
CanonicalPositionSchema,
|
|
7
7
|
CanonicalOccupancySchema,
|
|
8
8
|
CanonicalReportingEdgeSchema,
|
|
9
|
+
CanonicalUnresolvedManagerSchema,
|
|
9
10
|
CanonicalFactsSchema,
|
|
10
11
|
} from "../canonical-facts.js";
|
|
11
12
|
|
|
@@ -105,12 +106,14 @@ describe("CanonicalPositionSchema", () => {
|
|
|
105
106
|
externalSourceSystem: "workday",
|
|
106
107
|
title: "Staff Engineer",
|
|
107
108
|
unitExternalSourceId: "dept-9",
|
|
109
|
+
status: "filled",
|
|
108
110
|
provenance,
|
|
109
111
|
};
|
|
110
112
|
|
|
111
113
|
it("parses a seat placed in a unit by external reference", () => {
|
|
112
114
|
const p = CanonicalPositionSchema.parse(base);
|
|
113
115
|
expect(p.unitExternalSourceId).toBe("dept-9");
|
|
116
|
+
expect(p.status).toBe("filled");
|
|
114
117
|
});
|
|
115
118
|
|
|
116
119
|
it("rejects an empty title or missing unit reference", () => {
|
|
@@ -120,6 +123,95 @@ describe("CanonicalPositionSchema", () => {
|
|
|
120
123
|
const { unitExternalSourceId: _u, ...without } = base;
|
|
121
124
|
expect(() => CanonicalPositionSchema.parse(without)).toThrow();
|
|
122
125
|
});
|
|
126
|
+
|
|
127
|
+
it("accepts every reported seat-existence value", () => {
|
|
128
|
+
for (const status of ["planned", "open", "filled"]) {
|
|
129
|
+
expect(CanonicalPositionSchema.parse({ ...base, status }).status).toBe(
|
|
130
|
+
status,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("requires status — an adapter that cannot say must say 'filled'", () => {
|
|
136
|
+
const { status: _s, ...without } = base;
|
|
137
|
+
expect(() => CanonicalPositionSchema.parse(without)).toThrow();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("rejects a status outside the reported-existence axis", () => {
|
|
141
|
+
// `closed` is an in-graph lifecycle outcome, not something a source reports.
|
|
142
|
+
expect(() =>
|
|
143
|
+
CanonicalPositionSchema.parse({ ...base, status: "closed" }),
|
|
144
|
+
).toThrow();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
describe("CanonicalUnresolvedManagerSchema", () => {
|
|
149
|
+
const base = {
|
|
150
|
+
externalSourceId: "unresolved-mgr-42",
|
|
151
|
+
externalSourceSystem: "bamboohr",
|
|
152
|
+
externalManagerId: "42",
|
|
153
|
+
reportPositionExternalSourceIds: ["pos-7", "pos-8"],
|
|
154
|
+
provenance,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
it("parses a dangling supervisor reference with its report seats", () => {
|
|
158
|
+
const u = CanonicalUnresolvedManagerSchema.parse(base);
|
|
159
|
+
expect(u.externalManagerId).toBe("42");
|
|
160
|
+
expect(u.reportPositionExternalSourceIds).toEqual(["pos-7", "pos-8"]);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("defaults resolution to 'unknown' — never to a vacancy", () => {
|
|
164
|
+
expect(CanonicalUnresolvedManagerSchema.parse(base).resolution).toBe(
|
|
165
|
+
"unknown",
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("records a vacancy only when the source says so", () => {
|
|
170
|
+
expect(
|
|
171
|
+
CanonicalUnresolvedManagerSchema.parse({
|
|
172
|
+
...base,
|
|
173
|
+
resolution: "vacant_position",
|
|
174
|
+
}).resolution,
|
|
175
|
+
).toBe("vacant_position");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("accepts the remaining causes, which are not interchangeable", () => {
|
|
179
|
+
for (const resolution of ["inactive_employee", "not_visible"]) {
|
|
180
|
+
expect(
|
|
181
|
+
CanonicalUnresolvedManagerSchema.parse({ ...base, resolution })
|
|
182
|
+
.resolution,
|
|
183
|
+
).toBe(resolution);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("rejects an unknown resolution cause", () => {
|
|
188
|
+
expect(() =>
|
|
189
|
+
CanonicalUnresolvedManagerSchema.parse({
|
|
190
|
+
...base,
|
|
191
|
+
resolution: "terminated",
|
|
192
|
+
}),
|
|
193
|
+
).toThrow();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
it("requires the unresolvable manager id and a provenance envelope", () => {
|
|
197
|
+
expect(() =>
|
|
198
|
+
CanonicalUnresolvedManagerSchema.parse({
|
|
199
|
+
...base,
|
|
200
|
+
externalManagerId: "",
|
|
201
|
+
}),
|
|
202
|
+
).toThrow();
|
|
203
|
+
const { provenance: _omitted, ...without } = base;
|
|
204
|
+
expect(() => CanonicalUnresolvedManagerSchema.parse(without)).toThrow();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("accepts an empty report list (the reference dangles on its own)", () => {
|
|
208
|
+
expect(
|
|
209
|
+
CanonicalUnresolvedManagerSchema.parse({
|
|
210
|
+
...base,
|
|
211
|
+
reportPositionExternalSourceIds: [],
|
|
212
|
+
}).reportPositionExternalSourceIds,
|
|
213
|
+
).toEqual([]);
|
|
214
|
+
});
|
|
123
215
|
});
|
|
124
216
|
|
|
125
217
|
describe("CanonicalOccupancySchema", () => {
|
|
@@ -187,9 +279,11 @@ describe("CanonicalFactsSchema", () => {
|
|
|
187
279
|
positions: [],
|
|
188
280
|
occupancies: [],
|
|
189
281
|
reportingEdges: [],
|
|
282
|
+
unresolvedManagers: [],
|
|
190
283
|
});
|
|
191
284
|
expect(facts.people).toEqual([]);
|
|
192
285
|
expect(facts.reportingEdges).toEqual([]);
|
|
286
|
+
expect(facts.unresolvedManagers).toEqual([]);
|
|
193
287
|
});
|
|
194
288
|
|
|
195
289
|
it("parses a populated batch with one of each fact", () => {
|
|
@@ -216,6 +310,7 @@ describe("CanonicalFactsSchema", () => {
|
|
|
216
310
|
externalSourceSystem: "workday",
|
|
217
311
|
title: "Staff Engineer",
|
|
218
312
|
unitExternalSourceId: "dept-9",
|
|
313
|
+
status: "filled",
|
|
219
314
|
provenance,
|
|
220
315
|
},
|
|
221
316
|
],
|
|
@@ -238,9 +333,19 @@ describe("CanonicalFactsSchema", () => {
|
|
|
238
333
|
provenance,
|
|
239
334
|
},
|
|
240
335
|
],
|
|
336
|
+
unresolvedManagers: [
|
|
337
|
+
{
|
|
338
|
+
externalSourceId: "unresolved-mgr-42",
|
|
339
|
+
externalSourceSystem: "bamboohr",
|
|
340
|
+
externalManagerId: "42",
|
|
341
|
+
reportPositionExternalSourceIds: ["pos-7"],
|
|
342
|
+
provenance,
|
|
343
|
+
},
|
|
344
|
+
],
|
|
241
345
|
});
|
|
242
346
|
expect(facts.people).toHaveLength(1);
|
|
243
347
|
expect(facts.positions[0].unitExternalSourceId).toBe("dept-9");
|
|
348
|
+
expect(facts.unresolvedManagers[0].resolution).toBe("unknown");
|
|
244
349
|
});
|
|
245
350
|
|
|
246
351
|
it("requires every array key to be present", () => {
|
|
@@ -249,6 +354,18 @@ describe("CanonicalFactsSchema", () => {
|
|
|
249
354
|
).toThrow();
|
|
250
355
|
});
|
|
251
356
|
|
|
357
|
+
it("requires the unresolvedManagers channel — silence is not an option", () => {
|
|
358
|
+
expect(() =>
|
|
359
|
+
CanonicalFactsSchema.parse({
|
|
360
|
+
people: [],
|
|
361
|
+
orgUnits: [],
|
|
362
|
+
positions: [],
|
|
363
|
+
occupancies: [],
|
|
364
|
+
reportingEdges: [],
|
|
365
|
+
}),
|
|
366
|
+
).toThrow();
|
|
367
|
+
});
|
|
368
|
+
|
|
252
369
|
it("rejects a member that fails its element schema", () => {
|
|
253
370
|
expect(() =>
|
|
254
371
|
CanonicalFactsSchema.parse({
|
|
@@ -264,6 +381,7 @@ describe("CanonicalFactsSchema", () => {
|
|
|
264
381
|
positions: [],
|
|
265
382
|
occupancies: [],
|
|
266
383
|
reportingEdges: [],
|
|
384
|
+
unresolvedManagers: [],
|
|
267
385
|
}),
|
|
268
386
|
).toThrow();
|
|
269
387
|
});
|
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
StructureEvidenceSchema,
|
|
5
|
+
TOPOLOGY_ONLY_EVIDENCE_KINDS,
|
|
6
|
+
StructureUnitAuthoritySchema,
|
|
7
|
+
ProposedOrgUnitSchema,
|
|
8
|
+
PersonStructureOutcomeSchema,
|
|
9
|
+
StructureReviewItemSchema,
|
|
10
|
+
StructureProposalSchema,
|
|
11
|
+
StructurePersonFactSchema,
|
|
12
|
+
StructureReportingFactSchema,
|
|
13
|
+
ExistingUnitFactSchema,
|
|
14
|
+
ExistingPlacementFactSchema,
|
|
15
|
+
StructureInferenceSnapshotSchema,
|
|
16
|
+
} from "../structure-inference.js";
|
|
17
|
+
|
|
18
|
+
const UNIT_ID = "11111111-1111-4111-8111-111111111111";
|
|
19
|
+
const ORG_ID = "22222222-2222-4222-8222-222222222222";
|
|
20
|
+
|
|
21
|
+
const roleFamilyEvidence = {
|
|
22
|
+
kind: "role_family" as const,
|
|
23
|
+
family: "Technical Recruiter",
|
|
24
|
+
personIds: ["p1", "p2", "p3", "p4"],
|
|
25
|
+
count: 4,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const topologyEvidence = {
|
|
29
|
+
kind: "reporting_cluster" as const,
|
|
30
|
+
managerPersonId: "mgr-1",
|
|
31
|
+
personIds: ["p1", "p2"],
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
describe("StructureEvidenceSchema", () => {
|
|
35
|
+
it("round-trips every evidence kind the validator reasons over", () => {
|
|
36
|
+
const kinds = [
|
|
37
|
+
{ kind: "explicit_department", personIds: ["p1"], value: "People" },
|
|
38
|
+
{ kind: "explicit_division", personIds: ["p1"], value: "G&A" },
|
|
39
|
+
roleFamilyEvidence,
|
|
40
|
+
{ kind: "functional_leader", personId: "p9", function: "People" },
|
|
41
|
+
topologyEvidence,
|
|
42
|
+
{ kind: "administrative_pivot", personId: "p7", departmentEntropy: 1.9 },
|
|
43
|
+
{ kind: "controlled_ontology", concept: "Recruiting" },
|
|
44
|
+
];
|
|
45
|
+
for (const evidence of kinds) {
|
|
46
|
+
expect(StructureEvidenceSchema.parse(evidence)).toEqual(evidence);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("rejects prose masquerading as evidence — the whole point of typing it", () => {
|
|
51
|
+
expect(() =>
|
|
52
|
+
StructureEvidenceSchema.parse("several employees have similar roles"),
|
|
53
|
+
).toThrow();
|
|
54
|
+
expect(() =>
|
|
55
|
+
StructureEvidenceSchema.parse({ kind: "vibes", note: "feels right" }),
|
|
56
|
+
).toThrow();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("names administrative_pivot, so a proposal can explain a boundary it did NOT draw", () => {
|
|
60
|
+
const parsed = StructureEvidenceSchema.parse({
|
|
61
|
+
kind: "administrative_pivot",
|
|
62
|
+
personId: "olivia",
|
|
63
|
+
departmentEntropy: 1.94,
|
|
64
|
+
});
|
|
65
|
+
expect(parsed.kind).toBe("administrative_pivot");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("TOPOLOGY_ONLY_EVIDENCE_KINDS", () => {
|
|
70
|
+
it("names reporting_cluster and nothing else", () => {
|
|
71
|
+
expect(TOPOLOGY_ONLY_EVIDENCE_KINDS).toEqual(["reporting_cluster"]);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("holds only kinds StructureEvidence actually declares", () => {
|
|
75
|
+
for (const kind of TOPOLOGY_ONLY_EVIDENCE_KINDS) {
|
|
76
|
+
expect(() =>
|
|
77
|
+
StructureEvidenceSchema.parse({
|
|
78
|
+
kind,
|
|
79
|
+
managerPersonId: "mgr-1",
|
|
80
|
+
personIds: [],
|
|
81
|
+
}),
|
|
82
|
+
).not.toThrow();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("StructureUnitAuthoritySchema", () => {
|
|
88
|
+
it("is the anchor-vs-reorganizable axis, not the fact truth hierarchy", () => {
|
|
89
|
+
expect(StructureUnitAuthoritySchema.options).toEqual([
|
|
90
|
+
"human_confirmed",
|
|
91
|
+
"ai_inferred",
|
|
92
|
+
"hris_source",
|
|
93
|
+
]);
|
|
94
|
+
// The FactSourceTier members must NOT be accepted here — two closed sets
|
|
95
|
+
// meaning almost the same thing is exactly the ambiguity to avoid.
|
|
96
|
+
expect(() => StructureUnitAuthoritySchema.parse("inferred")).toThrow();
|
|
97
|
+
expect(() => StructureUnitAuthoritySchema.parse("user")).toThrow();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("ProposedOrgUnitSchema", () => {
|
|
102
|
+
const base = {
|
|
103
|
+
tempId: "u1",
|
|
104
|
+
targetUnitId: UNIT_ID,
|
|
105
|
+
identityConfidence: 0.95,
|
|
106
|
+
identityEvidence: [roleFamilyEvidence],
|
|
107
|
+
name: "People",
|
|
108
|
+
nameBasis: [roleFamilyEvidence],
|
|
109
|
+
parentTempId: null,
|
|
110
|
+
suggestedTypeLabel: "Department",
|
|
111
|
+
headPersonId: "p9",
|
|
112
|
+
nameConfidence: 0.8,
|
|
113
|
+
structureConfidence: 0.9,
|
|
114
|
+
provisional: false,
|
|
115
|
+
evidence: [roleFamilyEvidence],
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
it("carries targetUnitId so a rename preserves durable identity", () => {
|
|
119
|
+
expect(ProposedOrgUnitSchema.parse(base).targetUnitId).toBe(UNIT_ID);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("requires targetUnitId to be present-or-null, never absent", () => {
|
|
123
|
+
const { targetUnitId: _omitted, ...without } = base;
|
|
124
|
+
expect(() => ProposedOrgUnitSchema.parse(without)).toThrow();
|
|
125
|
+
expect(
|
|
126
|
+
ProposedOrgUnitSchema.parse({ ...base, targetUnitId: null }).targetUnitId,
|
|
127
|
+
).toBeNull();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("requires targetUnitId to be a durable uuid, not a name or a tempId", () => {
|
|
131
|
+
expect(() =>
|
|
132
|
+
ProposedOrgUnitSchema.parse({ ...base, targetUnitId: "People" }),
|
|
133
|
+
).toThrow();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects confidences outside [0,1]", () => {
|
|
137
|
+
expect(() =>
|
|
138
|
+
ProposedOrgUnitSchema.parse({ ...base, nameConfidence: 1.4 }),
|
|
139
|
+
).toThrow();
|
|
140
|
+
expect(() =>
|
|
141
|
+
ProposedOrgUnitSchema.parse({ ...base, structureConfidence: -0.1 }),
|
|
142
|
+
).toThrow();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("keeps nameBasis separate from boundary evidence", () => {
|
|
146
|
+
const parsed = ProposedOrgUnitSchema.parse({
|
|
147
|
+
...base,
|
|
148
|
+
nameBasis: [],
|
|
149
|
+
evidence: [topologyEvidence],
|
|
150
|
+
});
|
|
151
|
+
expect(parsed.nameBasis).toEqual([]);
|
|
152
|
+
expect(parsed.evidence).toEqual([topologyEvidence]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
describe("PersonStructureOutcomeSchema", () => {
|
|
157
|
+
it("round-trips all three outcomes — placed, needs_review, excluded", () => {
|
|
158
|
+
const placed = PersonStructureOutcomeSchema.parse({
|
|
159
|
+
kind: "placed",
|
|
160
|
+
personId: "p1",
|
|
161
|
+
unitTempId: "u1",
|
|
162
|
+
confidence: 0.9,
|
|
163
|
+
evidence: [roleFamilyEvidence],
|
|
164
|
+
conflicts: [],
|
|
165
|
+
});
|
|
166
|
+
expect(placed.kind).toBe("placed");
|
|
167
|
+
|
|
168
|
+
const review = PersonStructureOutcomeSchema.parse({
|
|
169
|
+
kind: "needs_review",
|
|
170
|
+
personId: "emily",
|
|
171
|
+
reason: "insufficient_evidence",
|
|
172
|
+
});
|
|
173
|
+
expect(review.kind).toBe("needs_review");
|
|
174
|
+
|
|
175
|
+
const excluded = PersonStructureOutcomeSchema.parse({
|
|
176
|
+
kind: "excluded",
|
|
177
|
+
personId: "bamboo-svc",
|
|
178
|
+
reason: "service_account",
|
|
179
|
+
});
|
|
180
|
+
expect(excluded.kind).toBe("excluded");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("gives an unplaceable person a REASON — never a guess, never an absence", () => {
|
|
184
|
+
expect(() =>
|
|
185
|
+
PersonStructureOutcomeSchema.parse({
|
|
186
|
+
kind: "needs_review",
|
|
187
|
+
personId: "emily",
|
|
188
|
+
}),
|
|
189
|
+
).toThrow();
|
|
190
|
+
expect(() =>
|
|
191
|
+
PersonStructureOutcomeSchema.parse({
|
|
192
|
+
kind: "needs_review",
|
|
193
|
+
personId: "emily",
|
|
194
|
+
reason: "felt_odd",
|
|
195
|
+
}),
|
|
196
|
+
).toThrow();
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("has no 'unplaced' or empty outcome — every person lands in one arm", () => {
|
|
200
|
+
expect(() =>
|
|
201
|
+
PersonStructureOutcomeSchema.parse({ kind: "unplaced", personId: "p1" }),
|
|
202
|
+
).toThrow();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("requires a placed outcome to name the unit it placed the person in", () => {
|
|
206
|
+
expect(() =>
|
|
207
|
+
PersonStructureOutcomeSchema.parse({
|
|
208
|
+
kind: "placed",
|
|
209
|
+
personId: "p1",
|
|
210
|
+
confidence: 0.9,
|
|
211
|
+
evidence: [],
|
|
212
|
+
conflicts: [],
|
|
213
|
+
}),
|
|
214
|
+
).toThrow();
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe("StructureReviewItemSchema", () => {
|
|
219
|
+
const base = {
|
|
220
|
+
kind: "placement_conflict",
|
|
221
|
+
personIds: ["clarissa"],
|
|
222
|
+
unitTempIds: ["u1", "u2"],
|
|
223
|
+
question: "Which unit does this Account Executive belong to?",
|
|
224
|
+
recommended: "u2",
|
|
225
|
+
alternatives: ["u1"],
|
|
226
|
+
signals: {
|
|
227
|
+
department: "Human Resources",
|
|
228
|
+
manager: "VP of Marketing",
|
|
229
|
+
roleFamily: "Account Executive",
|
|
230
|
+
},
|
|
231
|
+
confidence: 0.45,
|
|
232
|
+
evidence: [roleFamilyEvidence],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
it("carries the competing signals so a reviewer can see WHY it is ambiguous", () => {
|
|
236
|
+
const parsed = StructureReviewItemSchema.parse(base);
|
|
237
|
+
expect(Object.keys(parsed.signals)).toHaveLength(3);
|
|
238
|
+
expect(parsed.signals.manager).toBe("VP of Marketing");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("rejects an unknown review kind", () => {
|
|
242
|
+
expect(() =>
|
|
243
|
+
StructureReviewItemSchema.parse({ ...base, kind: "something_else" }),
|
|
244
|
+
).toThrow();
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe("StructureProposalSchema", () => {
|
|
249
|
+
const proposal = {
|
|
250
|
+
orgId: ORG_ID,
|
|
251
|
+
sourceSnapshotId: "snap-1",
|
|
252
|
+
structureRevision: "rev-7",
|
|
253
|
+
inputHash: "sha256:abc",
|
|
254
|
+
promptVersion: "3",
|
|
255
|
+
orgUnits: [
|
|
256
|
+
{
|
|
257
|
+
tempId: "u1",
|
|
258
|
+
targetUnitId: UNIT_ID,
|
|
259
|
+
identityConfidence: 0.95,
|
|
260
|
+
identityEvidence: [roleFamilyEvidence],
|
|
261
|
+
name: "People",
|
|
262
|
+
nameBasis: [roleFamilyEvidence],
|
|
263
|
+
parentTempId: null,
|
|
264
|
+
suggestedTypeLabel: "Department",
|
|
265
|
+
headPersonId: "p9",
|
|
266
|
+
nameConfidence: 0.8,
|
|
267
|
+
structureConfidence: 0.9,
|
|
268
|
+
provisional: false,
|
|
269
|
+
evidence: [roleFamilyEvidence],
|
|
270
|
+
},
|
|
271
|
+
],
|
|
272
|
+
personOutcomes: [
|
|
273
|
+
{
|
|
274
|
+
kind: "placed",
|
|
275
|
+
personId: "p1",
|
|
276
|
+
unitTempId: "u1",
|
|
277
|
+
confidence: 0.9,
|
|
278
|
+
evidence: [roleFamilyEvidence],
|
|
279
|
+
conflicts: [],
|
|
280
|
+
},
|
|
281
|
+
],
|
|
282
|
+
structureWarnings: [],
|
|
283
|
+
reviewItems: [],
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
it("round-trips a whole proposal without loss", () => {
|
|
287
|
+
expect(StructureProposalSchema.parse(proposal)).toEqual(proposal);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it("keeps inputHash and sourceSnapshotId as separate fields", () => {
|
|
291
|
+
// Staleness is decided by inputHash; sourceSnapshotId is lineage only.
|
|
292
|
+
const parsed = StructureProposalSchema.parse(proposal);
|
|
293
|
+
expect(parsed.inputHash).not.toBe(parsed.sourceSnapshotId);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("rejects an orgId that is not a durable uuid", () => {
|
|
297
|
+
expect(() =>
|
|
298
|
+
StructureProposalSchema.parse({ ...proposal, orgId: "Bamboo Workspace" }),
|
|
299
|
+
).toThrow();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("StructureInferenceSnapshotSchema", () => {
|
|
304
|
+
const person = {
|
|
305
|
+
personId: "p1",
|
|
306
|
+
sourcePersonId: "emp-42",
|
|
307
|
+
displayName: "Duke Smith",
|
|
308
|
+
title: "Technical Recruiter",
|
|
309
|
+
department: "Human Resources",
|
|
310
|
+
division: null,
|
|
311
|
+
location: null,
|
|
312
|
+
managerPersonId: "mgr-1",
|
|
313
|
+
directReportPersonIds: [],
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
const snapshot = {
|
|
317
|
+
orgId: ORG_ID,
|
|
318
|
+
sourceSnapshotId: "snap-1",
|
|
319
|
+
structureRevision: "rev-7",
|
|
320
|
+
persons: [person],
|
|
321
|
+
reporting: [
|
|
322
|
+
{
|
|
323
|
+
reportPersonId: "p1",
|
|
324
|
+
managerPersonId: "mgr-1",
|
|
325
|
+
relationshipType: "solid",
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
existingUnits: [
|
|
329
|
+
{
|
|
330
|
+
unitId: UNIT_ID,
|
|
331
|
+
name: "Human Resources",
|
|
332
|
+
parentUnitId: null,
|
|
333
|
+
depth: 2,
|
|
334
|
+
authority: "hris_source",
|
|
335
|
+
},
|
|
336
|
+
],
|
|
337
|
+
existingPlacements: [
|
|
338
|
+
{ personId: "p1", unitId: UNIT_ID, authority: "hris_source" },
|
|
339
|
+
],
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
it("round-trips the persisted-graph view the engine reasons over", () => {
|
|
343
|
+
expect(StructureInferenceSnapshotSchema.parse(snapshot)).toEqual(snapshot);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("accepts a person fact with sourcePersonId stripped for the model", () => {
|
|
347
|
+
const { sourcePersonId: _stripped, ...modelFacing } = person;
|
|
348
|
+
expect(
|
|
349
|
+
StructurePersonFactSchema.parse(modelFacing).sourcePersonId,
|
|
350
|
+
).toBeUndefined();
|
|
351
|
+
expect(
|
|
352
|
+
StructurePersonFactSchema.parse({ ...person, sourcePersonId: null })
|
|
353
|
+
.sourcePersonId,
|
|
354
|
+
).toBeNull();
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it("still requires the opaque personId — the only handle a proposal may use", () => {
|
|
358
|
+
const { personId: _omitted, ...without } = person;
|
|
359
|
+
expect(() => StructurePersonFactSchema.parse(without)).toThrow();
|
|
360
|
+
expect(() =>
|
|
361
|
+
StructurePersonFactSchema.parse({ ...person, personId: "" }),
|
|
362
|
+
).toThrow();
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it("carries the authority axis on every existing unit and placement", () => {
|
|
366
|
+
expect(() =>
|
|
367
|
+
ExistingUnitFactSchema.parse({
|
|
368
|
+
unitId: UNIT_ID,
|
|
369
|
+
name: "People",
|
|
370
|
+
parentUnitId: null,
|
|
371
|
+
depth: 2,
|
|
372
|
+
}),
|
|
373
|
+
).toThrow();
|
|
374
|
+
expect(
|
|
375
|
+
ExistingPlacementFactSchema.parse({
|
|
376
|
+
personId: "p1",
|
|
377
|
+
unitId: UNIT_ID,
|
|
378
|
+
authority: "human_confirmed",
|
|
379
|
+
}).authority,
|
|
380
|
+
).toBe("human_confirmed");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("keeps reporting a typed edge list, so the validator can prove it unchanged", () => {
|
|
384
|
+
expect(() =>
|
|
385
|
+
StructureReportingFactSchema.parse({
|
|
386
|
+
reportPersonId: "p1",
|
|
387
|
+
managerPersonId: "mgr-1",
|
|
388
|
+
relationshipType: "advisory",
|
|
389
|
+
}),
|
|
390
|
+
).toThrow();
|
|
391
|
+
});
|
|
392
|
+
});
|