@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "58.5.1",
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 = 'b4aa56c17639' as const;
3
- export const SPEC_HASH_FULL = 'b4aa56c1763945ca5c741250d51eafeff80cd2d48b5e0441918233b5f9a6a16b' as const;
2
+ export const SPEC_HASH = 'dd2a725d7ebd' as const;
3
+ export const SPEC_HASH_FULL = 'dd2a725d7ebd694db25efbe0e638077984af41551c1989fb374d9a7b8bc4eb3d' as const;
@@ -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",
@@ -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"