@company-semantics/contracts 52.1.0 → 53.0.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 +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +122 -69
- package/src/generated/openapi-routes.ts +3 -1
- package/src/identity/README.md +2 -2
- package/src/identity/__tests__/people-org-chart.test.ts +52 -17
- package/src/identity/__tests__/position-ref.test.ts +44 -0
- package/src/identity/index.ts +6 -2
- package/src/identity/people-org-chart.ts +26 -15
- package/src/identity/position-ref.ts +24 -0
- package/src/index.ts +21 -2
- package/src/org/README.md +10 -2
- package/src/org/__tests__/org-units.test.ts +1 -1
- package/src/org/__tests__/set-seat-manager.test.ts +177 -0
- package/src/org/index.ts +20 -2
- package/src/org/reconciliation.ts +162 -0
- package/src/org/schemas.ts +43 -17
- package/src/identity/org-chart-actor.ts +0 -24
package/package.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = 'b8fe0a02c492' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = 'b8fe0a02c49284e732adac870b9b0ff155865b53f5cc8571aa0eb77c4af78859' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -738,7 +738,7 @@ export interface paths {
|
|
|
738
738
|
patch: operations["changeMemberRole"];
|
|
739
739
|
trace?: never;
|
|
740
740
|
};
|
|
741
|
-
"/api/
|
|
741
|
+
"/api/reporting/positions/{positionId}/manager": {
|
|
742
742
|
parameters: {
|
|
743
743
|
query?: never;
|
|
744
744
|
header?: never;
|
|
@@ -746,14 +746,13 @@ export interface paths {
|
|
|
746
746
|
cookie?: never;
|
|
747
747
|
};
|
|
748
748
|
get?: never;
|
|
749
|
-
|
|
749
|
+
/** Set or clear a seat's solid-line manager (org-chart reporting edge) */
|
|
750
|
+
put: operations["setSeatManager"];
|
|
750
751
|
post?: never;
|
|
751
|
-
|
|
752
|
-
delete: operations["clearMemberManager"];
|
|
752
|
+
delete?: never;
|
|
753
753
|
options?: never;
|
|
754
754
|
head?: never;
|
|
755
|
-
|
|
756
|
-
patch: operations["setMemberManager"];
|
|
755
|
+
patch?: never;
|
|
757
756
|
trace?: never;
|
|
758
757
|
};
|
|
759
758
|
"/api/reporting/move/preview": {
|
|
@@ -1409,6 +1408,46 @@ export interface paths {
|
|
|
1409
1408
|
patch?: never;
|
|
1410
1409
|
trace?: never;
|
|
1411
1410
|
};
|
|
1411
|
+
"/api/org/reconciliation": {
|
|
1412
|
+
parameters: {
|
|
1413
|
+
query?: never;
|
|
1414
|
+
header?: never;
|
|
1415
|
+
path?: never;
|
|
1416
|
+
cookie?: never;
|
|
1417
|
+
};
|
|
1418
|
+
/**
|
|
1419
|
+
* List the workspace’s standing disagreements with the connected HRIS
|
|
1420
|
+
* @description The org’s still-open divergences, newest first, whether they arose during a sync run or during app use. Distinct from the run-scoped conflicts view, which reports what one run refused. Read-only: nothing here is sent to the HRIS.
|
|
1421
|
+
*/
|
|
1422
|
+
get: operations["getOrgReconciliation"];
|
|
1423
|
+
put?: never;
|
|
1424
|
+
post?: never;
|
|
1425
|
+
delete?: never;
|
|
1426
|
+
options?: never;
|
|
1427
|
+
head?: never;
|
|
1428
|
+
patch?: never;
|
|
1429
|
+
trace?: never;
|
|
1430
|
+
};
|
|
1431
|
+
"/api/org/reconciliation/divergences/{divergenceId}/resolution": {
|
|
1432
|
+
parameters: {
|
|
1433
|
+
query?: never;
|
|
1434
|
+
header?: never;
|
|
1435
|
+
path?: never;
|
|
1436
|
+
cookie?: never;
|
|
1437
|
+
};
|
|
1438
|
+
get?: never;
|
|
1439
|
+
put?: never;
|
|
1440
|
+
/**
|
|
1441
|
+
* Acknowledge a standing disagreement with the connected HRIS
|
|
1442
|
+
* @description Records LOCALLY that this workspace has seen the disagreement. Nothing is sent to the HRIS — the result’s sentToSource is always false — and the divergence stays open in the ledger, now carrying acknowledgedAt. Idempotent: acknowledging twice returns the first acknowledgement’s timestamp.
|
|
1443
|
+
*/
|
|
1444
|
+
post: operations["resolveOrgDivergence"];
|
|
1445
|
+
delete?: never;
|
|
1446
|
+
options?: never;
|
|
1447
|
+
head?: never;
|
|
1448
|
+
patch?: never;
|
|
1449
|
+
trace?: never;
|
|
1450
|
+
};
|
|
1412
1451
|
"/api/company-md/tree": {
|
|
1413
1452
|
parameters: {
|
|
1414
1453
|
query?: never;
|
|
@@ -4340,35 +4379,23 @@ export interface components {
|
|
|
4340
4379
|
/** @enum {string} */
|
|
4341
4380
|
newRole: "admin" | "member";
|
|
4342
4381
|
};
|
|
4343
|
-
|
|
4382
|
+
SetSeatManagerResponse: {
|
|
4344
4383
|
success: boolean;
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
kind: "member";
|
|
4349
|
-
/** Format: uuid */
|
|
4350
|
-
userId: string;
|
|
4351
|
-
} | {
|
|
4352
|
-
/** @constant */
|
|
4353
|
-
kind: "open-role";
|
|
4384
|
+
/** Format: uuid */
|
|
4385
|
+
reportPositionId: string;
|
|
4386
|
+
manager: {
|
|
4354
4387
|
/** Format: uuid */
|
|
4355
|
-
|
|
4356
|
-
}
|
|
4357
|
-
|
|
4388
|
+
positionId: string;
|
|
4389
|
+
} | null;
|
|
4390
|
+
/** @constant */
|
|
4391
|
+
relationshipType: "solid";
|
|
4358
4392
|
message: string;
|
|
4359
4393
|
};
|
|
4360
|
-
|
|
4394
|
+
SetSeatManagerRequest: {
|
|
4361
4395
|
manager: {
|
|
4362
|
-
/** @constant */
|
|
4363
|
-
kind: "member";
|
|
4364
|
-
/** Format: uuid */
|
|
4365
|
-
userId: string;
|
|
4366
|
-
} | {
|
|
4367
|
-
/** @constant */
|
|
4368
|
-
kind: "open-role";
|
|
4369
4396
|
/** Format: uuid */
|
|
4370
|
-
|
|
4371
|
-
};
|
|
4397
|
+
positionId: string;
|
|
4398
|
+
} | null;
|
|
4372
4399
|
};
|
|
4373
4400
|
MovePreviewRequest: {
|
|
4374
4401
|
/** @constant */
|
|
@@ -4934,6 +4961,10 @@ export interface components {
|
|
|
4934
4961
|
/** @enum {string} */
|
|
4935
4962
|
decision: "accept" | "reject";
|
|
4936
4963
|
};
|
|
4964
|
+
ResolveOrgDivergenceRequest: {
|
|
4965
|
+
/** @constant */
|
|
4966
|
+
resolution: "acknowledged";
|
|
4967
|
+
};
|
|
4937
4968
|
CompanyMdListResponse: {
|
|
4938
4969
|
items: {
|
|
4939
4970
|
id: string;
|
|
@@ -5673,7 +5704,7 @@ export interface components {
|
|
|
5673
5704
|
/** @enum {string} */
|
|
5674
5705
|
status: "open" | "hiring" | "filled" | "closed";
|
|
5675
5706
|
filledByUserId: string | null;
|
|
5676
|
-
|
|
5707
|
+
reportsToPositionId: string | null;
|
|
5677
5708
|
createdAt: string;
|
|
5678
5709
|
updatedAt: string;
|
|
5679
5710
|
}[];
|
|
@@ -5691,7 +5722,7 @@ export interface components {
|
|
|
5691
5722
|
/** @enum {string} */
|
|
5692
5723
|
status: "open" | "hiring" | "filled" | "closed";
|
|
5693
5724
|
filledByUserId: string | null;
|
|
5694
|
-
|
|
5725
|
+
reportsToPositionId: string | null;
|
|
5695
5726
|
createdAt: string;
|
|
5696
5727
|
updatedAt: string;
|
|
5697
5728
|
};
|
|
@@ -5708,7 +5739,8 @@ export interface components {
|
|
|
5708
5739
|
hiring: boolean;
|
|
5709
5740
|
occupants: {
|
|
5710
5741
|
/** Format: uuid */
|
|
5711
|
-
|
|
5742
|
+
personId: string;
|
|
5743
|
+
userId: string | null;
|
|
5712
5744
|
fullName: string;
|
|
5713
5745
|
avatarUrl: string | null;
|
|
5714
5746
|
primaryUnitId: string | null;
|
|
@@ -5720,6 +5752,7 @@ export interface components {
|
|
|
5720
5752
|
mutability: "derived" | "user_managed";
|
|
5721
5753
|
}[];
|
|
5722
5754
|
}[];
|
|
5755
|
+
integrity: "canonical_placement_conflict" | null;
|
|
5723
5756
|
}[];
|
|
5724
5757
|
seatEdges: {
|
|
5725
5758
|
/** Format: uuid */
|
|
@@ -7926,50 +7959,28 @@ export interface operations {
|
|
|
7926
7959
|
};
|
|
7927
7960
|
};
|
|
7928
7961
|
};
|
|
7929
|
-
|
|
7930
|
-
parameters: {
|
|
7931
|
-
query?: never;
|
|
7932
|
-
header?: never;
|
|
7933
|
-
path: {
|
|
7934
|
-
id: string;
|
|
7935
|
-
};
|
|
7936
|
-
cookie?: never;
|
|
7937
|
-
};
|
|
7938
|
-
requestBody?: never;
|
|
7939
|
-
responses: {
|
|
7940
|
-
/** @description Manager cleared successfully */
|
|
7941
|
-
200: {
|
|
7942
|
-
headers: {
|
|
7943
|
-
[name: string]: unknown;
|
|
7944
|
-
};
|
|
7945
|
-
content: {
|
|
7946
|
-
"application/json": components["schemas"]["SetMemberManagerResponse"];
|
|
7947
|
-
};
|
|
7948
|
-
};
|
|
7949
|
-
};
|
|
7950
|
-
};
|
|
7951
|
-
setMemberManager: {
|
|
7962
|
+
setSeatManager: {
|
|
7952
7963
|
parameters: {
|
|
7953
7964
|
query?: never;
|
|
7954
7965
|
header?: never;
|
|
7955
7966
|
path: {
|
|
7956
|
-
|
|
7967
|
+
positionId: string;
|
|
7957
7968
|
};
|
|
7958
7969
|
cookie?: never;
|
|
7959
7970
|
};
|
|
7960
7971
|
requestBody: {
|
|
7961
7972
|
content: {
|
|
7962
|
-
"application/json": components["schemas"]["
|
|
7973
|
+
"application/json": components["schemas"]["SetSeatManagerRequest"];
|
|
7963
7974
|
};
|
|
7964
7975
|
};
|
|
7965
7976
|
responses: {
|
|
7966
|
-
/** @description
|
|
7977
|
+
/** @description The seat's solid-line manager edge was set or cleared */
|
|
7967
7978
|
200: {
|
|
7968
7979
|
headers: {
|
|
7969
7980
|
[name: string]: unknown;
|
|
7970
7981
|
};
|
|
7971
7982
|
content: {
|
|
7972
|
-
"application/json": components["schemas"]["
|
|
7983
|
+
"application/json": components["schemas"]["SetSeatManagerResponse"];
|
|
7973
7984
|
};
|
|
7974
7985
|
};
|
|
7975
7986
|
};
|
|
@@ -8898,6 +8909,55 @@ export interface operations {
|
|
|
8898
8909
|
};
|
|
8899
8910
|
};
|
|
8900
8911
|
};
|
|
8912
|
+
getOrgReconciliation: {
|
|
8913
|
+
parameters: {
|
|
8914
|
+
query?: never;
|
|
8915
|
+
header?: never;
|
|
8916
|
+
path?: never;
|
|
8917
|
+
cookie?: never;
|
|
8918
|
+
};
|
|
8919
|
+
requestBody?: never;
|
|
8920
|
+
responses: {
|
|
8921
|
+
/** @description The org’s open divergences, newest first — each carrying both sides, the winning tier and the human-readable reason (contracts OrgReconciliationLedger) */
|
|
8922
|
+
200: {
|
|
8923
|
+
headers: {
|
|
8924
|
+
[name: string]: unknown;
|
|
8925
|
+
};
|
|
8926
|
+
content?: never;
|
|
8927
|
+
};
|
|
8928
|
+
};
|
|
8929
|
+
};
|
|
8930
|
+
resolveOrgDivergence: {
|
|
8931
|
+
parameters: {
|
|
8932
|
+
query?: never;
|
|
8933
|
+
header?: never;
|
|
8934
|
+
path: {
|
|
8935
|
+
divergenceId: string;
|
|
8936
|
+
};
|
|
8937
|
+
cookie?: never;
|
|
8938
|
+
};
|
|
8939
|
+
requestBody: {
|
|
8940
|
+
content: {
|
|
8941
|
+
"application/json": components["schemas"]["ResolveOrgDivergenceRequest"];
|
|
8942
|
+
};
|
|
8943
|
+
};
|
|
8944
|
+
responses: {
|
|
8945
|
+
/** @description The acknowledgement, with sentToSource pinned false (contracts ResolveOrgDivergenceResult) */
|
|
8946
|
+
200: {
|
|
8947
|
+
headers: {
|
|
8948
|
+
[name: string]: unknown;
|
|
8949
|
+
};
|
|
8950
|
+
content?: never;
|
|
8951
|
+
};
|
|
8952
|
+
/** @description No open divergence with that id — it belongs to another workspace, a later statement superseded it, or it has since been settled */
|
|
8953
|
+
404: {
|
|
8954
|
+
headers: {
|
|
8955
|
+
[name: string]: unknown;
|
|
8956
|
+
};
|
|
8957
|
+
content?: never;
|
|
8958
|
+
};
|
|
8959
|
+
};
|
|
8960
|
+
};
|
|
8901
8961
|
getCompanyMdTree: {
|
|
8902
8962
|
parameters: {
|
|
8903
8963
|
query?: {
|
|
@@ -10848,17 +10908,10 @@ export interface operations {
|
|
|
10848
10908
|
requestBody: {
|
|
10849
10909
|
content: {
|
|
10850
10910
|
"application/json": {
|
|
10851
|
-
manager:
|
|
10852
|
-
/** @constant */
|
|
10853
|
-
kind: "member";
|
|
10854
|
-
/** Format: uuid */
|
|
10855
|
-
userId: string;
|
|
10856
|
-
} | {
|
|
10857
|
-
/** @constant */
|
|
10858
|
-
kind: "open-role";
|
|
10911
|
+
manager: {
|
|
10859
10912
|
/** Format: uuid */
|
|
10860
|
-
|
|
10861
|
-
}
|
|
10913
|
+
positionId: string;
|
|
10914
|
+
} | null;
|
|
10862
10915
|
};
|
|
10863
10916
|
};
|
|
10864
10917
|
};
|
|
@@ -138,6 +138,8 @@ export const openApiRoutes = {
|
|
|
138
138
|
'/api/org/cancel-deletion': ['POST'],
|
|
139
139
|
'/api/org/delete': ['POST'],
|
|
140
140
|
'/api/org/deletion-eligibility': ['GET'],
|
|
141
|
+
'/api/org/reconciliation': ['GET'],
|
|
142
|
+
'/api/org/reconciliation/divergences/{divergenceId}/resolution': ['POST'],
|
|
141
143
|
'/api/org/system-events': ['GET'],
|
|
142
144
|
'/api/org/system-events/{id}/acknowledge': ['POST'],
|
|
143
145
|
'/api/org/transfer-eligibility': ['GET'],
|
|
@@ -154,6 +156,7 @@ export const openApiRoutes = {
|
|
|
154
156
|
'/api/rbac/roles': ['GET'],
|
|
155
157
|
'/api/reporting/move/apply': ['POST'],
|
|
156
158
|
'/api/reporting/move/preview': ['POST'],
|
|
159
|
+
'/api/reporting/positions/{positionId}/manager': ['PUT'],
|
|
157
160
|
'/api/scope/check': ['GET'],
|
|
158
161
|
'/api/scope/check-batch': ['POST'],
|
|
159
162
|
'/api/shared/{token}': ['GET'],
|
|
@@ -192,7 +195,6 @@ export const openApiRoutes = {
|
|
|
192
195
|
'/api/workspace/invites/{id}': ['DELETE'],
|
|
193
196
|
'/api/workspace/members': ['GET'],
|
|
194
197
|
'/api/workspace/members/{id}': ['DELETE', 'GET'],
|
|
195
|
-
'/api/workspace/members/{id}/manager': ['DELETE', 'PATCH'],
|
|
196
198
|
'/api/workspace/members/{id}/role': ['PATCH'],
|
|
197
199
|
'/api/workspace/name': ['PATCH'],
|
|
198
200
|
'/api/workspace/organization': ['PATCH'],
|
package/src/identity/README.md
CHANGED
|
@@ -49,8 +49,6 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
49
49
|
- `MeResponse` _(type)_
|
|
50
50
|
- `MeResponseSchema` — Full identity context returned by GET /api/me.
|
|
51
51
|
- `NameSource` _(type)_ — Source of the user's name data.
|
|
52
|
-
- `OrgChartActorRef` _(type)_
|
|
53
|
-
- `OrgChartActorRefSchema`
|
|
54
52
|
- `PeopleOrgChartOwnedUnit` _(type)_
|
|
55
53
|
- `PeopleOrgChartOwnedUnitSchema`
|
|
56
54
|
- `PeopleOrgChartResponse` _(type)_
|
|
@@ -65,6 +63,8 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
65
63
|
- `PeopleOrgChartSeatStatusSchema`
|
|
66
64
|
- `Person` _(type)_
|
|
67
65
|
- `PersonSchema`
|
|
66
|
+
- `PositionRef` _(type)_
|
|
67
|
+
- `PositionRefSchema` — A reference to a POSITION in the org graph.
|
|
68
68
|
- `ProfileResponse` _(type)_
|
|
69
69
|
- `ProfileResponseSchema`
|
|
70
70
|
- `ReportingRelationshipType` _(type)_
|
|
@@ -51,25 +51,60 @@ describe("PeopleOrgChartSeatOccupantSchema", () => {
|
|
|
51
51
|
});
|
|
52
52
|
|
|
53
53
|
describe("PeopleOrgChartSeatSchema", () => {
|
|
54
|
+
const filledSeat = {
|
|
55
|
+
id: POSITION_ID,
|
|
56
|
+
unitId: UNIT_ID,
|
|
57
|
+
title: "Analyst",
|
|
58
|
+
status: "filled",
|
|
59
|
+
hiring: false,
|
|
60
|
+
occupants: [
|
|
61
|
+
{
|
|
62
|
+
personId: PERSON_ID,
|
|
63
|
+
userId: null,
|
|
64
|
+
fullName: "Ada Lovelace",
|
|
65
|
+
avatarUrl: null,
|
|
66
|
+
primaryUnitId: null,
|
|
67
|
+
ownedUnits: [],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
70
|
+
integrity: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
54
73
|
it("admits a filled seat whose only occupant is accountless", () => {
|
|
55
|
-
const seat = PeopleOrgChartSeatSchema.parse(
|
|
56
|
-
id: POSITION_ID,
|
|
57
|
-
unitId: UNIT_ID,
|
|
58
|
-
title: "Analyst",
|
|
59
|
-
status: "filled",
|
|
60
|
-
hiring: false,
|
|
61
|
-
occupants: [
|
|
62
|
-
{
|
|
63
|
-
personId: PERSON_ID,
|
|
64
|
-
userId: null,
|
|
65
|
-
fullName: "Ada Lovelace",
|
|
66
|
-
avatarUrl: null,
|
|
67
|
-
primaryUnitId: null,
|
|
68
|
-
ownedUnits: [],
|
|
69
|
-
},
|
|
70
|
-
],
|
|
71
|
-
});
|
|
74
|
+
const seat = PeopleOrgChartSeatSchema.parse(filledSeat);
|
|
72
75
|
expect(seat.occupants).toHaveLength(1);
|
|
73
76
|
expect(seat.occupants[0]?.userId).toBeNull();
|
|
77
|
+
expect(seat.integrity).toBeNull();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// The marker is what separates a breached seat from a vacancy: both carry
|
|
81
|
+
// `occupants: []`, so a consumer reading only the occupant list would offer
|
|
82
|
+
// to fill a seat whose holder the server deliberately withheld.
|
|
83
|
+
it("admits a seat whose withheld holder is flagged as a placement conflict", () => {
|
|
84
|
+
const seat = PeopleOrgChartSeatSchema.parse({
|
|
85
|
+
...filledSeat,
|
|
86
|
+
occupants: [],
|
|
87
|
+
integrity: "canonical_placement_conflict",
|
|
88
|
+
});
|
|
89
|
+
expect(seat.integrity).toBe("canonical_placement_conflict");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// .nullable() WITHOUT .optional(), for the same reason as occupant.userId:
|
|
93
|
+
// the server always emits the key, so "no marker" must be spelled null rather
|
|
94
|
+
// than left to an absent field a consumer would read as healthy by default.
|
|
95
|
+
it("rejects an absent integrity — nullable is not optional", () => {
|
|
96
|
+
const { integrity: _omitted, ...withoutIntegrity } = filledSeat;
|
|
97
|
+
expect(PeopleOrgChartSeatSchema.safeParse(withoutIntegrity).success).toBe(
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("rejects an unknown integrity marker", () => {
|
|
103
|
+
expect(
|
|
104
|
+
PeopleOrgChartSeatSchema.safeParse({
|
|
105
|
+
...filledSeat,
|
|
106
|
+
integrity: "something_else",
|
|
107
|
+
}).success,
|
|
108
|
+
).toBe(false);
|
|
74
109
|
});
|
|
75
110
|
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { PositionRefSchema } from "../position-ref.js";
|
|
4
|
+
|
|
5
|
+
const POSITION_ID = "11111111-1111-4111-8111-111111111111";
|
|
6
|
+
const USER_ID = "22222222-2222-4222-8222-222222222222";
|
|
7
|
+
|
|
8
|
+
describe("PositionRefSchema", () => {
|
|
9
|
+
it("accepts a reference carrying a uuid positionId", () => {
|
|
10
|
+
expect(PositionRefSchema.parse({ positionId: POSITION_ID })).toEqual({
|
|
11
|
+
positionId: POSITION_ID,
|
|
12
|
+
});
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("rejects a non-uuid positionId", () => {
|
|
16
|
+
expect(PositionRefSchema.safeParse({ positionId: "seat-1" }).success).toBe(
|
|
17
|
+
false,
|
|
18
|
+
);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("rejects an absent positionId", () => {
|
|
22
|
+
expect(PositionRefSchema.safeParse({}).success).toBe(false);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// The retired OrgChartActorRef union spelled a filled seat as
|
|
26
|
+
// `{ kind: 'member', userId }`. Nothing in the write vocabulary speaks that
|
|
27
|
+
// shape any more: a user id is not a way to name a chart actor, because most
|
|
28
|
+
// people in an org graph have no account at all.
|
|
29
|
+
it("does not admit the retired member arm — a userId is not a position", () => {
|
|
30
|
+
const result = PositionRefSchema.safeParse({
|
|
31
|
+
kind: "member",
|
|
32
|
+
userId: USER_ID,
|
|
33
|
+
});
|
|
34
|
+
expect(result.success).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Not strict(): unknown keys are stripped rather than rejected, matching the
|
|
38
|
+
// rest of the identity vocabulary.
|
|
39
|
+
it("strips unknown keys rather than rejecting them", () => {
|
|
40
|
+
expect(
|
|
41
|
+
PositionRefSchema.parse({ positionId: POSITION_ID, kind: "open-role" }),
|
|
42
|
+
).toEqual({ positionId: POSITION_ID });
|
|
43
|
+
});
|
|
44
|
+
});
|
package/src/identity/index.ts
CHANGED
|
@@ -64,7 +64,6 @@ export type {
|
|
|
64
64
|
// People Org Chart (GET /api/users/org-chart)
|
|
65
65
|
export {
|
|
66
66
|
ReportingRelationshipTypeSchema,
|
|
67
|
-
OrgChartActorRefSchema,
|
|
68
67
|
PeopleOrgChartOwnedUnitSchema,
|
|
69
68
|
PeopleOrgChartSeatStatusSchema,
|
|
70
69
|
PeopleOrgChartSeatOccupantSchema,
|
|
@@ -74,7 +73,6 @@ export {
|
|
|
74
73
|
} from "./people-org-chart";
|
|
75
74
|
export type {
|
|
76
75
|
ReportingRelationshipType,
|
|
77
|
-
OrgChartActorRef,
|
|
78
76
|
PeopleOrgChartOwnedUnit,
|
|
79
77
|
PeopleOrgChartSeatStatus,
|
|
80
78
|
PeopleOrgChartSeatOccupant,
|
|
@@ -83,6 +81,12 @@ export type {
|
|
|
83
81
|
PeopleOrgChartResponse,
|
|
84
82
|
} from "./people-org-chart";
|
|
85
83
|
|
|
84
|
+
// Position Ref — the single org-graph WRITE reference (ADR-CONTRACTS-131).
|
|
85
|
+
// Not chart vocabulary: three write schemas outside the chart speak it, which
|
|
86
|
+
// is why it is named for the Position and exported from its own module.
|
|
87
|
+
export { PositionRefSchema } from "./position-ref";
|
|
88
|
+
export type { PositionRef } from "./position-ref";
|
|
89
|
+
|
|
86
90
|
// Person — the org-graph node for a human (0..1 User link), per ADR-CTRL-182
|
|
87
91
|
export { PersonSchema } from "./person";
|
|
88
92
|
export type { Person } from "./person";
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
* The chart is position-centric (ADR-CONT-093/094): every actor is a `seat`
|
|
8
8
|
* (a `positions` row) carrying its seat-scoped attributes plus an `occupants[]`
|
|
9
9
|
* array; reporting is expressed seat→seat in `seatEdges[]`. A VACANT seat (no
|
|
10
|
-
* occupants) is the old "open role"; an OCCUPIED
|
|
10
|
+
* occupants AND a null `integrity` marker) is the old "open role"; an OCCUPIED
|
|
11
|
+
* seat is the old "member". A seat whose `integrity` marker is set is neither:
|
|
12
|
+
* its holder exists but was withheld, so it must never be offered as an open
|
|
13
|
+
* role.
|
|
11
14
|
*
|
|
12
15
|
* Person-level reporting is intentionally distinct from
|
|
13
16
|
* `org_unit_relationships.type = 'reports_to'` (unit-to-unit graph overlay,
|
|
@@ -25,14 +28,6 @@ export type ReportingRelationshipType = z.infer<
|
|
|
25
28
|
typeof ReportingRelationshipTypeSchema
|
|
26
29
|
>;
|
|
27
30
|
|
|
28
|
-
// Actor reference (member | open-role). Retained WRITE-ONLY: the set-manager
|
|
29
|
-
// request/response (`org/schemas`) still speaks this vocabulary; the read model
|
|
30
|
-
// is now seat→seat (ADR-CONT-094) and no longer references it. Defined in its
|
|
31
|
-
// own zero-dependency module to avoid a module-init cycle with `org/schemas`,
|
|
32
|
-
// and re-exported here so the existing barrel import site keeps working.
|
|
33
|
-
export { OrgChartActorRefSchema } from "./org-chart-actor";
|
|
34
|
-
export type { OrgChartActorRef } from "./org-chart-actor";
|
|
35
|
-
|
|
36
31
|
// ---------------------------------------------------------------------------
|
|
37
32
|
// Owned unit — shared by the seat occupant (below)
|
|
38
33
|
// ---------------------------------------------------------------------------
|
|
@@ -67,13 +62,14 @@ export type PeopleOrgChartOwnedUnit = z.infer<
|
|
|
67
62
|
// its SEAT-SCOPED attributes (unit placement, title, seat-existence status,
|
|
68
63
|
// hiring/staffing sub-state) plus an `occupants[]` array of PERSON-SCOPED
|
|
69
64
|
// attributes for whoever currently holds it. The split is structural, not
|
|
70
|
-
// member-vs-open-role: a VACANT seat (`occupants: []`
|
|
65
|
+
// member-vs-open-role: a VACANT seat (`occupants: []` with `integrity: null`)
|
|
66
|
+
// is the old "open role";
|
|
71
67
|
// an OCCUPIED seat (`occupants.length >= 1`) is the old "member" (N occupants
|
|
72
68
|
// for a co-occupied seat — forward-compatible; live data is 0/1). Reporting is
|
|
73
69
|
// expressed seat→seat in `seatEdges[]`, NOT on the seat, so the edge list stays
|
|
74
|
-
// the single source of truth for placement (ADR-CTRL-159).
|
|
75
|
-
//
|
|
76
|
-
//
|
|
70
|
+
// the single source of truth for placement (ADR-CTRL-159). The write model
|
|
71
|
+
// names the same actors with a `PositionRef` (`identity/position-ref`), so read
|
|
72
|
+
// and write now agree that a chart actor IS a position.
|
|
77
73
|
//
|
|
78
74
|
// `seats[]` + `seatEdges[]` are now the ONLY shape — the legacy
|
|
79
75
|
// `nodes`/`edges`/`openRoles` half was removed in ADR-CONT-094 (breaking).
|
|
@@ -144,7 +140,22 @@ export const PeopleOrgChartSeatSchema = z.object({
|
|
|
144
140
|
// The seat's current holders. [] ⇒ vacant (the old "open role"); >= 1 ⇒
|
|
145
141
|
// occupied (the old "member"). Every canonical occupant appears, account or
|
|
146
142
|
// not — occupancy, not account linkage, is what admits a person here.
|
|
143
|
+
//
|
|
144
|
+
// READ WITH `integrity` BELOW: `occupants: []` means vacant ONLY when
|
|
145
|
+
// `integrity` is null.
|
|
147
146
|
occupants: z.array(PeopleOrgChartSeatOccupantSchema),
|
|
147
|
+
// Structural integrity of the OCCUPANT LIST above. null ⇒ the list is
|
|
148
|
+
// complete and `occupants: []` is a true vacancy.
|
|
149
|
+
//
|
|
150
|
+
// `canonical_placement_conflict` ⇒ at least one person holding this seat was
|
|
151
|
+
// WITHHELD from `occupants` because they hold more than one live canonical
|
|
152
|
+
// placement (ADR-BE-559). The projector refuses to bless one of the
|
|
153
|
+
// conflicting placements, so the seat renders with its holder unnamed — and
|
|
154
|
+
// an EMPTY `occupants` on such a seat is NOT a vacancy. A consumer must not
|
|
155
|
+
// offer to fill, staff, or reorg a flagged seat as an open role: the seat has
|
|
156
|
+
// a holder the server declines to name, and the breach is recorded
|
|
157
|
+
// server-side as `org_chart.canonical_placement_conflict`.
|
|
158
|
+
integrity: z.enum(["canonical_placement_conflict"]).nullable(),
|
|
148
159
|
});
|
|
149
160
|
|
|
150
161
|
export type PeopleOrgChartSeat = z.infer<typeof PeopleOrgChartSeatSchema>;
|
|
@@ -152,8 +163,8 @@ export type PeopleOrgChartSeat = z.infer<typeof PeopleOrgChartSeatSchema>;
|
|
|
152
163
|
// A seat→seat reporting edge — one row per `position_reporting` row (NOT a
|
|
153
164
|
// member cross-product). Endpoints are positionIds; resolve each to its seat in
|
|
154
165
|
// `seats[]`. Mirrors the legacy person-keyed `PeopleOrgChartEdge`, but keyed by
|
|
155
|
-
// the stable seat identity so a vacant seat's edge is
|
|
156
|
-
// `
|
|
166
|
+
// the stable seat identity so a vacant or accountless seat's edge is
|
|
167
|
+
// expressible at all — the same identity the write model spells `PositionRef`.
|
|
157
168
|
export const PeopleOrgChartSeatEdgeSchema = z.object({
|
|
158
169
|
reportPositionId: z.string().uuid(),
|
|
159
170
|
managerPositionId: z.string().uuid(),
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A reference to a POSITION in the org graph.
|
|
5
|
+
*
|
|
6
|
+
* Replaces the two-arm OrgChartActorRef union, which was already degenerate:
|
|
7
|
+
* `openRoleId` WAS a positions.id, and `{ kind:'member', userId }` was only a
|
|
8
|
+
* slow way to name a seat -- every consumer immediately resolved it through
|
|
9
|
+
* `resolveSeat`. Once occupants are person-keyed the `member` arm has no
|
|
10
|
+
* definition at all, because most people in an org graph have no account.
|
|
11
|
+
*
|
|
12
|
+
* Named PositionRef, not OrgChartSeatRef: it is consumed by the member-manager
|
|
13
|
+
* schema, the open-role reports-to schema and the reporting route, so it
|
|
14
|
+
* already spreads well past the chart. The thing referenced is a Position.
|
|
15
|
+
*
|
|
16
|
+
* An OBJECT rather than a bare uuid so `{ manager: PositionRef | null }` keeps
|
|
17
|
+
* `null` meaning "clear", the field name says what the uuid is, and future edge
|
|
18
|
+
* attributes have a home.
|
|
19
|
+
*
|
|
20
|
+
* Zero-dependency (zod only) to avoid the module-init cycle that caused the
|
|
21
|
+
* original actor-ref file to be split out.
|
|
22
|
+
*/
|
|
23
|
+
export const PositionRefSchema = z.object({ positionId: z.string().uuid() });
|
|
24
|
+
export type PositionRef = z.infer<typeof PositionRefSchema>;
|
package/src/index.ts
CHANGED
|
@@ -175,7 +175,6 @@ export type {
|
|
|
175
175
|
// People Org Chart (GET /api/users/org-chart)
|
|
176
176
|
export {
|
|
177
177
|
ReportingRelationshipTypeSchema,
|
|
178
|
-
OrgChartActorRefSchema,
|
|
179
178
|
PeopleOrgChartOwnedUnitSchema,
|
|
180
179
|
PeopleOrgChartSeatStatusSchema,
|
|
181
180
|
PeopleOrgChartSeatOccupantSchema,
|
|
@@ -185,7 +184,6 @@ export {
|
|
|
185
184
|
} from "./identity/index";
|
|
186
185
|
export type {
|
|
187
186
|
ReportingRelationshipType,
|
|
188
|
-
OrgChartActorRef,
|
|
189
187
|
PeopleOrgChartOwnedUnit,
|
|
190
188
|
PeopleOrgChartSeatStatus,
|
|
191
189
|
PeopleOrgChartSeatOccupant,
|
|
@@ -194,6 +192,10 @@ export type {
|
|
|
194
192
|
PeopleOrgChartResponse,
|
|
195
193
|
} from "./identity/index";
|
|
196
194
|
|
|
195
|
+
// Position Ref — the single org-graph WRITE reference (ADR-CONTRACTS-131)
|
|
196
|
+
export { PositionRefSchema } from "./identity/index";
|
|
197
|
+
export type { PositionRef } from "./identity/index";
|
|
198
|
+
|
|
197
199
|
// Person — the org-graph node for a human (0..1 User link), per ADR-CTRL-182
|
|
198
200
|
export { PersonSchema } from "./identity/index";
|
|
199
201
|
export type { Person } from "./identity/index";
|
|
@@ -681,6 +683,23 @@ export type {
|
|
|
681
683
|
OrgStructureInferenceDecisionResult,
|
|
682
684
|
} from "./org/index";
|
|
683
685
|
|
|
686
|
+
// Org reconciliation ledger: the org's STANDING disagreements with the connected
|
|
687
|
+
// HRIS — the open ones only, sync-time (runId set) and app-time (runId null)
|
|
688
|
+
// alike. Complements the run-scoped conflicts view rather than replacing it, and
|
|
689
|
+
// resolution is a LOCAL decision only. (ADR-BE-564)
|
|
690
|
+
export {
|
|
691
|
+
OrgDivergenceSchema,
|
|
692
|
+
OrgReconciliationLedgerSchema,
|
|
693
|
+
OrgDivergenceResolutionSchema,
|
|
694
|
+
ResolveOrgDivergenceResultSchema,
|
|
695
|
+
} from "./org/index";
|
|
696
|
+
export type {
|
|
697
|
+
OrgDivergence,
|
|
698
|
+
OrgReconciliationLedger,
|
|
699
|
+
OrgDivergenceResolution,
|
|
700
|
+
ResolveOrgDivergenceResult,
|
|
701
|
+
} from "./org/index";
|
|
702
|
+
|
|
684
703
|
// Org transformation wire vocabulary: intent -> simulation -> preview. The
|
|
685
704
|
// engine turns a TransformationRequest into scored mutation bundles and returns
|
|
686
705
|
// a TransformationPreview the operator confirms before apply. (ADR-CONTRACTS-072)
|
package/src/org/README.md
CHANGED
|
@@ -178,6 +178,10 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
178
178
|
- `OrgBudgetConfigSchema`
|
|
179
179
|
- `OrgDeletionStatus` _(type)_
|
|
180
180
|
- `OrgDeletionStatusSchema`
|
|
181
|
+
- `OrgDivergence` _(type)_
|
|
182
|
+
- `OrgDivergenceResolution` _(type)_
|
|
183
|
+
- `OrgDivergenceResolutionSchema` — What resolving a divergence means today.
|
|
184
|
+
- `OrgDivergenceSchema`
|
|
181
185
|
- `OrgDomain` _(type)_ — Organization domain claim and verification status.
|
|
182
186
|
- `OrgIntent` _(type)_
|
|
183
187
|
- `OrgIntentSchema` — A requested org-graph change expressed as intent.
|
|
@@ -191,6 +195,8 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
191
195
|
- `OrgLevelIconSchema`
|
|
192
196
|
- `OrgOwnerIcon` _(type)_
|
|
193
197
|
- `OrgOwnerIconSchema` — Icon options for the `owner` org-chart role.
|
|
198
|
+
- `OrgReconciliationLedger` _(type)_
|
|
199
|
+
- `OrgReconciliationLedgerSchema`
|
|
194
200
|
- `OrgScopedContext` _(type)_ — Validated organization-scoped context for policy implementations.
|
|
195
201
|
- `OrgStructureInferenceDecisionResult` _(type)_
|
|
196
202
|
- `OrgStructureInferenceDecisionResultSchema`
|
|
@@ -282,6 +288,8 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
282
288
|
- `RemoveMemberRequest` _(type)_ — Request payload for removing a member from the workspace.
|
|
283
289
|
- `RemoveMemberResponse` _(type)_
|
|
284
290
|
- `RemoveMemberResponseSchema`
|
|
291
|
+
- `ResolveOrgDivergenceResult` _(type)_
|
|
292
|
+
- `ResolveOrgDivergenceResultSchema`
|
|
285
293
|
- `RoleCatalogEntry` _(type)_ — Entry in the RBAC roles catalog (GET /api/rbac/roles).
|
|
286
294
|
- `RoleCatalogEntrySchema`
|
|
287
295
|
- `RoleCatalogResponseSchema`
|
|
@@ -293,8 +301,8 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
293
301
|
- `SelectionRationaleSchema`
|
|
294
302
|
- `SetActiveOrgResponse` _(type)_
|
|
295
303
|
- `SetActiveOrgResponseSchema`
|
|
296
|
-
- `
|
|
297
|
-
- `
|
|
304
|
+
- `SetSeatManagerResponse` _(type)_
|
|
305
|
+
- `SetSeatManagerResponseSchema` — Response for the one idempotent set-or-clear of a seat's solid-line manager — the reporting edge the org…
|
|
298
306
|
- `SharePolicy` _(type)_ — Document sharing policy. - restricted: Only explicit ACL + owning unit + org admins - orgread: All org…
|
|
299
307
|
- `ShareState` _(type)_ — Complete sharing state for a document.
|
|
300
308
|
- `SourceAuthority` _(type)_
|
|
@@ -129,7 +129,7 @@ describe("OpenRoleSchema", () => {
|
|
|
129
129
|
targetStartDate: "2026-09-01",
|
|
130
130
|
status: "open",
|
|
131
131
|
filledByUserId: null,
|
|
132
|
-
|
|
132
|
+
reportsToPositionId: null,
|
|
133
133
|
createdAt: "2026-04-17T00:00:00Z",
|
|
134
134
|
updatedAt: "2026-04-17T00:00:00Z",
|
|
135
135
|
...overrides,
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SetSeatManagerResponseSchema,
|
|
4
|
+
WorkspaceMemberDetailSchema,
|
|
5
|
+
WorkspaceMembersResponseSchema,
|
|
6
|
+
OpenRoleSchema,
|
|
7
|
+
} from "../schemas.js";
|
|
8
|
+
|
|
9
|
+
const REPORT_POSITION_ID = "11111111-1111-4111-8111-111111111111";
|
|
10
|
+
const MANAGER_POSITION_ID = "22222222-2222-4222-8222-222222222222";
|
|
11
|
+
const USER_ID = "33333333-3333-4333-8333-333333333333";
|
|
12
|
+
const UNIT_ID = "44444444-4444-4444-8444-444444444444";
|
|
13
|
+
const ORG_ID = "55555555-5555-4555-8555-555555555555";
|
|
14
|
+
const ROLE_ID = "66666666-6666-4666-8666-666666666666";
|
|
15
|
+
|
|
16
|
+
const makeResponse = (overrides: Record<string, unknown> = {}) => ({
|
|
17
|
+
success: true,
|
|
18
|
+
reportPositionId: REPORT_POSITION_ID,
|
|
19
|
+
manager: { positionId: MANAGER_POSITION_ID },
|
|
20
|
+
relationshipType: "solid",
|
|
21
|
+
message: "Reporting line updated.",
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("SetSeatManagerResponseSchema", () => {
|
|
26
|
+
it("accepts a position-keyed set and a position-keyed clear", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
SetSeatManagerResponseSchema.parse(makeResponse()),
|
|
29
|
+
).not.toThrow();
|
|
30
|
+
expect(() =>
|
|
31
|
+
SetSeatManagerResponseSchema.parse(
|
|
32
|
+
makeResponse({ manager: null, message: "Reporting line cleared." }),
|
|
33
|
+
),
|
|
34
|
+
).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("requires reportPositionId to be a uuid, not an opaque member id", () => {
|
|
38
|
+
expect(
|
|
39
|
+
SetSeatManagerResponseSchema.safeParse(
|
|
40
|
+
makeResponse({ reportPositionId: "member-1" }),
|
|
41
|
+
).success,
|
|
42
|
+
).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The whole point of the literal: this route rides
|
|
46
|
+
// setSolidManagerByPositions, which only ever writes a solid edge. A wider
|
|
47
|
+
// relationship vocabulary here would promise a dotted-line write the route
|
|
48
|
+
// cannot perform.
|
|
49
|
+
it("rejects any relationshipType other than the solid literal", () => {
|
|
50
|
+
expect(
|
|
51
|
+
SetSeatManagerResponseSchema.safeParse(
|
|
52
|
+
makeResponse({ relationshipType: "dotted" }),
|
|
53
|
+
).success,
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The retired shape named the subordinate by user (`memberId`) and the
|
|
58
|
+
// manager by a member-or-open-role union. Neither survives: a stray memberId
|
|
59
|
+
// is stripped, and it cannot stand in for the report seat.
|
|
60
|
+
it("does not admit the retired memberId key as the report reference", () => {
|
|
61
|
+
expect(
|
|
62
|
+
SetSeatManagerResponseSchema.parse(makeResponse({ memberId: USER_ID })),
|
|
63
|
+
).toEqual(makeResponse());
|
|
64
|
+
const { reportPositionId: _dropped, ...withoutReport } = makeResponse();
|
|
65
|
+
expect(
|
|
66
|
+
SetSeatManagerResponseSchema.safeParse({
|
|
67
|
+
...withoutReport,
|
|
68
|
+
memberId: USER_ID,
|
|
69
|
+
}).success,
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects a manager named by userId rather than positionId", () => {
|
|
74
|
+
expect(
|
|
75
|
+
SetSeatManagerResponseSchema.safeParse(
|
|
76
|
+
makeResponse({ manager: { kind: "member", userId: USER_ID } }),
|
|
77
|
+
).success,
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const makeMember = (overrides: Record<string, unknown> = {}) => ({
|
|
83
|
+
id: USER_ID,
|
|
84
|
+
name: "Ada Lovelace",
|
|
85
|
+
email: "ada@example.com",
|
|
86
|
+
jobTitle: "Principal Engineer",
|
|
87
|
+
seatPositionId: REPORT_POSITION_ID,
|
|
88
|
+
role: null,
|
|
89
|
+
roleNames: [],
|
|
90
|
+
joinedAt: "2026-04-17T00:00:00Z",
|
|
91
|
+
lastActiveAt: null,
|
|
92
|
+
primaryUnitId: UNIT_ID,
|
|
93
|
+
unitMemberships: [],
|
|
94
|
+
unitDesignations: [],
|
|
95
|
+
unitMembershipsTruncated: false,
|
|
96
|
+
inviteStatus: "active",
|
|
97
|
+
...overrides,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("WorkspaceMember seatPositionId", () => {
|
|
101
|
+
it("carries the seat on the list shape, so a READ resolves it", () => {
|
|
102
|
+
expect(() =>
|
|
103
|
+
WorkspaceMembersResponseSchema.parse({
|
|
104
|
+
items: [makeMember()],
|
|
105
|
+
nextCursor: null,
|
|
106
|
+
hasMore: false,
|
|
107
|
+
}),
|
|
108
|
+
).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("is inherited by the detail shape", () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
WorkspaceMemberDetailSchema.parse({
|
|
114
|
+
...makeMember(),
|
|
115
|
+
effectiveScopes: [],
|
|
116
|
+
recentActions: [],
|
|
117
|
+
}),
|
|
118
|
+
).not.toThrow();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Null is a real state — a member with no materialized seat. The read reports
|
|
122
|
+
// it honestly rather than forcing the server to mint one.
|
|
123
|
+
it("accepts null for a member with no materialized seat", () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
WorkspaceMemberDetailSchema.parse({
|
|
126
|
+
...makeMember({ seatPositionId: null }),
|
|
127
|
+
effectiveScopes: [],
|
|
128
|
+
recentActions: [],
|
|
129
|
+
}),
|
|
130
|
+
).not.toThrow();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("is required — a member shape without it no longer parses", () => {
|
|
134
|
+
const { seatPositionId: _dropped, ...withoutSeat } = makeMember();
|
|
135
|
+
expect(
|
|
136
|
+
WorkspaceMemberDetailSchema.safeParse({
|
|
137
|
+
...withoutSeat,
|
|
138
|
+
effectiveScopes: [],
|
|
139
|
+
recentActions: [],
|
|
140
|
+
}).success,
|
|
141
|
+
).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("OpenRole reportsToPositionId", () => {
|
|
146
|
+
const makeOpenRole = (overrides: Record<string, unknown> = {}) => ({
|
|
147
|
+
id: ROLE_ID,
|
|
148
|
+
orgId: ORG_ID,
|
|
149
|
+
unitId: UNIT_ID,
|
|
150
|
+
title: "Senior Engineer",
|
|
151
|
+
targetStartDate: null,
|
|
152
|
+
status: "open",
|
|
153
|
+
filledByUserId: null,
|
|
154
|
+
reportsToPositionId: MANAGER_POSITION_ID,
|
|
155
|
+
createdAt: "2026-04-17T00:00:00Z",
|
|
156
|
+
updatedAt: "2026-04-17T00:00:00Z",
|
|
157
|
+
...overrides,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("names the manager by position, and null still means unmanaged", () => {
|
|
161
|
+
expect(() => OpenRoleSchema.parse(makeOpenRole())).not.toThrow();
|
|
162
|
+
expect(() =>
|
|
163
|
+
OpenRoleSchema.parse(makeOpenRole({ reportsToPositionId: null })),
|
|
164
|
+
).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// The retired reportsToUserId flattening read null whenever the manager had
|
|
168
|
+
// no account — which, post-HRIS, is nearly every manager.
|
|
169
|
+
it("no longer accepts the retired reportsToUserId flattening", () => {
|
|
170
|
+
const { reportsToPositionId: _dropped, ...withoutPosition } =
|
|
171
|
+
makeOpenRole();
|
|
172
|
+
expect(
|
|
173
|
+
OpenRoleSchema.safeParse({ ...withoutPosition, reportsToUserId: USER_ID })
|
|
174
|
+
.success,
|
|
175
|
+
).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/org/index.ts
CHANGED
|
@@ -150,6 +150,24 @@ export type {
|
|
|
150
150
|
OrgStructureInferenceDecisionResult,
|
|
151
151
|
} from "./reasoning-review";
|
|
152
152
|
|
|
153
|
+
// Org reconciliation ledger: the org's STANDING disagreements with the connected
|
|
154
|
+
// HRIS — the open ones only, whether they arose during a sync (runId set) or
|
|
155
|
+
// during app use (runId null). Complements the run-scoped conflicts view; does
|
|
156
|
+
// not replace it. Resolution is a LOCAL decision (`sentToSource` is the literal
|
|
157
|
+
// false). (ADR-BE-564)
|
|
158
|
+
export {
|
|
159
|
+
OrgDivergenceSchema,
|
|
160
|
+
OrgReconciliationLedgerSchema,
|
|
161
|
+
OrgDivergenceResolutionSchema,
|
|
162
|
+
ResolveOrgDivergenceResultSchema,
|
|
163
|
+
} from "./reconciliation";
|
|
164
|
+
export type {
|
|
165
|
+
OrgDivergence,
|
|
166
|
+
OrgReconciliationLedger,
|
|
167
|
+
OrgDivergenceResolution,
|
|
168
|
+
ResolveOrgDivergenceResult,
|
|
169
|
+
} from "./reconciliation";
|
|
170
|
+
|
|
153
171
|
// Org transformation wire vocabulary: intent -> simulation -> preview. The
|
|
154
172
|
// engine turns a TransformationRequest into scored mutation bundles and returns
|
|
155
173
|
// a TransformationPreview the operator confirms before apply. (ADR-CONTRACTS-072)
|
|
@@ -296,7 +314,7 @@ export {
|
|
|
296
314
|
TestSsoResultSchema,
|
|
297
315
|
RemoveMemberResponseSchema,
|
|
298
316
|
ChangeMemberRoleResponseSchema,
|
|
299
|
-
|
|
317
|
+
SetSeatManagerResponseSchema,
|
|
300
318
|
SubmitInteractiveTaskResponseSchema,
|
|
301
319
|
UserOrgsResponseSchema,
|
|
302
320
|
SetActiveOrgResponseSchema,
|
|
@@ -321,7 +339,7 @@ export type {
|
|
|
321
339
|
TestSsoResult as TestSsoResultDto,
|
|
322
340
|
RemoveMemberResponse,
|
|
323
341
|
ChangeMemberRoleResponse,
|
|
324
|
-
|
|
342
|
+
SetSeatManagerResponse,
|
|
325
343
|
SubmitInteractiveTaskResponse,
|
|
326
344
|
UserOrgsResponse,
|
|
327
345
|
SetActiveOrgResponse,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org reconciliation ledger — what this workspace currently disagrees with the
|
|
3
|
+
* connected HRIS about.
|
|
4
|
+
*
|
|
5
|
+
* A {@link ConflictRecordSchema} row is the audit trail of ONE reconciliation
|
|
6
|
+
* event. The ledger is the standing view over those rows: for each contested
|
|
7
|
+
* `(entityType, entityId, field)` it keeps the workspace's CURRENT word and
|
|
8
|
+
* drops the ones the two sides have since agreed on. It answers "what is still
|
|
9
|
+
* open?", where the run-scoped conflicts view answers "what did THIS run
|
|
10
|
+
* refuse?" — the two coexist and neither replaces the other (ADR-BE-564).
|
|
11
|
+
*
|
|
12
|
+
* `runId` is NULLABLE here, and that is the whole reason this is a separate
|
|
13
|
+
* shape rather than a reuse of `ConflictRecordSchema` (whose `runId` is
|
|
14
|
+
* required). A divergence can arise OUTSIDE a sync run — at invite acceptance,
|
|
15
|
+
* or from a canonical-placement integrity breach — and such a row is deliberately
|
|
16
|
+
* not tied to a run, so it neither disappears when the run is pruned nor
|
|
17
|
+
* multiplies once per sync.
|
|
18
|
+
*
|
|
19
|
+
* Like the sibling org schemas, `entityType` and `field` are OPEN strings (no
|
|
20
|
+
* enum) so new contested kinds need no schema change, and `winningTier` REUSES
|
|
21
|
+
* the closed {@link FactSourceTierSchema} precedence axis rather than redefining
|
|
22
|
+
* it. Both competing values are serialized as strings and are nullable, because
|
|
23
|
+
* either side may hold no value.
|
|
24
|
+
*
|
|
25
|
+
* ## Resolution is LOCAL, always
|
|
26
|
+
*
|
|
27
|
+
* {@link ResolveOrgDivergenceResultSchema} is the seam a later write-back phase
|
|
28
|
+
* plugs into, and it is shaped so that phase cannot arrive by accident:
|
|
29
|
+
* `sentToSource` is the literal `false`, so any surface claiming a divergence
|
|
30
|
+
* was pushed upstream fails to typecheck against this contract. Today the HRIS
|
|
31
|
+
* client is read-only (directory + changed-since over a hardcoded GET) and no
|
|
32
|
+
* write scope is requested at connect time. Resolving a divergence records a
|
|
33
|
+
* decision in THIS workspace; nothing may state or imply the HRIS was updated.
|
|
34
|
+
*
|
|
35
|
+
* Resolving therefore does NOT close a divergence: the two sides still hold
|
|
36
|
+
* different values, so the entry stays in the ledger and carries
|
|
37
|
+
* `acknowledgedAt`. A shape that dropped it on resolution would let a click hide
|
|
38
|
+
* a live disagreement — the failure this ledger exists to prevent. What DOES
|
|
39
|
+
* remove an entry is the two sides agreeing.
|
|
40
|
+
*/
|
|
41
|
+
import { z } from "zod";
|
|
42
|
+
|
|
43
|
+
import { FactSourceTierSchema } from "./structure-facts";
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// OrgDivergence — one open disagreement between the HRIS and this workspace
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export const OrgDivergenceSchema = z.object({
|
|
50
|
+
/** Identifier of the ledger row carrying the workspace's current word. */
|
|
51
|
+
id: z.string().uuid(),
|
|
52
|
+
/**
|
|
53
|
+
* The sync run that observed this disagreement, when one did. `null` marks a
|
|
54
|
+
* STANDING divergence recorded outside any run (invite acceptance, a
|
|
55
|
+
* canonical-placement integrity breach).
|
|
56
|
+
*/
|
|
57
|
+
runId: z.string().uuid().nullable(),
|
|
58
|
+
/**
|
|
59
|
+
* Kind of org-graph entity the contested field belongs to (e.g. "person",
|
|
60
|
+
* "position", "occupancy"). An OPEN string so new entity kinds need no schema
|
|
61
|
+
* change.
|
|
62
|
+
*/
|
|
63
|
+
entityType: z.string().min(1),
|
|
64
|
+
/** Identifier of the specific entity instance in disagreement. */
|
|
65
|
+
entityId: z.string().uuid(),
|
|
66
|
+
/** Name of the single contested field. */
|
|
67
|
+
field: z.string().min(1),
|
|
68
|
+
/**
|
|
69
|
+
* What the HRIS asserts for this field, serialized as a string. `null` when
|
|
70
|
+
* the imported side carries no value.
|
|
71
|
+
*/
|
|
72
|
+
importedValue: z.string().nullable(),
|
|
73
|
+
/**
|
|
74
|
+
* What this workspace currently holds for this field, serialized as a string.
|
|
75
|
+
* `null` when the workspace holds no value.
|
|
76
|
+
*/
|
|
77
|
+
currentValue: z.string().nullable(),
|
|
78
|
+
/**
|
|
79
|
+
* The value in force, serialized as a string. `null` when the resolution left
|
|
80
|
+
* the field empty.
|
|
81
|
+
*/
|
|
82
|
+
winningValue: z.string().nullable(),
|
|
83
|
+
/**
|
|
84
|
+
* The precedence tier of the value in force — the same closed truth-hierarchy
|
|
85
|
+
* axis used across the org model. Reused, not redefined.
|
|
86
|
+
*/
|
|
87
|
+
winningTier: FactSourceTierSchema,
|
|
88
|
+
/**
|
|
89
|
+
* The sentence a human reads: it names BOTH sides and why the winner won.
|
|
90
|
+
* Capped at 2000 chars to mirror `ConflictRecord.reason`. `null` when the row
|
|
91
|
+
* was recorded without a narrative.
|
|
92
|
+
*/
|
|
93
|
+
reason: z.string().max(2000).nullable(),
|
|
94
|
+
/** ISO timestamp the workspace's current word on this field was recorded. */
|
|
95
|
+
recordedAt: z.string().datetime(),
|
|
96
|
+
/**
|
|
97
|
+
* ISO timestamp someone in this workspace ACKNOWLEDGED this entry, or `null`.
|
|
98
|
+
*
|
|
99
|
+
* A triage marker, not a resolution: the two sides still differ and nothing
|
|
100
|
+
* was sent to the HRIS, so the entry is still open and still listed. It is
|
|
101
|
+
* keyed to THIS row, so a disagreement that moves — a superseding row becomes
|
|
102
|
+
* the entry — arrives unacknowledged, which is the right default for a
|
|
103
|
+
* statement nobody has read yet.
|
|
104
|
+
*/
|
|
105
|
+
acknowledgedAt: z.string().datetime().nullable(),
|
|
106
|
+
/** Who acknowledged it. `null` when nobody has, or when that user is gone. */
|
|
107
|
+
acknowledgedByUserId: z.string().uuid().nullable(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export type OrgDivergence = z.infer<typeof OrgDivergenceSchema>;
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// OrgReconciliationLedger — the org's open divergences, newest first
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export const OrgReconciliationLedgerSchema = z.object({
|
|
117
|
+
/**
|
|
118
|
+
* The org's still-open divergences, newest first. One entry per contested
|
|
119
|
+
* `(entityType, entityId, field)`; settled disagreements are omitted.
|
|
120
|
+
*/
|
|
121
|
+
divergences: z.array(OrgDivergenceSchema),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type OrgReconciliationLedger = z.infer<
|
|
125
|
+
typeof OrgReconciliationLedgerSchema
|
|
126
|
+
>;
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Resolution — a LOCAL decision, never an upstream write
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What resolving a divergence means today. `acknowledged` records that someone
|
|
134
|
+
* in this workspace has seen and accepted the disagreement; it does not, and
|
|
135
|
+
* cannot, change anything in the HRIS.
|
|
136
|
+
*/
|
|
137
|
+
export const OrgDivergenceResolutionSchema = z.enum(["acknowledged"]);
|
|
138
|
+
|
|
139
|
+
export type OrgDivergenceResolution = z.infer<
|
|
140
|
+
typeof OrgDivergenceResolutionSchema
|
|
141
|
+
>;
|
|
142
|
+
|
|
143
|
+
export const ResolveOrgDivergenceResultSchema = z.object({
|
|
144
|
+
/** Identifier of the divergence that was resolved. */
|
|
145
|
+
id: z.string().uuid(),
|
|
146
|
+
/** How it was resolved. Local decisions only — see the module docblock. */
|
|
147
|
+
resolution: OrgDivergenceResolutionSchema,
|
|
148
|
+
/** ISO timestamp the decision was recorded in this workspace. */
|
|
149
|
+
resolvedAt: z.string().datetime(),
|
|
150
|
+
/**
|
|
151
|
+
* Whether anything was written back to the source system. Pinned to the
|
|
152
|
+
* literal `false`: the HRIS client is read-only and no write scope is
|
|
153
|
+
* requested, so a surface that claims otherwise cannot typecheck against this
|
|
154
|
+
* contract. A future write-back phase widens this deliberately, in its own
|
|
155
|
+
* decision record — not by accident.
|
|
156
|
+
*/
|
|
157
|
+
sentToSource: z.literal(false),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export type ResolveOrgDivergenceResult = z.infer<
|
|
161
|
+
typeof ResolveOrgDivergenceResultSchema
|
|
162
|
+
>;
|
package/src/org/schemas.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { CursorPageSchema } from "../api/primitives";
|
|
15
15
|
import { OrgChartRoleSchema } from "../permissions/orgchart-roles";
|
|
16
|
-
import {
|
|
16
|
+
import { PositionRefSchema } from "../identity/position-ref";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Sub-schemas
|
|
@@ -90,6 +90,17 @@ const WorkspaceMemberSchema = z.object({
|
|
|
90
90
|
avatarUrl: z.string().nullable().optional(),
|
|
91
91
|
/** The occupant's Position seat title (org-scoped). Null when the seat is untitled. */
|
|
92
92
|
jobTitle: z.string().nullable(),
|
|
93
|
+
/**
|
|
94
|
+
* The member's Position seat in the org graph — the id every reporting write
|
|
95
|
+
* is keyed on (ADR-CONTRACTS-131). Present here so a user-ADDRESSED surface
|
|
96
|
+
* (the members table, a person picker) can resolve a seat by READ and hand
|
|
97
|
+
* the position straight to `PUT /api/reporting/positions/{positionId}/manager`,
|
|
98
|
+
* instead of a write path minting one with `resolveSeat(create: true)`.
|
|
99
|
+
*
|
|
100
|
+
* Nullable: a member with no materialized seat is a legitimate state, and a
|
|
101
|
+
* read must report that honestly rather than force the server to create one.
|
|
102
|
+
*/
|
|
103
|
+
seatPositionId: z.string().uuid().nullable(),
|
|
93
104
|
/**
|
|
94
105
|
* Display role — the member's highest org-chart standing, or `null` for a
|
|
95
106
|
* plain member with no org-chart authority. Render via `orgChartRoleLabel`.
|
|
@@ -524,30 +535,39 @@ export type ChangeMemberRoleResponse = z.infer<
|
|
|
524
535
|
>;
|
|
525
536
|
|
|
526
537
|
// ---------------------------------------------------------------------------
|
|
527
|
-
//
|
|
538
|
+
// PUT /api/reporting/positions/:positionId/manager
|
|
528
539
|
// ---------------------------------------------------------------------------
|
|
529
540
|
|
|
530
541
|
/**
|
|
531
|
-
* Response for
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
* `
|
|
535
|
-
*
|
|
542
|
+
* Response for the one idempotent set-or-clear of a seat's solid-line manager —
|
|
543
|
+
* the reporting edge the org chart is built from. Position-keyed at BOTH ends
|
|
544
|
+
* (ADR-CONTRACTS-131): `reportPositionId` is the seat whose edge moved and
|
|
545
|
+
* `manager` is the POSITION it now reports to, null after a clear. A position
|
|
546
|
+
* names a vacant seat and an accountless occupant's seat just as well as a
|
|
547
|
+
* filled one, which the retired member-or-open-role union could not.
|
|
548
|
+
*
|
|
549
|
+
* `relationshipType` is a literal rather than the identity-domain
|
|
550
|
+
* `ReportingRelationshipTypeSchema`: importing that here would invert the
|
|
551
|
+
* existing people-org-chart → org/schemas dependency and recreate the
|
|
552
|
+
* module-init cycle. This route only ever writes solid edges, so the narrower
|
|
553
|
+
* literal is also the truthful shape. (`PositionReportingRelationshipTypeSchema`
|
|
554
|
+
* in ./position-reporting carries the open {solid, dotted} vocabulary for the
|
|
555
|
+
* read model.)
|
|
536
556
|
*
|
|
537
557
|
* Distinct from team membership (RBAC scopes) and `primary_unit_id` (home
|
|
538
558
|
* unit): neither of those places a person in the reporting hierarchy.
|
|
539
559
|
* See ADR-BE-253.
|
|
540
560
|
*/
|
|
541
|
-
export const
|
|
561
|
+
export const SetSeatManagerResponseSchema = z.object({
|
|
542
562
|
success: z.boolean(),
|
|
543
|
-
|
|
544
|
-
manager:
|
|
545
|
-
relationshipType: z.
|
|
563
|
+
reportPositionId: z.string().uuid(),
|
|
564
|
+
manager: PositionRefSchema.nullable(),
|
|
565
|
+
relationshipType: z.literal("solid"),
|
|
546
566
|
message: z.string(),
|
|
547
567
|
});
|
|
548
568
|
|
|
549
|
-
export type
|
|
550
|
-
typeof
|
|
569
|
+
export type SetSeatManagerResponse = z.infer<
|
|
570
|
+
typeof SetSeatManagerResponseSchema
|
|
551
571
|
>;
|
|
552
572
|
|
|
553
573
|
// ---------------------------------------------------------------------------
|
|
@@ -1134,10 +1154,16 @@ export const OpenRoleSchema = z.object({
|
|
|
1134
1154
|
targetStartDate: z.string().nullable(),
|
|
1135
1155
|
status: OpenRoleStatusSchema,
|
|
1136
1156
|
filledByUserId: z.string().uuid().nullable(),
|
|
1137
|
-
// The open role's own reporting edge (ADR-BE-291): defaults to its creator
|
|
1138
|
-
// editable/clearable. Drives chart placement now that the unit-head
|
|
1139
|
-
// retired (ADR-BE-292). Null → unmanaged (Unassigned bucket).
|
|
1140
|
-
|
|
1157
|
+
// The open role's own reporting edge (ADR-BE-291): defaults to its creator's
|
|
1158
|
+
// seat, editable/clearable. Drives chart placement now that the unit-head
|
|
1159
|
+
// anchor is retired (ADR-BE-292). Null → unmanaged (Unassigned bucket).
|
|
1160
|
+
//
|
|
1161
|
+
// The manager POSITION, not a user (ADR-CONTRACTS-131). The retired
|
|
1162
|
+
// user-keyed flattening was a lie in two directions: an open-role manager
|
|
1163
|
+
// already surfaced as null because it had no user to collapse to, and
|
|
1164
|
+
// post-HRIS every open role reporting to an imported (accountless) manager
|
|
1165
|
+
// would read null too.
|
|
1166
|
+
reportsToPositionId: z.string().uuid().nullable(),
|
|
1141
1167
|
createdAt: z.string(),
|
|
1142
1168
|
updatedAt: z.string(),
|
|
1143
1169
|
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Org-chart actor reference — either endpoint of a reporting edge.
|
|
3
|
-
*
|
|
4
|
-
* A node in the people chart that can sit on EITHER side of a reporting edge: a
|
|
5
|
-
* filled member (keyed by occupant `userId`) OR an unfilled open-role seat
|
|
6
|
-
* (keyed by its own `openRoleId`). A vacant seat is a first-class reporting
|
|
7
|
-
* actor — it can be a manager that real people report to (ADR-BE-312), not just
|
|
8
|
-
* a report. The discriminant lets an edge point at either kind without losing
|
|
9
|
-
* what the id means. Mirrors the app's `ActorIdentity` shape exactly so the app
|
|
10
|
-
* consumes this type directly (one common spine across wire + UI).
|
|
11
|
-
*
|
|
12
|
-
* Lives in its own zero-dependency module (zod only) so both `people-org-chart`
|
|
13
|
-
* (which already depends on `org/schemas` for AuthorityMutability) and
|
|
14
|
-
* `org/schemas` (which needs it for the set-manager request/response) can import
|
|
15
|
-
* it without forming a module-init cycle.
|
|
16
|
-
*/
|
|
17
|
-
import { z } from "zod";
|
|
18
|
-
|
|
19
|
-
export const OrgChartActorRefSchema = z.discriminatedUnion("kind", [
|
|
20
|
-
z.object({ kind: z.literal("member"), userId: z.string().uuid() }),
|
|
21
|
-
z.object({ kind: z.literal("open-role"), openRoleId: z.string().uuid() }),
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
export type OrgChartActorRef = z.infer<typeof OrgChartActorRefSchema>;
|