@company-semantics/contracts 18.2.0 → 19.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +5 -0
- package/src/identity/README.md +6 -0
- package/src/identity/__tests__/identity-link.test.ts +52 -0
- package/src/identity/__tests__/person.test.ts +40 -0
- package/src/identity/identity-link.ts +38 -0
- package/src/identity/index.ts +10 -0
- package/src/identity/people-org-chart.ts +18 -0
- package/src/identity/person.ts +28 -0
- package/src/index.ts +36 -0
- package/src/org/README.md +14 -0
- package/src/org/__tests__/occupancy.test.ts +74 -0
- package/src/org/__tests__/position-reporting.test.ts +88 -0
- package/src/org/__tests__/positions.test.ts +70 -0
- package/src/org/__tests__/source-authority.test.ts +64 -0
- package/src/org/index.ts +26 -0
- package/src/org/occupancy.ts +69 -0
- package/src/org/position-reporting.ts +63 -0
- package/src/org/positions.ts +65 -0
- package/src/org/source-authority.ts +44 -0
package/package.json
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
// AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
|
|
2
|
-
export const SPEC_HASH = '
|
|
3
|
-
export const SPEC_HASH_FULL = '
|
|
2
|
+
export const SPEC_HASH = '814c99a18543' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = '814c99a18543f448daaed5312db276f7704a9b63710c512b1a298be5fc652105' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -4745,6 +4745,11 @@ export interface components {
|
|
|
4745
4745
|
jobTitle: string | null;
|
|
4746
4746
|
avatarUrl: string | null;
|
|
4747
4747
|
primaryUnitId: string | null;
|
|
4748
|
+
ownedUnits: {
|
|
4749
|
+
/** Format: uuid */
|
|
4750
|
+
id: string;
|
|
4751
|
+
name: string;
|
|
4752
|
+
}[];
|
|
4748
4753
|
}[];
|
|
4749
4754
|
edges: {
|
|
4750
4755
|
/** Format: uuid */
|
package/src/identity/README.md
CHANGED
|
@@ -44,6 +44,8 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
44
44
|
- `BannerDismissedListResponseSchema`
|
|
45
45
|
- `DeletionBlocker` _(type)_ — Conditions that block account deletion.
|
|
46
46
|
- `ISODateString` _(type)_ — ISO 8601 date-time string.
|
|
47
|
+
- `IdentityLink` _(type)_
|
|
48
|
+
- `IdentityLinkSchema`
|
|
47
49
|
- `MeResponse` _(type)_
|
|
48
50
|
- `MeResponseSchema` — Full identity context returned by GET /api/me.
|
|
49
51
|
- `NameSource` _(type)_ — Source of the user's name data.
|
|
@@ -53,8 +55,12 @@ TypeScript types and functions for user identity and display name resolution.
|
|
|
53
55
|
- `PeopleOrgChartNodeSchema`
|
|
54
56
|
- `PeopleOrgChartOpenRole` _(type)_
|
|
55
57
|
- `PeopleOrgChartOpenRoleSchema`
|
|
58
|
+
- `PeopleOrgChartOwnedUnit` _(type)_
|
|
59
|
+
- `PeopleOrgChartOwnedUnitSchema`
|
|
56
60
|
- `PeopleOrgChartResponse` _(type)_
|
|
57
61
|
- `PeopleOrgChartResponseSchema`
|
|
62
|
+
- `Person` _(type)_
|
|
63
|
+
- `PersonSchema`
|
|
58
64
|
- `ProfileResponse` _(type)_
|
|
59
65
|
- `ProfileResponseSchema`
|
|
60
66
|
- `ReportingRelationshipType` _(type)_
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { IdentityLinkSchema } from "../identity-link.js";
|
|
3
|
+
|
|
4
|
+
const validLink = {
|
|
5
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
6
|
+
orgId: "22222222-2222-4222-8222-222222222222",
|
|
7
|
+
provider: "bamboohr",
|
|
8
|
+
externalId: "emp-4081",
|
|
9
|
+
personId: "33333333-3333-4333-8333-333333333333",
|
|
10
|
+
createdAt: "2026-06-21T10:00:00.000Z",
|
|
11
|
+
updatedAt: "2026-06-21T10:00:00.000Z",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe("IdentityLinkSchema", () => {
|
|
15
|
+
it("accepts a valid identity link record", () => {
|
|
16
|
+
expect(IdentityLinkSchema.parse(validLink)).toEqual(validLink);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("accepts arbitrary provider strings (open vocabulary)", () => {
|
|
20
|
+
for (const provider of ["bamboohr", "workday", "scim", "custom-hris"]) {
|
|
21
|
+
expect(
|
|
22
|
+
IdentityLinkSchema.safeParse({ ...validLink, provider }).success,
|
|
23
|
+
).toBe(true);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("rejects an empty provider", () => {
|
|
28
|
+
expect(
|
|
29
|
+
IdentityLinkSchema.safeParse({ ...validLink, provider: "" }).success,
|
|
30
|
+
).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("rejects an empty externalId", () => {
|
|
34
|
+
expect(
|
|
35
|
+
IdentityLinkSchema.safeParse({ ...validLink, externalId: "" }).success,
|
|
36
|
+
).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a non-uuid personId", () => {
|
|
40
|
+
expect(
|
|
41
|
+
IdentityLinkSchema.safeParse({ ...validLink, personId: "not-a-uuid" })
|
|
42
|
+
.success,
|
|
43
|
+
).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects a non-datetime createdAt", () => {
|
|
47
|
+
expect(
|
|
48
|
+
IdentityLinkSchema.safeParse({ ...validLink, createdAt: "yesterday" })
|
|
49
|
+
.success,
|
|
50
|
+
).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { PersonSchema } from "../person.js";
|
|
4
|
+
|
|
5
|
+
describe("PersonSchema", () => {
|
|
6
|
+
const base = {
|
|
7
|
+
id: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
8
|
+
orgId: "6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b",
|
|
9
|
+
displayName: "Ada Lovelace",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
it("parses a minimal accountless person", () => {
|
|
13
|
+
const person = PersonSchema.parse(base);
|
|
14
|
+
expect(person.displayName).toBe("Ada Lovelace");
|
|
15
|
+
expect(person.userId).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("accepts a null userId (accountless) and a uuid userId (linked)", () => {
|
|
19
|
+
expect(PersonSchema.parse({ ...base, userId: null }).userId).toBeNull();
|
|
20
|
+
const linked = PersonSchema.parse({
|
|
21
|
+
...base,
|
|
22
|
+
userId: "9c5b94b1-35ad-49bb-b118-8e8fc24abf80",
|
|
23
|
+
});
|
|
24
|
+
expect(linked.userId).toBe("9c5b94b1-35ad-49bb-b118-8e8fc24abf80");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("accepts a null primaryEmail and validates email format", () => {
|
|
28
|
+
expect(
|
|
29
|
+
PersonSchema.parse({ ...base, primaryEmail: null }).primaryEmail,
|
|
30
|
+
).toBeNull();
|
|
31
|
+
expect(() =>
|
|
32
|
+
PersonSchema.parse({ ...base, primaryEmail: "not-an-email" }),
|
|
33
|
+
).toThrow();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("rejects an empty displayName and non-uuid id", () => {
|
|
37
|
+
expect(() => PersonSchema.parse({ ...base, displayName: "" })).toThrow();
|
|
38
|
+
expect(() => PersonSchema.parse({ ...base, id: "nope" })).toThrow();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Identity Link Vocabulary
|
|
3
|
+
*
|
|
4
|
+
* An IdentityLink is a durable map from an external system's identifier to a
|
|
5
|
+
* Person in the org graph. It generalizes the legacy
|
|
6
|
+
* `users.external_source_id` / `users.external_source_system` pair into a
|
|
7
|
+
* first-class, per-source record: a BambooHR link is simply an IdentityLink
|
|
8
|
+
* with `provider: 'bamboohr'`.
|
|
9
|
+
*
|
|
10
|
+
* `provider` is an OPEN string (e.g. 'bamboohr', 'workday', 'scim') rather than
|
|
11
|
+
* an enum, so onboarding a new identity source needs no schema change.
|
|
12
|
+
*
|
|
13
|
+
* Logical uniqueness is the tuple (orgId, provider, externalId): a given
|
|
14
|
+
* external id from a given provider, within a given org, maps to exactly one
|
|
15
|
+
* Person. The (orgId, provider, externalId) tuple is the natural key.
|
|
16
|
+
*
|
|
17
|
+
* @see ADR-CONT-032 for identity vocabulary design rationale
|
|
18
|
+
*/
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// IdentityLink — external system id → Person
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export const IdentityLinkSchema = z.object({
|
|
26
|
+
id: z.string().uuid(),
|
|
27
|
+
orgId: z.string().uuid(),
|
|
28
|
+
// Open string (NOT an enum) so new sources need no schema change.
|
|
29
|
+
provider: z.string().min(1),
|
|
30
|
+
// The opaque identifier the external system uses for this person.
|
|
31
|
+
externalId: z.string().min(1),
|
|
32
|
+
// The Person this external identity resolves to (Person.id).
|
|
33
|
+
personId: z.string().uuid(),
|
|
34
|
+
createdAt: z.string().datetime(),
|
|
35
|
+
updatedAt: z.string().datetime(),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export type IdentityLink = z.infer<typeof IdentityLinkSchema>;
|
package/src/identity/index.ts
CHANGED
|
@@ -65,6 +65,7 @@ export type {
|
|
|
65
65
|
export {
|
|
66
66
|
ReportingRelationshipTypeSchema,
|
|
67
67
|
PeopleOrgChartNodeSchema,
|
|
68
|
+
PeopleOrgChartOwnedUnitSchema,
|
|
68
69
|
PeopleOrgChartEdgeSchema,
|
|
69
70
|
PeopleOrgChartOpenRoleSchema,
|
|
70
71
|
PeopleOrgChartResponseSchema,
|
|
@@ -72,7 +73,16 @@ export {
|
|
|
72
73
|
export type {
|
|
73
74
|
ReportingRelationshipType,
|
|
74
75
|
PeopleOrgChartNode,
|
|
76
|
+
PeopleOrgChartOwnedUnit,
|
|
75
77
|
PeopleOrgChartEdge,
|
|
76
78
|
PeopleOrgChartOpenRole,
|
|
77
79
|
PeopleOrgChartResponse,
|
|
78
80
|
} from "./people-org-chart";
|
|
81
|
+
|
|
82
|
+
// Person — the org-graph node for a human (0..1 User link), per ADR-CTRL-182
|
|
83
|
+
export { PersonSchema } from "./person";
|
|
84
|
+
export type { Person } from "./person";
|
|
85
|
+
|
|
86
|
+
// Identity Link — external system id → Person (provider is an open string)
|
|
87
|
+
export { IdentityLinkSchema } from "./identity-link";
|
|
88
|
+
export type { IdentityLink } from "./identity-link";
|
|
@@ -26,12 +26,30 @@ export type ReportingRelationshipType = z.infer<
|
|
|
26
26
|
// Node — a person in the chart
|
|
27
27
|
// ---------------------------------------------------------------------------
|
|
28
28
|
|
|
29
|
+
// A unit this person is a *local structural owner* of — i.e. holds a grant with
|
|
30
|
+
// `authority_source = 'structural'` directly on that unit (ADR-BE-168). Carries
|
|
31
|
+
// the unit name so the chart can render an owner tooltip ("Unit owner of …")
|
|
32
|
+
// without a second lookup. The org root unit appears here for the account
|
|
33
|
+
// owner(s) (the CEO), whom the chart marks with the owner icon instead.
|
|
34
|
+
export const PeopleOrgChartOwnedUnitSchema = z.object({
|
|
35
|
+
id: z.string().uuid(),
|
|
36
|
+
name: z.string(),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type PeopleOrgChartOwnedUnit = z.infer<
|
|
40
|
+
typeof PeopleOrgChartOwnedUnitSchema
|
|
41
|
+
>;
|
|
42
|
+
|
|
29
43
|
export const PeopleOrgChartNodeSchema = z.object({
|
|
30
44
|
id: z.string().uuid(),
|
|
31
45
|
fullName: z.string(),
|
|
32
46
|
jobTitle: z.string().nullable(),
|
|
33
47
|
avatarUrl: z.string().nullable(),
|
|
34
48
|
primaryUnitId: z.string().uuid().nullable(),
|
|
49
|
+
// Units this person is a local structural owner of. Empty ⇒ not a unit owner.
|
|
50
|
+
// Drives the chart's owner key + tooltip; the account owner (root-unit owner)
|
|
51
|
+
// is rendered with the org owner icon (crown) instead.
|
|
52
|
+
ownedUnits: z.array(PeopleOrgChartOwnedUnitSchema),
|
|
35
53
|
});
|
|
36
54
|
|
|
37
55
|
export type PeopleOrgChartNode = z.infer<typeof PeopleOrgChartNodeSchema>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Person Identity Vocabulary
|
|
3
|
+
*
|
|
4
|
+
* Person: the human in the org — the org-graph node. Distinct from `User` (an
|
|
5
|
+
* authenticated account). A Person has 0..1 linked User (`userId`); accountless
|
|
6
|
+
* People are valid from day one. Identity vocabulary, NOT a structure fact, so it
|
|
7
|
+
* carries no FactProvenance envelope. See control ADR-CTRL-182.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
|
|
11
|
+
// ---------------------------------------------------------------------------
|
|
12
|
+
// Person — the org-graph node for a human, with an optional 0..1 User link
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
|
|
15
|
+
export const PersonSchema = z.object({
|
|
16
|
+
/** Unique identifier for the person within the org graph. */
|
|
17
|
+
id: z.string().uuid(),
|
|
18
|
+
/** Owning organization (tenant) this person belongs to. */
|
|
19
|
+
orgId: z.string().uuid(),
|
|
20
|
+
/** Human-facing name of the person in the org. */
|
|
21
|
+
displayName: z.string().min(1),
|
|
22
|
+
/** Primary email if known; null for people imported without one. */
|
|
23
|
+
primaryEmail: z.string().email().nullable().optional(),
|
|
24
|
+
/** The authenticated account this person is, if any (0..1). Null when accountless. */
|
|
25
|
+
userId: z.string().uuid().nullable().optional(),
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
export type Person = z.infer<typeof PersonSchema>;
|
package/src/index.ts
CHANGED
|
@@ -171,6 +171,7 @@ export type {
|
|
|
171
171
|
export {
|
|
172
172
|
ReportingRelationshipTypeSchema,
|
|
173
173
|
PeopleOrgChartNodeSchema,
|
|
174
|
+
PeopleOrgChartOwnedUnitSchema,
|
|
174
175
|
PeopleOrgChartEdgeSchema,
|
|
175
176
|
PeopleOrgChartOpenRoleSchema,
|
|
176
177
|
PeopleOrgChartResponseSchema,
|
|
@@ -178,11 +179,20 @@ export {
|
|
|
178
179
|
export type {
|
|
179
180
|
ReportingRelationshipType,
|
|
180
181
|
PeopleOrgChartNode,
|
|
182
|
+
PeopleOrgChartOwnedUnit,
|
|
181
183
|
PeopleOrgChartEdge,
|
|
182
184
|
PeopleOrgChartOpenRole,
|
|
183
185
|
PeopleOrgChartResponse,
|
|
184
186
|
} from "./identity/index";
|
|
185
187
|
|
|
188
|
+
// Person — the org-graph node for a human (0..1 User link), per ADR-CTRL-182
|
|
189
|
+
export { PersonSchema } from "./identity/index";
|
|
190
|
+
export type { Person } from "./identity/index";
|
|
191
|
+
|
|
192
|
+
// Identity Link — external system id → Person (provider is an open string)
|
|
193
|
+
export { IdentityLinkSchema } from "./identity/index";
|
|
194
|
+
export type { IdentityLink } from "./identity/index";
|
|
195
|
+
|
|
186
196
|
// Auth domain types
|
|
187
197
|
export type { AuthStartMode, AuthStartResponse } from "./auth/index";
|
|
188
198
|
export { OTPErrorCode } from "./auth/index";
|
|
@@ -367,6 +377,32 @@ export type {
|
|
|
367
377
|
HomeAssignment,
|
|
368
378
|
} from "./org/index";
|
|
369
379
|
|
|
380
|
+
// Occupancy: the holds_position relation (M:N, co-occupancy first-class).
|
|
381
|
+
// Vacancy = a position with no active occupancy. (ADR-CONT-083 / ADR-CTRL-182)
|
|
382
|
+
export { OccupancyStatusSchema, OccupancySchema } from "./org/index";
|
|
383
|
+
export type { OccupancyStatus, Occupancy } from "./org/index";
|
|
384
|
+
|
|
385
|
+
// Position: the org seat (planned/open/filled/closed) — a seat, not a person
|
|
386
|
+
// or a title. Carries a FactProvenance envelope. (ADR-CONT-083 / ADR-CTRL-182)
|
|
387
|
+
export { PositionStatusSchema, PositionSchema } from "./org/index";
|
|
388
|
+
export type { PositionStatus, Position } from "./org/index";
|
|
389
|
+
|
|
390
|
+
// Position reporting: the canonical seat -> manager-seat reporting edge. The
|
|
391
|
+
// person hierarchy derives from this composed with occupancy. (ADR-CONT-083 / ADR-CTRL-182)
|
|
392
|
+
export {
|
|
393
|
+
PositionReportingRelationshipTypeSchema,
|
|
394
|
+
PositionReportingSchema,
|
|
395
|
+
} from "./org/index";
|
|
396
|
+
export type {
|
|
397
|
+
PositionReportingRelationshipType,
|
|
398
|
+
PositionReporting,
|
|
399
|
+
} from "./org/index";
|
|
400
|
+
|
|
401
|
+
// Source authority: per-field ownership — who may WRITE a given entity field.
|
|
402
|
+
// Orthogonal to provenance (who reported). (ADR-CONT-083 / ADR-CTRL-182)
|
|
403
|
+
export { SourceAuthoritySchema } from "./org/index";
|
|
404
|
+
export type { SourceAuthority } from "./org/index";
|
|
405
|
+
|
|
370
406
|
// Authority & Delegation vocabulary
|
|
371
407
|
export {
|
|
372
408
|
AuthoritySourceSchema,
|
package/src/org/README.md
CHANGED
|
@@ -105,6 +105,10 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
105
105
|
- `MissingAtNextLevel` _(type)_
|
|
106
106
|
- `MissingAtNextLevelSchema` — Active users home directly at this unit who have no level-(depth+1) home assignment within its subtree.
|
|
107
107
|
- `ORG_UNITS_ROUTES` — Canonical route path constants for the `/api/org-units` surface.
|
|
108
|
+
- `Occupancy` _(type)_
|
|
109
|
+
- `OccupancySchema`
|
|
110
|
+
- `OccupancyStatus` _(type)_
|
|
111
|
+
- `OccupancyStatusSchema` — Lifecycle of a single occupancy of a position.
|
|
108
112
|
- `OidcValidationResult` _(type)_ — Result of validating an OIDC discovery URL.
|
|
109
113
|
- `OidcValidationResultDto` _(type)_ — Result of validating an OIDC discovery URL.
|
|
110
114
|
- `OidcValidationResultSchema`
|
|
@@ -204,6 +208,14 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
204
208
|
- `PermissionAuditEntry` _(type)_ — Single entry in the permission change audit log.
|
|
205
209
|
- `Phase3AuditAction` _(type)_ — Audit actions for Phase 3 workspace expansion features.
|
|
206
210
|
- `Phase4AuditAction` _(type)_ — Audit actions for Phase 4 enterprise identity features.
|
|
211
|
+
- `Position` _(type)_
|
|
212
|
+
- `PositionReporting` _(type)_
|
|
213
|
+
- `PositionReportingRelationshipType` _(type)_
|
|
214
|
+
- `PositionReportingRelationshipTypeSchema` — The kind of reporting line.
|
|
215
|
+
- `PositionReportingSchema`
|
|
216
|
+
- `PositionSchema`
|
|
217
|
+
- `PositionStatus` _(type)_
|
|
218
|
+
- `PositionStatusSchema` — Lifecycle of a seat's EXISTENCE — distinct from a hiring pipeline.
|
|
207
219
|
- `ProviderStatus` _(type)_ — Provider-level configuration lifecycle.
|
|
208
220
|
- `ProviderSuggestion` _(type)_ — MX-based provider suggestion for SSO setup.
|
|
209
221
|
- `RemoveMemberRequest` _(type)_ — Request payload for removing a member from the workspace.
|
|
@@ -222,6 +234,8 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
222
234
|
- `SetMemberManagerResponseSchema` — Response for setting (PATCH) or clearing (DELETE) a member's solid-line manager — the person-to-person…
|
|
223
235
|
- `SharePolicy` _(type)_ — Document sharing policy. - restricted: Only explicit ACL + owning unit + org admins - orgread: All org…
|
|
224
236
|
- `ShareState` _(type)_ — Complete sharing state for a document.
|
|
237
|
+
- `SourceAuthority` _(type)_
|
|
238
|
+
- `SourceAuthoritySchema`
|
|
225
239
|
- `SsoCredentialStatus` _(type)_ — Boolean credential presence indicators (never expose actual values).
|
|
226
240
|
- `SsoDiscoveryConfig` _(type)_ — OIDC discovery configuration for the SSO provider.
|
|
227
241
|
- `SsoEnforcementStatus` _(type)_ — SSO enforcement status for the workspace.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { OccupancySchema, OccupancyStatusSchema } from "../occupancy.js";
|
|
4
|
+
|
|
5
|
+
describe("OccupancySchema", () => {
|
|
6
|
+
const base = {
|
|
7
|
+
id: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
8
|
+
orgId: "6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b",
|
|
9
|
+
positionId: "9c5b94b1-35ad-49bb-b118-8e8fc24abf80",
|
|
10
|
+
personId: "7d793037-a076-4f50-9c0e-0e4c0b9d5b1f",
|
|
11
|
+
status: "active",
|
|
12
|
+
provenance: {
|
|
13
|
+
tier: "user",
|
|
14
|
+
source: "manual",
|
|
15
|
+
confidence: null,
|
|
16
|
+
locked: true,
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
it("parses a minimal active occupancy without dates", () => {
|
|
21
|
+
const occ = OccupancySchema.parse(base);
|
|
22
|
+
expect(occ.status).toBe("active");
|
|
23
|
+
expect(occ.startsAt).toBeUndefined();
|
|
24
|
+
expect(occ.endsAt).toBeUndefined();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("accepts null and ISO datetime for startsAt/endsAt", () => {
|
|
28
|
+
expect(
|
|
29
|
+
OccupancySchema.parse({ ...base, startsAt: null, endsAt: null }).endsAt,
|
|
30
|
+
).toBeNull();
|
|
31
|
+
const dated = OccupancySchema.parse({
|
|
32
|
+
...base,
|
|
33
|
+
startsAt: "2026-01-01T00:00:00.000Z",
|
|
34
|
+
endsAt: "2026-06-01T00:00:00.000Z",
|
|
35
|
+
});
|
|
36
|
+
expect(dated.startsAt).toBe("2026-01-01T00:00:00.000Z");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("rejects a non-ISO datetime", () => {
|
|
40
|
+
expect(() =>
|
|
41
|
+
OccupancySchema.parse({ ...base, startsAt: "2026-01-01" }),
|
|
42
|
+
).toThrow();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("rejects non-uuid ids", () => {
|
|
46
|
+
expect(() => OccupancySchema.parse({ ...base, id: "nope" })).toThrow();
|
|
47
|
+
expect(() =>
|
|
48
|
+
OccupancySchema.parse({ ...base, positionId: "nope" }),
|
|
49
|
+
).toThrow();
|
|
50
|
+
expect(() =>
|
|
51
|
+
OccupancySchema.parse({ ...base, personId: "nope" }),
|
|
52
|
+
).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("requires a provenance envelope (structure fact)", () => {
|
|
56
|
+
const { provenance: _omitted, ...withoutProvenance } = base;
|
|
57
|
+
expect(() => OccupancySchema.parse(withoutProvenance)).toThrow();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("rejects an unknown status", () => {
|
|
61
|
+
expect(() =>
|
|
62
|
+
OccupancySchema.parse({ ...base, status: "vacant" }),
|
|
63
|
+
).toThrow();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("OccupancyStatusSchema is the closed pending/active/ended set", () => {
|
|
67
|
+
expect(OccupancyStatusSchema.options).toEqual([
|
|
68
|
+
"pending",
|
|
69
|
+
"active",
|
|
70
|
+
"ended",
|
|
71
|
+
]);
|
|
72
|
+
expect(() => OccupancyStatusSchema.parse("filled")).toThrow();
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
PositionReportingSchema,
|
|
4
|
+
PositionReportingRelationshipTypeSchema,
|
|
5
|
+
} from "../position-reporting.js";
|
|
6
|
+
|
|
7
|
+
const validEdge = {
|
|
8
|
+
id: "11111111-1111-4111-8111-111111111111",
|
|
9
|
+
orgId: "22222222-2222-4222-8222-222222222222",
|
|
10
|
+
reportPositionId: "33333333-3333-4333-8333-333333333333",
|
|
11
|
+
managerPositionId: "44444444-4444-4444-8444-444444444444",
|
|
12
|
+
relationshipType: "solid" as const,
|
|
13
|
+
provenance: {
|
|
14
|
+
tier: "user" as const,
|
|
15
|
+
source: "manual",
|
|
16
|
+
confidence: null,
|
|
17
|
+
locked: true,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe("PositionReportingSchema", () => {
|
|
22
|
+
it("accepts a valid position reporting edge", () => {
|
|
23
|
+
expect(PositionReportingSchema.parse(validEdge)).toEqual(validEdge);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("accepts an edge without provenance (lightweight projection)", () => {
|
|
27
|
+
const { provenance: _omit, ...lightweight } = validEdge;
|
|
28
|
+
expect(PositionReportingSchema.safeParse(lightweight).success).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("accepts both relationship types", () => {
|
|
32
|
+
for (const relationshipType of ["solid", "dotted"]) {
|
|
33
|
+
expect(
|
|
34
|
+
PositionReportingSchema.safeParse({ ...validEdge, relationshipType })
|
|
35
|
+
.success,
|
|
36
|
+
).toBe(true);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects an unknown relationship type", () => {
|
|
41
|
+
expect(
|
|
42
|
+
PositionReportingSchema.safeParse({
|
|
43
|
+
...validEdge,
|
|
44
|
+
relationshipType: "matrix",
|
|
45
|
+
}).success,
|
|
46
|
+
).toBe(false);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("rejects a non-uuid reportPositionId", () => {
|
|
50
|
+
expect(
|
|
51
|
+
PositionReportingSchema.safeParse({
|
|
52
|
+
...validEdge,
|
|
53
|
+
reportPositionId: "not-a-uuid",
|
|
54
|
+
}).success,
|
|
55
|
+
).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("rejects a non-uuid managerPositionId", () => {
|
|
59
|
+
expect(
|
|
60
|
+
PositionReportingSchema.safeParse({
|
|
61
|
+
...validEdge,
|
|
62
|
+
managerPositionId: "not-a-uuid",
|
|
63
|
+
}).success,
|
|
64
|
+
).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("rejects a missing orgId", () => {
|
|
68
|
+
const { orgId: _omit, ...noOrg } = validEdge;
|
|
69
|
+
expect(PositionReportingSchema.safeParse(noOrg).success).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("PositionReportingRelationshipTypeSchema", () => {
|
|
74
|
+
it("accepts solid and dotted", () => {
|
|
75
|
+
expect(PositionReportingRelationshipTypeSchema.parse("solid")).toBe(
|
|
76
|
+
"solid",
|
|
77
|
+
);
|
|
78
|
+
expect(PositionReportingRelationshipTypeSchema.parse("dotted")).toBe(
|
|
79
|
+
"dotted",
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("rejects any other value", () => {
|
|
84
|
+
expect(
|
|
85
|
+
PositionReportingRelationshipTypeSchema.safeParse("solid-line").success,
|
|
86
|
+
).toBe(false);
|
|
87
|
+
});
|
|
88
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { PositionSchema, PositionStatusSchema } from "../positions.js";
|
|
4
|
+
|
|
5
|
+
describe("PositionSchema", () => {
|
|
6
|
+
const base = {
|
|
7
|
+
id: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
8
|
+
orgId: "6ec0bd7f-11c0-43da-975e-2a8ad9ebae0b",
|
|
9
|
+
unitId: "9c5b94b1-35ad-49bb-b118-8e8fc24abf80",
|
|
10
|
+
title: "Staff Engineer — Platform",
|
|
11
|
+
status: "open",
|
|
12
|
+
lifecycleOwner: "org_design",
|
|
13
|
+
provenance: {
|
|
14
|
+
tier: "user",
|
|
15
|
+
source: "manual",
|
|
16
|
+
confidence: null,
|
|
17
|
+
locked: true,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
it("parses a valid open position", () => {
|
|
22
|
+
const position = PositionSchema.parse(base);
|
|
23
|
+
expect(position.status).toBe("open");
|
|
24
|
+
expect(position.title).toBe("Staff Engineer — Platform");
|
|
25
|
+
expect(position.lifecycleOwner).toBe("org_design");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("accepts every lifecycle status", () => {
|
|
29
|
+
for (const status of ["planned", "open", "filled", "closed"] as const) {
|
|
30
|
+
expect(PositionSchema.parse({ ...base, status }).status).toBe(status);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("rejects a non-uuid id, orgId, or unitId", () => {
|
|
35
|
+
expect(() => PositionSchema.parse({ ...base, id: "nope" })).toThrow();
|
|
36
|
+
expect(() => PositionSchema.parse({ ...base, orgId: "nope" })).toThrow();
|
|
37
|
+
expect(() => PositionSchema.parse({ ...base, unitId: "nope" })).toThrow();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("rejects an empty title or lifecycleOwner", () => {
|
|
41
|
+
expect(() => PositionSchema.parse({ ...base, title: "" })).toThrow();
|
|
42
|
+
expect(() =>
|
|
43
|
+
PositionSchema.parse({ ...base, lifecycleOwner: "" }),
|
|
44
|
+
).toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects an invalid status and a hiring-only status", () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
PositionSchema.parse({ ...base, status: "archived" }),
|
|
50
|
+
).toThrow();
|
|
51
|
+
// 'hiring' belongs to OpenRoleStatus, not the seat-existence lifecycle.
|
|
52
|
+
expect(() => PositionSchema.parse({ ...base, status: "hiring" })).toThrow();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("requires a provenance envelope (Position is a structure fact)", () => {
|
|
56
|
+
const { provenance: _provenance, ...withoutProvenance } = base;
|
|
57
|
+
expect(() => PositionSchema.parse(withoutProvenance)).toThrow();
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("PositionStatusSchema", () => {
|
|
62
|
+
it("is a closed set distinct from OpenRoleStatus", () => {
|
|
63
|
+
expect(PositionStatusSchema.options).toEqual([
|
|
64
|
+
"planned",
|
|
65
|
+
"open",
|
|
66
|
+
"filled",
|
|
67
|
+
"closed",
|
|
68
|
+
]);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { SourceAuthoritySchema } from "../source-authority.js";
|
|
4
|
+
|
|
5
|
+
describe("SourceAuthoritySchema", () => {
|
|
6
|
+
const base = {
|
|
7
|
+
entityType: "position",
|
|
8
|
+
entityId: "3f2504e0-4f89-41d3-9a0c-0305e82c3301",
|
|
9
|
+
field: "title",
|
|
10
|
+
systemOfRecord: "hris",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
it("parses a valid source-authority record", () => {
|
|
14
|
+
const authority = SourceAuthoritySchema.parse(base);
|
|
15
|
+
expect(authority.entityType).toBe("position");
|
|
16
|
+
expect(authority.field).toBe("title");
|
|
17
|
+
expect(authority.systemOfRecord).toBe("hris");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("accepts any entity kind as an open string", () => {
|
|
21
|
+
for (const entityType of [
|
|
22
|
+
"position",
|
|
23
|
+
"occupancy",
|
|
24
|
+
"person",
|
|
25
|
+
"future_kind",
|
|
26
|
+
]) {
|
|
27
|
+
expect(
|
|
28
|
+
SourceAuthoritySchema.parse({ ...base, entityType }).entityType,
|
|
29
|
+
).toBe(entityType);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("accepts any system of record as an open string", () => {
|
|
34
|
+
for (const systemOfRecord of ["hris", "manual", "scim", "future_system"]) {
|
|
35
|
+
expect(
|
|
36
|
+
SourceAuthoritySchema.parse({ ...base, systemOfRecord }).systemOfRecord,
|
|
37
|
+
).toBe(systemOfRecord);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("rejects a non-uuid entityId", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
SourceAuthoritySchema.parse({ ...base, entityId: "nope" }),
|
|
44
|
+
).toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("rejects an empty entityType, field, or systemOfRecord", () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
SourceAuthoritySchema.parse({ ...base, entityType: "" }),
|
|
50
|
+
).toThrow();
|
|
51
|
+
expect(() => SourceAuthoritySchema.parse({ ...base, field: "" })).toThrow();
|
|
52
|
+
expect(() =>
|
|
53
|
+
SourceAuthoritySchema.parse({ ...base, systemOfRecord: "" }),
|
|
54
|
+
).toThrow();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("does not carry a provenance envelope (distinct from provenance)", () => {
|
|
58
|
+
const parsed = SourceAuthoritySchema.parse({
|
|
59
|
+
...base,
|
|
60
|
+
provenance: { tier: "user", source: "manual" },
|
|
61
|
+
});
|
|
62
|
+
expect("provenance" in parsed).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
package/src/org/index.ts
CHANGED
|
@@ -100,6 +100,32 @@ export type {
|
|
|
100
100
|
HomeAssignment,
|
|
101
101
|
} from "./structure-facts";
|
|
102
102
|
|
|
103
|
+
// Occupancy: the holds_position relation (M:N, co-occupancy first-class).
|
|
104
|
+
// Vacancy = a position with no active occupancy. (ADR-CONT-083 / ADR-CTRL-182)
|
|
105
|
+
export { OccupancyStatusSchema, OccupancySchema } from "./occupancy";
|
|
106
|
+
export type { OccupancyStatus, Occupancy } from "./occupancy";
|
|
107
|
+
|
|
108
|
+
// Position: the org seat (planned/open/filled/closed) — a seat, not a person
|
|
109
|
+
// or a title. Carries a FactProvenance envelope. (ADR-CONT-083 / ADR-CTRL-182)
|
|
110
|
+
export { PositionStatusSchema, PositionSchema } from "./positions";
|
|
111
|
+
export type { PositionStatus, Position } from "./positions";
|
|
112
|
+
|
|
113
|
+
// Position reporting: the canonical seat -> manager-seat reporting edge. The
|
|
114
|
+
// person hierarchy derives from this composed with occupancy. (ADR-CONT-083 / ADR-CTRL-182)
|
|
115
|
+
export {
|
|
116
|
+
PositionReportingRelationshipTypeSchema,
|
|
117
|
+
PositionReportingSchema,
|
|
118
|
+
} from "./position-reporting";
|
|
119
|
+
export type {
|
|
120
|
+
PositionReportingRelationshipType,
|
|
121
|
+
PositionReporting,
|
|
122
|
+
} from "./position-reporting";
|
|
123
|
+
|
|
124
|
+
// Source authority: per-field ownership — who may WRITE a given entity field.
|
|
125
|
+
// Orthogonal to provenance (who reported). (ADR-CONT-083 / ADR-CTRL-182)
|
|
126
|
+
export { SourceAuthoritySchema } from "./source-authority";
|
|
127
|
+
export type { SourceAuthority } from "./source-authority";
|
|
128
|
+
|
|
103
129
|
// Canonical OrgUnit tree ordering (PRD-00506)
|
|
104
130
|
export type { TreeOrderableNode } from "./tree-ordering";
|
|
105
131
|
export { orderTreeNodes } from "./tree-ordering";
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Occupancy: the `holds_position` relation between a Person and a Position.
|
|
3
|
+
*
|
|
4
|
+
* A Position is a SEAT in the org graph; an Occupancy records that a particular
|
|
5
|
+
* Person holds that seat over some interval. The relation is many-to-many and
|
|
6
|
+
* co-occupancy is FIRST-CLASS (ADR-CTRL-182 clause 2 / contracts ADR-CONT-083):
|
|
7
|
+
*
|
|
8
|
+
* - A single Position MAY be held by several People at once (job share,
|
|
9
|
+
* transition overlap, interim + permanent), so co-occupancy is a normal
|
|
10
|
+
* state, not an error to be reconciled away.
|
|
11
|
+
* - A single Person MAY hold several Positions at once (one human, many seats).
|
|
12
|
+
*
|
|
13
|
+
* VACANCY is the absence of any active Occupancy for a Position — a Position with
|
|
14
|
+
* no `status: "active"` Occupancy is vacant. Vacancy is therefore derived, never
|
|
15
|
+
* stored: do not add a `vacant` flag to Position; query Occupancy instead.
|
|
16
|
+
*
|
|
17
|
+
* Occupancy is a STRUCTURE FACT: it carries a {@link FactProvenanceSchema}
|
|
18
|
+
* envelope so the truth hierarchy (user > sync > import > inferred) and the
|
|
19
|
+
* append+supersede correction model apply to it exactly as they do to reporting
|
|
20
|
+
* edges, memberships, and home assignments. This is the distinction from
|
|
21
|
+
* Person/IdentityLink, which are identity vocabulary and carry no provenance.
|
|
22
|
+
*
|
|
23
|
+
* `positionId` is a forward reference to a Position seat id (a bare uuid here);
|
|
24
|
+
* `personId` references the org-graph {@link PersonSchema} node, not a User
|
|
25
|
+
* account.
|
|
26
|
+
*/
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
|
|
29
|
+
import { FactProvenanceSchema } from "./structure-facts";
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Occupancy status — closed lifecycle axis
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Lifecycle of a single occupancy of a position. Closed set.
|
|
37
|
+
*
|
|
38
|
+
* - `pending` — a future hold not yet in effect (e.g. an accepted offer with a
|
|
39
|
+
* start date in the future). Does NOT make the position occupied.
|
|
40
|
+
* - `active` — the person currently holds the position. The only status that
|
|
41
|
+
* counts toward occupancy; a position with no `active` occupancy is vacant.
|
|
42
|
+
* - `ended` — the hold has concluded (kept as history, never deleted).
|
|
43
|
+
*/
|
|
44
|
+
export const OccupancyStatusSchema = z.enum(["pending", "active", "ended"]);
|
|
45
|
+
export type OccupancyStatus = z.infer<typeof OccupancyStatusSchema>;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Occupancy — the holds_position structure fact
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export const OccupancySchema = z.object({
|
|
52
|
+
/** Unique identifier for this occupancy record within the org graph. */
|
|
53
|
+
id: z.string().uuid(),
|
|
54
|
+
/** Owning organization (tenant) this occupancy belongs to. */
|
|
55
|
+
orgId: z.string().uuid(),
|
|
56
|
+
/** The position (seat) being held. Forward reference to a Position id. */
|
|
57
|
+
positionId: z.string().uuid(),
|
|
58
|
+
/** The person holding the position — an org-graph node, not a User account. */
|
|
59
|
+
personId: z.string().uuid(),
|
|
60
|
+
/** Lifecycle of this hold; only `active` makes the position occupied. */
|
|
61
|
+
status: OccupancyStatusSchema,
|
|
62
|
+
/** ISO start of the hold; `null`/absent when unknown or open-started. */
|
|
63
|
+
startsAt: z.string().datetime().nullable().optional(),
|
|
64
|
+
/** ISO end of the hold; `null`/absent while the hold is still open. */
|
|
65
|
+
endsAt: z.string().datetime().nullable().optional(),
|
|
66
|
+
/** Provenance of this occupancy fact (truth hierarchy + supersede model). */
|
|
67
|
+
provenance: FactProvenanceSchema,
|
|
68
|
+
});
|
|
69
|
+
export type Occupancy = z.infer<typeof OccupancySchema>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Position Reporting Vocabulary
|
|
3
|
+
*
|
|
4
|
+
* A PositionReporting record is the CANONICAL reporting edge of the org graph:
|
|
5
|
+
* a position reports to a manager position. The person-level "who reports to
|
|
6
|
+
* whom" hierarchy is DERIVED from this edge composed with occupancy (who holds
|
|
7
|
+
* each position) — see ADR-CTRL-182 clause 3. Modelling reporting at the
|
|
8
|
+
* position layer (seat → seat) rather than the person layer keeps the chart
|
|
9
|
+
* stable across re-orgs and vacancies: the edge survives even when a seat is
|
|
10
|
+
* temporarily unfilled.
|
|
11
|
+
*
|
|
12
|
+
* `relationshipType` distinguishes a `solid` line (primary, single chain of
|
|
13
|
+
* accountability) from a `dotted` line (secondary / matrixed reporting). It is
|
|
14
|
+
* defined locally here as a closed two-member enum so the org module stays
|
|
15
|
+
* self-contained and takes on no dependency on the identity domain.
|
|
16
|
+
*
|
|
17
|
+
* NOTE ON PLACEMENT / PROVENANCE: ADR-CONT-083 sketches the Position family in
|
|
18
|
+
* the identity domain and describes authority via a separate SourceAuthority
|
|
19
|
+
* qualifier. This module instead lives in the org domain and carries the shared
|
|
20
|
+
* {@link FactProvenanceSchema} envelope — consistent with the other structural
|
|
21
|
+
* facts in this domain (reporting edges, home assignments). The reporting edge
|
|
22
|
+
* is a structure fact, so it records its own provenance.
|
|
23
|
+
*
|
|
24
|
+
* @see ADR-CONT-083 for the Position/Occupancy/PositionReporting vocabulary
|
|
25
|
+
* @see structure-facts.ts for the FactProvenance envelope
|
|
26
|
+
*/
|
|
27
|
+
import { z } from "zod";
|
|
28
|
+
import { FactProvenanceSchema } from "./structure-facts";
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Reporting relationship type — solid (primary) vs dotted (matrixed)
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The kind of reporting line. Closed set: `solid` is the primary accountability
|
|
36
|
+
* chain (a position has exactly one solid manager); `dotted` is a secondary /
|
|
37
|
+
* matrixed line. Defined locally to keep the org module self-contained.
|
|
38
|
+
*/
|
|
39
|
+
export const PositionReportingRelationshipTypeSchema = z.enum([
|
|
40
|
+
"solid",
|
|
41
|
+
"dotted",
|
|
42
|
+
]);
|
|
43
|
+
export type PositionReportingRelationshipType = z.infer<
|
|
44
|
+
typeof PositionReportingRelationshipTypeSchema
|
|
45
|
+
>;
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// PositionReporting — position → manager position canonical edge
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
export const PositionReportingSchema = z.object({
|
|
52
|
+
id: z.string().uuid(),
|
|
53
|
+
orgId: z.string().uuid(),
|
|
54
|
+
// The reporting (subordinate) position — the seat that reports up.
|
|
55
|
+
reportPositionId: z.string().uuid(),
|
|
56
|
+
// The manager position this seat reports to.
|
|
57
|
+
managerPositionId: z.string().uuid(),
|
|
58
|
+
// Solid (primary) vs dotted (matrixed) reporting line.
|
|
59
|
+
relationshipType: PositionReportingRelationshipTypeSchema,
|
|
60
|
+
// Provenance of this reporting edge. Optional on lightweight projections.
|
|
61
|
+
provenance: FactProvenanceSchema.optional(),
|
|
62
|
+
});
|
|
63
|
+
export type PositionReporting = z.infer<typeof PositionReportingSchema>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Position Vocabulary — the org seat.
|
|
3
|
+
*
|
|
4
|
+
* A Position is a SEAT in the org, NOT a person and NOT a title (ADR-CTRL-182
|
|
5
|
+
* clause 1). It is the slot a Person can occupy: it exists independently of
|
|
6
|
+
* whoever fills it, can be `planned` before anyone is hired, sits `open` while
|
|
7
|
+
* vacant, becomes `filled` when a Person occupies it, and is `closed` when the
|
|
8
|
+
* seat is retired. `title` is a human-facing LABEL for the seat (e.g. "Staff
|
|
9
|
+
* Engineer — Platform"); it is descriptive metadata, never the occupant's
|
|
10
|
+
* identity.
|
|
11
|
+
*
|
|
12
|
+
* Position is a structure fact (it lives in a unit and is sourced/imported like
|
|
13
|
+
* other org-structure edges), so unlike `Person` it carries a
|
|
14
|
+
* {@link FactProvenanceSchema} envelope. See control ADR-CTRL-182 for the
|
|
15
|
+
* org-model rationale and the contracts-side ADR-CONT-083 for the API addition.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
|
|
19
|
+
import { FactProvenanceSchema } from "./structure-facts";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Position lifecycle status
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Lifecycle of a seat's EXISTENCE — distinct from a hiring pipeline. A seat is
|
|
27
|
+
* `planned` (intended, not yet active), `open` (active and vacant), `filled` (a
|
|
28
|
+
* Person occupies it), or `closed` (retired). This is deliberately NOT
|
|
29
|
+
* `OpenRoleStatusSchema` (which models the hiring funnel and has `hiring`): a
|
|
30
|
+
* Position's status is about whether the seat is, not about recruiting for it.
|
|
31
|
+
*/
|
|
32
|
+
export const PositionStatusSchema = z.enum([
|
|
33
|
+
"planned",
|
|
34
|
+
"open",
|
|
35
|
+
"filled",
|
|
36
|
+
"closed",
|
|
37
|
+
]);
|
|
38
|
+
export type PositionStatus = z.infer<typeof PositionStatusSchema>;
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Position — the org seat
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
export const PositionSchema = z.object({
|
|
45
|
+
/** Unique identifier for the seat within the org graph. */
|
|
46
|
+
id: z.string().uuid(),
|
|
47
|
+
/** Owning organization (tenant) this seat belongs to. */
|
|
48
|
+
orgId: z.string().uuid(),
|
|
49
|
+
/** The org unit the seat lives in (references `org_units`). */
|
|
50
|
+
unitId: z.string().uuid(),
|
|
51
|
+
/** Human-facing LABEL for the seat, NOT the occupant's identity. */
|
|
52
|
+
title: z.string().min(1),
|
|
53
|
+
/** Lifecycle of the seat's existence. */
|
|
54
|
+
status: PositionStatusSchema,
|
|
55
|
+
/**
|
|
56
|
+
* Who owns the seat's EXISTENCE (its creation, retitling, retirement) — an
|
|
57
|
+
* OPEN string (no enum) so new owner kinds need no schema change, mirroring
|
|
58
|
+
* the `provider`/`source` open-string idiom. This is about the seat, not who
|
|
59
|
+
* fills it.
|
|
60
|
+
*/
|
|
61
|
+
lifecycleOwner: z.string().min(1),
|
|
62
|
+
/** Provenance of this seat as a structure fact. */
|
|
63
|
+
provenance: FactProvenanceSchema,
|
|
64
|
+
});
|
|
65
|
+
export type Position = z.infer<typeof PositionSchema>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SourceAuthority Vocabulary — the per-field ownership axis.
|
|
3
|
+
*
|
|
4
|
+
* A SourceAuthority binds a single FIELD of a single org-graph entity to its
|
|
5
|
+
* `systemOfRecord` — the authority that may WRITE that field (ADR-CTRL-182
|
|
6
|
+
* clause 4). It answers "who owns this value?", a question that is deliberately
|
|
7
|
+
* DISTINCT from provenance: provenance ({@link FactProvenanceSchema}) records
|
|
8
|
+
* who *reported* a fact and how confident it is, whereas SourceAuthority
|
|
9
|
+
* records who is *allowed to change* it. The two axes are orthogonal — a fact
|
|
10
|
+
* may be reported manually yet owned by an HRIS — so this schema does NOT embed
|
|
11
|
+
* or reference the provenance envelope.
|
|
12
|
+
*
|
|
13
|
+
* Like `Person`, SourceAuthority is identity/ownership vocabulary, not a
|
|
14
|
+
* structure fact, so it carries no provenance envelope. `entityType` and
|
|
15
|
+
* `systemOfRecord` are OPEN strings (no enum) so new entity kinds and new
|
|
16
|
+
* systems of record need no schema change, mirroring the `provider`/`source`
|
|
17
|
+
* open-string idiom used elsewhere in the org model. See control ADR-CTRL-182.
|
|
18
|
+
*/
|
|
19
|
+
import { z } from "zod";
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// SourceAuthority — per-field ownership: who may WRITE a given entity field
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
export const SourceAuthoritySchema = z.object({
|
|
26
|
+
/**
|
|
27
|
+
* Kind of org-graph entity the owned field belongs to (e.g. "position",
|
|
28
|
+
* "occupancy", "person"). An OPEN string so new entity kinds need no schema
|
|
29
|
+
* change.
|
|
30
|
+
*/
|
|
31
|
+
entityType: z.string().min(1),
|
|
32
|
+
/** Identifier of the specific entity instance that owns the field. */
|
|
33
|
+
entityId: z.string().uuid(),
|
|
34
|
+
/** Name of the single field this authority record governs. */
|
|
35
|
+
field: z.string().min(1),
|
|
36
|
+
/**
|
|
37
|
+
* The authority that may WRITE this field — the system of record. An OPEN
|
|
38
|
+
* string (no enum) so new systems of record need no schema change. This is
|
|
39
|
+
* about who may change the value, NOT who reported it (provenance).
|
|
40
|
+
*/
|
|
41
|
+
systemOfRecord: z.string().min(1),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
export type SourceAuthority = z.infer<typeof SourceAuthoritySchema>;
|