@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.
- package/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +122 -69
- package/src/generated/openapi-routes.ts +3 -1
- package/src/identity/README.md +2 -2
- package/src/identity/__tests__/people-org-chart.test.ts +52 -17
- package/src/identity/__tests__/position-ref.test.ts +44 -0
- package/src/identity/index.ts +6 -2
- package/src/identity/people-org-chart.ts +26 -15
- package/src/identity/position-ref.ts +24 -0
- package/src/index.ts +21 -2
- package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
- package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
- package/src/notifications/renderers/README.md +8 -4
- package/src/notifications/renderers/ascii/README.md +75 -0
- package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
- package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
- package/src/notifications/renderers/ascii/chat.ts +179 -0
- package/src/notifications/renderers/ascii/cta.ts +57 -0
- package/src/notifications/renderers/ascii/geometry.ts +112 -0
- package/src/notifications/renderers/ascii/index.ts +40 -0
- package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
- package/src/notifications/renderers/ascii/rule.ts +53 -0
- package/src/notifications/renderers/ascii/runs.ts +84 -0
- package/src/notifications/renderers/ascii/signature.ts +41 -0
- package/src/notifications/renderers/ascii/wrap.ts +96 -0
- package/src/notifications/renderers/brand.ts +12 -0
- package/src/notifications/renderers/email/chat.ts +62 -146
- package/src/notifications/renderers/email/constants.ts +17 -2
- package/src/notifications/renderers/email/cta.ts +8 -13
- package/src/notifications/renderers/email/render.ts +29 -5
- package/src/notifications/renderers/layout.ts +31 -0
- package/src/notifications/renderers/slack/README.md +135 -79
- package/src/notifications/renderers/slack/__tests__/README.md +3 -2
- package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
- package/src/notifications/renderers/slack/blocks.ts +149 -0
- package/src/notifications/renderers/slack/chat.ts +167 -0
- package/src/notifications/renderers/slack/cta.ts +69 -0
- package/src/notifications/renderers/slack/index.ts +136 -229
- package/src/notifications/renderers/slack/message.ts +23 -0
- package/src/org/README.md +10 -2
- package/src/org/__tests__/org-units.test.ts +1 -1
- package/src/org/__tests__/set-seat-manager.test.ts +177 -0
- package/src/org/index.ts +20 -2
- package/src/org/reconciliation.ts +162 -0
- package/src/org/schemas.ts +43 -17
- package/src/identity/org-chart-actor.ts +0 -24
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SetSeatManagerResponseSchema,
|
|
4
|
+
WorkspaceMemberDetailSchema,
|
|
5
|
+
WorkspaceMembersResponseSchema,
|
|
6
|
+
OpenRoleSchema,
|
|
7
|
+
} from "../schemas.js";
|
|
8
|
+
|
|
9
|
+
const REPORT_POSITION_ID = "11111111-1111-4111-8111-111111111111";
|
|
10
|
+
const MANAGER_POSITION_ID = "22222222-2222-4222-8222-222222222222";
|
|
11
|
+
const USER_ID = "33333333-3333-4333-8333-333333333333";
|
|
12
|
+
const UNIT_ID = "44444444-4444-4444-8444-444444444444";
|
|
13
|
+
const ORG_ID = "55555555-5555-4555-8555-555555555555";
|
|
14
|
+
const ROLE_ID = "66666666-6666-4666-8666-666666666666";
|
|
15
|
+
|
|
16
|
+
const makeResponse = (overrides: Record<string, unknown> = {}) => ({
|
|
17
|
+
success: true,
|
|
18
|
+
reportPositionId: REPORT_POSITION_ID,
|
|
19
|
+
manager: { positionId: MANAGER_POSITION_ID },
|
|
20
|
+
relationshipType: "solid",
|
|
21
|
+
message: "Reporting line updated.",
|
|
22
|
+
...overrides,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("SetSeatManagerResponseSchema", () => {
|
|
26
|
+
it("accepts a position-keyed set and a position-keyed clear", () => {
|
|
27
|
+
expect(() =>
|
|
28
|
+
SetSeatManagerResponseSchema.parse(makeResponse()),
|
|
29
|
+
).not.toThrow();
|
|
30
|
+
expect(() =>
|
|
31
|
+
SetSeatManagerResponseSchema.parse(
|
|
32
|
+
makeResponse({ manager: null, message: "Reporting line cleared." }),
|
|
33
|
+
),
|
|
34
|
+
).not.toThrow();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("requires reportPositionId to be a uuid, not an opaque member id", () => {
|
|
38
|
+
expect(
|
|
39
|
+
SetSeatManagerResponseSchema.safeParse(
|
|
40
|
+
makeResponse({ reportPositionId: "member-1" }),
|
|
41
|
+
).success,
|
|
42
|
+
).toBe(false);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
// The whole point of the literal: this route rides
|
|
46
|
+
// setSolidManagerByPositions, which only ever writes a solid edge. A wider
|
|
47
|
+
// relationship vocabulary here would promise a dotted-line write the route
|
|
48
|
+
// cannot perform.
|
|
49
|
+
it("rejects any relationshipType other than the solid literal", () => {
|
|
50
|
+
expect(
|
|
51
|
+
SetSeatManagerResponseSchema.safeParse(
|
|
52
|
+
makeResponse({ relationshipType: "dotted" }),
|
|
53
|
+
).success,
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// The retired shape named the subordinate by user (`memberId`) and the
|
|
58
|
+
// manager by a member-or-open-role union. Neither survives: a stray memberId
|
|
59
|
+
// is stripped, and it cannot stand in for the report seat.
|
|
60
|
+
it("does not admit the retired memberId key as the report reference", () => {
|
|
61
|
+
expect(
|
|
62
|
+
SetSeatManagerResponseSchema.parse(makeResponse({ memberId: USER_ID })),
|
|
63
|
+
).toEqual(makeResponse());
|
|
64
|
+
const { reportPositionId: _dropped, ...withoutReport } = makeResponse();
|
|
65
|
+
expect(
|
|
66
|
+
SetSeatManagerResponseSchema.safeParse({
|
|
67
|
+
...withoutReport,
|
|
68
|
+
memberId: USER_ID,
|
|
69
|
+
}).success,
|
|
70
|
+
).toBe(false);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("rejects a manager named by userId rather than positionId", () => {
|
|
74
|
+
expect(
|
|
75
|
+
SetSeatManagerResponseSchema.safeParse(
|
|
76
|
+
makeResponse({ manager: { kind: "member", userId: USER_ID } }),
|
|
77
|
+
).success,
|
|
78
|
+
).toBe(false);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const makeMember = (overrides: Record<string, unknown> = {}) => ({
|
|
83
|
+
id: USER_ID,
|
|
84
|
+
name: "Ada Lovelace",
|
|
85
|
+
email: "ada@example.com",
|
|
86
|
+
jobTitle: "Principal Engineer",
|
|
87
|
+
seatPositionId: REPORT_POSITION_ID,
|
|
88
|
+
role: null,
|
|
89
|
+
roleNames: [],
|
|
90
|
+
joinedAt: "2026-04-17T00:00:00Z",
|
|
91
|
+
lastActiveAt: null,
|
|
92
|
+
primaryUnitId: UNIT_ID,
|
|
93
|
+
unitMemberships: [],
|
|
94
|
+
unitDesignations: [],
|
|
95
|
+
unitMembershipsTruncated: false,
|
|
96
|
+
inviteStatus: "active",
|
|
97
|
+
...overrides,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("WorkspaceMember seatPositionId", () => {
|
|
101
|
+
it("carries the seat on the list shape, so a READ resolves it", () => {
|
|
102
|
+
expect(() =>
|
|
103
|
+
WorkspaceMembersResponseSchema.parse({
|
|
104
|
+
items: [makeMember()],
|
|
105
|
+
nextCursor: null,
|
|
106
|
+
hasMore: false,
|
|
107
|
+
}),
|
|
108
|
+
).not.toThrow();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("is inherited by the detail shape", () => {
|
|
112
|
+
expect(() =>
|
|
113
|
+
WorkspaceMemberDetailSchema.parse({
|
|
114
|
+
...makeMember(),
|
|
115
|
+
effectiveScopes: [],
|
|
116
|
+
recentActions: [],
|
|
117
|
+
}),
|
|
118
|
+
).not.toThrow();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Null is a real state — a member with no materialized seat. The read reports
|
|
122
|
+
// it honestly rather than forcing the server to mint one.
|
|
123
|
+
it("accepts null for a member with no materialized seat", () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
WorkspaceMemberDetailSchema.parse({
|
|
126
|
+
...makeMember({ seatPositionId: null }),
|
|
127
|
+
effectiveScopes: [],
|
|
128
|
+
recentActions: [],
|
|
129
|
+
}),
|
|
130
|
+
).not.toThrow();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("is required — a member shape without it no longer parses", () => {
|
|
134
|
+
const { seatPositionId: _dropped, ...withoutSeat } = makeMember();
|
|
135
|
+
expect(
|
|
136
|
+
WorkspaceMemberDetailSchema.safeParse({
|
|
137
|
+
...withoutSeat,
|
|
138
|
+
effectiveScopes: [],
|
|
139
|
+
recentActions: [],
|
|
140
|
+
}).success,
|
|
141
|
+
).toBe(false);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("OpenRole reportsToPositionId", () => {
|
|
146
|
+
const makeOpenRole = (overrides: Record<string, unknown> = {}) => ({
|
|
147
|
+
id: ROLE_ID,
|
|
148
|
+
orgId: ORG_ID,
|
|
149
|
+
unitId: UNIT_ID,
|
|
150
|
+
title: "Senior Engineer",
|
|
151
|
+
targetStartDate: null,
|
|
152
|
+
status: "open",
|
|
153
|
+
filledByUserId: null,
|
|
154
|
+
reportsToPositionId: MANAGER_POSITION_ID,
|
|
155
|
+
createdAt: "2026-04-17T00:00:00Z",
|
|
156
|
+
updatedAt: "2026-04-17T00:00:00Z",
|
|
157
|
+
...overrides,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("names the manager by position, and null still means unmanaged", () => {
|
|
161
|
+
expect(() => OpenRoleSchema.parse(makeOpenRole())).not.toThrow();
|
|
162
|
+
expect(() =>
|
|
163
|
+
OpenRoleSchema.parse(makeOpenRole({ reportsToPositionId: null })),
|
|
164
|
+
).not.toThrow();
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// The retired reportsToUserId flattening read null whenever the manager had
|
|
168
|
+
// no account — which, post-HRIS, is nearly every manager.
|
|
169
|
+
it("no longer accepts the retired reportsToUserId flattening", () => {
|
|
170
|
+
const { reportsToPositionId: _dropped, ...withoutPosition } =
|
|
171
|
+
makeOpenRole();
|
|
172
|
+
expect(
|
|
173
|
+
OpenRoleSchema.safeParse({ ...withoutPosition, reportsToUserId: USER_ID })
|
|
174
|
+
.success,
|
|
175
|
+
).toBe(false);
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/org/index.ts
CHANGED
|
@@ -150,6 +150,24 @@ export type {
|
|
|
150
150
|
OrgStructureInferenceDecisionResult,
|
|
151
151
|
} from "./reasoning-review";
|
|
152
152
|
|
|
153
|
+
// Org reconciliation ledger: the org's STANDING disagreements with the connected
|
|
154
|
+
// HRIS — the open ones only, whether they arose during a sync (runId set) or
|
|
155
|
+
// during app use (runId null). Complements the run-scoped conflicts view; does
|
|
156
|
+
// not replace it. Resolution is a LOCAL decision (`sentToSource` is the literal
|
|
157
|
+
// false). (ADR-BE-564)
|
|
158
|
+
export {
|
|
159
|
+
OrgDivergenceSchema,
|
|
160
|
+
OrgReconciliationLedgerSchema,
|
|
161
|
+
OrgDivergenceResolutionSchema,
|
|
162
|
+
ResolveOrgDivergenceResultSchema,
|
|
163
|
+
} from "./reconciliation";
|
|
164
|
+
export type {
|
|
165
|
+
OrgDivergence,
|
|
166
|
+
OrgReconciliationLedger,
|
|
167
|
+
OrgDivergenceResolution,
|
|
168
|
+
ResolveOrgDivergenceResult,
|
|
169
|
+
} from "./reconciliation";
|
|
170
|
+
|
|
153
171
|
// Org transformation wire vocabulary: intent -> simulation -> preview. The
|
|
154
172
|
// engine turns a TransformationRequest into scored mutation bundles and returns
|
|
155
173
|
// a TransformationPreview the operator confirms before apply. (ADR-CONTRACTS-072)
|
|
@@ -296,7 +314,7 @@ export {
|
|
|
296
314
|
TestSsoResultSchema,
|
|
297
315
|
RemoveMemberResponseSchema,
|
|
298
316
|
ChangeMemberRoleResponseSchema,
|
|
299
|
-
|
|
317
|
+
SetSeatManagerResponseSchema,
|
|
300
318
|
SubmitInteractiveTaskResponseSchema,
|
|
301
319
|
UserOrgsResponseSchema,
|
|
302
320
|
SetActiveOrgResponseSchema,
|
|
@@ -321,7 +339,7 @@ export type {
|
|
|
321
339
|
TestSsoResult as TestSsoResultDto,
|
|
322
340
|
RemoveMemberResponse,
|
|
323
341
|
ChangeMemberRoleResponse,
|
|
324
|
-
|
|
342
|
+
SetSeatManagerResponse,
|
|
325
343
|
SubmitInteractiveTaskResponse,
|
|
326
344
|
UserOrgsResponse,
|
|
327
345
|
SetActiveOrgResponse,
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Org reconciliation ledger — what this workspace currently disagrees with the
|
|
3
|
+
* connected HRIS about.
|
|
4
|
+
*
|
|
5
|
+
* A {@link ConflictRecordSchema} row is the audit trail of ONE reconciliation
|
|
6
|
+
* event. The ledger is the standing view over those rows: for each contested
|
|
7
|
+
* `(entityType, entityId, field)` it keeps the workspace's CURRENT word and
|
|
8
|
+
* drops the ones the two sides have since agreed on. It answers "what is still
|
|
9
|
+
* open?", where the run-scoped conflicts view answers "what did THIS run
|
|
10
|
+
* refuse?" — the two coexist and neither replaces the other (ADR-BE-564).
|
|
11
|
+
*
|
|
12
|
+
* `runId` is NULLABLE here, and that is the whole reason this is a separate
|
|
13
|
+
* shape rather than a reuse of `ConflictRecordSchema` (whose `runId` is
|
|
14
|
+
* required). A divergence can arise OUTSIDE a sync run — at invite acceptance,
|
|
15
|
+
* or from a canonical-placement integrity breach — and such a row is deliberately
|
|
16
|
+
* not tied to a run, so it neither disappears when the run is pruned nor
|
|
17
|
+
* multiplies once per sync.
|
|
18
|
+
*
|
|
19
|
+
* Like the sibling org schemas, `entityType` and `field` are OPEN strings (no
|
|
20
|
+
* enum) so new contested kinds need no schema change, and `winningTier` REUSES
|
|
21
|
+
* the closed {@link FactSourceTierSchema} precedence axis rather than redefining
|
|
22
|
+
* it. Both competing values are serialized as strings and are nullable, because
|
|
23
|
+
* either side may hold no value.
|
|
24
|
+
*
|
|
25
|
+
* ## Resolution is LOCAL, always
|
|
26
|
+
*
|
|
27
|
+
* {@link ResolveOrgDivergenceResultSchema} is the seam a later write-back phase
|
|
28
|
+
* plugs into, and it is shaped so that phase cannot arrive by accident:
|
|
29
|
+
* `sentToSource` is the literal `false`, so any surface claiming a divergence
|
|
30
|
+
* was pushed upstream fails to typecheck against this contract. Today the HRIS
|
|
31
|
+
* client is read-only (directory + changed-since over a hardcoded GET) and no
|
|
32
|
+
* write scope is requested at connect time. Resolving a divergence records a
|
|
33
|
+
* decision in THIS workspace; nothing may state or imply the HRIS was updated.
|
|
34
|
+
*
|
|
35
|
+
* Resolving therefore does NOT close a divergence: the two sides still hold
|
|
36
|
+
* different values, so the entry stays in the ledger and carries
|
|
37
|
+
* `acknowledgedAt`. A shape that dropped it on resolution would let a click hide
|
|
38
|
+
* a live disagreement — the failure this ledger exists to prevent. What DOES
|
|
39
|
+
* remove an entry is the two sides agreeing.
|
|
40
|
+
*/
|
|
41
|
+
import { z } from "zod";
|
|
42
|
+
|
|
43
|
+
import { FactSourceTierSchema } from "./structure-facts";
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// OrgDivergence — one open disagreement between the HRIS and this workspace
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export const OrgDivergenceSchema = z.object({
|
|
50
|
+
/** Identifier of the ledger row carrying the workspace's current word. */
|
|
51
|
+
id: z.string().uuid(),
|
|
52
|
+
/**
|
|
53
|
+
* The sync run that observed this disagreement, when one did. `null` marks a
|
|
54
|
+
* STANDING divergence recorded outside any run (invite acceptance, a
|
|
55
|
+
* canonical-placement integrity breach).
|
|
56
|
+
*/
|
|
57
|
+
runId: z.string().uuid().nullable(),
|
|
58
|
+
/**
|
|
59
|
+
* Kind of org-graph entity the contested field belongs to (e.g. "person",
|
|
60
|
+
* "position", "occupancy"). An OPEN string so new entity kinds need no schema
|
|
61
|
+
* change.
|
|
62
|
+
*/
|
|
63
|
+
entityType: z.string().min(1),
|
|
64
|
+
/** Identifier of the specific entity instance in disagreement. */
|
|
65
|
+
entityId: z.string().uuid(),
|
|
66
|
+
/** Name of the single contested field. */
|
|
67
|
+
field: z.string().min(1),
|
|
68
|
+
/**
|
|
69
|
+
* What the HRIS asserts for this field, serialized as a string. `null` when
|
|
70
|
+
* the imported side carries no value.
|
|
71
|
+
*/
|
|
72
|
+
importedValue: z.string().nullable(),
|
|
73
|
+
/**
|
|
74
|
+
* What this workspace currently holds for this field, serialized as a string.
|
|
75
|
+
* `null` when the workspace holds no value.
|
|
76
|
+
*/
|
|
77
|
+
currentValue: z.string().nullable(),
|
|
78
|
+
/**
|
|
79
|
+
* The value in force, serialized as a string. `null` when the resolution left
|
|
80
|
+
* the field empty.
|
|
81
|
+
*/
|
|
82
|
+
winningValue: z.string().nullable(),
|
|
83
|
+
/**
|
|
84
|
+
* The precedence tier of the value in force — the same closed truth-hierarchy
|
|
85
|
+
* axis used across the org model. Reused, not redefined.
|
|
86
|
+
*/
|
|
87
|
+
winningTier: FactSourceTierSchema,
|
|
88
|
+
/**
|
|
89
|
+
* The sentence a human reads: it names BOTH sides and why the winner won.
|
|
90
|
+
* Capped at 2000 chars to mirror `ConflictRecord.reason`. `null` when the row
|
|
91
|
+
* was recorded without a narrative.
|
|
92
|
+
*/
|
|
93
|
+
reason: z.string().max(2000).nullable(),
|
|
94
|
+
/** ISO timestamp the workspace's current word on this field was recorded. */
|
|
95
|
+
recordedAt: z.string().datetime(),
|
|
96
|
+
/**
|
|
97
|
+
* ISO timestamp someone in this workspace ACKNOWLEDGED this entry, or `null`.
|
|
98
|
+
*
|
|
99
|
+
* A triage marker, not a resolution: the two sides still differ and nothing
|
|
100
|
+
* was sent to the HRIS, so the entry is still open and still listed. It is
|
|
101
|
+
* keyed to THIS row, so a disagreement that moves — a superseding row becomes
|
|
102
|
+
* the entry — arrives unacknowledged, which is the right default for a
|
|
103
|
+
* statement nobody has read yet.
|
|
104
|
+
*/
|
|
105
|
+
acknowledgedAt: z.string().datetime().nullable(),
|
|
106
|
+
/** Who acknowledged it. `null` when nobody has, or when that user is gone. */
|
|
107
|
+
acknowledgedByUserId: z.string().uuid().nullable(),
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
export type OrgDivergence = z.infer<typeof OrgDivergenceSchema>;
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// OrgReconciliationLedger — the org's open divergences, newest first
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
export const OrgReconciliationLedgerSchema = z.object({
|
|
117
|
+
/**
|
|
118
|
+
* The org's still-open divergences, newest first. One entry per contested
|
|
119
|
+
* `(entityType, entityId, field)`; settled disagreements are omitted.
|
|
120
|
+
*/
|
|
121
|
+
divergences: z.array(OrgDivergenceSchema),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type OrgReconciliationLedger = z.infer<
|
|
125
|
+
typeof OrgReconciliationLedgerSchema
|
|
126
|
+
>;
|
|
127
|
+
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// Resolution — a LOCAL decision, never an upstream write
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What resolving a divergence means today. `acknowledged` records that someone
|
|
134
|
+
* in this workspace has seen and accepted the disagreement; it does not, and
|
|
135
|
+
* cannot, change anything in the HRIS.
|
|
136
|
+
*/
|
|
137
|
+
export const OrgDivergenceResolutionSchema = z.enum(["acknowledged"]);
|
|
138
|
+
|
|
139
|
+
export type OrgDivergenceResolution = z.infer<
|
|
140
|
+
typeof OrgDivergenceResolutionSchema
|
|
141
|
+
>;
|
|
142
|
+
|
|
143
|
+
export const ResolveOrgDivergenceResultSchema = z.object({
|
|
144
|
+
/** Identifier of the divergence that was resolved. */
|
|
145
|
+
id: z.string().uuid(),
|
|
146
|
+
/** How it was resolved. Local decisions only — see the module docblock. */
|
|
147
|
+
resolution: OrgDivergenceResolutionSchema,
|
|
148
|
+
/** ISO timestamp the decision was recorded in this workspace. */
|
|
149
|
+
resolvedAt: z.string().datetime(),
|
|
150
|
+
/**
|
|
151
|
+
* Whether anything was written back to the source system. Pinned to the
|
|
152
|
+
* literal `false`: the HRIS client is read-only and no write scope is
|
|
153
|
+
* requested, so a surface that claims otherwise cannot typecheck against this
|
|
154
|
+
* contract. A future write-back phase widens this deliberately, in its own
|
|
155
|
+
* decision record — not by accident.
|
|
156
|
+
*/
|
|
157
|
+
sentToSource: z.literal(false),
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
export type ResolveOrgDivergenceResult = z.infer<
|
|
161
|
+
typeof ResolveOrgDivergenceResultSchema
|
|
162
|
+
>;
|
package/src/org/schemas.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { z } from "zod";
|
|
14
14
|
import { CursorPageSchema } from "../api/primitives";
|
|
15
15
|
import { OrgChartRoleSchema } from "../permissions/orgchart-roles";
|
|
16
|
-
import {
|
|
16
|
+
import { PositionRefSchema } from "../identity/position-ref";
|
|
17
17
|
|
|
18
18
|
// ---------------------------------------------------------------------------
|
|
19
19
|
// Sub-schemas
|
|
@@ -90,6 +90,17 @@ const WorkspaceMemberSchema = z.object({
|
|
|
90
90
|
avatarUrl: z.string().nullable().optional(),
|
|
91
91
|
/** The occupant's Position seat title (org-scoped). Null when the seat is untitled. */
|
|
92
92
|
jobTitle: z.string().nullable(),
|
|
93
|
+
/**
|
|
94
|
+
* The member's Position seat in the org graph — the id every reporting write
|
|
95
|
+
* is keyed on (ADR-CONTRACTS-131). Present here so a user-ADDRESSED surface
|
|
96
|
+
* (the members table, a person picker) can resolve a seat by READ and hand
|
|
97
|
+
* the position straight to `PUT /api/reporting/positions/{positionId}/manager`,
|
|
98
|
+
* instead of a write path minting one with `resolveSeat(create: true)`.
|
|
99
|
+
*
|
|
100
|
+
* Nullable: a member with no materialized seat is a legitimate state, and a
|
|
101
|
+
* read must report that honestly rather than force the server to create one.
|
|
102
|
+
*/
|
|
103
|
+
seatPositionId: z.string().uuid().nullable(),
|
|
93
104
|
/**
|
|
94
105
|
* Display role — the member's highest org-chart standing, or `null` for a
|
|
95
106
|
* plain member with no org-chart authority. Render via `orgChartRoleLabel`.
|
|
@@ -524,30 +535,39 @@ export type ChangeMemberRoleResponse = z.infer<
|
|
|
524
535
|
>;
|
|
525
536
|
|
|
526
537
|
// ---------------------------------------------------------------------------
|
|
527
|
-
//
|
|
538
|
+
// PUT /api/reporting/positions/:positionId/manager
|
|
528
539
|
// ---------------------------------------------------------------------------
|
|
529
540
|
|
|
530
541
|
/**
|
|
531
|
-
* Response for
|
|
532
|
-
*
|
|
533
|
-
*
|
|
534
|
-
* `
|
|
535
|
-
*
|
|
542
|
+
* Response for the one idempotent set-or-clear of a seat's solid-line manager —
|
|
543
|
+
* the reporting edge the org chart is built from. Position-keyed at BOTH ends
|
|
544
|
+
* (ADR-CONTRACTS-131): `reportPositionId` is the seat whose edge moved and
|
|
545
|
+
* `manager` is the POSITION it now reports to, null after a clear. A position
|
|
546
|
+
* names a vacant seat and an accountless occupant's seat just as well as a
|
|
547
|
+
* filled one, which the retired member-or-open-role union could not.
|
|
548
|
+
*
|
|
549
|
+
* `relationshipType` is a literal rather than the identity-domain
|
|
550
|
+
* `ReportingRelationshipTypeSchema`: importing that here would invert the
|
|
551
|
+
* existing people-org-chart → org/schemas dependency and recreate the
|
|
552
|
+
* module-init cycle. This route only ever writes solid edges, so the narrower
|
|
553
|
+
* literal is also the truthful shape. (`PositionReportingRelationshipTypeSchema`
|
|
554
|
+
* in ./position-reporting carries the open {solid, dotted} vocabulary for the
|
|
555
|
+
* read model.)
|
|
536
556
|
*
|
|
537
557
|
* Distinct from team membership (RBAC scopes) and `primary_unit_id` (home
|
|
538
558
|
* unit): neither of those places a person in the reporting hierarchy.
|
|
539
559
|
* See ADR-BE-253.
|
|
540
560
|
*/
|
|
541
|
-
export const
|
|
561
|
+
export const SetSeatManagerResponseSchema = z.object({
|
|
542
562
|
success: z.boolean(),
|
|
543
|
-
|
|
544
|
-
manager:
|
|
545
|
-
relationshipType: z.
|
|
563
|
+
reportPositionId: z.string().uuid(),
|
|
564
|
+
manager: PositionRefSchema.nullable(),
|
|
565
|
+
relationshipType: z.literal("solid"),
|
|
546
566
|
message: z.string(),
|
|
547
567
|
});
|
|
548
568
|
|
|
549
|
-
export type
|
|
550
|
-
typeof
|
|
569
|
+
export type SetSeatManagerResponse = z.infer<
|
|
570
|
+
typeof SetSeatManagerResponseSchema
|
|
551
571
|
>;
|
|
552
572
|
|
|
553
573
|
// ---------------------------------------------------------------------------
|
|
@@ -1134,10 +1154,16 @@ export const OpenRoleSchema = z.object({
|
|
|
1134
1154
|
targetStartDate: z.string().nullable(),
|
|
1135
1155
|
status: OpenRoleStatusSchema,
|
|
1136
1156
|
filledByUserId: z.string().uuid().nullable(),
|
|
1137
|
-
// The open role's own reporting edge (ADR-BE-291): defaults to its creator
|
|
1138
|
-
// editable/clearable. Drives chart placement now that the unit-head
|
|
1139
|
-
// retired (ADR-BE-292). Null → unmanaged (Unassigned bucket).
|
|
1140
|
-
|
|
1157
|
+
// The open role's own reporting edge (ADR-BE-291): defaults to its creator's
|
|
1158
|
+
// seat, editable/clearable. Drives chart placement now that the unit-head
|
|
1159
|
+
// anchor is retired (ADR-BE-292). Null → unmanaged (Unassigned bucket).
|
|
1160
|
+
//
|
|
1161
|
+
// The manager POSITION, not a user (ADR-CONTRACTS-131). The retired
|
|
1162
|
+
// user-keyed flattening was a lie in two directions: an open-role manager
|
|
1163
|
+
// already surfaced as null because it had no user to collapse to, and
|
|
1164
|
+
// post-HRIS every open role reporting to an imported (accountless) manager
|
|
1165
|
+
// would read null too.
|
|
1166
|
+
reportsToPositionId: z.string().uuid().nullable(),
|
|
1141
1167
|
createdAt: z.string(),
|
|
1142
1168
|
updatedAt: z.string(),
|
|
1143
1169
|
});
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Org-chart actor reference — either endpoint of a reporting edge.
|
|
3
|
-
*
|
|
4
|
-
* A node in the people chart that can sit on EITHER side of a reporting edge: a
|
|
5
|
-
* filled member (keyed by occupant `userId`) OR an unfilled open-role seat
|
|
6
|
-
* (keyed by its own `openRoleId`). A vacant seat is a first-class reporting
|
|
7
|
-
* actor — it can be a manager that real people report to (ADR-BE-312), not just
|
|
8
|
-
* a report. The discriminant lets an edge point at either kind without losing
|
|
9
|
-
* what the id means. Mirrors the app's `ActorIdentity` shape exactly so the app
|
|
10
|
-
* consumes this type directly (one common spine across wire + UI).
|
|
11
|
-
*
|
|
12
|
-
* Lives in its own zero-dependency module (zod only) so both `people-org-chart`
|
|
13
|
-
* (which already depends on `org/schemas` for AuthorityMutability) and
|
|
14
|
-
* `org/schemas` (which needs it for the set-manager request/response) can import
|
|
15
|
-
* it without forming a module-init cycle.
|
|
16
|
-
*/
|
|
17
|
-
import { z } from "zod";
|
|
18
|
-
|
|
19
|
-
export const OrgChartActorRefSchema = z.discriminatedUnion("kind", [
|
|
20
|
-
z.object({ kind: z.literal("member"), userId: z.string().uuid() }),
|
|
21
|
-
z.object({ kind: z.literal("open-role"), openRoleId: z.string().uuid() }),
|
|
22
|
-
]);
|
|
23
|
-
|
|
24
|
-
export type OrgChartActorRef = z.infer<typeof OrgChartActorRefSchema>;
|