@company-semantics/contracts 88.1.0 → 89.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.
@@ -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
+ >;
@@ -93,6 +93,10 @@ export const openApiRoutes = {
93
93
  '/api/executions/{executionId}/undo': ['POST'],
94
94
  '/api/factory/floor': ['GET'],
95
95
  '/api/factory/kpis': ['GET'],
96
+ '/api/factory/quality/changes': ['POST'],
97
+ '/api/factory/quality/deployments': ['POST'],
98
+ '/api/factory/quality/plans/{planId}': ['GET'],
99
+ '/api/factory/quality/plans/{planId}/decision': ['GET'],
96
100
  '/api/factory/snapshot': ['GET'],
97
101
  '/api/ingestion/operations/{id}': ['GET'],
98
102
  '/api/internal-admin/impersonate/end': ['POST'],
@@ -134,7 +134,7 @@ Doctrine: control ADR `source-discovery-relevance-funnel`. Vocabulary: ADR
134
134
  `unclassified` (so `sourceClass` and `recommendedUse` are both null) or
135
135
  `classified` (both non-null). A shown line can still be
136
136
  `pending_classification` in its `machineState`.
137
- - **Reasons are a discriminated union.** `relevanceReasons` (≤ 8) is
137
+ - **Reasons are a discriminated union.** `relevanceReasons` (≤ 16) is
138
138
  `RelevanceReason`: `lexicon` (a product-owned `term` and a `count`),
139
139
  `signal` (a `value` or null), `evaluation` (a code only) or `model` (a
140
140
  `score` or null). Each kind has its own disjoint code set, and the app
@@ -155,6 +155,42 @@ Doctrine: control ADR `source-discovery-relevance-funnel`. Vocabulary: ADR
155
155
  `filesNotRediscovered` and `filesPendingClassification`, counted by
156
156
  `candidateTotalsOf` over distinct files.
157
157
 
158
+ ## Document type
159
+
160
+ Doctrine: control ADR `source-discovery-observation-vs-policy`. Vocabulary: ADR
161
+ `source-discovery-document-type-vocabulary`. The model NAMES what a source is;
162
+ product policy in the backend decides what that means.
163
+
164
+ - **Structural, never a verdict.** `DOCUMENT_TYPES` (in `vocabulary.ts`) says
165
+ what a file IS — `corporate_formation_record`, never `irrelevant_doc` —
166
+ never how relevant it is. A medium is not a meaning: `image_or_screenshot`
167
+ is graded on its content. `unknown` is a real answer (the model could not
168
+ tell, or two reads disagreed) and is always last.
169
+ - **The resolved type travels, nothing else.** The candidate item's
170
+ `documentType` and `documentTypeConfidence` (0..1) carry the RESOLVED type,
171
+ after the backend reconciles the T1 and T2 observations. Both are null on an
172
+ unclassified line and on every line of a run pinned before the typed path
173
+ (v1), where `sourceClass` alone is set. A known type always carries a
174
+ confidence; `unknown` and null never do. Per-tier types and per-layer scores
175
+ are backend evidence and stay off the wire.
176
+ - **Policy lives in the backend.** Families, relevance policy modes, ceilings,
177
+ confidence floors, strategy signals and peek verdicts are backend product
178
+ policy, never contracts vocabulary
179
+ (INV-SOURCESYNC-OBSERVATION-IS-SEPARATE-FROM-POLICY). Changing what a type
180
+ means is a backend change, not a release.
181
+ - **Same word, separate axis.** Five members are also words in other sets:
182
+ `board_deck`, `board_minutes`, `investor_update`, `roadmap`
183
+ (`SOURCE_CLASSES`) and `unknown` (`SHARING_BREADTHS`). `DOCUMENT_TYPES`
184
+ therefore sits outside the cross-set disjointness check, and its overlap is
185
+ pinned to exactly those five.
186
+ - **Typed-relevance reasons.** Model codes `type_policy` (the policy-applied
187
+ score of the resolved type) and `peek_strategy_evidence_present` /
188
+ `peek_strategy_evidence_absent`. Evaluation codes
189
+ `peek_insufficient_evidence`, `type_disagreement`, `type_below_confidence`
190
+ and `execution_failed_t1` / `execution_failed_t2`: a tier that did not run
191
+ is uncertainty, never irrelevance. `peek_confirms` / `peek_contradicts`
192
+ stay for v1-pinned rows.
193
+
158
194
  ## Public API
159
195
 
160
196
  - `SOURCE_CLASSES` / `SourceClass` — what kind of document a source is.
@@ -172,10 +208,14 @@ Doctrine: control ADR `source-discovery-relevance-funnel`. Vocabulary: ADR
172
208
  `pending`, `approved` or `rejected` (no verb writes `rejected` yet).
173
209
  - `SOURCE_SYNC_SCOPE_APPROVER_KINDS` / `SourceSyncScopeApproverKind` —
174
210
  `org_admin` or `unit_head`.
211
+ - `DOCUMENT_TYPES` / `DocumentType` / `KnownDocumentType` — what a source is,
212
+ structurally; `KnownDocumentType` excludes `unknown` (see Document type).
175
213
  - `SourceSyncDiscoveryRunResponseSchema` / `SourceSyncDiscoveryRunResponse` —
176
214
  the discovery run started, and the workflow narrating it.
177
215
  - `SourceSyncCandidateItemSchema` / `SourceSyncCandidateItem` — one source
178
- proposed for one target document, with the server's `included`.
216
+ proposed for one target document, with the server's `included` and the
217
+ resolved `documentType` / `documentTypeConfidence` (null when unclassified
218
+ or v1-pinned).
179
219
  - `SourceSyncCandidateSetResponseSchema` / `SourceSyncCandidateSetResponse` —
180
220
  the draft set under review: its revision, items, presented questions,
181
221
  distinct-file `totals` and cost estimate.
@@ -267,5 +307,7 @@ Doctrine: control ADR `source-discovery-relevance-funnel`. Vocabulary: ADR
267
307
 
268
308
  `rules.ts` imports only `zod`, `../api/primitives` and `./vocabulary`.
269
309
  `upkeep.ts` and `reach.ts` import only `zod` and `../api/primitives`.
310
+ `candidate-item.ts` imports only `zod`, `../api/primitives`, `./vocabulary`
311
+ and `./relevance`.
270
312
  `relevance.ts` imports only `zod`.
271
313
  `vocabulary.ts` itself has no imports.
@@ -18,6 +18,8 @@
18
18
  * standing orders and the Drive-reach shapes.
19
19
  * - Every runtime name `relevance.ts` declares is reachable from the root as
20
20
  * the same binding.
21
+ * - `DOCUMENT_TYPES` is reachable from the root as the same binding, and its
22
+ * two types are named by the barrel.
21
23
  * - `ACTION_ITEM_KINDS` at the root carries `sourceSync.approval_pending`.
22
24
  * - The root re-exports the domain barrel explicitly.
23
25
  *
@@ -67,18 +69,20 @@ import type {
67
69
  SourceSyncStandingOrder,
68
70
  SourceSyncStandingOrdersResponse,
69
71
  } from "../../index";
72
+ import * as candidateItem from "../candidate-item";
70
73
  import * as reach from "../reach";
71
74
  import * as relevance from "../relevance";
72
75
  import * as rules from "../rules";
73
76
  import * as schemas from "../schemas";
74
77
  import * as upkeep from "../upkeep";
78
+ import * as vocabulary from "../vocabulary";
75
79
 
76
80
  /** Runtime names Release C adds, keyed to the module that declares them. */
77
81
  const RELEASE_C_RUNTIME_NAMES: ReadonlyArray<
78
82
  readonly [name: string, module: Record<string, unknown>]
79
83
  > = [
80
84
  ["SOURCE_SYNC_RISK_REASONS", schemas],
81
- ["SourceSyncCandidateItemPatchSchema", schemas],
85
+ ["SourceSyncCandidateItemPatchSchema", candidateItem],
82
86
  ["SourceSyncAddCandidateRequestSchema", schemas],
83
87
  ["SourceSyncApproveRequestSchema", schemas],
84
88
  ["SourceSyncScopeApprovalSchema", schemas],
@@ -250,6 +254,12 @@ describe("source-sync Release C package surface", () => {
250
254
  }
251
255
  });
252
256
 
257
+ it("every document-type name is reachable from the root as the same binding", () => {
258
+ expect(rootBarrel.DOCUMENT_TYPES).toBe(vocabulary.DOCUMENT_TYPES);
259
+ expect([...barrelTypeExports]).toContain("DocumentType");
260
+ expect([...barrelTypeExports]).toContain("KnownDocumentType");
261
+ });
262
+
253
263
  it("exposes sourceSync.approval_pending in ACTION_ITEM_KINDS at the root", () => {
254
264
  expect(rootBarrel.ACTION_ITEM_KINDS).toContain(
255
265
  "sourceSync.approval_pending",
@@ -14,6 +14,9 @@
14
14
  * - Relevance and treatment are two axes (control ADR source-discovery-relevance-funnel):
15
15
  * a hidden line is never included; an unclassified line has no recommendation and
16
16
  * no class; `selectionSource` is present exactly when `use` is.
17
+ * - The resolved document type (control ADR source-discovery-observation-vs-policy)
18
+ * is a REQUIRED nullable key: an unclassified line has none, and a confidence
19
+ * travels exactly with a known type. A line carries at most sixteen reasons.
17
20
  * - A relevance reason's code belongs to its kind's closed set.
18
21
  * - A candidate patch changes exactly one thing: bringing a line into review never
19
22
  * also commits a treatment, and a patch can never hide a line.
@@ -28,6 +31,8 @@ import {
28
31
  SourceSyncCandidateSetResponseSchema,
29
32
  SourceSyncDiscoveryRunResponseSchema,
30
33
  } from "../index";
34
+ import * as root from "../../index";
35
+ import * as candidateItemModule from "../candidate-item";
31
36
 
32
37
  const RUN_ID = "3f1a2b4c-5d6e-4f70-8192-a3b4c5d6e7f8";
33
38
  const WORKFLOW_ID = "4a2b3c5d-6e7f-4a81-9b2c-b4c5d6e7f8a9";
@@ -52,6 +57,8 @@ function candidateItem(overrides: Record<string, unknown> = {}) {
52
57
  visibility: "shown",
53
58
  classificationState: "classified",
54
59
  sourceClass: "board_deck",
60
+ documentType: null,
61
+ documentTypeConfidence: null,
55
62
  periodLabel: "Q3 2026",
56
63
  seriesRole: "current",
57
64
  relevance: 0.92,
@@ -142,6 +149,18 @@ function candidateSet(overrides: Record<string, unknown> = {}) {
142
149
  }
143
150
 
144
151
  describe("SourceSyncCandidateItemSchema", () => {
152
+ it("the candidate item schema moved without changing what it accepts", () => {
153
+ const parsed = SourceSyncCandidateItemSchema.safeParse(candidateItem());
154
+ expect(parsed.success).toBe(true);
155
+ expect(parsed.data).toEqual(candidateItem());
156
+ expect(root.SourceSyncCandidateItemSchema).toBe(
157
+ candidateItemModule.SourceSyncCandidateItemSchema,
158
+ );
159
+ expect(root.SourceSyncCandidateItemPatchSchema).toBe(
160
+ candidateItemModule.SourceSyncCandidateItemPatchSchema,
161
+ );
162
+ });
163
+
145
164
  it("refuses a candidate item with no audience fit key at all", () => {
146
165
  const { audienceFit: _omitted, ...item } = candidateItem();
147
166
  const parsed = SourceSyncCandidateItemSchema.safeParse(item);
@@ -362,15 +381,104 @@ describe("candidate relevance", () => {
362
381
  ]);
363
382
  });
364
383
 
365
- it("refuses more than eight reasons", () => {
384
+ it("accepts sixteen reasons", () => {
366
385
  const reason = { kind: "evaluation", code: "explored_t1" };
367
386
  expect(
368
387
  SourceSyncCandidateItemSchema.safeParse(
369
- candidateItem({ relevanceReasons: Array(9).fill(reason) }),
388
+ candidateItem({ relevanceReasons: Array(16).fill(reason) }),
389
+ ).success,
390
+ ).toBe(true);
391
+ });
392
+
393
+ it("refuses more than sixteen reasons", () => {
394
+ const reason = { kind: "evaluation", code: "explored_t1" };
395
+ expect(
396
+ SourceSyncCandidateItemSchema.safeParse(
397
+ candidateItem({ relevanceReasons: Array(17).fill(reason) }),
398
+ ).success,
399
+ ).toBe(false);
400
+ });
401
+
402
+ it("a typed item carries its resolved document type and confidence", () => {
403
+ const parsed = SourceSyncCandidateItemSchema.safeParse(
404
+ candidateItem({
405
+ documentType: "corporate_formation_record",
406
+ documentTypeConfidence: 0.92,
407
+ sourceClass: "other",
408
+ }),
409
+ );
410
+ expect(parsed.success).toBe(true);
411
+ expect(parsed.data?.documentType).toBe("corporate_formation_record");
412
+ expect(parsed.data?.documentTypeConfidence).toBe(0.92);
413
+ });
414
+
415
+ it("a v1-pinned classified item has no document type", () => {
416
+ const parsed = SourceSyncCandidateItemSchema.safeParse(
417
+ candidateItem({ sourceClass: "board_minutes" }),
418
+ );
419
+ expect(parsed.success).toBe(true);
420
+ expect(parsed.data?.documentType).toBeNull();
421
+ expect(parsed.data?.documentTypeConfidence).toBeNull();
422
+ });
423
+
424
+ it("an unclassified line has no document type", () => {
425
+ const parsed = SourceSyncCandidateItemSchema.safeParse(
426
+ candidateItem({
427
+ visibility: "hidden",
428
+ classificationState: "unclassified",
429
+ recommendedUse: null,
430
+ sourceClass: null,
431
+ included: false,
432
+ documentType: "strategy_memo",
433
+ documentTypeConfidence: 0.9,
434
+ }),
435
+ );
436
+ expect(parsed.success).toBe(false);
437
+ expect(parsed.error?.issues[0]?.message).toBe(
438
+ "an unclassified line has no document type",
439
+ );
440
+ });
441
+
442
+ it("a known document type carries a confidence; unknown and absent carry none", () => {
443
+ for (const [documentType, documentTypeConfidence, ok] of [
444
+ ["strategy_memo", 0.9, true],
445
+ ["strategy_memo", null, false],
446
+ ["unknown", null, true],
447
+ ["unknown", 0.4, false],
448
+ [null, null, true],
449
+ [null, 0.4, false],
450
+ ] as const) {
451
+ const parsed = SourceSyncCandidateItemSchema.safeParse(
452
+ candidateItem({ documentType, documentTypeConfidence }),
453
+ );
454
+ expect(parsed.success).toBe(ok);
455
+ if (!ok) {
456
+ expect(parsed.error?.issues[0]?.message).toBe(
457
+ "a known document type carries a confidence; unknown and absent carry none",
458
+ );
459
+ }
460
+ }
461
+ });
462
+
463
+ it("refuses a document type outside the closed set", () => {
464
+ expect(
465
+ SourceSyncCandidateItemSchema.safeParse(
466
+ candidateItem({
467
+ documentType: "irrelevant_doc",
468
+ documentTypeConfidence: 0.9,
469
+ }),
370
470
  ).success,
371
471
  ).toBe(false);
372
472
  });
373
473
 
474
+ it("the document type keys are required", () => {
475
+ for (const key of ["documentType", "documentTypeConfidence"]) {
476
+ const item: Record<string, unknown> = candidateItem();
477
+ delete item[key];
478
+ expect(SourceSyncCandidateItemSchema.safeParse(item).success).toBe(false);
479
+ }
480
+ });
481
+
374
482
  it("a reason whose code belongs to another kind is refused", () => {
375
483
  for (const reason of [
376
484
  { kind: "lexicon", code: "version_churn", term: "board", count: 1 },
@@ -382,6 +490,37 @@ describe("candidate relevance", () => {
382
490
  }
383
491
  });
384
492
 
493
+ it("accepts the typed-relevance reason codes", () => {
494
+ const relevanceReasons = [
495
+ { kind: "model", code: "type_policy", score: 0.05 },
496
+ { kind: "model", code: "peek_strategy_evidence_present", score: 0.8 },
497
+ { kind: "model", code: "peek_strategy_evidence_absent", score: 0.1 },
498
+ { kind: "evaluation", code: "peek_insufficient_evidence" },
499
+ { kind: "evaluation", code: "type_disagreement" },
500
+ { kind: "evaluation", code: "type_below_confidence" },
501
+ { kind: "evaluation", code: "execution_failed_t1" },
502
+ { kind: "evaluation", code: "execution_failed_t2" },
503
+ ];
504
+ expect(
505
+ SourceSyncCandidateItemSchema.safeParse(
506
+ candidateItem({ relevanceReasons }),
507
+ ).success,
508
+ ).toBe(true);
509
+ });
510
+
511
+ it("a typed-relevance code under the wrong kind is refused", () => {
512
+ for (const reason of [
513
+ { kind: "evaluation", code: "type_policy" },
514
+ { kind: "model", code: "type_disagreement", score: 0.5 },
515
+ ]) {
516
+ expect(
517
+ SourceSyncCandidateItemSchema.safeParse(
518
+ candidateItem({ relevanceReasons: [reason] }),
519
+ ).success,
520
+ ).toBe(false);
521
+ }
522
+ });
523
+
385
524
  it("refuses a lexicon reason without a term or a count, and a model score outside 0..1", () => {
386
525
  for (const reason of [
387
526
  { kind: "lexicon", code: "title_term", count: 1 },
@@ -16,12 +16,12 @@ import { DecisionEvidenceRowSchema } from "../../decisions/presentation";
16
16
  import {
17
17
  SourceSyncApplyResponseSchema,
18
18
  SourceSyncApproveResponseSchema,
19
- SourceSyncCandidateItemSchema,
20
19
  SourceSyncCandidateSetResponseSchema,
21
20
  SourceSyncDocChangeSchema,
22
21
  SourceSyncScopeApprovalProjectionSchema,
23
22
  SourceSyncSynthesisResponseSchema,
24
23
  } from "../schemas";
24
+ import { SourceSyncCandidateItemSchema } from "../candidate-item";
25
25
 
26
26
  const UNIT_SCOPE = "unit:3f1c2d4e-5a6b-4c7d-8e9f-a0b1c2d3e4f5";
27
27
 
@@ -11,6 +11,9 @@
11
11
  * - `decoy` is a source class; `unknown` is a sharing breadth
12
12
  * - No member string appears in two sets
13
13
  * - The four relevance reason code sets are pairwise disjoint
14
+ * - DOCUMENT_TYPES has exactly its thirty members, `unknown` last; it sits
15
+ * OUTSIDE the disjointness table and shares a word with another set only
16
+ * where pinned (a structural name on a separate axis)
14
17
  */
15
18
 
16
19
  import { describe, it, expect } from "vitest";
@@ -33,6 +36,7 @@ import {
33
36
 
34
37
  import {
35
38
  AUDIENCE_FITS,
39
+ DOCUMENT_TYPES,
36
40
  SHARING_BREADTHS,
37
41
  SOURCE_CLASSES,
38
42
  SOURCE_SENSITIVITY_CLASSES,
@@ -175,12 +179,24 @@ const VOCABULARIES = [
175
179
  "folder_truncated",
176
180
  "explored_t1",
177
181
  "explored_t2",
182
+ "peek_insufficient_evidence",
183
+ "type_disagreement",
184
+ "type_below_confidence",
185
+ "execution_failed_t1",
186
+ "execution_failed_t2",
178
187
  ],
179
188
  },
180
189
  {
181
190
  name: "MODEL_REASON_CODES",
182
191
  tuple: MODEL_REASON_CODES,
183
- members: ["title_path_score", "peek_confirms", "peek_contradicts"],
192
+ members: [
193
+ "title_path_score",
194
+ "peek_confirms",
195
+ "peek_contradicts",
196
+ "type_policy",
197
+ "peek_strategy_evidence_present",
198
+ "peek_strategy_evidence_absent",
199
+ ],
184
200
  },
185
201
  {
186
202
  name: "TARGET_AUDIENCES",
@@ -277,3 +293,59 @@ describe("Source Sync closed sets", () => {
277
293
  });
278
294
  });
279
295
  });
296
+
297
+ describe("document types", () => {
298
+ it("has exactly the thirty document types, in order", () => {
299
+ expect([...DOCUMENT_TYPES]).toEqual([
300
+ "board_deck",
301
+ "board_minutes",
302
+ "strategy_memo",
303
+ "planning_doc",
304
+ "okr_doc",
305
+ "roadmap",
306
+ "operating_review",
307
+ "all_hands_deck",
308
+ "offsite_notes",
309
+ "investor_update",
310
+ "pitch_deck",
311
+ "product_spec",
312
+ "research_analysis",
313
+ "general_meeting_notes",
314
+ "financial_plan",
315
+ "financial_actuals",
316
+ "equity_admin_record",
317
+ "corporate_formation_record",
318
+ "corporate_governance_record",
319
+ "government_filing",
320
+ "equity_agreement",
321
+ "commercial_agreement",
322
+ "employment_agreement",
323
+ "transaction_record",
324
+ "personal_document",
325
+ "social_event_material",
326
+ "coursework",
327
+ "empty_template",
328
+ "image_or_screenshot",
329
+ "unknown",
330
+ ]);
331
+ expect(new Set(DOCUMENT_TYPES).size).toBe(30);
332
+ });
333
+
334
+ it("unknown is the last document type", () => {
335
+ expect(DOCUMENT_TYPES[DOCUMENT_TYPES.length - 1]).toBe("unknown");
336
+ });
337
+
338
+ it("document types share a member with another set only where pinned", () => {
339
+ const others = new Set<string>(
340
+ VOCABULARIES.flatMap(({ tuple }) => [...tuple]),
341
+ );
342
+ const shared = DOCUMENT_TYPES.filter((t) => others.has(t)).sort();
343
+ expect(shared).toEqual([
344
+ "board_deck",
345
+ "board_minutes",
346
+ "investor_update",
347
+ "roadmap",
348
+ "unknown",
349
+ ]);
350
+ });
351
+ });