@company-semantics/contracts 13.9.0 → 13.11.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "13.9.0",
3
+ "version": "13.11.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -2041,6 +2041,58 @@ export interface paths {
2041
2041
  patch?: never;
2042
2042
  trace?: never;
2043
2043
  };
2044
+ "/api/org-units/{unitId}/open-roles": {
2045
+ parameters: {
2046
+ query?: never;
2047
+ header?: never;
2048
+ path?: never;
2049
+ cookie?: never;
2050
+ };
2051
+ /** List open roles of an org unit */
2052
+ get: operations["listOrgUnitOpenRoles"];
2053
+ put?: never;
2054
+ /** Create an open role (placeholder seat) in an org unit */
2055
+ post: operations["createOrgUnitOpenRole"];
2056
+ delete?: never;
2057
+ options?: never;
2058
+ head?: never;
2059
+ patch?: never;
2060
+ trace?: never;
2061
+ };
2062
+ "/api/org-units/{unitId}/open-roles/{roleId}": {
2063
+ parameters: {
2064
+ query?: never;
2065
+ header?: never;
2066
+ path?: never;
2067
+ cookie?: never;
2068
+ };
2069
+ get?: never;
2070
+ put?: never;
2071
+ post?: never;
2072
+ delete?: never;
2073
+ options?: never;
2074
+ head?: never;
2075
+ /** Advance (hiring) or close an open role */
2076
+ patch: operations["updateOrgUnitOpenRoleStatus"];
2077
+ trace?: never;
2078
+ };
2079
+ "/api/org-units/{unitId}/open-roles/{roleId}/fill": {
2080
+ parameters: {
2081
+ query?: never;
2082
+ header?: never;
2083
+ path?: never;
2084
+ cookie?: never;
2085
+ };
2086
+ get?: never;
2087
+ put?: never;
2088
+ /** Fill an open role by seating an existing member or inviting a person */
2089
+ post: operations["fillOrgUnitOpenRole"];
2090
+ delete?: never;
2091
+ options?: never;
2092
+ head?: never;
2093
+ patch?: never;
2094
+ trace?: never;
2095
+ };
2044
2096
  "/api/users/org-chart": {
2045
2097
  parameters: {
2046
2098
  query?: never;
@@ -3636,6 +3688,8 @@ export interface components {
3636
3688
  };
3637
3689
  /** @enum {string} */
3638
3690
  status: "pending" | "accepted" | "expired" | "revoked";
3691
+ /** Format: uuid */
3692
+ homeUnitId?: string;
3639
3693
  createdAt: string;
3640
3694
  expiresAt: string;
3641
3695
  acceptedAt?: string;
@@ -3646,6 +3700,8 @@ export interface components {
3646
3700
  email: string;
3647
3701
  /** @enum {string} */
3648
3702
  role: "admin" | "member";
3703
+ /** Format: uuid */
3704
+ homeUnitId?: string;
3649
3705
  };
3650
3706
  InviteListResponse: {
3651
3707
  id: string;
@@ -3659,6 +3715,8 @@ export interface components {
3659
3715
  };
3660
3716
  /** @enum {string} */
3661
3717
  status: "pending" | "accepted" | "expired" | "revoked";
3718
+ /** Format: uuid */
3719
+ homeUnitId?: string;
3662
3720
  createdAt: string;
3663
3721
  expiresAt: string;
3664
3722
  acceptedAt?: string;
@@ -4564,6 +4622,42 @@ export interface components {
4564
4622
  updatedAt: string;
4565
4623
  };
4566
4624
  };
4625
+ OpenRoleListResponse: {
4626
+ /** Format: uuid */
4627
+ unitId: string;
4628
+ openRoles: {
4629
+ /** Format: uuid */
4630
+ id: string;
4631
+ /** Format: uuid */
4632
+ orgId: string;
4633
+ /** Format: uuid */
4634
+ unitId: string;
4635
+ title: string | null;
4636
+ targetStartDate: string | null;
4637
+ /** @enum {string} */
4638
+ status: "open" | "hiring" | "filled" | "closed";
4639
+ filledByUserId: string | null;
4640
+ createdAt: string;
4641
+ updatedAt: string;
4642
+ }[];
4643
+ };
4644
+ OpenRoleResponse: {
4645
+ openRole: {
4646
+ /** Format: uuid */
4647
+ id: string;
4648
+ /** Format: uuid */
4649
+ orgId: string;
4650
+ /** Format: uuid */
4651
+ unitId: string;
4652
+ title: string | null;
4653
+ targetStartDate: string | null;
4654
+ /** @enum {string} */
4655
+ status: "open" | "hiring" | "filled" | "closed";
4656
+ filledByUserId: string | null;
4657
+ createdAt: string;
4658
+ updatedAt: string;
4659
+ };
4660
+ };
4567
4661
  PeopleOrgChartResponse: {
4568
4662
  nodes: {
4569
4663
  /** Format: uuid */
@@ -8457,6 +8551,126 @@ export interface operations {
8457
8551
  };
8458
8552
  };
8459
8553
  };
8554
+ listOrgUnitOpenRoles: {
8555
+ parameters: {
8556
+ query?: never;
8557
+ header?: never;
8558
+ path: {
8559
+ unitId: string;
8560
+ };
8561
+ cookie?: never;
8562
+ };
8563
+ requestBody?: never;
8564
+ responses: {
8565
+ /** @description Open roles of the unit (all lifecycle states) */
8566
+ 200: {
8567
+ headers: {
8568
+ [name: string]: unknown;
8569
+ };
8570
+ content: {
8571
+ "application/json": components["schemas"]["OpenRoleListResponse"];
8572
+ };
8573
+ };
8574
+ };
8575
+ };
8576
+ createOrgUnitOpenRole: {
8577
+ parameters: {
8578
+ query?: never;
8579
+ header?: never;
8580
+ path: {
8581
+ unitId: string;
8582
+ };
8583
+ cookie?: never;
8584
+ };
8585
+ requestBody: {
8586
+ content: {
8587
+ "application/json": {
8588
+ title?: string | null;
8589
+ targetStartDate?: string | null;
8590
+ };
8591
+ };
8592
+ };
8593
+ responses: {
8594
+ /** @description Created open role */
8595
+ 201: {
8596
+ headers: {
8597
+ [name: string]: unknown;
8598
+ };
8599
+ content: {
8600
+ "application/json": components["schemas"]["OpenRoleResponse"];
8601
+ };
8602
+ };
8603
+ };
8604
+ };
8605
+ updateOrgUnitOpenRoleStatus: {
8606
+ parameters: {
8607
+ query?: never;
8608
+ header?: never;
8609
+ path: {
8610
+ unitId: string;
8611
+ roleId: string;
8612
+ };
8613
+ cookie?: never;
8614
+ };
8615
+ requestBody: {
8616
+ content: {
8617
+ "application/json": {
8618
+ /** @enum {string} */
8619
+ status: "hiring" | "closed";
8620
+ };
8621
+ };
8622
+ };
8623
+ responses: {
8624
+ /** @description Updated open role */
8625
+ 200: {
8626
+ headers: {
8627
+ [name: string]: unknown;
8628
+ };
8629
+ content: {
8630
+ "application/json": components["schemas"]["OpenRoleResponse"];
8631
+ };
8632
+ };
8633
+ };
8634
+ };
8635
+ fillOrgUnitOpenRole: {
8636
+ parameters: {
8637
+ query?: never;
8638
+ header?: never;
8639
+ path: {
8640
+ unitId: string;
8641
+ roleId: string;
8642
+ };
8643
+ cookie?: never;
8644
+ };
8645
+ requestBody: {
8646
+ content: {
8647
+ "application/json": {
8648
+ /** @constant */
8649
+ mode: "existing";
8650
+ /** Format: uuid */
8651
+ userId: string;
8652
+ } | {
8653
+ /** @constant */
8654
+ mode: "invite";
8655
+ /** Format: email */
8656
+ email: string;
8657
+ /** @enum {string} */
8658
+ role: "admin" | "member";
8659
+ };
8660
+ };
8661
+ };
8662
+ responses: {
8663
+ /** @description Filled open role */
8664
+ 200: {
8665
+ headers: {
8666
+ [name: string]: unknown;
8667
+ };
8668
+ content: {
8669
+ "application/json": components["schemas"]["OpenRoleResponse"];
8670
+ };
8671
+ };
8672
+ };
8673
+ };
8460
8674
  getPeopleOrgChart: {
8461
8675
  parameters: {
8462
8676
  query?: never;
@@ -86,6 +86,9 @@ export const openApiRoutes = {
86
86
  '/api/org-units/{unitId}/memberships/{userId}': ['DELETE'],
87
87
  '/api/org-units/{unitId}/memberships/{userId}/role': ['PUT'],
88
88
  '/api/org-units/{unitId}/my-authority': ['GET'],
89
+ '/api/org-units/{unitId}/open-roles': ['GET', 'POST'],
90
+ '/api/org-units/{unitId}/open-roles/{roleId}': ['PATCH'],
91
+ '/api/org-units/{unitId}/open-roles/{roleId}/fill': ['POST'],
89
92
  '/api/org-units/{unitId}/owners': ['GET'],
90
93
  '/api/org-units/{unitId}/permissions': ['GET'],
91
94
  '/api/org-units/{unitId}/relationships': ['GET', 'POST'],
@@ -66,11 +66,13 @@ export {
66
66
  ReportingRelationshipTypeSchema,
67
67
  PeopleOrgChartNodeSchema,
68
68
  PeopleOrgChartEdgeSchema,
69
+ PeopleOrgChartOpenRoleSchema,
69
70
  PeopleOrgChartResponseSchema,
70
71
  } from "./people-org-chart";
71
72
  export type {
72
73
  ReportingRelationshipType,
73
74
  PeopleOrgChartNode,
74
75
  PeopleOrgChartEdge,
76
+ PeopleOrgChartOpenRole,
75
77
  PeopleOrgChartResponse,
76
78
  } from "./people-org-chart";
@@ -48,6 +48,24 @@ export const PeopleOrgChartEdgeSchema = z.object({
48
48
 
49
49
  export type PeopleOrgChartEdge = z.infer<typeof PeopleOrgChartEdgeSchema>;
50
50
 
51
+ // ---------------------------------------------------------------------------
52
+ // Open role — a persisted placeholder seat rendered as a dashed "Open role"
53
+ // card in its unit's sibling column. Only active roles (open + hiring) are
54
+ // included; filled/closed roles never appear here. `unitId` seats the card in
55
+ // the right column and resolves its attach-person, mirroring how empty-unit
56
+ // placeholders and pending-invite ghosts are positioned.
57
+ // ---------------------------------------------------------------------------
58
+
59
+ export const PeopleOrgChartOpenRoleSchema = z.object({
60
+ id: z.string().uuid(),
61
+ unitId: z.string().uuid(),
62
+ title: z.string().nullable(),
63
+ });
64
+
65
+ export type PeopleOrgChartOpenRole = z.infer<
66
+ typeof PeopleOrgChartOpenRoleSchema
67
+ >;
68
+
51
69
  // ---------------------------------------------------------------------------
52
70
  // GET /api/users/org-chart
53
71
  // ---------------------------------------------------------------------------
@@ -55,6 +73,7 @@ export type PeopleOrgChartEdge = z.infer<typeof PeopleOrgChartEdgeSchema>;
55
73
  export const PeopleOrgChartResponseSchema = z.object({
56
74
  nodes: z.array(PeopleOrgChartNodeSchema),
57
75
  edges: z.array(PeopleOrgChartEdgeSchema),
76
+ openRoles: z.array(PeopleOrgChartOpenRoleSchema),
58
77
  });
59
78
 
60
79
  export type PeopleOrgChartResponse = z.infer<
package/src/index.ts CHANGED
@@ -172,12 +172,14 @@ export {
172
172
  ReportingRelationshipTypeSchema,
173
173
  PeopleOrgChartNodeSchema,
174
174
  PeopleOrgChartEdgeSchema,
175
+ PeopleOrgChartOpenRoleSchema,
175
176
  PeopleOrgChartResponseSchema,
176
177
  } from "./identity/index";
177
178
  export type {
178
179
  ReportingRelationshipType,
179
180
  PeopleOrgChartNode,
180
181
  PeopleOrgChartEdge,
182
+ PeopleOrgChartOpenRole,
181
183
  PeopleOrgChartResponse,
182
184
  } from "./identity/index";
183
185
 
@@ -14,6 +14,10 @@ import {
14
14
  OrgUnitMembershipSourceSchema,
15
15
  OrgUnitPermissionsEntrySchema,
16
16
  ListOrgUnitPermissionsResponseSchema,
17
+ OpenRoleSchema,
18
+ CreateOpenRoleRequestSchema,
19
+ UpdateOpenRoleStatusRequestSchema,
20
+ FillOpenRoleRequestSchema,
17
21
  } from "../schemas.js";
18
22
 
19
23
  const UUID_A = "11111111-1111-4111-8111-111111111111";
@@ -92,12 +96,113 @@ describe("OrgUnitTreeNodeSchema", () => {
92
96
  depth: 3,
93
97
  hasChildren: true,
94
98
  memberCount: 5,
99
+ openRoleCount: 1,
95
100
  missingAtNextLevel: null,
96
101
  };
97
102
  expect(() => OrgUnitTreeNodeSchema.parse(base)).not.toThrow();
98
103
  expect(() => OrgUnitTreeNodeSchema.parse({ ...base, depth: 0 })).toThrow();
99
104
  expect(() => OrgUnitTreeNodeSchema.parse({ ...base, depth: 6 })).toThrow();
100
105
  });
106
+
107
+ it("requires a non-negative openRoleCount", () => {
108
+ const base = {
109
+ ...makeUnit(),
110
+ depth: 3,
111
+ hasChildren: true,
112
+ memberCount: 5,
113
+ openRoleCount: 0,
114
+ missingAtNextLevel: null,
115
+ };
116
+ expect(() => OrgUnitTreeNodeSchema.parse(base)).not.toThrow();
117
+ expect(() =>
118
+ OrgUnitTreeNodeSchema.parse({ ...base, openRoleCount: -1 }),
119
+ ).toThrow();
120
+ });
121
+ });
122
+
123
+ describe("OpenRoleSchema", () => {
124
+ const makeOpenRole = (overrides: Record<string, unknown> = {}) => ({
125
+ id: UUID_A,
126
+ orgId: UUID_B,
127
+ unitId: UUID_C,
128
+ title: "Senior Engineer",
129
+ targetStartDate: "2026-09-01",
130
+ status: "open",
131
+ filledByUserId: null,
132
+ createdAt: "2026-04-17T00:00:00Z",
133
+ updatedAt: "2026-04-17T00:00:00Z",
134
+ ...overrides,
135
+ });
136
+
137
+ it("accepts a valid open role and the four lifecycle states", () => {
138
+ expect(() => OpenRoleSchema.parse(makeOpenRole())).not.toThrow();
139
+ for (const status of ["open", "hiring", "filled", "closed"]) {
140
+ expect(() =>
141
+ OpenRoleSchema.parse(makeOpenRole({ status })),
142
+ ).not.toThrow();
143
+ }
144
+ });
145
+
146
+ it("allows null title and targetStartDate, rejects unknown status", () => {
147
+ expect(() =>
148
+ OpenRoleSchema.parse(
149
+ makeOpenRole({ title: null, targetStartDate: null }),
150
+ ),
151
+ ).not.toThrow();
152
+ expect(() =>
153
+ OpenRoleSchema.parse(makeOpenRole({ status: "vacant" })),
154
+ ).toThrow();
155
+ });
156
+ });
157
+
158
+ describe("Open role request schemas", () => {
159
+ it("CreateOpenRoleRequest accepts empty body and ISO date, rejects bad date", () => {
160
+ expect(() => CreateOpenRoleRequestSchema.parse({})).not.toThrow();
161
+ expect(() =>
162
+ CreateOpenRoleRequestSchema.parse({
163
+ title: "PM",
164
+ targetStartDate: "2026-09-01",
165
+ }),
166
+ ).not.toThrow();
167
+ expect(() =>
168
+ CreateOpenRoleRequestSchema.parse({ targetStartDate: "Sept 1" }),
169
+ ).toThrow();
170
+ });
171
+
172
+ it("UpdateOpenRoleStatusRequest only accepts hiring|closed", () => {
173
+ expect(() =>
174
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "hiring" }),
175
+ ).not.toThrow();
176
+ expect(() =>
177
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "closed" }),
178
+ ).not.toThrow();
179
+ expect(() =>
180
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "filled" }),
181
+ ).toThrow();
182
+ expect(() =>
183
+ UpdateOpenRoleStatusRequestSchema.parse({ status: "open" }),
184
+ ).toThrow();
185
+ });
186
+
187
+ it("FillOpenRoleRequest discriminates on mode", () => {
188
+ expect(() =>
189
+ FillOpenRoleRequestSchema.parse({ mode: "existing", userId: UUID_A }),
190
+ ).not.toThrow();
191
+ expect(() =>
192
+ FillOpenRoleRequestSchema.parse({
193
+ mode: "invite",
194
+ email: "a@b.com",
195
+ role: "member",
196
+ }),
197
+ ).not.toThrow();
198
+ // wrong branch fields
199
+ expect(() =>
200
+ FillOpenRoleRequestSchema.parse({ mode: "existing", email: "a@b.com" }),
201
+ ).toThrow();
202
+ expect(() =>
203
+ FillOpenRoleRequestSchema.parse({ mode: "invite", userId: UUID_A }),
204
+ ).toThrow();
205
+ });
101
206
  });
