@company-semantics/contracts 52.0.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.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +122 -69
  4. package/src/generated/openapi-routes.ts +3 -1
  5. package/src/identity/README.md +2 -2
  6. package/src/identity/__tests__/people-org-chart.test.ts +52 -17
  7. package/src/identity/__tests__/position-ref.test.ts +44 -0
  8. package/src/identity/index.ts +6 -2
  9. package/src/identity/people-org-chart.ts +26 -15
  10. package/src/identity/position-ref.ts +24 -0
  11. package/src/index.ts +21 -2
  12. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
  13. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
  14. package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
  15. package/src/notifications/renderers/README.md +8 -4
  16. package/src/notifications/renderers/ascii/README.md +75 -0
  17. package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
  18. package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
  19. package/src/notifications/renderers/ascii/chat.ts +179 -0
  20. package/src/notifications/renderers/ascii/cta.ts +57 -0
  21. package/src/notifications/renderers/ascii/geometry.ts +112 -0
  22. package/src/notifications/renderers/ascii/index.ts +40 -0
  23. package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
  24. package/src/notifications/renderers/ascii/rule.ts +53 -0
  25. package/src/notifications/renderers/ascii/runs.ts +84 -0
  26. package/src/notifications/renderers/ascii/signature.ts +41 -0
  27. package/src/notifications/renderers/ascii/wrap.ts +96 -0
  28. package/src/notifications/renderers/brand.ts +12 -0
  29. package/src/notifications/renderers/email/chat.ts +62 -146
  30. package/src/notifications/renderers/email/constants.ts +17 -2
  31. package/src/notifications/renderers/email/cta.ts +8 -13
  32. package/src/notifications/renderers/email/render.ts +29 -5
  33. package/src/notifications/renderers/layout.ts +31 -0
  34. package/src/notifications/renderers/slack/README.md +135 -79
  35. package/src/notifications/renderers/slack/__tests__/README.md +3 -2
  36. package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
  37. package/src/notifications/renderers/slack/blocks.ts +149 -0
  38. package/src/notifications/renderers/slack/chat.ts +167 -0
  39. package/src/notifications/renderers/slack/cta.ts +69 -0
  40. package/src/notifications/renderers/slack/index.ts +136 -229
  41. package/src/notifications/renderers/slack/message.ts +23 -0
  42. package/src/org/README.md +10 -2
  43. package/src/org/__tests__/org-units.test.ts +1 -1
  44. package/src/org/__tests__/set-seat-manager.test.ts +177 -0
  45. package/src/org/index.ts +20 -2
  46. package/src/org/reconciliation.ts +162 -0
  47. package/src/org/schemas.ts +43 -17
  48. package/src/identity/org-chart-actor.ts +0 -24
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "52.0.0",
3
+ "version": "53.0.0",
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 = 'b8fe0a02c492' as const;
3
+ export const SPEC_HASH_FULL = 'b8fe0a02c49284e732adac870b9b0ff155865b53f5cc8571aa0eb77c4af78859' 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;
@@ -4340,35 +4379,23 @@ export interface components {
4340
4379
  /** @enum {string} */
4341
4380
  newRole: "admin" | "member";
4342
4381
  };
4343
- SetMemberManagerResponse: {
4382
+ SetSeatManagerResponse: {
4344
4383
  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";
4384
+ /** Format: uuid */
4385
+ reportPositionId: string;
4386
+ manager: {
4354
4387
  /** Format: uuid */
4355
- openRoleId: string;
4356
- }) | null;
4357
- relationshipType: string;
4388
+ positionId: string;
4389
+ } | null;
4390
+ /** @constant */
4391
+ relationshipType: "solid";
4358
4392
  message: string;
4359
4393
  };
4360
- SetMemberManagerRequest: {
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
- openRoleId: string;
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
- reportsToUserId: string | null;
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
- reportsToUserId: string | null;
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
- userId: string;
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
- 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: {
7962
+ setSeatManager: {
7952
7963
  parameters: {
7953
7964
  query?: never;
7954
7965
  header?: never;
7955
7966
  path: {
7956
- id: string;
7967
+ positionId: string;
7957
7968
  };
7958
7969
  cookie?: never;
7959
7970
  };
7960
7971
  requestBody: {
7961
7972
  content: {
7962
- "application/json": components["schemas"]["SetMemberManagerRequest"];
7973
+ "application/json": components["schemas"]["SetSeatManagerRequest"];
7963
7974
  };
7964
7975
  };
7965
7976
  responses: {
7966
- /** @description Manager set successfully */
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"]["SetMemberManagerResponse"];
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
- openRoleId: string;
10861
- }) | null;
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'],
@@ -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)