@company-semantics/contracts 88.1.0 → 88.2.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": "88.1.0",
3
+ "version": "88.2.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,3 +1,3 @@
1
1
  // AUTO-GENERATED — do not edit. Run pnpm generate:spec-hash to regenerate.
2
- export const SPEC_HASH = 'edc033bdac67' as const;
3
- export const SPEC_HASH_FULL = 'edc033bdac6767eaf9965214f6dfed6c536d48fb5f1f77a48e1af26533633fe1' as const;
2
+ export const SPEC_HASH = 'fa844a445bc5' as const;
3
+ export const SPEC_HASH_FULL = 'fa844a445bc5f62afa70d732ba33c9ce0b90b89f0634fabb2d6342743c0efeee' as const;
@@ -8897,6 +8897,20 @@ export interface components {
8897
8897
  text: string;
8898
8898
  detail?: string;
8899
8899
  };
8900
+ rows?: {
8901
+ rowId: string;
8902
+ label: string;
8903
+ note?: string;
8904
+ evidence: {
8905
+ label: string;
8906
+ value: string;
8907
+ withheld: boolean;
8908
+ }[];
8909
+ selectedChoiceId: string | null;
8910
+ recommendedChoiceId: string | null;
8911
+ allowedChoiceIds: string[];
8912
+ required: boolean;
8913
+ }[];
8900
8914
  }[];
8901
8915
  withheldQuestionCount: number;
8902
8916
  totals: {
@@ -9023,6 +9037,20 @@ export interface components {
9023
9037
  text: string;
9024
9038
  detail?: string;
9025
9039
  };
9040
+ rows?: {
9041
+ rowId: string;
9042
+ label: string;
9043
+ note?: string;
9044
+ evidence: {
9045
+ label: string;
9046
+ value: string;
9047
+ withheld: boolean;
9048
+ }[];
9049
+ selectedChoiceId: string | null;
9050
+ recommendedChoiceId: string | null;
9051
+ allowedChoiceIds: string[];
9052
+ required: boolean;
9053
+ }[];
9026
9054
  }[];
9027
9055
  withheldQuestionCount: number;
9028
9056
  changes: {
@@ -69,6 +69,11 @@ decision-effect-is-a-domain-parameter.
69
69
  effect and no apply-protocol address; `questionId` is its only id. Its `kind`
70
70
  is a bounded open string because each domain has its own kinds. Promoted
71
71
  because the backend renders it and the app consumes it.
72
+ - **A matrix keeps its state on rows.** A presentation with `rows` is a matrix:
73
+ each row is one subject answered with one of `choices` (the columns). Columns
74
+ carry no `selected`/`recommended`; each row holds its own, within its
75
+ `allowedChoiceIds`. One answer per row, no free text. Enforced by
76
+ `matrixPresentationDefect` inside the schema (ADR-CONTRACTS-213).
72
77
 
73
78
  ## Measured evidence is optional
74
79
 
@@ -11,6 +11,7 @@ import { describe, expect, it } from "vitest";
11
11
  import {
12
12
  DecisionChoicePresentationSchema,
13
13
  DecisionQuestionPresentationSchema,
14
+ matrixPresentationDefect,
14
15
  } from "../index";
15
16
 
16
17
  function presentedQuestion(overrides: Record<string, unknown> = {}) {
@@ -110,3 +111,155 @@ describe("DecisionQuestionPresentationSchema", () => {
110
111
  );
111
112
  });
112
113
  });
