@company-semantics/contracts 52.1.0 → 53.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "52.1.0",
3
+ "version": "53.0.1",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'd0171aedf623' as const;
3
- export const SPEC_HASH_FULL = 'd0171aedf623604dc721fb42b626ebc019afcc2fda9be59e1f06360cd6a4fcca' as const;
2
+ export const SPEC_HASH = '691f7bd81d0c' as const;
3
+ export const SPEC_HASH_FULL = '691f7bd81d0c34aebd47c457a170dd48443a3f9eb007312fa97af00d9a4ccf30' as const;
@@ -738,7 +738,7 @@ export interface paths {
738
738
  patch: operations["changeMemberRole"];
739
739
  trace?: never;
740
740
  };
741
- "/api/workspace/members/{id}/manager": {
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
- put?: never;
749
+ /** Set or clear a seat's solid-line manager (org-chart reporting edge) */
750
+ put: operations["setSeatManager"];
750
751
  post?: never;
751
- /** Clear a member's solid-line manager (org-chart reporting edge) */
752
- delete: operations["clearMemberManager"];
752
+ delete?: never;
753
753
  options?: never;
754
754
  head?: never;
755
- /** Set a member's solid-line manager (org-chart reporting edge) */
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;
@@ -4091,6 +4130,7 @@ export interface components {
4091
4130
  email: string;
4092
4131
  avatarUrl?: string | null;
4093
4132
  jobTitle: string | null;
4133
+ seatPositionId: string | null;
4094
4134
  role: ("owner" | "unit_owner" | "delegate" | "admin") | null;
4095
4135
  roleNames: string[];
4096
4136
  joinedAt: string;
@@ -4288,6 +4328,7 @@ export interface components {
4288
4328
  email: string;
4289
4329
  avatarUrl?: string | null;
4290
4330
  jobTitle: string | null;
4331
+ seatPositionId: string | null;
4291
4332
  role: ("owner" | "unit_owner" | "delegate" | "admin") | null;
4292
4333
  roleNames: string[];
4293
4334
  joinedAt: string;
@@ -4340,35 +4381,23 @@ export interface components {
4340
4381
  /** @enum {string} */
4341
4382
  newRole: "admin" | "member";
4342
4383
  };
4343
- SetMemberManagerResponse: {
4384
+ SetSeatManagerResponse: {
4344
4385
  success: boolean;
4345
- memberId: string;
4346
- manager: ({
4347
- /** @constant */
4348
- kind: "member";
4349
- /** Format: uuid */
4350
- userId: string;
4351
- } | {
4352
- /** @constant */
4353
- kind: "open-role";
4386
+ /** Format: uuid */
4387
+ reportPositionId: string;
4388
+ manager: {
4354
4389
  /** Format: uuid */
4355
- openRoleId: string;
4356
- }) | null;
4357
- relationshipType: string;
4390
+ positionId: string;
4391
+ } | null;
4392
+ /** @constant */
4393
+ relationshipType: "solid";
4358
4394
  message: string;
4359
4395
  };
4360
- SetMemberManagerRequest: {
4396
+ SetSeatManagerRequest: {
4361
4397
  manager: {
4362
- /** @constant */
4363
- kind: "member";
4364
- /** Format: uuid */
4365
- userId: string;
4366
- } | {
4367
- /** @constant */
4368
- kind: "open-role";
4369
4398
  /** Format: uuid */
4370
- openRoleId: string;
4371
- };
4399
+ positionId: string;
4400
+ } | null;
4372
4401
  };
4373
4402
  MovePreviewRequest: {
4374
4403
  /** @constant */
@@ -4934,6 +4963,10 @@ export interface components {
4934
4963
  /** @enum {string} */
4935
4964
  decision: "accept" | "reject";
4936
4965
  };
4966
+ ResolveOrgDivergenceRequest: {
4967
+ /** @constant */
4968
+ resolution: "acknowledged";
4969
+ };
4937
4970
  CompanyMdListResponse: {
4938
4971
  items: {
4939
4972
  id: string;
@@ -5673,7 +5706,7 @@ export interface components {
5673
5706
  /** @enum {string} */
5674
5707
  status: "open" | "hiring" | "filled" | "closed";
5675
5708
  filledByUserId: string | null;
5676
- reportsToUserId: string | null;
5709
+ reportsToPositionId: string | null;
5677
5710
  createdAt: string;
5678
5711
  updatedAt: string;
5679
5712
  }[];
@@ -5691,7 +5724,7 @@ export interface components {
5691
5724
  /** @enum {string} */
5692
5725
  status: "open" | "hiring" | "filled" | "closed";
5693
5726
  filledByUserId: string | null;
5694
- reportsToUserId: string | null;
5727
+ reportsToPositionId: string | null;
5695
5728
  createdAt: string;
5696
5729
  updatedAt: string;
5697
5730
  };
@@ -5708,7 +5741,8 @@ export interface components {
5708
5741
  hiring: boolean;
5709
5742
  occupants: {
5710
5743
  /** Format: uuid */
5711
- userId: string;
5744
+ personId: string;
5745
+ userId: string | null;
5712
5746
  fullName: string;
5713
5747
  avatarUrl: string | null;
5714
5748
  primaryUnitId: string | null;
@@ -5720,6 +5754,7 @@ export interface components {
5720
5754
  mutability: "derived" | "user_managed";
5721
5755
  }[];
5722
5756
  }[];
5757
+ integrity: "canonical_placement_conflict" | null;
5723
5758
  }[];
5724
5759
  seatEdges: {
5725
5760
  /** Format: uuid */
@@ -7926,50 +7961,28 @@ export interface operations {
7926
7961
  };
7927
7962
  };
7928
7963
  };
7929
- clearMemberManager: {
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: {
7964
+ setSeatManager: {
7952
7965
  parameters: {
7953
7966
  query?: never;
7954
7967
  header?: never;
7955
7968
  path: {
7956
- id: string;
7969
+ positionId: string;
7957
7970
  };
7958
7971
  cookie?: never;
7959
7972
  };
7960
7973
  requestBody: {
7961
7974
  content: {
7962
- "application/json": components["schemas"]["SetMemberManagerRequest"];
7975
+ "application/json": components["schemas"]["SetSeatManagerRequest"];
7963
7976
  };
7964
7977
  };
7965
7978
  responses: {
7966
- /** @description Manager set successfully */
7979
+ /** @description The seat's solid-line manager edge was set or cleared */
7967
7980
  200: {
7968
7981
  headers: {
7969
7982
  [name: string]: unknown;
7970
7983
  };
7971
7984
  content: {
7972
- "application/json": components["schemas"]["SetMemberManagerResponse"];
7985
+ "application/json": components["schemas"]["SetSeatManagerResponse"];
7973
7986
  };
7974
7987
  };
7975
7988
  };
@@ -8898,6 +8911,55 @@ export interface operations {
8898
8911
  };
8899
8912
  };
8900
8913
  };
8914
+ getOrgReconciliation: {
8915
+ parameters: {
8916
+ query?: never;
8917
+ header?: never;
8918
+ path?: never;
8919
+ cookie?: never;
8920
+ };
8921
+ requestBody?: never;
8922
+ responses: {
8923
+ /** @description The org’s open divergences, newest first — each carrying both sides, the winning tier and the human-readable reason (contracts OrgReconciliationLedger) */
8924
+ 200: {
8925
+ headers: {
8926
+ [name: string]: unknown;
8927
+ };
8928
+ content?: never;
8929
+ };
8930
+ };
8931
+ };
8932
+ resolveOrgDivergence: {
8933
+ parameters: {
8934
+ query?: never;
8935
+ header?: never;
8936
+ path: {
8937
+ divergenceId: string;
8938
+ };
8939
+ cookie?: never;
8940
+ };
8941
+ requestBody: {
8942
+ content: {
8943
+ "application/json": components["schemas"]["ResolveOrgDivergenceRequest"];
8944
+ };
8945
+ };
8946
+ responses: {
8947
+ /** @description The acknowledgement, with sentToSource pinned false (contracts ResolveOrgDivergenceResult) */
8948
+ 200: {
8949
+ headers: {
8950
+ [name: string]: unknown;
8951
+ };
8952
+ content?: never;
8953
+ };
8954
+ /** @description No open divergence with that id — it belongs to another workspace, a later statement superseded it, or it has since been settled */
8955
+ 404: {
8956
+ headers: {
8957
+ [name: string]: unknown;
8958
+ };
8959
+ content?: never;
8960
+ };
8961
+ };
8962
+ };
8901
8963
  getCompanyMdTree: {
8902
8964
  parameters: {
8903
8965
  query?: {
@@ -10848,17 +10910,10 @@ export interface operations {
10848
10910
  requestBody: {
10849
10911
  content: {
10850
10912
  "application/json": {
10851
- manager: ({
10852
- /** @constant */
10853
- kind: "member";
10854
- /** Format: uuid */
10855
- userId: string;
10856
- } | {
10857
- /** @constant */
10858
- kind: "open-role";
10913
+ manager: {
10859
10914
  /** Format: uuid */
10860
- openRoleId: string;
10861
- }) | null;
10915
+ positionId: string;
10916
+ } | null;
10862
10917
  };
10863
10918
  };
10864
10919
  };
@@ -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'],
@@ -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
+ });
@@ -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 seat is the old "member".
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: []`) is the old "open role";
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). `OrgChartActorRef`
75
- // is retained write-only (set-manager request/response) and is absent from this
76
- // read model.
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 expressible without an
156
- // `OrgChartActorRef`.
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
- - `SetMemberManagerResponse` _(type)_
297
- - `SetMemberManagerResponseSchema` — Response for setting (PATCH) or clearing (DELETE) a member's solid-line manager — the reporting edge that the
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
- reportsToUserId: null,
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
- SetMemberManagerResponseSchema,
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
- SetMemberManagerResponse,
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
+ >;
@@ -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 { OrgChartActorRefSchema } from "../identity/org-chart-actor";
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
- // PATCH/DELETE /api/workspace/members/:id/manager
538
+ // PUT /api/reporting/positions/:positionId/manager
528
539
  // ---------------------------------------------------------------------------
529
540
 
530
541
  /**
531
- * Response for setting (PATCH) or clearing (DELETE) a member's solid-line
532
- * manager — the reporting edge that the org chart is built from. `manager` is
533
- * the actor the member now reports to: a `member` (person→person) OR an
534
- * `open-role` seat (person reports to a vacant role — ADR-BE-312); null after a
535
- * clear.
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 SetMemberManagerResponseSchema = z.object({
561
+ export const SetSeatManagerResponseSchema = z.object({
542
562
  success: z.boolean(),
543
- memberId: z.string(),
544
- manager: OrgChartActorRefSchema.nullable(),
545
- relationshipType: z.string(),
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 SetMemberManagerResponse = z.infer<
550
- typeof SetMemberManagerResponseSchema
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 anchor is
1139
- // retired (ADR-BE-292). Null → unmanaged (Unassigned bucket).
1140
- reportsToUserId: z.string().uuid().nullable(),
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>;