102
207
 
103
208
  describe("OrgUnitMembershipSchema", () => {
@@ -10,6 +10,7 @@ describe("VIEW_SCOPE_MAP golden snapshot", () => {
10
10
  "teamwork-member": "org.view_teamwork",
11
11
  "company-md": "org.view_company_md",
12
12
  "internal-admin": "internal.view_admin",
13
+ "execution-detail": "org.view_timeline",
13
14
  teams: null,
14
15
  chat: null,
15
16
  settings: null,
@@ -28,6 +29,7 @@ describe("getViewScope", () => {
28
29
  expect(getViewScope("teamwork")).toBe("org.view_teamwork");
29
30
  expect(getViewScope("company-md")).toBe("org.view_company_md");
30
31
  expect(getViewScope("internal-admin")).toBe("internal.view_admin");
32
+ expect(getViewScope("execution-detail")).toBe("org.view_timeline");
31
33
  });
32
34
 
33
35
  it("returns null for public views", () => {
package/src/org/index.ts CHANGED
@@ -219,6 +219,7 @@ export type {
219
219
  OrgUnitRelationshipRole,
220
220
  OrgUnitMembershipStatus,
221
221
  OrgUnitMembershipSource,
222
+ OpenRoleStatus,
222
223
  OrgUnitErrorCode,
223
224
  OrgTreeResponse,
224
225
  } from "./org-units";
@@ -236,6 +237,13 @@ export {
236
237
  OrgUnitSchema,
237
238
  OrgUnitTreeNodeSchema,
238
239
  OrgUnitMembershipSchema,
240
+ OpenRoleStatusSchema,
241
+ OpenRoleSchema,
242
+ OpenRoleResponseSchema,
243
+ OpenRoleListResponseSchema,
244
+ CreateOpenRoleRequestSchema,
245
+ UpdateOpenRoleStatusRequestSchema,
246
+ FillOpenRoleRequestSchema,
239
247
  OrgUnitRelationshipSchema,
240
248
  OrgLevelConfigSchema,
241
249
  OrgLevelIconSchema,
@@ -259,6 +267,12 @@ export type {
259
267
  OrgUnit,
260
268
  OrgUnitTreeNode,
261
269
  OrgUnitMembership,
270
+ OpenRole,
271
+ OpenRoleResponse,
272
+ OpenRoleListResponse,
273
+ CreateOpenRoleRequest,
274
+ UpdateOpenRoleStatusRequest,
275
+ FillOpenRoleRequest,
262
276
  OrgUnitRelationship,
263
277
  OrgLevelConfig,
264
278
  OrgLevelIcon,
@@ -56,6 +56,19 @@ export type OrgUnitMembershipSource =
56
56
  | "scim"
57
57
  | "hris";
58
58
 
59
+ /**
60
+ * Hiring lifecycle of an open role — a persisted placeholder for the person
61
+ * who will sit in an org unit (PRD open-roles). A unit is seeded with one
62
+ * `open` role at creation so it never renders as a unit with nobody.
63
+ *
64
+ * Transitions: `open → hiring → filled` and `open|hiring → closed`. `filled`
65
+ * and `closed` are terminal. `filled` means a real `org_unit_membership` was
66
+ * created from this role (the row is kept as staffing history); `closed` means
67
+ * the seat was cancelled. Only `open` and `hiring` are "active" — they are the
68
+ * states that render an Open role card and count toward `openRoleCount`.
69
+ */
70
+ export type OpenRoleStatus = "open" | "hiring" | "filled" | "closed";
71
+
59
72
  /**
60
73
  * Canonical route path constants for the `/api/org-units` surface.
61
74
  *
@@ -87,6 +100,11 @@ export const ORG_UNITS_ROUTES = {
87
100
  membershipRole: (unitId: string, userId: string) =>
88
101
  `/api/org-units/${unitId}/memberships/${userId}/role`,
89
102
  permissions: (unitId: string) => `/api/org-units/${unitId}/permissions`,
103
+ openRoles: (unitId: string) => `/api/org-units/${unitId}/open-roles`,
104
+ openRoleById: (unitId: string, roleId: string) =>
105
+ `/api/org-units/${unitId}/open-roles/${roleId}`,
106
+ fillOpenRole: (unitId: string, roleId: string) =>
107
+ `/api/org-units/${unitId}/open-roles/${roleId}/fill`,
90
108
  } as const;
91
109
 
92
110
  /**
@@ -855,6 +855,18 @@ export const OrgUnitMembershipSourceSchema = z.enum([
855
855
  "hris",
856
856
  ]);
857
857
 
858
+ /**
859
+ * Hiring lifecycle of an open role. See `OpenRoleStatus` in `./org-units` for
860
+ * the transition rules. Only `open` and `hiring` are "active" (render a card
861
+ * and count toward `openRoleCount`); `filled`/`closed` are terminal.
862
+ */
863
+ export const OpenRoleStatusSchema = z.enum([
864
+ "open",
865
+ "hiring",
866
+ "filled",
867
+ "closed",
868
+ ]);
869
+
858
870
  export const OrgUnitErrorCodeSchema = z.enum([
859
871
  "CYCLE_BLOCKED",
860
872
  "DEPTH_EXCEEDED",
@@ -903,6 +915,12 @@ export const OrgUnitTreeNodeSchema = OrgUnitSchema.extend({
903
915
  depth: z.number().int().min(1).max(5),
904
916
  hasChildren: z.boolean(),
905
917
  memberCount: z.number().int().min(0),
918
+ /**
919
+ * Active open roles (`open` + `hiring`) rolled up over this unit's subtree,
920
+ * the same way `memberCount` rolls up real members. Drives the "(N + M open)"
921
+ * count split in the people surfaces. `memberCount` stays real-members-only.
922
+ */
923
+ openRoleCount: z.number().int().min(0),
906
924
  missingAtNextLevel: MissingAtNextLevelSchema.nullable(),
907
925
  });
908
926
 
@@ -919,6 +937,25 @@ export const OrgUnitMembershipSchema = z.object({
919
937
  updatedAt: z.string(),
920
938
  });
921
939
 
940
+ /**
941
+ * An open role — a persisted placeholder for the person who will sit in a unit.
942
+ * Carries unit membership (a seat) but never authority. `title` and
943
+ * `targetStartDate` are optional planning attributes. On `fill`, `filledByUserId`
944
+ * points at the seated user and a real `org_unit_membership` is created; the row
945
+ * is kept as staffing history. `targetStartDate` is an ISO date (YYYY-MM-DD).
946
+ */
947
+ export const OpenRoleSchema = z.object({
948
+ id: z.string().uuid(),
949
+ orgId: z.string().uuid(),
950
+ unitId: z.string().uuid(),
951
+ title: z.string().nullable(),
952
+ targetStartDate: z.string().nullable(),
953
+ status: OpenRoleStatusSchema,
954
+ filledByUserId: z.string().uuid().nullable(),
955
+ createdAt: z.string(),
956
+ updatedAt: z.string(),
957
+ });
958
+
922
959
  // ---------------------------------------------------------------------------
923
960
  // Authority & Delegation vocabulary
924
961
  //
@@ -1177,6 +1214,49 @@ export const OrgUnitMembershipListResponseSchema = z.object({
1177
1214
  memberships: z.array(OrgUnitMembershipSchema),
1178
1215
  });
1179
1216
 
1217
+ // ---------------------------------------------------------------------------
1218
+ // Open roles (PRD open-roles)
1219
+ // Request schemas are colocated here (a narrow deviation from ADR-CONT-044, as
1220
+ // with UpdateOrgUnitRequest) so the app's add-role form and the backend
1221
+ // validate against one published shape.
1222
+ // ---------------------------------------------------------------------------
1223
+
1224
+ export const OpenRoleResponseSchema = z.object({ openRole: OpenRoleSchema });
1225
+
1226
+ export const OpenRoleListResponseSchema = z.object({
1227
+ unitId: z.string().uuid(),
1228
+ openRoles: z.array(OpenRoleSchema),
1229
+ });
1230
+
1231
+ /** ISO calendar date (YYYY-MM-DD) — version-agnostic vs `z.string().date()`. */
1232
+ const IsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, {
1233
+ message: "must be an ISO date (YYYY-MM-DD)",
1234
+ });
1235
+
1236
+ export const CreateOpenRoleRequestSchema = z.object({
1237
+ title: z.string().min(1).max(255).nullable().optional(),
1238
+ targetStartDate: IsoDateSchema.nullable().optional(),
1239
+ });
1240
+
1241
+ /** Only the non-terminal client transitions are accepted; `fill` is its own
1242
+ * endpoint and `open` is the seeded initial state, never set by the client. */
1243
+ export const UpdateOpenRoleStatusRequestSchema = z.object({
1244
+ status: z.enum(["hiring", "closed"]),
1245
+ });
1246
+
1247
+ /**
1248
+ * Fill an open role by seating an existing org member (`userId`) or inviting a
1249
+ * new person by email. Exactly one branch — a discriminated union keyed on `mode`.
1250
+ */
1251
+ export const FillOpenRoleRequestSchema = z.discriminatedUnion("mode", [
1252
+ z.object({ mode: z.literal("existing"), userId: z.string().uuid() }),
1253
+ z.object({
1254
+ mode: z.literal("invite"),
1255
+ email: z.string().email(),
1256
+ role: z.enum(["admin", "member"]),
1257
+ }),
1258
+ ]);
1259
+
1180
1260
  /**
1181
1261
  * Effective manager of an org unit — a user whose membership (direct or
1182
1262
  * inherited from an ancestor) grants the `orgUnit.{id}.manage` capability.
@@ -1249,6 +1329,14 @@ export type OrgUnitMembershipResponse = z.infer<
1249
1329
  export type OrgUnitMembershipListResponse = z.infer<
1250
1330
  typeof OrgUnitMembershipListResponseSchema
1251
1331
  >;
1332
+ export type OpenRole = z.infer<typeof OpenRoleSchema>;
1333
+ export type OpenRoleResponse = z.infer<typeof OpenRoleResponseSchema>;
1334
+ export type OpenRoleListResponse = z.infer<typeof OpenRoleListResponseSchema>;
1335
+ export type CreateOpenRoleRequest = z.infer<typeof CreateOpenRoleRequestSchema>;
1336
+ export type UpdateOpenRoleStatusRequest = z.infer<
1337
+ typeof UpdateOpenRoleStatusRequestSchema
1338
+ >;
1339
+ export type FillOpenRoleRequest = z.infer<typeof FillOpenRoleRequestSchema>;
1252
1340
  export type OrgUnitPermissionsEntry = z.infer<
1253
1341
  typeof OrgUnitPermissionsEntrySchema
1254
1342
  >;
@@ -23,6 +23,11 @@ export const VIEW_SCOPE_MAP = {
23
23
  "teamwork-member": "org.view_teamwork",
24
24
  "company-md": "org.view_company_md",
25
25
  "internal-admin": "internal.view_admin",
26
+ // `execution-detail` (/@org/executions/{id}) is gated behind the same scope as
27
+ // its only entry point, the timeline. Per-execution visibility is additionally
28
+ // enforced at the data layer (GET /summary + /result return 404 when the
29
+ // execution is not visible). See ADR-APP-045.
30
+ "execution-detail": "org.view_timeline",
26
31
  // Public views (require only authentication)
27
32
  // `teams`: team/directory visibility is membership-derived (ADR-BE-241 tier 1),
28
33
  // not a scope. Repointed from the never-enforced `org.view_teams` to null when