114
+
115
+ /**
116
+ * A MATRIX question: one subject per row, each answered with one of the
117
+ * question's choices. Rows carry the state; the columns carry none.
118
+ */
119
+ function matrixQuestion(overrides: Record<string, unknown> = {}) {
120
+ return presentedQuestion({
121
+ questionId: "treatment_matrix:s1",
122
+ kind: "treatment_matrix",
123
+ header: "Strategy memo",
124
+ question:
125
+ "Plan was found relevant to 2 places. How should it be used in each?",
126
+ choices: [
127
+ {
128
+ choiceId: "attached",
129
+ label: "Attach",
130
+ recommended: false,
131
+ selected: false,
132
+ },
133
+ {
134
+ choiceId: "direction_only",
135
+ label: "Direction only",
136
+ recommended: false,
137
+ selected: false,
138
+ },
139
+ {
140
+ choiceId: "excluded",
141
+ label: "Leave out",
142
+ recommended: false,
143
+ selected: false,
144
+ },
145
+ ],
146
+ multiSelect: false,
147
+ freeFormAllowed: false,
148
+ recommendation: { strength: "confident" },
149
+ rows: [
150
+ {
151
+ rowId: "item-1",
152
+ label: "Support",
153
+ note: "Shared more narrowly than this doc",
154
+ evidence: [],
155
+ selectedChoiceId: null,
156
+ recommendedChoiceId: "direction_only",
157
+ allowedChoiceIds: ["attached", "direction_only", "excluded"],
158
+ required: true,
159
+ },
160
+ {
161
+ rowId: "item-2",
162
+ label: "Engineering",
163
+ evidence: [
164
+ { label: "Why it was found", value: "Title search", withheld: false },
165
+ ],
166
+ selectedChoiceId: "attached",
167
+ recommendedChoiceId: null,
168
+ allowedChoiceIds: ["attached", "direction_only", "excluded"],
169
+ required: false,
170
+ },
171
+ ],
172
+ ...overrides,
173
+ });
174
+ }
175
+
176
+ describe("DecisionQuestionPresentationSchema — matrix", () => {
177
+ it("accepts a matrix whose rows hold their own selection and recommendation", () => {
178
+ const parsed =
179
+ DecisionQuestionPresentationSchema.safeParse(matrixQuestion());
180
+ expect(parsed.success).toBe(true);
181
+ expect(parsed.data?.rows).toHaveLength(2);
182
+ });
183
+
184
+ it("leaves a question without rows untouched", () => {
185
+ expect(
186
+ matrixPresentationDefect(
187
+ DecisionQuestionPresentationSchema.parse(presentedQuestion()),
188
+ ),
189
+ ).toBeNull();
190
+ });
191
+
192
+ it.each([
193
+ ["matrix_multi_select", { multiSelect: true }],
194
+ ["matrix_free_form", { freeFormAllowed: true }],
195
+ ["matrix_recommendation_mismatch", { recommendation: null }],
196
+ ])("refuses %s", (defect, overrides) => {
197
+ const parsed = DecisionQuestionPresentationSchema.safeParse(
198
+ matrixQuestion(overrides),
199
+ );
200
+ expect(parsed.success).toBe(false);
201
+ expect(parsed.error?.issues[0]?.message).toBe(defect);
202
+ expect(parsed.error?.issues[0]?.path).toEqual(["rows"]);
203
+ });
204
+
205
+ it("refuses state on a column", () => {
206
+ const base = matrixQuestion();
207
+ const choices = base.choices.map((c, i) =>
208
+ i === 0 ? { ...c, selected: true } : c,
209
+ );
210
+ const parsed = DecisionQuestionPresentationSchema.safeParse({
211
+ ...base,
212
+ choices,
213
+ });
214
+ expect(parsed.error?.issues[0]?.message).toBe("matrix_column_state");
215
+ });
216
+
217
+ it("refuses two rows with one id", () => {
218
+ const base = matrixQuestion();
219
+ const rows = [base.rows[0], { ...base.rows[1], rowId: "item-1" }];
220
+ const parsed = DecisionQuestionPresentationSchema.safeParse({
221
+ ...base,
222
+ rows,
223
+ });
224
+ expect(parsed.error?.issues[0]?.message).toBe("matrix_duplicate_row");
225
+ });
226
+
227
+ it("refuses a row allowing a choice that is not a column", () => {
228
+ const base = matrixQuestion();
229
+ const rows = [
230
+ { ...base.rows[0], allowedChoiceIds: ["attached", "archived"] },
231
+ base.rows[1],
232
+ ];
233
+ const parsed = DecisionQuestionPresentationSchema.safeParse({
234
+ ...base,
235
+ rows,
236
+ });
237
+ expect(parsed.error?.issues[0]?.message).toBe("matrix_unknown_choice");
238
+ });
239
+
240
+ it("refuses a selection or recommendation the row does not allow", () => {
241
+ const base = matrixQuestion();
242
+ const narrowed = { ...base.rows[1], allowedChoiceIds: ["excluded"] };
243
+ const selected = DecisionQuestionPresentationSchema.safeParse({
244
+ ...base,
245
+ rows: [base.rows[0], narrowed],
246
+ });
247
+ expect(selected.error?.issues[0]?.message).toBe(
248
+ "matrix_selection_not_allowed",
249
+ );
250
+ const recommended = DecisionQuestionPresentationSchema.safeParse({
251
+ ...base,
252
+ rows: [{ ...base.rows[0], allowedChoiceIds: ["excluded"] }, base.rows[1]],
253
+ });
254
+ expect(recommended.error?.issues[0]?.message).toBe(
255
+ "matrix_recommendation_not_allowed",
256
+ );
257
+ });
258
+
259
+ it("refuses an empty row list", () => {
260
+ const parsed = DecisionQuestionPresentationSchema.safeParse(
261
+ matrixQuestion({ rows: [] }),
262
+ );
263
+ expect(parsed.success).toBe(false);
264
+ });
265
+ });
@@ -47,10 +47,15 @@ export {
47
47
  decisionAnswerSchemaOf,
48
48
  } from "./schemas";
49
49
 
50
- export type { DecisionQuestionPresentation } from "./presentation";
50
+ export type {
51
+ DecisionMatrixRowPresentation,
52
+ DecisionQuestionPresentation,
53
+ } from "./presentation";
51
54
 
52
55
  export {
53
56
  DecisionChoicePresentationSchema,
54
57
  DecisionEvidenceRowSchema,
58
+ DecisionMatrixRowPresentationSchema,
55
59
  DecisionQuestionPresentationSchema,
60
+ matrixPresentationDefect,
56
61
  } from "./presentation";
