@company-semantics/contracts 58.5.1 → 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/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +3 -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/execution/__tests__/registry.test.ts +21 -0
- package/src/execution/kinds.ts +4 -0
- package/src/execution/registry.ts +23 -0
- package/src/index.ts +6 -0
- package/src/integrations/README.md +2 -0
- package/src/integrations/index.ts +10 -2
- package/src/integrations/schemas.ts +87 -0
- package/src/message-parts/__tests__/confirmation.test.ts +1 -0
- package/src/message-parts/confirmation.ts +1 -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": {
|
|
@@ -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 = 'dd2a725d7ebd' as const;
|
|
3
|
+
export const SPEC_HASH_FULL = 'dd2a725d7ebd694db25efbe0e638077984af41551c1989fb374d9a7b8bc4eb3d' as const;
|
package/src/api/generated.ts
CHANGED
|
@@ -5065,7 +5065,7 @@ export interface components {
|
|
|
5065
5065
|
summary: {
|
|
5066
5066
|
executionId: string;
|
|
5067
5067
|
/** @enum {string} */
|
|
5068
|
-
kind: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
5068
|
+
kind: "integration.connect" | "integration.disconnect" | "integration.purge" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
5069
5069
|
target: {
|
|
5070
5070
|
/** @constant */
|
|
5071
5071
|
type: "slack";
|
|
@@ -9116,7 +9116,7 @@ export interface operations {
|
|
|
9116
9116
|
query?: {
|
|
9117
9117
|
cursor?: string;
|
|
9118
9118
|
limit?: number;
|
|
9119
|
-
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
9119
|
+
kind?: "integration.connect" | "integration.disconnect" | "integration.purge" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
9120
9120
|
targetType?: string;
|
|
9121
9121
|
periodStart?: string;
|
|
9122
9122
|
periodEnd?: string;
|
|
@@ -9251,7 +9251,7 @@ export interface operations {
|
|
|
9251
9251
|
query?: {
|
|
9252
9252
|
limit?: number;
|
|
9253
9253
|
offset?: number;
|
|
9254
|
-
kind?: "integration.connect" | "integration.disconnect" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
9254
|
+
kind?: "integration.connect" | "integration.disconnect" | "integration.purge" | "profile.update" | "slack.send" | "data.ingest" | "data.scope" | "system.cleanup" | "member.changeManager" | "companyMd.ingest" | "companyMd.commentPass" | "semantic.transform" | "companyMd.importContextDoc";
|
|
9255
9255
|
kindPrefix?: string;
|
|
9256
9256
|
};
|
|
9257
9257
|
header?: never;
|
|
@@ -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
|
+
}
|
|
@@ -53,6 +53,27 @@ describe("EXECUTION_KINDS golden snapshot", () => {
|
|
|
53
53
|
templateId: "integration.disconnect",
|
|
54
54
|
},
|
|
55
55
|
},
|
|
56
|
+
"integration.purge": {
|
|
57
|
+
kind: "integration.purge",
|
|
58
|
+
domain: "integration",
|
|
59
|
+
display: {
|
|
60
|
+
label: "Purge integration data",
|
|
61
|
+
pastTenseLabel: "Integration data purged",
|
|
62
|
+
icon: "unlink",
|
|
63
|
+
},
|
|
64
|
+
governance: {
|
|
65
|
+
visibility: "admin",
|
|
66
|
+
requiresAdmin: true,
|
|
67
|
+
},
|
|
68
|
+
ui: {
|
|
69
|
+
showInAdmin: true,
|
|
70
|
+
showInTimeline: true,
|
|
71
|
+
confirmBeforeRun: true,
|
|
72
|
+
},
|
|
73
|
+
explanation: {
|
|
74
|
+
templateId: "integration.purge",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
56
77
|
"profile.update": {
|
|
57
78
|
kind: "profile.update",
|
|
58
79
|
domain: "profile",
|
package/src/execution/kinds.ts
CHANGED
|
@@ -36,6 +36,10 @@
|
|
|
36
36
|
export type ExecutionKind =
|
|
37
37
|
| "integration.connect"
|
|
38
38
|
| "integration.disconnect"
|
|
39
|
+
/** Break-glass HRIS purge: disconnect plus an irreversible unwind of all
|
|
40
|
+
* facts still exclusively source-owned, tombstoning the connection as
|
|
41
|
+
* `purged`. Only providers enrolled in RETAIN_ON_DISCONNECT qualify. */
|
|
42
|
+
| "integration.purge"
|
|
39
43
|
| "profile.update"
|
|
40
44
|
| "slack.send"
|
|
41
45
|
| "data.ingest"
|