@company-semantics/contracts 58.6.0 → 59.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 +8 -3
- package/src/decisions/README.md +41 -0
- package/src/decisions/__tests__/README.md +37 -0
- package/src/decisions/__tests__/schemas.test.ts +146 -0
- package/src/decisions/index.ts +32 -0
- package/src/decisions/schemas.ts +107 -0
- package/src/decisions/types.ts +192 -0
- package/src/index.ts +6 -0
- package/src/org/README.md +3 -1
- package/src/org/__tests__/structure-inference.test.ts +70 -2
- package/src/org/index.ts +1 -0
- package/src/org/structure-inference.ts +36 -16
- package/src/resource-key-tables.ts +191 -0
- package/src/resource-keys.ts +12 -176
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "59.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -72,7 +72,11 @@
|
|
|
72
72
|
"types": "./src/ingestion/index.ts",
|
|
73
73
|
"default": "./src/ingestion/index.ts"
|
|
74
74
|
},
|
|
75
|
-
"./schemas/guard-result.schema.json": "./schemas/guard-result.schema.json"
|
|
75
|
+
"./schemas/guard-result.schema.json": "./schemas/guard-result.schema.json",
|
|
76
|
+
"./decisions": {
|
|
77
|
+
"types": "./src/decisions/index.ts",
|
|
78
|
+
"default": "./src/decisions/index.ts"
|
|
79
|
+
}
|
|
76
80
|
},
|
|
77
81
|
"types": "./src/index.ts",
|
|
78
82
|
"files": [
|
|
@@ -127,7 +131,8 @@
|
|
|
127
131
|
"repo-map:check": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-ci/scripts/generate-repo-map.ts\" --roots src --name company-semantics-contracts --check",
|
|
128
132
|
"readme-api": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-ci/scripts/generate-readme-api.ts\" --roots src --write --jsdoc",
|
|
129
133
|
"readme-api:check": "tsx \"$(git rev-parse --path-format=absolute --git-common-dir)/../../company-semantics-ci/scripts/generate-readme-api.ts\" --roots src --check --jsdoc",
|
|
130
|
-
"validate:amp": "tsx scripts/validate-amp.ts"
|
|
134
|
+
"validate:amp": "tsx scripts/validate-amp.ts",
|
|
135
|
+
"file-sizes:pin": "sh scripts/ci/file-sizes-pin.sh"
|
|
131
136
|
},
|
|
132
137
|
"packageManager": "pnpm@10.25.0",
|
|
133
138
|
"engines": {
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# decisions/
|
|
2
|
+
|
|
3
|
+
A question the system asks a human, and what answering it does.
|
|
4
|
+
|
|
5
|
+
## Purpose
|
|
6
|
+
|
|
7
|
+
The surface-agnostic vocabulary for a decidable question. One derivation
|
|
8
|
+
produces a `DecisionQuestion`; the org-settings review panel renders it as radio
|
|
9
|
+
buttons, the chat surface projects it onto an interactive task, and an MCP tool
|
|
10
|
+
lists it for an agent. None of those owns the shape, so it lives here.
|
|
11
|
+
|
|
12
|
+
It exists because an option used to be a bare string doing three jobs at once —
|
|
13
|
+
the label a reviewer read, the value on the receipt, and the payload the apply
|
|
14
|
+
consumed. A unit was renamed to the literal sentence
|
|
15
|
+
`Rename the existing unit to 'People' (same unitId, preserving history)` because
|
|
16
|
+
the label WAS the payload. `label` and `effect` are separate channels here so
|
|
17
|
+
that cannot recur.
|
|
18
|
+
|
|
19
|
+
## Invariants
|
|
20
|
+
|
|
21
|
+
- **An apply reads `effect`, never `label`.** The label is prose for a human and
|
|
22
|
+
may say anything that makes the consequence clear.
|
|
23
|
+
- **An option is named by `id`, never by its label.** Labels get reworded; an id
|
|
24
|
+
is what a receipt, a ledger row, and an agent can all still resolve.
|
|
25
|
+
- `recommendedOptionId` must name one of `options`, and option ids are unique
|
|
26
|
+
within a question — both enforced by `DecisionQuestionSchema`.
|
|
27
|
+
- Choosing the recommendation is the **identity transform**. A recommendation
|
|
28
|
+
describes what the surrounding proposal already encodes, so following it
|
|
29
|
+
rewrites nothing.
|
|
30
|
+
- `DecisionEffect` is a **closed union**. An answer may only do things a reader
|
|
31
|
+
of `types.ts` can enumerate.
|
|
32
|
+
- **`freeText` is never applied as an effect on its own.** It is recorded beside
|
|
33
|
+
a chosen option, or resolved into a proposed effect that is shown back before
|
|
34
|
+
anything runs. Turning free text into an action without showing the action is
|
|
35
|
+
the one thing this vocabulary must not enable.
|
|
36
|
+
- `id` is **content-derived and durable** across re-derivations. An index into a
|
|
37
|
+
payload is not an identity.
|
|
38
|
+
|
|
39
|
+
## Dependencies
|
|
40
|
+
|
|
41
|
+
`zod` only. This package stays pure vocabulary — no domain logic, no I/O.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# decisions/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the one claim the type system cannot make: that an option's **effect** is
|
|
6
|
+
the payload and its **label** is only prose.
|
|
7
|
+
|
|
8
|
+
- `schemas.test.ts` — the load-bearing case is
|
|
9
|
+
`carries the effect separately from the label`, built from the exact question
|
|
10
|
+
that produced the defect: the option labelled
|
|
11
|
+
`Rename the existing unit to 'People' (same unitId, preserving history)` must
|
|
12
|
+
parse to `{ kind: "rename_unit", name: "People" }`. A regression that made the
|
|
13
|
+
label load-bearing again would still typecheck, because both are strings.
|
|
14
|
+
|
|
15
|
+
Also the cross-field rules an extending domain inherits and could otherwise
|
|
16
|
+
silently drop: a `recommendedOptionId` naming no option (there would be no
|
|
17
|
+
answer to "what happens if you do nothing", which is the common case), duplicate
|
|
18
|
+
option ids, and a single-option question — a notification, not a question. Plus
|
|
19
|
+
the bounds that matter at a boundary: a rename name past `org_units.name`'s 255
|
|
20
|
+
(the defect's sentence cleared every length check in the system), an unknown
|
|
21
|
+
effect kind, and `archive_unit` requiring a real uuid because it addresses a
|
|
22
|
+
DURABLE unit rather than a proposal-scoped temp id.
|
|
23
|
+
|
|
24
|
+
## Invariants
|
|
25
|
+
|
|
26
|
+
- These assert VOCABULARY and SHAPE, never behaviour. How an apply consumes an
|
|
27
|
+
effect belongs in backend's structure suites.
|
|
28
|
+
- Fixtures use the REAL question from the incident rather than a synthetic one,
|
|
29
|
+
so a reader can see what the regression looked like.
|
|
30
|
+
|
|
31
|
+
## Public API
|
|
32
|
+
|
|
33
|
+
None — test-only.
|
|
34
|
+
|
|
35
|
+
## Dependencies
|
|
36
|
+
|
|
37
|
+
`vitest` and `../index`.
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The decision vocabulary's boundary rules.
|
|
3
|
+
*
|
|
4
|
+
* Regression origin: an org unit was renamed to the literal option label
|
|
5
|
+
* `Rename the existing unit to 'People' (same unitId, preserving history)`
|
|
6
|
+
* because the label WAS the payload. These lock the separation that replaced it.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
DecisionAnswerSchema,
|
|
12
|
+
DecisionEffectSchema,
|
|
13
|
+
DecisionQuestionSchema,
|
|
14
|
+
followsRecommendation,
|
|
15
|
+
optionById,
|
|
16
|
+
recommendedOption,
|
|
17
|
+
type DecisionOption,
|
|
18
|
+
type DecisionQuestion,
|
|
19
|
+
} from "../index";
|
|
20
|
+
|
|
21
|
+
const KEEP: DecisionOption = {
|
|
22
|
+
id: "keep",
|
|
23
|
+
label:
|
|
24
|
+
"Keep the name 'Human Resources', matching the department string carried by 18 people.",
|
|
25
|
+
effect: { kind: "none" },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const RENAME: DecisionOption = {
|
|
29
|
+
id: "rename",
|
|
30
|
+
label:
|
|
31
|
+
"Rename the existing unit to 'People' (same unitId, preserving history)",
|
|
32
|
+
description: "Keeps its documents, history and permissions.",
|
|
33
|
+
effect: { kind: "rename_unit", unitTempId: "u_hr", name: "People" },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const RENAME_QUESTION: DecisionQuestion = {
|
|
37
|
+
id: "unit_name:af3dfd54",
|
|
38
|
+
header: "Unit name",
|
|
39
|
+
question:
|
|
40
|
+
"Should the Human Resources unit be renamed 'People' to match its leader's title?",
|
|
41
|
+
options: [KEEP, RENAME],
|
|
42
|
+
recommendedOptionId: "keep",
|
|
43
|
+
multiSelect: false,
|
|
44
|
+
freeFormAllowed: true,
|
|
45
|
+
confidence: 0.75,
|
|
46
|
+
signals: {
|
|
47
|
+
Departments: "'Human Resources' is the source label for 18 people.",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
describe("DecisionQuestionSchema", () => {
|
|
52
|
+
it("accepts a question whose labels are prose and whose effect carries the name", () => {
|
|
53
|
+
const parsed = DecisionQuestionSchema.parse(RENAME_QUESTION);
|
|
54
|
+
const rename = parsed.options.find((option) => option.id === "rename");
|
|
55
|
+
// The whole point: the NAME lives in the effect, not scraped from the label.
|
|
56
|
+
expect(rename?.effect).toEqual({
|
|
57
|
+
kind: "rename_unit",
|
|
58
|
+
unitTempId: "u_hr",
|
|
59
|
+
name: "People",
|
|
60
|
+
});
|
|
61
|
+
expect(rename?.label).toContain("preserving history");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects a recommendation that names no option", () => {
|
|
65
|
+
const result = DecisionQuestionSchema.safeParse({
|
|
66
|
+
...RENAME_QUESTION,
|
|
67
|
+
recommendedOptionId: "nonexistent",
|
|
68
|
+
});
|
|
69
|
+
expect(result.success).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("rejects duplicate option ids", () => {
|
|
73
|
+
const result = DecisionQuestionSchema.safeParse({
|
|
74
|
+
...RENAME_QUESTION,
|
|
75
|
+
options: [KEEP, KEEP],
|
|
76
|
+
});
|
|
77
|
+
expect(result.success).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("rejects a single-option question - that is a notification, not a question", () => {
|
|
81
|
+
const result = DecisionQuestionSchema.safeParse({
|
|
82
|
+
...RENAME_QUESTION,
|
|
83
|
+
options: [KEEP],
|
|
84
|
+
});
|
|
85
|
+
expect(result.success).toBe(false);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("DecisionEffectSchema", () => {
|
|
90
|
+
it("rejects a rename whose name is a whole sentence past the column bound", () => {
|
|
91
|
+
const result = DecisionEffectSchema.safeParse({
|
|
92
|
+
kind: "rename_unit",
|
|
93
|
+
unitTempId: "u_hr",
|
|
94
|
+
name: "x".repeat(256),
|
|
95
|
+
});
|
|
96
|
+
expect(result.success).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("rejects an unknown effect kind rather than passing it through", () => {
|
|
100
|
+
const result = DecisionEffectSchema.safeParse({
|
|
101
|
+
kind: "delete_everything",
|
|
102
|
+
});
|
|
103
|
+
expect(result.success).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("requires a real uuid for an archive, which addresses a DURABLE unit", () => {
|
|
107
|
+
const result = DecisionEffectSchema.safeParse({
|
|
108
|
+
kind: "archive_unit",
|
|
109
|
+
unitId: "u_hr",
|
|
110
|
+
});
|
|
111
|
+
expect(result.success).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("answer helpers", () => {
|
|
116
|
+
it("resolves the recommended option and an option by id", () => {
|
|
117
|
+
expect(recommendedOption(RENAME_QUESTION)?.id).toBe("keep");
|
|
118
|
+
expect(optionById(RENAME_QUESTION, "rename")?.effect.kind).toBe(
|
|
119
|
+
"rename_unit",
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("treats the recommendation as the identity case", () => {
|
|
124
|
+
expect(
|
|
125
|
+
followsRecommendation(RENAME_QUESTION, {
|
|
126
|
+
questionId: RENAME_QUESTION.id,
|
|
127
|
+
chosenOptionIds: ["keep"],
|
|
128
|
+
}),
|
|
129
|
+
).toBe(true);
|
|
130
|
+
expect(
|
|
131
|
+
followsRecommendation(RENAME_QUESTION, {
|
|
132
|
+
questionId: RENAME_QUESTION.id,
|
|
133
|
+
chosenOptionIds: ["rename"],
|
|
134
|
+
}),
|
|
135
|
+
).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("accepts a free-form answer that chooses nothing", () => {
|
|
139
|
+
const parsed = DecisionAnswerSchema.parse({
|
|
140
|
+
questionId: RENAME_QUESTION.id,
|
|
141
|
+
chosenOptionIds: [],
|
|
142
|
+
freeText: "actually call it People Ops",
|
|
143
|
+
});
|
|
144
|
+
expect(parsed.freeText).toBe("actually call it People Ops");
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Decision vocabulary barrel — a question the system asks a human, and what
|
|
3
|
+
* answering it does.
|
|
4
|
+
*
|
|
5
|
+
* Import from '@company-semantics/contracts/decisions'.
|
|
6
|
+
*
|
|
7
|
+
* @see ./types.ts for the design rationale (display and effect are separate
|
|
8
|
+
* channels, permanently)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
DecisionEffect,
|
|
13
|
+
DecisionOption,
|
|
14
|
+
DecisionQuestion,
|
|
15
|
+
DecisionAnswer,
|
|
16
|
+
} from "./types";
|
|
17
|
+
|
|
18
|
+
export {
|
|
19
|
+
DECISION_EFFECT_KINDS,
|
|
20
|
+
recommendedOption,
|
|
21
|
+
optionById,
|
|
22
|
+
followsRecommendation,
|
|
23
|
+
} from "./types";
|
|
24
|
+
|
|
25
|
+
export {
|
|
26
|
+
DecisionEffectSchema,
|
|
27
|
+
DecisionOptionSchema,
|
|
28
|
+
DecisionQuestionBaseSchema,
|
|
29
|
+
DecisionQuestionSchema,
|
|
30
|
+
refineDecisionQuestion,
|
|
31
|
+
DecisionAnswerSchema,
|
|
32
|
+
} from "./schemas";
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime schemas for the decision vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Separate from `./types.ts` because the TYPES are the contract every consumer
|
|
5
|
+
* reads, while these schemas are what a boundary uses to prove an untrusted
|
|
6
|
+
* payload matches it. Both directions matter: a proposal's questions arrive from
|
|
7
|
+
* a model, and an answer arrives from a browser.
|
|
8
|
+
*
|
|
9
|
+
* @see ./types.ts for what each field means and why
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
export const DecisionEffectSchema = z.discriminatedUnion("kind", [
|
|
15
|
+
z.object({ kind: z.literal("none") }),
|
|
16
|
+
z.object({
|
|
17
|
+
kind: z.literal("rename_unit"),
|
|
18
|
+
unitTempId: z.string().min(1),
|
|
19
|
+
// Bounded to `org_units.name`'s own limit. The defect this vocabulary exists
|
|
20
|
+
// to prevent produced a 62-character SENTENCE as a unit name, which every
|
|
21
|
+
// length bound in the system happily accepted.
|
|
22
|
+
name: z.string().min(1).max(255),
|
|
23
|
+
}),
|
|
24
|
+
z.object({
|
|
25
|
+
kind: z.literal("place_people"),
|
|
26
|
+
personIds: z.array(z.string()),
|
|
27
|
+
unitTempId: z.string().min(1),
|
|
28
|
+
}),
|
|
29
|
+
z.object({ kind: z.literal("unadopt_unit"), unitTempId: z.string().min(1) }),
|
|
30
|
+
z.object({ kind: z.literal("archive_unit"), unitId: z.string().uuid() }),
|
|
31
|
+
z.object({
|
|
32
|
+
kind: z.literal("needs_reinference"),
|
|
33
|
+
note: z.string().min(1).max(500),
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
export const DecisionOptionSchema = z.object({
|
|
38
|
+
id: z.string().min(1).max(64),
|
|
39
|
+
label: z.string().min(1).max(500),
|
|
40
|
+
description: z.string().max(1000).optional(),
|
|
41
|
+
effect: DecisionEffectSchema,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The question's own fields, WITHOUT the cross-field checks.
|
|
46
|
+
*
|
|
47
|
+
* Exported so a domain can `.extend(...)` it with its own addressing (which
|
|
48
|
+
* people, which units) and re-apply {@link refineDecisionQuestion}. Zod's
|
|
49
|
+
* `superRefine` returns a `ZodEffects`, which cannot be extended — so the base
|
|
50
|
+
* and the refinement have to be separable or every domain would restate the
|
|
51
|
+
* shape and drift from it.
|
|
52
|
+
*/
|
|
53
|
+
export const DecisionQuestionBaseSchema = z.object({
|
|
54
|
+
id: z.string().min(1).max(200),
|
|
55
|
+
header: z.string().min(1).max(60),
|
|
56
|
+
question: z.string().min(1).max(1000),
|
|
57
|
+
// Two is the floor: one option is a notification, not a question.
|
|
58
|
+
options: z.array(DecisionOptionSchema).min(2).max(8),
|
|
59
|
+
recommendedOptionId: z.string().min(1),
|
|
60
|
+
multiSelect: z.boolean(),
|
|
61
|
+
freeFormAllowed: z.boolean(),
|
|
62
|
+
confidence: z.number().min(0).max(1),
|
|
63
|
+
signals: z.record(z.string(), z.string()),
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The cross-field rules every decision question obeys, wherever it is declared.
|
|
68
|
+
*
|
|
69
|
+
* Kept as a standalone refiner so a domain that extends
|
|
70
|
+
* {@link DecisionQuestionBaseSchema} inherits the rules rather than reimplementing
|
|
71
|
+
* (and eventually contradicting) them.
|
|
72
|
+
*/
|
|
73
|
+
export function refineDecisionQuestion(
|
|
74
|
+
question: {
|
|
75
|
+
options: ReadonlyArray<{ id: string }>;
|
|
76
|
+
recommendedOptionId: string;
|
|
77
|
+
},
|
|
78
|
+
ctx: z.RefinementCtx,
|
|
79
|
+
): void {
|
|
80
|
+
const ids = question.options.map((option) => option.id);
|
|
81
|
+
if (new Set(ids).size !== ids.length) {
|
|
82
|
+
ctx.addIssue({
|
|
83
|
+
code: z.ZodIssueCode.custom,
|
|
84
|
+
path: ["options"],
|
|
85
|
+
message: "option ids must be unique within a question",
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
// A recommendation naming no option means "what happens if you do nothing"
|
|
89
|
+
// has no answer — and doing nothing is the common case.
|
|
90
|
+
if (!ids.includes(question.recommendedOptionId)) {
|
|
91
|
+
ctx.addIssue({
|
|
92
|
+
code: z.ZodIssueCode.custom,
|
|
93
|
+
path: ["recommendedOptionId"],
|
|
94
|
+
message: `recommendedOptionId '${question.recommendedOptionId}' names no option`,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const DecisionQuestionSchema = DecisionQuestionBaseSchema.superRefine(
|
|
100
|
+
refineDecisionQuestion,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
export const DecisionAnswerSchema = z.object({
|
|
104
|
+
questionId: z.string().min(1).max(200),
|
|
105
|
+
chosenOptionIds: z.array(z.string().min(1).max(64)).max(8),
|
|
106
|
+
freeText: z.string().max(2000).optional(),
|
|
107
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A question the system asks a human, and what answering it DOES.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. A review question used to be three strings: `question`, a
|
|
5
|
+
* `recommended` option and a list of `alternatives`. The option string did
|
|
6
|
+
* triple duty — the radio label a reviewer read, the value recorded on the
|
|
7
|
+
* receipt, AND the payload the apply consumed. That works only while every
|
|
8
|
+
* option happens to be bare data. It stopped working the moment options carried
|
|
9
|
+
* their consequence in prose, which is what a reviewer actually needs to read:
|
|
10
|
+
* an org unit was renamed to the literal sentence
|
|
11
|
+
* `Rename the existing unit to 'People' (same unitId, preserving history)`.
|
|
12
|
+
*
|
|
13
|
+
* So DISPLAY and EFFECT are separate channels here, permanently.
|
|
14
|
+
* {@link DecisionOption.label} is prose, rendered verbatim, free to say whatever
|
|
15
|
+
* makes the consequence clear. {@link DecisionOption.effect} is the machine
|
|
16
|
+
* meaning, and it is the only thing an apply is allowed to read.
|
|
17
|
+
*
|
|
18
|
+
* SHAPE. Modelled on the question surface an agent already presents well: a
|
|
19
|
+
* question, a short header, two-to-four options that each carry both a label and
|
|
20
|
+
* what it means, one of them recommended, and — where the domain can honour it —
|
|
21
|
+
* an "other" arm for an answer nobody enumerated.
|
|
22
|
+
*
|
|
23
|
+
* SURFACE-AGNOSTIC ON PURPOSE. The same question is rendered as radio buttons in
|
|
24
|
+
* org settings, is projected onto the chat interactive-task surface, and is
|
|
25
|
+
* listed by an MCP tool for an agent. None of those may own the vocabulary, so
|
|
26
|
+
* it lives here.
|
|
27
|
+
*
|
|
28
|
+
* @see ADR-CONTRACTS-147
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What choosing an option DOES.
|
|
33
|
+
*
|
|
34
|
+
* A closed union rather than a free-form patch: an effect is produced by a
|
|
35
|
+
* derivation the human did not write and consumed by an apply that writes
|
|
36
|
+
* durable structure, so the set of things an answer can do has to be
|
|
37
|
+
* enumerable by a reader of this file.
|
|
38
|
+
*
|
|
39
|
+
* `none` is a first-class member, not a gap. Several genuine questions have no
|
|
40
|
+
* deterministic rewrite — redrawing a unit boundary needs re-inference, not a
|
|
41
|
+
* label swap — and saying so explicitly is honest where silently recording the
|
|
42
|
+
* answer and doing nothing is not.
|
|
43
|
+
*/
|
|
44
|
+
export type DecisionEffect =
|
|
45
|
+
/** Answering changes nothing. The answer is recorded, and that is the whole effect. */
|
|
46
|
+
| { readonly kind: "none" }
|
|
47
|
+
/**
|
|
48
|
+
* Rename a unit the proposal already contains, addressed by its
|
|
49
|
+
* proposal-scoped temp id. The name is THIS field — never the option's label.
|
|
50
|
+
*/
|
|
51
|
+
| {
|
|
52
|
+
readonly kind: "rename_unit";
|
|
53
|
+
readonly unitTempId: string;
|
|
54
|
+
readonly name: string;
|
|
55
|
+
}
|
|
56
|
+
/** Move these people's placements to the named proposed unit. */
|
|
57
|
+
| {
|
|
58
|
+
readonly kind: "place_people";
|
|
59
|
+
readonly personIds: readonly string[];
|
|
60
|
+
readonly unitTempId: string;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Stop reusing a pre-existing durable unit: create the proposed unit instead
|
|
64
|
+
* and leave the reviewer's own where it is.
|
|
65
|
+
*/
|
|
66
|
+
| { readonly kind: "unadopt_unit"; readonly unitTempId: string }
|
|
67
|
+
/** Archive a DURABLE unit, addressed by its real id. Soft delete, reversible. */
|
|
68
|
+
| { readonly kind: "archive_unit"; readonly unitId: string }
|
|
69
|
+
/**
|
|
70
|
+
* The answer is understood but cannot be honoured mechanically — it needs the
|
|
71
|
+
* derivation to run again. Carries the reason so the follow-up has something
|
|
72
|
+
* to act on, and so the reviewer is not told "saved" when nothing moved.
|
|
73
|
+
*/
|
|
74
|
+
| { readonly kind: "needs_reinference"; readonly note: string };
|
|
75
|
+
|
|
76
|
+
/** The closed set of effect kinds, as runtime data for validators. */
|
|
77
|
+
export const DECISION_EFFECT_KINDS = [
|
|
78
|
+
"none",
|
|
79
|
+
"rename_unit",
|
|
80
|
+
"place_people",
|
|
81
|
+
"unadopt_unit",
|
|
82
|
+
"archive_unit",
|
|
83
|
+
"needs_reinference",
|
|
84
|
+
] as const satisfies ReadonlyArray<DecisionEffect["kind"]>;
|
|
85
|
+
|
|
86
|
+
/** One answer a human may give, with what it means and what it does. */
|
|
87
|
+
export interface DecisionOption {
|
|
88
|
+
/**
|
|
89
|
+
* Stable within its question, and the value an answer names.
|
|
90
|
+
*
|
|
91
|
+
* An id rather than the label, because a label is prose: it gets reworded, it
|
|
92
|
+
* is long, it cannot be typed back by an agent, and matching on it makes the
|
|
93
|
+
* display copy load-bearing. `keep` / `rename` — short, and meaningful in a
|
|
94
|
+
* receipt read a year later.
|
|
95
|
+
*/
|
|
96
|
+
readonly id: string;
|
|
97
|
+
/** Rendered verbatim to the human. Prose; may carry the consequence. */
|
|
98
|
+
readonly label: string;
|
|
99
|
+
/** The implication, shown under the label. */
|
|
100
|
+
readonly description?: string;
|
|
101
|
+
/** What choosing this option does. The ONLY channel an apply may read. */
|
|
102
|
+
readonly effect: DecisionEffect;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** A question, its options, and everything a human needs to answer it well. */
|
|
106
|
+
export interface DecisionQuestion {
|
|
107
|
+
/**
|
|
108
|
+
* Stable identity for this question, DURABLE across re-derivations.
|
|
109
|
+
*
|
|
110
|
+
* Content-derived from what the question is about, so answering it once can
|
|
111
|
+
* outlive the run that asked it. An index into a payload cannot: it is valid
|
|
112
|
+
* only while that exact payload is.
|
|
113
|
+
*/
|
|
114
|
+
readonly id: string;
|
|
115
|
+
/** Short chip, e.g. `Unit name`. Two or three words. */
|
|
116
|
+
readonly header: string;
|
|
117
|
+
/** The question, phrased for a human. */
|
|
118
|
+
readonly question: string;
|
|
119
|
+
/** The offered answers. At least two, or it is not a question. */
|
|
120
|
+
readonly options: readonly DecisionOption[];
|
|
121
|
+
/** Which option applies when the human does not choose. Must name one of `options`. */
|
|
122
|
+
readonly recommendedOptionId: string;
|
|
123
|
+
/** Whether more than one option may be chosen at once. */
|
|
124
|
+
readonly multiSelect: boolean;
|
|
125
|
+
/**
|
|
126
|
+
* Whether an answer outside `options` is accepted.
|
|
127
|
+
*
|
|
128
|
+
* False does not mean the human is wrong to want one — it means this question
|
|
129
|
+
* has no way to honour it, so offering the box would be a lie.
|
|
130
|
+
*/
|
|
131
|
+
readonly freeFormAllowed: boolean;
|
|
132
|
+
/** How sure the derivation is of its recommendation, 0..1. */
|
|
133
|
+
readonly confidence: number;
|
|
134
|
+
/** Each competing signal and a one-line reading of what it says. */
|
|
135
|
+
readonly signals: Readonly<Record<string, string>>;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** One answer to one question. */
|
|
139
|
+
export interface DecisionAnswer {
|
|
140
|
+
readonly questionId: string;
|
|
141
|
+
/**
|
|
142
|
+
* The chosen options. Empty only when the answer is purely free-form.
|
|
143
|
+
*
|
|
144
|
+
* A list even for a single-select question, so a multi-select answer is the
|
|
145
|
+
* same shape rather than a second one.
|
|
146
|
+
*/
|
|
147
|
+
readonly chosenOptionIds: readonly string[];
|
|
148
|
+
/**
|
|
149
|
+
* What the human typed — the "other" arm, or a note attached to a chosen
|
|
150
|
+
* option.
|
|
151
|
+
*
|
|
152
|
+
* NEVER applied as an effect on its own. It is either recorded alongside the
|
|
153
|
+
* chosen option, or resolved into a proposed effect that is shown back before
|
|
154
|
+
* anything runs. Interpreting free text INTO an action without showing the
|
|
155
|
+
* action is the one thing this vocabulary must not enable.
|
|
156
|
+
*/
|
|
157
|
+
readonly freeText?: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Find the recommended option, or undefined when the question is malformed. */
|
|
161
|
+
export function recommendedOption(
|
|
162
|
+
question: DecisionQuestion,
|
|
163
|
+
): DecisionOption | undefined {
|
|
164
|
+
return question.options.find(
|
|
165
|
+
(option) => option.id === question.recommendedOptionId,
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Find an option by id. */
|
|
170
|
+
export function optionById(
|
|
171
|
+
question: DecisionQuestion,
|
|
172
|
+
optionId: string,
|
|
173
|
+
): DecisionOption | undefined {
|
|
174
|
+
return question.options.find((option) => option.id === optionId);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* True when the answer is exactly the recommendation.
|
|
179
|
+
*
|
|
180
|
+
* The identity case: a recommendation is what the surrounding proposal already
|
|
181
|
+
* encodes, so following it must be a no-op rather than a rewrite that happens to
|
|
182
|
+
* land on the same value.
|
|
183
|
+
*/
|
|
184
|
+
export function followsRecommendation(
|
|
185
|
+
question: DecisionQuestion,
|
|
186
|
+
answer: DecisionAnswer,
|
|
187
|
+
): boolean {
|
|
188
|
+
return (
|
|
189
|
+
answer.chosenOptionIds.length === 1 &&
|
|
190
|
+
answer.chosenOptionIds[0] === question.recommendedOptionId
|
|
191
|
+
);
|
|
192
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1376,3 +1376,9 @@ export * from "./ingestion";
|
|
|
1376
1376
|
// HrConnectionStatus / HrConnectInput — see ./integrations/schemas.ts.
|
|
1377
1377
|
// Pairs with the `HR` member of INTEGRATION_CATEGORIES above.
|
|
1378
1378
|
export * from "./integrations";
|
|
1379
|
+
|
|
1380
|
+
// Decision vocabulary (ADR-CONTRACTS-147 / ADR-BE-639)
|
|
1381
|
+
// A question the system asks a human, and what answering it DOES. Display and
|
|
1382
|
+
// effect are separate channels: an option's `label` is prose for a reader, its
|
|
1383
|
+
// `effect` is the only thing an apply may read. See ./decisions/types.ts.
|
|
1384
|
+
export * from "./decisions";
|
package/src/org/README.md
CHANGED
|
@@ -340,6 +340,7 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
340
340
|
- `RoleCatalogEntry` _(type)_ — Entry in the RBAC roles catalog (GET /api/rbac/roles).
|
|
341
341
|
- `RoleCatalogEntrySchema`
|
|
342
342
|
- `RoleCatalogResponseSchema`
|
|
343
|
+
- `STRUCTURE_REVIEW_ITEM_KINDS` — A question the engine is asking a human, with the competing signals attached.
|
|
343
344
|
- `ScopeCheckBatchResponse` _(type)_
|
|
344
345
|
- `ScopeCheckBatchResponseSchema`
|
|
345
346
|
- `ScopeCheckResponse` _(type)_
|
|
@@ -385,7 +386,7 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
385
386
|
- `StructureReportingFact` _(type)_
|
|
386
387
|
- `StructureReportingFactSchema` — One reporting edge, at person granularity.
|
|
387
388
|
- `StructureReviewItem` _(type)_
|
|
388
|
-
- `StructureReviewItemSchema` — A
|
|
389
|
+
- `StructureReviewItemSchema` — A review item IS a {@link DecisionQuestion}, plus the addressing that says what the question is about.
|
|
389
390
|
- `StructureUnitAuthority` _(type)_
|
|
390
391
|
- `StructureUnitAuthoritySchema` — How much license the engine has over an EXISTING unit or placement.
|
|
391
392
|
- `SubmitInteractiveTaskResponse` _(type)_
|
|
@@ -474,6 +475,7 @@ Shared type vocabulary for organization ownership, type classification, and tran
|
|
|
474
475
|
**Internal domains:**
|
|
475
476
|
|
|
476
477
|
- `api`
|
|
478
|
+
- `decisions`
|
|
477
479
|
- `identity`
|
|
478
480
|
- `permissions`
|
|
479
481
|
|
|
@@ -218,11 +218,34 @@ describe("PersonStructureOutcomeSchema", () => {
|
|
|
218
218
|
describe("StructureReviewItemSchema", () => {
|
|
219
219
|
const base = {
|
|
220
220
|
kind: "placement_conflict",
|
|
221
|
+
id: "placement_conflict:clarissa",
|
|
222
|
+
header: "Placement",
|
|
221
223
|
personIds: ["clarissa"],
|
|
222
224
|
unitTempIds: ["u1", "u2"],
|
|
223
225
|
question: "Which unit does this Account Executive belong to?",
|
|
224
|
-
|
|
225
|
-
|
|
226
|
+
options: [
|
|
227
|
+
{
|
|
228
|
+
id: "u2",
|
|
229
|
+
label: "Sales — matching the role family the other 11 AEs sit in",
|
|
230
|
+
effect: {
|
|
231
|
+
kind: "place_people",
|
|
232
|
+
personIds: ["clarissa"],
|
|
233
|
+
unitTempId: "u2",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
{
|
|
237
|
+
id: "u1",
|
|
238
|
+
label: "Marketing — matching the manager she reports to",
|
|
239
|
+
effect: {
|
|
240
|
+
kind: "place_people",
|
|
241
|
+
personIds: ["clarissa"],
|
|
242
|
+
unitTempId: "u1",
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
recommendedOptionId: "u2",
|
|
247
|
+
multiSelect: false,
|
|
248
|
+
freeFormAllowed: false,
|
|
226
249
|
signals: {
|
|
227
250
|
department: "Human Resources",
|
|
228
251
|
manager: "VP of Marketing",
|
|
@@ -243,6 +266,51 @@ describe("StructureReviewItemSchema", () => {
|
|
|
243
266
|
StructureReviewItemSchema.parse({ ...base, kind: "something_else" }),
|
|
244
267
|
).toThrow();
|
|
245
268
|
});
|
|
269
|
+
|
|
270
|
+
it("accepts the three rule-derived kinds the backend attaches after the gate", () => {
|
|
271
|
+
for (const kind of [
|
|
272
|
+
"reporting_anomaly",
|
|
273
|
+
"unit_adoption",
|
|
274
|
+
"unit_retirement",
|
|
275
|
+
]) {
|
|
276
|
+
expect(() =>
|
|
277
|
+
StructureReviewItemSchema.parse({ ...base, kind }),
|
|
278
|
+
).not.toThrow();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("inherits the decision rules — a recommendation must name a real option", () => {
|
|
283
|
+
expect(() =>
|
|
284
|
+
StructureReviewItemSchema.parse({ ...base, recommendedOptionId: "u9" }),
|
|
285
|
+
).toThrow();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("carries the effect separately from the label, so an apply never reads prose", () => {
|
|
289
|
+
const parsed = StructureReviewItemSchema.parse({
|
|
290
|
+
...base,
|
|
291
|
+
kind: "unit_name",
|
|
292
|
+
options: [
|
|
293
|
+
{
|
|
294
|
+
id: "keep",
|
|
295
|
+
label: "Keep the name 'Human Resources'.",
|
|
296
|
+
effect: { kind: "none" },
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
id: "rename",
|
|
300
|
+
label:
|
|
301
|
+
"Rename the existing unit to 'People' (same unitId, preserving history)",
|
|
302
|
+
effect: { kind: "rename_unit", unitTempId: "u1", name: "People" },
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
recommendedOptionId: "keep",
|
|
306
|
+
});
|
|
307
|
+
const rename = parsed.options.find((option) => option.id === "rename");
|
|
308
|
+
expect(rename?.effect).toEqual({
|
|
309
|
+
kind: "rename_unit",
|
|
310
|
+
unitTempId: "u1",
|
|
311
|
+
name: "People",
|
|
312
|
+
});
|
|
313
|
+
});
|
|
246
314
|
});
|
|
247
315
|
|
|
248
316
|
describe("StructureProposalSchema", () => {
|
package/src/org/index.ts
CHANGED
|
@@ -69,6 +69,10 @@
|
|
|
69
69
|
import { z } from "zod";
|
|
70
70
|
|
|
71
71
|
import { PositionReportingRelationshipTypeSchema } from "./position-reporting";
|
|
72
|
+
import {
|
|
73
|
+
DecisionQuestionBaseSchema,
|
|
74
|
+
refineDecisionQuestion,
|
|
75
|
+
} from "../decisions/schemas";
|
|
72
76
|
|
|
73
77
|
// ---------------------------------------------------------------------------
|
|
74
78
|
// StructureEvidence — typed support for a boundary, a name or a placement
|
|
@@ -378,28 +382,44 @@ export type PersonStructureOutcome = z.infer<
|
|
|
378
382
|
* never asks scores well on placement accuracy while being WORSE for the
|
|
379
383
|
* product than one that reports three signals disagreeing.
|
|
380
384
|
*/
|
|
381
|
-
export const
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
385
|
+
export const STRUCTURE_REVIEW_ITEM_KINDS = [
|
|
386
|
+
"placement_conflict",
|
|
387
|
+
"unit_boundary",
|
|
388
|
+
"unit_name",
|
|
389
|
+
"unit_head",
|
|
390
|
+
"reporting_anomaly",
|
|
391
|
+
"unit_adoption",
|
|
392
|
+
"unit_retirement",
|
|
393
|
+
] as const;
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* A review item IS a {@link DecisionQuestion}, plus the addressing that says
|
|
397
|
+
* what the question is about.
|
|
398
|
+
*
|
|
399
|
+
* It used to carry `recommended: string` and `alternatives: string[]` instead of
|
|
400
|
+
* options — three strings where the option text was simultaneously the label a
|
|
401
|
+
* reviewer read, the value on the receipt, and the payload the apply consumed.
|
|
402
|
+
* A unit was renamed to the literal sentence
|
|
403
|
+
* `Rename the existing unit to 'People' (same unitId, preserving history)`
|
|
404
|
+
* because of it. `options[].effect` is the channel that replaced the third job;
|
|
405
|
+
* see `../decisions/types.ts`.
|
|
406
|
+
*/
|
|
407
|
+
export const StructureReviewItemSchema = DecisionQuestionBaseSchema.extend({
|
|
408
|
+
kind: z.enum(STRUCTURE_REVIEW_ITEM_KINDS),
|
|
388
409
|
/** Opaque person ids this question is about. */
|
|
389
410
|
personIds: z.array(z.string()),
|
|
390
411
|
/** Proposal-scoped unit handles this question is about. */
|
|
391
412
|
unitTempIds: z.array(z.string()),
|
|
392
|
-
/**
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
413
|
+
/**
|
|
414
|
+
* DURABLE `org_units.id`s the question is about — a different address space
|
|
415
|
+
* from {@link StructureReviewItemSchema.shape.unitTempIds}, which is
|
|
416
|
+
* proposal-scoped. Needed because reconciliation asks about units that already
|
|
417
|
+
* exist and which the proposal may not contain at all.
|
|
418
|
+
*/
|
|
419
|
+
unitIds: z.array(z.string()).optional(),
|
|
398
420
|
/** The competing signals, so a reviewer can see WHY it is ambiguous. */
|
|
399
|
-
signals: z.record(z.string(), z.string()),
|
|
400
|
-
confidence: z.number().min(0).max(1),
|
|
401
421
|
evidence: z.array(StructureEvidenceSchema),
|
|
402
|
-
});
|
|
422
|
+
}).superRefine(refineDecisionQuestion);
|
|
403
423
|
export type StructureReviewItem = z.infer<typeof StructureReviewItemSchema>;
|
|
404
424
|
|
|
405
425
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fromQueryKey's routing tables, and the drift detector over them.
|
|
3
|
+
*
|
|
4
|
+
* Data, not operations: `resource-keys.ts` owns the round trip and reads these.
|
|
5
|
+
* Split out of that module, which had reached its size envelope — the seam is the
|
|
6
|
+
* one its own docblock already names (vocabulary in `resource-key-types.ts`,
|
|
7
|
+
* operations in `resource-keys.ts`, and these tables which are neither).
|
|
8
|
+
* See ADR-CONTRACTS-145.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ResourceKey } from "./resource-key-types";
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* ---------------------------------------------------------------------------
|
|
15
|
+
* fromQueryKey's routing tables, and the drift detector over them.
|
|
16
|
+
* ---------------------------------------------------------------------------
|
|
17
|
+
*
|
|
18
|
+
* `toQueryKey` has always been safe: its switch is exhaustive and its `never`
|
|
19
|
+
* guard makes a new union member a compile error. `fromQueryKey` was not. Its
|
|
20
|
+
* five lookup tables are hand-maintained, and a member missing from all of them
|
|
21
|
+
* fell through to a RUNTIME `throw` — which `matchesResourceKey` catches and
|
|
22
|
+
* turns into "matches nothing", and which `useResource` turns into a query that
|
|
23
|
+
* never fetches. Silent in both directions (ADR-CONTRACTS-119).
|
|
24
|
+
*
|
|
25
|
+
* Two mechanisms, and BOTH are needed:
|
|
26
|
+
*
|
|
27
|
+
* `satisfies` on each table catches a WRONG entry — a typo, or a literal
|
|
28
|
+
* parked under the wrong scope. It cannot catch an entry that is simply
|
|
29
|
+
* absent, because an array is free to be a subset of its element type.
|
|
30
|
+
*
|
|
31
|
+
* `_EveryResourceKeyIsRouted` below catches the ABSENT entry, which is the
|
|
32
|
+
* failure that actually shipped. It is the half that closes the hole.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The identity segments a member carries — every field that is neither the type
|
|
37
|
+
* tag nor a scope discriminator. `member` → `"memberId"`; `commentThreads` →
|
|
38
|
+
* `"subjectType" | "subjectId"`; a scope-only key → `never`.
|
|
39
|
+
*/
|
|
40
|
+
type IdentityFieldsOf<T extends ResourceKey["type"]> = Exclude<
|
|
41
|
+
keyof Extract<ResourceKey, { type: T }>,
|
|
42
|
+
"type" | "orgId" | "userId" | "scope"
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Type literals whose whole shape is the tag plus one scope discriminator —
|
|
47
|
+
* i.e. the members that belong in a scope ARRAY rather than an identity MAP.
|
|
48
|
+
* Distributes over the union (`K` is a naked type parameter).
|
|
49
|
+
*/
|
|
50
|
+
type ScopeOnlyType<K = ResourceKey> = K extends {
|
|
51
|
+
type: infer T extends ResourceKey["type"];
|
|
52
|
+
}
|
|
53
|
+
? [IdentityFieldsOf<T>] extends [never]
|
|
54
|
+
? T
|
|
55
|
+
: never
|
|
56
|
+
: never;
|
|
57
|
+
|
|
58
|
+
type OrgScopedType = Extract<
|
|
59
|
+
ScopeOnlyType,
|
|
60
|
+
Extract<ResourceKey, { orgId: string }>["type"]
|
|
61
|
+
>;
|
|
62
|
+
type UserScopedType = Extract<
|
|
63
|
+
ScopeOnlyType,
|
|
64
|
+
Extract<ResourceKey, { userId: string }>["type"]
|
|
65
|
+
>;
|
|
66
|
+
type SystemScopedType = Extract<
|
|
67
|
+
ScopeOnlyType,
|
|
68
|
+
Extract<ResourceKey, { scope: "system" }>["type"]
|
|
69
|
+
>;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Identities with a single extra segment. The `satisfies` checks the FIELD NAME
|
|
73
|
+
* against that member's own keys, so renaming `memberId` in the union — or
|
|
74
|
+
* pointing a key at a field it does not have — fails to compile here.
|
|
75
|
+
*/
|
|
76
|
+
export const IDENTITY_FIELDS = {
|
|
77
|
+
member: "memberId",
|
|
78
|
+
team: "teamId",
|
|
79
|
+
department: "departmentId",
|
|
80
|
+
chat: "chatId",
|
|
81
|
+
companyMdDoc: "slug",
|
|
82
|
+
companyMdContextBank: "slug",
|
|
83
|
+
companyMdAccessRequests: "docId",
|
|
84
|
+
companyMdDocHistory: "docId",
|
|
85
|
+
orgUnit: "unitId",
|
|
86
|
+
orgUnitChildren: "unitId",
|
|
87
|
+
orgUnitAncestors: "unitId",
|
|
88
|
+
orgUnitMemberships: "unitId",
|
|
89
|
+
orgUnitPermissions: "unitId",
|
|
90
|
+
orgUnitOpenRoles: "unitId",
|
|
91
|
+
orgUnitMyAuthority: "unitId",
|
|
92
|
+
} as const satisfies { [T in ResourceKey["type"]]?: IdentityFieldsOf<T> };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Identities with a COMPOSITE (multi-segment) identity. Checked before
|
|
96
|
+
* {@link IDENTITY_FIELDS}, which hard-asserts a two-element `rest` and would
|
|
97
|
+
* otherwise reject these.
|
|
98
|
+
*
|
|
99
|
+
* A map of its own rather than widening `IDENTITY_FIELDS` to `string | string[]`:
|
|
100
|
+
* the single-segment case is every other key in the union, and making it pay for
|
|
101
|
+
* this one would put a branch in the hot path for no reader's benefit.
|
|
102
|
+
*/
|
|
103
|
+
export const COMPOSITE_IDENTITY_FIELDS = {
|
|
104
|
+
commentThreads: ["subjectType", "subjectId"],
|
|
105
|
+
companyMdDocVersion: ["docId", "versionId"],
|
|
106
|
+
} as const satisfies {
|
|
107
|
+
[T in ResourceKey["type"]]?: readonly IdentityFieldsOf<T>[];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const ORG_SCOPED_TYPES = [
|
|
111
|
+
"members",
|
|
112
|
+
"departments",
|
|
113
|
+
"chats",
|
|
114
|
+
"teams",
|
|
115
|
+
"integrations",
|
|
116
|
+
"invites",
|
|
117
|
+
"orgDirectory",
|
|
118
|
+
"auditEvents",
|
|
119
|
+
"timeline",
|
|
120
|
+
"workspace",
|
|
121
|
+
"workspaceDomains",
|
|
122
|
+
"authSettings",
|
|
123
|
+
"billing",
|
|
124
|
+
"aiUsage",
|
|
125
|
+
"deletionEligibility",
|
|
126
|
+
"transferOwnership",
|
|
127
|
+
"companyMdDocs",
|
|
128
|
+
"directGrants",
|
|
129
|
+
"orgTree",
|
|
130
|
+
"orgLevelConfig",
|
|
131
|
+
"peopleOrgChart",
|
|
132
|
+
"orgUnitOwners",
|
|
133
|
+
"actionItems",
|
|
134
|
+
"feed",
|
|
135
|
+
"orgSystemEvents",
|
|
136
|
+
] as const satisfies readonly OrgScopedType[];
|
|
137
|
+
|
|
138
|
+
export const USER_SCOPED_TYPES = [
|
|
139
|
+
"dismissedBanners",
|
|
140
|
+
"userOrgs",
|
|
141
|
+
"sessions",
|
|
142
|
+
"userMd",
|
|
143
|
+
"viewer",
|
|
144
|
+
] as const satisfies readonly UserScopedType[];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* System-scoped types (ADR-CONTRACTS-052) — tenant-less super-admin resources.
|
|
148
|
+
* Their query key is [type, 'system']; they carry no orgId or userId.
|
|
149
|
+
* Exported only for `resource-keys.ts`, the parser that reads them; like the other
|
|
150
|
+
* scope arrays they are not part of the package barrel.
|
|
151
|
+
*/
|
|
152
|
+
export const SYSTEM_SCOPED_TYPES = [
|
|
153
|
+
"internalAdminAiProviders",
|
|
154
|
+
"internalAdminPrompts",
|
|
155
|
+
"internalAdminAiRuntimeDefaults",
|
|
156
|
+
"factoryFloor",
|
|
157
|
+
"factorySnapshot",
|
|
158
|
+
"factoryKpis",
|
|
159
|
+
] as const satisfies readonly SystemScopedType[];
|
|
160
|
+
|
|
161
|
+
/** Every literal `fromQueryKey` can route, across all five tables. */
|
|
162
|
+
type RoutedType =
|
|
163
|
+
| keyof typeof IDENTITY_FIELDS
|
|
164
|
+
| keyof typeof COMPOSITE_IDENTITY_FIELDS
|
|
165
|
+
| (typeof ORG_SCOPED_TYPES)[number]
|
|
166
|
+
| (typeof USER_SCOPED_TYPES)[number]
|
|
167
|
+
| (typeof SYSTEM_SCOPED_TYPES)[number];
|
|
168
|
+
|
|
169
|
+
type Assert<T extends true> = T;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* THE DRIFT DETECTOR. Register a union member and forget its routing table and
|
|
173
|
+
* this fails to compile — instead of shipping a key that parses nowhere,
|
|
174
|
+
* matches nothing, and disables the query that reads it.
|
|
175
|
+
*
|
|
176
|
+
* The false branch resolves to the UNROUTED LITERALS rather than to `false`, so
|
|
177
|
+
* the diagnostic names the culprit:
|
|
178
|
+
* `Type '"directGrants"' does not satisfy the constraint 'true'`.
|
|
179
|
+
*
|
|
180
|
+
* EXPORTED, though nothing consumes it. This package ships `src`, so every
|
|
181
|
+
* consumer typechecks this file under ITS OWN compiler options — and the
|
|
182
|
+
* backend sets `noUnusedLocals`, which rejects an unexported type alias that
|
|
183
|
+
* nothing references. Keeping it private broke `tsc` in a consumer while
|
|
184
|
+
* passing here, so the export is what makes the assertion portable, not a
|
|
185
|
+
* widening of the public vocabulary. Do not "tidy" it away.
|
|
186
|
+
*/
|
|
187
|
+
export type RoutingExhaustivenessWitness = Assert<
|
|
188
|
+
[Exclude<ResourceKey["type"], RoutedType>] extends [never]
|
|
189
|
+
? true
|
|
190
|
+
: Exclude<ResourceKey["type"], RoutedType>
|
|
191
|
+
>;
|
package/src/resource-keys.ts
CHANGED
|
@@ -25,184 +25,20 @@ export function resolveScope(context: {
|
|
|
25
25
|
return { orgId: context.impersonatedOrgId ?? context.orgId };
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
-
/*
|
|
29
|
-
* ---------------------------------------------------------------------------
|
|
30
|
-
* fromQueryKey's routing tables, and the drift detector over them.
|
|
31
|
-
* ---------------------------------------------------------------------------
|
|
32
|
-
*
|
|
33
|
-
* `toQueryKey` has always been safe: its switch is exhaustive and its `never`
|
|
34
|
-
* guard makes a new union member a compile error. `fromQueryKey` was not. Its
|
|
35
|
-
* five lookup tables are hand-maintained, and a member missing from all of them
|
|
36
|
-
* fell through to a RUNTIME `throw` — which `matchesResourceKey` catches and
|
|
37
|
-
* turns into "matches nothing", and which `useResource` turns into a query that
|
|
38
|
-
* never fetches. Silent in both directions (ADR-CONTRACTS-119).
|
|
39
|
-
*
|
|
40
|
-
* Two mechanisms, and BOTH are needed:
|
|
41
|
-
*
|
|
42
|
-
* `satisfies` on each table catches a WRONG entry — a typo, or a literal
|
|
43
|
-
* parked under the wrong scope. It cannot catch an entry that is simply
|
|
44
|
-
* absent, because an array is free to be a subset of its element type.
|
|
45
|
-
*
|
|
46
|
-
* `_EveryResourceKeyIsRouted` below catches the ABSENT entry, which is the
|
|
47
|
-
* failure that actually shipped. It is the half that closes the hole.
|
|
48
|
-
*/
|
|
49
|
-
|
|
50
|
-
/**
|
|
51
|
-
* The identity segments a member carries — every field that is neither the type
|
|
52
|
-
* tag nor a scope discriminator. `member` → `"memberId"`; `commentThreads` →
|
|
53
|
-
* `"subjectType" | "subjectId"`; a scope-only key → `never`.
|
|
54
|
-
*/
|
|
55
|
-
type IdentityFieldsOf<T extends ResourceKey["type"]> = Exclude<
|
|
56
|
-
keyof Extract<ResourceKey, { type: T }>,
|
|
57
|
-
"type" | "orgId" | "userId" | "scope"
|
|
58
|
-
>;
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Type literals whose whole shape is the tag plus one scope discriminator —
|
|
62
|
-
* i.e. the members that belong in a scope ARRAY rather than an identity MAP.
|
|
63
|
-
* Distributes over the union (`K` is a naked type parameter).
|
|
64
|
-
*/
|
|
65
|
-
type ScopeOnlyType<K = ResourceKey> = K extends {
|
|
66
|
-
type: infer T extends ResourceKey["type"];
|
|
67
|
-
}
|
|
68
|
-
? [IdentityFieldsOf<T>] extends [never]
|
|
69
|
-
? T
|
|
70
|
-
: never
|
|
71
|
-
: never;
|
|
72
|
-
|
|
73
|
-
type OrgScopedType = Extract<
|
|
74
|
-
ScopeOnlyType,
|
|
75
|
-
Extract<ResourceKey, { orgId: string }>["type"]
|
|
76
|
-
>;
|
|
77
|
-
type UserScopedType = Extract<
|
|
78
|
-
ScopeOnlyType,
|
|
79
|
-
Extract<ResourceKey, { userId: string }>["type"]
|
|
80
|
-
>;
|
|
81
|
-
type SystemScopedType = Extract<
|
|
82
|
-
ScopeOnlyType,
|
|
83
|
-
Extract<ResourceKey, { scope: "system" }>["type"]
|
|
84
|
-
>;
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Identities with a single extra segment. The `satisfies` checks the FIELD NAME
|
|
88
|
-
* against that member's own keys, so renaming `memberId` in the union — or
|
|
89
|
-
* pointing a key at a field it does not have — fails to compile here.
|
|
90
|
-
*/
|
|
91
|
-
const IDENTITY_FIELDS = {
|
|
92
|
-
member: "memberId",
|
|
93
|
-
team: "teamId",
|
|
94
|
-
department: "departmentId",
|
|
95
|
-
chat: "chatId",
|
|
96
|
-
companyMdDoc: "slug",
|
|
97
|
-
companyMdContextBank: "slug",
|
|
98
|
-
companyMdAccessRequests: "docId",
|
|
99
|
-
companyMdDocHistory: "docId",
|
|
100
|
-
orgUnit: "unitId",
|
|
101
|
-
orgUnitChildren: "unitId",
|
|
102
|
-
orgUnitAncestors: "unitId",
|
|
103
|
-
orgUnitMemberships: "unitId",
|
|
104
|
-
orgUnitPermissions: "unitId",
|
|
105
|
-
orgUnitOpenRoles: "unitId",
|
|
106
|
-
orgUnitMyAuthority: "unitId",
|
|
107
|
-
} as const satisfies { [T in ResourceKey["type"]]?: IdentityFieldsOf<T> };
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Identities with a COMPOSITE (multi-segment) identity. Checked before
|
|
111
|
-
* {@link IDENTITY_FIELDS}, which hard-asserts a two-element `rest` and would
|
|
112
|
-
* otherwise reject these.
|
|
113
|
-
*
|
|
114
|
-
* A map of its own rather than widening `IDENTITY_FIELDS` to `string | string[]`:
|
|
115
|
-
* the single-segment case is every other key in the union, and making it pay for
|
|
116
|
-
* this one would put a branch in the hot path for no reader's benefit.
|
|
117
|
-
*/
|
|
118
|
-
const COMPOSITE_IDENTITY_FIELDS = {
|
|
119
|
-
commentThreads: ["subjectType", "subjectId"],
|
|
120
|
-
companyMdDocVersion: ["docId", "versionId"],
|
|
121
|
-
} as const satisfies {
|
|
122
|
-
[T in ResourceKey["type"]]?: readonly IdentityFieldsOf<T>[];
|
|
123
|
-
};
|
|
124
|
-
|
|
125
|
-
const ORG_SCOPED_TYPES = [
|
|
126
|
-
"members",
|
|
127
|
-
"departments",
|
|
128
|
-
"chats",
|
|
129
|
-
"teams",
|
|
130
|
-
"integrations",
|
|
131
|
-
"invites",
|
|
132
|
-
"orgDirectory",
|
|
133
|
-
"auditEvents",
|
|
134
|
-
"timeline",
|
|
135
|
-
"workspace",
|
|
136
|
-
"workspaceDomains",
|
|
137
|
-
"authSettings",
|
|
138
|
-
"billing",
|
|
139
|
-
"aiUsage",
|
|
140
|
-
"deletionEligibility",
|
|
141
|
-
"transferOwnership",
|
|
142
|
-
"companyMdDocs",
|
|
143
|
-
"directGrants",
|
|
144
|
-
"orgTree",
|
|
145
|
-
"orgLevelConfig",
|
|
146
|
-
"peopleOrgChart",
|
|
147
|
-
"orgUnitOwners",
|
|
148
|
-
"actionItems",
|
|
149
|
-
"feed",
|
|
150
|
-
"orgSystemEvents",
|
|
151
|
-
] as const satisfies readonly OrgScopedType[];
|
|
152
|
-
|
|
153
|
-
const USER_SCOPED_TYPES = [
|
|
154
|
-
"dismissedBanners",
|
|
155
|
-
"userOrgs",
|
|
156
|
-
"sessions",
|
|
157
|
-
"userMd",
|
|
158
|
-
"viewer",
|
|
159
|
-
] as const satisfies readonly UserScopedType[];
|
|
160
|
-
|
|
161
28
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
*
|
|
165
|
-
|
|
166
|
-
const SYSTEM_SCOPED_TYPES = [
|
|
167
|
-
"internalAdminAiProviders",
|
|
168
|
-
"internalAdminPrompts",
|
|
169
|
-
"internalAdminAiRuntimeDefaults",
|
|
170
|
-
"factoryFloor",
|
|
171
|
-
"factorySnapshot",
|
|
172
|
-
"factoryKpis",
|
|
173
|
-
] as const satisfies readonly SystemScopedType[];
|
|
174
|
-
|
|
175
|
-
/** Every literal `fromQueryKey` can route, across all five tables. */
|
|
176
|
-
type RoutedType =
|
|
177
|
-
| keyof typeof IDENTITY_FIELDS
|
|
178
|
-
| keyof typeof COMPOSITE_IDENTITY_FIELDS
|
|
179
|
-
| (typeof ORG_SCOPED_TYPES)[number]
|
|
180
|
-
| (typeof USER_SCOPED_TYPES)[number]
|
|
181
|
-
| (typeof SYSTEM_SCOPED_TYPES)[number];
|
|
182
|
-
|
|
183
|
-
type Assert<T extends true> = T;
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* THE DRIFT DETECTOR. Register a union member and forget its routing table and
|
|
187
|
-
* this fails to compile — instead of shipping a key that parses nowhere,
|
|
188
|
-
* matches nothing, and disables the query that reads it.
|
|
189
|
-
*
|
|
190
|
-
* The false branch resolves to the UNROUTED LITERALS rather than to `false`, so
|
|
191
|
-
* the diagnostic names the culprit:
|
|
192
|
-
* `Type '"directGrants"' does not satisfy the constraint 'true'`.
|
|
193
|
-
*
|
|
194
|
-
* EXPORTED, though nothing consumes it. This package ships `src`, so every
|
|
195
|
-
* consumer typechecks this file under ITS OWN compiler options — and the
|
|
196
|
-
* backend sets `noUnusedLocals`, which rejects an unexported type alias that
|
|
197
|
-
* nothing references. Keeping it private broke `tsc` in a consumer while
|
|
198
|
-
* passing here, so the export is what makes the assertion portable, not a
|
|
199
|
-
* widening of the public vocabulary. Do not "tidy" it away.
|
|
29
|
+
* The routing tables `fromQueryKey` reads, and the drift detector over them,
|
|
30
|
+
* live in `resource-key-tables.ts`. `RoutingExhaustivenessWitness` is
|
|
31
|
+
* re-exported so its import path is unchanged — see the note on its definition
|
|
32
|
+
* for why it is exported at all.
|
|
200
33
|
*/
|
|
201
|
-
export type RoutingExhaustivenessWitness
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
34
|
+
export type { RoutingExhaustivenessWitness } from "./resource-key-tables";
|
|
35
|
+
import {
|
|
36
|
+
IDENTITY_FIELDS,
|
|
37
|
+
COMPOSITE_IDENTITY_FIELDS,
|
|
38
|
+
ORG_SCOPED_TYPES,
|
|
39
|
+
USER_SCOPED_TYPES,
|
|
40
|
+
SYSTEM_SCOPED_TYPES,
|
|
41
|
+
} from "./resource-key-tables";
|
|
206
42
|
|
|
207
43
|
/**
|
|
208
44
|
* Canonical ResourceKey → query key conversion.
|