@@ -39,7 +39,29 @@ export const DecisionEvidenceRowSchema = z.object({
39
39
  withheld: z.boolean(),
40
40
  });
41
41
 
42
- export const DecisionQuestionPresentationSchema = z.object({
42
+ /**
43
+ * One row of a MATRIX question: a single subject answered with one of the
44
+ * question's choices (the matrix's columns). Each row is its own answer, so the
45
+ * row — not the column — carries selection and recommendation state.
46
+ */
47
+ export const DecisionMatrixRowPresentationSchema = z.object({
48
+ /** The row's submit address, never rendered. */
49
+ rowId: z.string().min(1).max(64),
50
+ label: z.string().min(1).max(200),
51
+ /** A short line under the label: why this row is asked, or why it is not. */
52
+ note: z.string().min(1).max(300).optional(),
53
+ evidence: z.array(DecisionEvidenceRowSchema).max(6),
54
+ /** The choice this row holds now. Selection is STATE the server owns. */
55
+ selectedChoiceId: z.string().min(1).max(64).nullable(),
56
+ /** The choice the derivation believes is right for this row, independent of selection. */
57
+ recommendedChoiceId: z.string().min(1).max(64).nullable(),
58
+ /** The choices this row may take, in the question's column order. */
59
+ allowedChoiceIds: z.array(z.string().min(1).max(64)).min(1).max(8),
60
+ /** True when the question is unanswered until this row holds a choice. */
61
+ required: z.boolean(),
62
+ });
63
+
64
+ const DecisionQuestionPresentationBaseSchema = z.object({
43
65
  questionId: z.string().min(1).max(200),
44
66
  /** A public semantic discriminator. Never an id, never reasoning. */
45
67
  kind: z.string().min(1).max(64),
@@ -69,8 +91,67 @@ export const DecisionQuestionPresentationSchema = z.object({
69
91
  detail: z.string().max(2000).optional(),
70
92
  })
71
93
  .optional(),
94
+ /**
95
+ * Present exactly when the question is a MATRIX: one subject per row, each
96
+ * answered with one of `choices` (the columns). A matrix takes no free text
97
+ * and one choice per row, and its columns carry no state — `selected` and
98
+ * `recommended` are false on every choice; each row holds its own.
99
+ */
100
+ rows: z.array(DecisionMatrixRowPresentationSchema).min(1).max(20).optional(),
72
101
  });
73
102
 
103
+ type DecisionQuestionPresentationBase = z.infer<
104
+ typeof DecisionQuestionPresentationBaseSchema
105
+ >;
106
+
107
+ /** Why a matrix question is malformed, or null. Pure. */
108
+ export function matrixPresentationDefect(
109
+ question: DecisionQuestionPresentationBase,
110
+ ): string | null {
111
+ const rows = question.rows;
112
+ if (rows === undefined) return null;
113
+ if (question.multiSelect) return "matrix_multi_select";
114
+ if (question.freeFormAllowed) return "matrix_free_form";
115
+ if (question.choices.some((c) => c.selected || c.recommended)) {
116
+ return "matrix_column_state";
117
+ }
118
+ const columns = new Set(question.choices.map((c) => c.choiceId));
119
+ if (new Set(rows.map((r) => r.rowId)).size !== rows.length) {
120
+ return "matrix_duplicate_row";
121
+ }
122
+ for (const row of rows) {
123
+ if (row.allowedChoiceIds.some((id) => !columns.has(id))) {
124
+ return "matrix_unknown_choice";
125
+ }
126
+ const allowed = new Set(row.allowedChoiceIds);
127
+ if (row.selectedChoiceId !== null && !allowed.has(row.selectedChoiceId)) {
128
+ return "matrix_selection_not_allowed";
129
+ }
130
+ if (
131
+ row.recommendedChoiceId !== null &&
132
+ !allowed.has(row.recommendedChoiceId)
133
+ ) {
134
+ return "matrix_recommendation_not_allowed";
135
+ }
136
+ }
137
+ const anyRecommended = rows.some((r) => r.recommendedChoiceId !== null);
138
+ if (anyRecommended !== (question.recommendation !== null)) {
139
+ return "matrix_recommendation_mismatch";
140
+ }
141
+ return null;
142
+ }
143
+
144
+ export const DecisionQuestionPresentationSchema =
145
+ DecisionQuestionPresentationBaseSchema.superRefine((question, ctx) => {
146
+ const defect = matrixPresentationDefect(question);
147
+ if (defect !== null) {
148
+ ctx.addIssue({ code: "custom", path: ["rows"], message: defect });
149
+ }
150
+ });
151
+
74
152
  export type DecisionQuestionPresentation = z.infer<
75
153
  typeof DecisionQuestionPresentationSchema
76
154
  >;
155
+ export type DecisionMatrixRowPresentation = z.infer<
156
+ typeof DecisionMatrixRowPresentationSchema
157
+ >;