@company-semantics/contracts 83.2.0 → 84.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": "83.2.0",
3
+ "version": "84.0.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 = 'ed793823fd73' as const;
3
- export const SPEC_HASH_FULL = 'ed793823fd7383fa2708a90aca342dd575fcc636aa8286958832e4096f032745' as const;
2
+ export const SPEC_HASH = '53f462b4c90b' as const;
3
+ export const SPEC_HASH_FULL = '53f462b4c90ba419e71d55d82e19bb6a9836d95aeaaae6299b8124ac340320d8' as const;
@@ -8876,7 +8876,10 @@ export interface components {
8876
8876
  targetDocId: string;
8877
8877
  targetDocTitle: string;
8878
8878
  /** @enum {string} */
8879
- sourceClass: "board_deck" | "board_minutes" | "investor_update" | "financial_brief" | "budget_forecast" | "okr_plan" | "annual_plan" | "all_hands" | "unit_strategy" | "roadmap" | "qbr" | "other" | "decoy";
8879
+ visibility: "hidden" | "shown";
8880
+ /** @enum {string} */
8881
+ classificationState: "unclassified" | "classified";
8882
+ sourceClass: ("board_deck" | "board_minutes" | "investor_update" | "financial_brief" | "budget_forecast" | "okr_plan" | "annual_plan" | "all_hands" | "unit_strategy" | "roadmap" | "qbr" | "other" | "decoy") | null;
8880
8883
  periodLabel: string | null;
8881
8884
  seriesRole: ("current" | "history") | null;
8882
8885
  relevance: number;
@@ -8886,9 +8889,42 @@ export interface components {
8886
8889
  sharingBreadth: "restricted_few" | "group" | "domain" | "anyone" | "unknown";
8887
8890
  sharedWithCount: number | null;
8888
8891
  audienceFit: ("fits" | "widens") | null;
8889
- /** @enum {string} */
8890
- recommendedUse: "attached" | "direction_only" | "excluded";
8892
+ recommendedUse: ("attached" | "direction_only" | "excluded") | null;
8891
8893
  use: ("attached" | "direction_only" | "excluded") | null;
8894
+ selectionSource: ("person" | "approval_default") | null;
8895
+ personHoldReasons: ("relevance_override" | "treatment_selection" | "target_override" | "manual_add")[];
8896
+ /** @enum {string} */
8897
+ machineState: "fresh" | "refreshed" | "filtered_out" | "not_rediscovered" | "pending_classification" | "retired";
8898
+ relevanceReasons: ({
8899
+ /** @constant */
8900
+ kind: "lexicon";
8901
+ /** @enum {string} */
8902
+ code: "title_term" | "folder_term" | "negative_title_term" | "negative_folder_term";
8903
+ term: string;
8904
+ count: number;
8905
+ } | {
8906
+ /** @constant */
8907
+ kind: "signal";
8908
+ /** @enum {string} */
8909
+ code: "folder_probe_hit" | "title_probe_hit" | "version_churn" | "mime_class" | "size_band" | "dated_series" | "recent_edit" | "owner_unit_head" | "owner_executive" | "sharing_breadth" | "duplicate_title" | "personal_mime";
8910
+ value: number | null;
8911
+ } | {
8912
+ /** @constant */
8913
+ kind: "evaluation";
8914
+ /** @enum {string} */
8915
+ code: "not_evaluated_t1" | "not_evaluated_t2" | "folder_unresolved" | "folder_truncated" | "explored_t1" | "explored_t2";
8916
+ } | {
8917
+ /** @constant */
8918
+ kind: "model";
8919
+ /** @enum {string} */
8920
+ code: "title_path_score" | "peek_confirms" | "peek_contradicts";
8921
+ score: number | null;
8922
+ })[];
8923
+ targetAudience: ("restricted" | "unit" | "org_wide") | null;
8924
+ attention: {
8925
+ t1: ("exploit" | "explore") | null;
8926
+ t2: ("exploit" | "explore") | null;
8927
+ };
8892
8928
  included: boolean;
8893
8929
  treatmentUnresolved: boolean;
8894
8930
  matchReason: string;
@@ -8931,6 +8967,9 @@ export interface components {
8931
8967
  files: number;
8932
8968
  filesIncluded: number;
8933
8969
  filesNeedingChoice: number;
8970
+ filesHidden: number;
8971
+ filesNotRediscovered: number;
8972
+ filesPendingClassification: number;
8934
8973
  };
8935
8974
  scopes: {
8936
8975
  scopeKey: string;
@@ -8953,6 +8992,16 @@ export interface components {
8953
8992
  highMicroUsd: string;
8954
8993
  capMicroUsd: string;
8955
8994
  } | null;
8995
+ discovery: {
8996
+ foundThisRun: number;
8997
+ retainedDecisions: number;
8998
+ notRediscovered: number;
8999
+ pendingClassification: number;
9000
+ filesDropped: number;
9001
+ coveragePercent: number;
9002
+ /** @enum {string} */
9003
+ scanOutcome: "complete" | "budget_exhausted" | "file_ceiling" | "stopped";
9004
+ } | null;
8956
9005
  };
8957
9006
  SourceSyncApproveResponse: {
8958
9007
  /** Format: uuid */
@@ -17884,6 +17933,8 @@ export interface operations {
17884
17933
  requestBody: {
17885
17934
  content: {
17886
17935
  "application/json": {
17936
+ /** @constant */
17937
+ visibility?: "shown";
17887
17938
  /** @enum {string} */
17888
17939
  use?: "attached" | "direction_only" | "excluded";
17889
17940
  /** @enum {string} */
@@ -107,6 +107,54 @@ way. This directory holds that shared spelling.
107
107
  domain is the largest trust change in the wave, and an empty body is
108
108
  indistinguishable from a mistaken or replayed body-less POST.
109
109
 
110
+ ## Relevance
111
+
112
+ Doctrine: control ADR `source-discovery-relevance-funnel`. Vocabulary: ADR
113
+ `source-discovery-relevance-vocabulary`. The sets live in `relevance.ts`.
114
+
115
+ - **Two axes on the wire.**
116
+ - **Relevance** is the funnel's verdict: `visibility` is `shown` or
117
+ `hidden`.
118
+ - **Treatment** is a person's decision: `recommendedUse` (machine state,
119
+ null until classified), `use` (the committed treatment, null until
120
+ chosen) and `selectionSource` (`person` or `approval_default`, present
121
+ exactly when `use` is).
122
+
123
+ Neither axis changes the other. The disclosure question is asked only of
124
+ lines that cleared relevance.
125
+
126
+ - **Hidden is not dropped.** `SOURCE_RELEVANCE_TIERS` includes `dropped` so the
127
+ backend can mirror it in persistence, but the item schema excludes it: a
128
+ dropped sighting never reaches the wire. A hidden line sits behind the
129
+ drawer, is never `included`, and a person can bring it into review with the
130
+ patch `{ visibility: "shown" }`. The patch changes exactly one field, so
131
+ promoting a line never also commits a treatment, and no client can hide a
132
+ line.
133
+ - **Classification is separate from visibility.** `classificationState` is
134
+ `unclassified` (so `sourceClass` and `recommendedUse` are both null) or
135
+ `classified` (both non-null). A shown line can still be
136
+ `pending_classification` in its `machineState`.
137
+ - **Reasons are a discriminated union.** `relevanceReasons` (≤ 8) is
138
+ `RelevanceReason`: `lexicon` (a product-owned `term` and a `count`),
139
+ `signal` (a `value` or null), `evaluation` (a code only) or `model` (a
140
+ `score` or null). Each kind has its own disjoint code set, and the app
141
+ switches over `kind`, then `code`, exhaustively. `relevance` is the fused
142
+ score, and `matchReason` is server copy rendered from the reasons
143
+ (deprecated).
144
+ - **Holds and lifecycle travel on the item.** `personHoldReasons` are the
145
+ field-level barriers a person's actions placed on the line. `machineState`
146
+ is its lifecycle (`fresh`, `refreshed`, `filtered_out`, `not_rediscovered`,
147
+ `pending_classification`, `retired`). `attention` says which lane
148
+ (`exploit` or `explore`) earned each model tier's look.
149
+ - **Attach shows its audience.** `targetAudience` is the audience Attach would
150
+ disclose to. Null means unknown and reads as "may widen".
151
+ - **No single ambiguous total.** `discovery` on the set response carries the
152
+ last completed run's counts (found, retained decisions, not rediscovered,
153
+ pending classification, dropped, coverage and how the scan ended). It is
154
+ null before any run completes. `totals` adds `filesHidden`,
155
+ `filesNotRediscovered` and `filesPendingClassification`, counted by
156
+ `candidateTotalsOf` over distinct files.
157
+
110
158
  ## Public API
111
159
 
112
160
  - `SOURCE_CLASSES` / `SourceClass` — what kind of document a source is.
@@ -135,7 +183,16 @@ way. This directory holds that shared spelling.
135
183
  `candidateTotalsOf(items)` — THE file count: distinct `sourceId`, never lines
136
184
  (ADR-CONTRACTS-195).
137
185
  - `SourceSyncCandidateItemPatchSchema` / `SourceSyncCandidateItemPatch` — one
138
- edit to one candidate item (use, class or target); never empty.
186
+ edit to one candidate item: exactly one of visibility (`shown` only), use,
187
+ class or target.
188
+ - `SOURCE_RELEVANCE_TIERS`, `CLASSIFICATION_STATES`, `RELEVANCE_REASON_KINDS`,
189
+ `LEXICON_REASON_CODES`, `SIGNAL_REASON_CODES`, `EVALUATION_REASON_CODES`,
190
+ `MODEL_REASON_CODES`, `TARGET_AUDIENCES`, `ATTENTION_LANES`,
191
+ `CANDIDATE_MACHINE_STATES`, `SELECTION_SOURCES`, `PERSON_HOLD_REASONS`,
192
+ `SCAN_OUTCOMES` and their derived unions — the relevance vocabulary (see
193
+ Relevance).
194
+ - `RelevanceReasonSchema` / `RelevanceReason` — why a candidate scored as it
195
+ did: a discriminated union on `kind`.
139
196
  - `SourceSyncAddCandidateRequestSchema` / `SourceSyncAddCandidateRequest` —
140
197
  "add one I missed": a provider file id picked in the provider's own search.
141
198
  - `SourceSyncApproveRequestSchema` / `SourceSyncApproveRequest` — approve the
@@ -210,4 +267,5 @@ way. This directory holds that shared spelling.
210
267
 
211
268
  `rules.ts` imports only `zod`, `../api/primitives` and `./vocabulary`.
212
269
  `upkeep.ts` and `reach.ts` import only `zod` and `../api/primitives`.
270
+ `relevance.ts` imports only `zod`.
213
271
  `vocabulary.ts` itself has no imports.
@@ -5,8 +5,8 @@
5
5
  * backend answers with.
6
6
  *
7
7
  * INVARIANTS TESTED:
8
- * - A candidate patch changes something: an empty patch is refused, and an
9
- * unknown key is an error, not surplus.
8
+ * - A candidate patch changes exactly one thing: an empty patch is refused, and
9
+ * an unknown key is an error, not surplus.
10
10
  * - An approval echoes the revision the person reviewed and carries the spend
11
11
  * cap as integer micro-USD; either one missing is refused.
12
12
  * - An approval answers PER SCOPE: one row per scope approved, each bound to its
@@ -16,6 +16,8 @@
16
16
  * below, which fails typecheck if one is missing at the root).
17
17
  * - The same two checks hold for the Release D names: staleness findings,
18
18
  * standing orders and the Drive-reach shapes.
19
+ * - Every runtime name `relevance.ts` declares is reachable from the root as
20
+ * the same binding.
19
21
  * - `ACTION_ITEM_KINDS` at the root carries `sourceSync.approval_pending`.
20
22
  * - The root re-exports the domain barrel explicitly.
21
23
  *
@@ -66,6 +68,7 @@ import type {
66
68
  SourceSyncStandingOrdersResponse,
67
69
  } from "../../index";
68
70
  import * as reach from "../reach";
71
+ import * as relevance from "../relevance";
69
72
  import * as rules from "../rules";
70
73
  import * as schemas from "../schemas";
71
74
  import * as upkeep from "../upkeep";
@@ -236,6 +239,17 @@ describe("source-sync Release C package surface", () => {
236
239
  }
237
240
  });
238
241
 
242
+ it("every discovery relevance set is exported from the package root", () => {
243
+ const root = rootBarrel as Record<string, unknown>;
244
+ const names = Object.keys(relevance);
245
+ expect(names.length).toBeGreaterThan(0);
246
+ for (const name of names) {
247
+ expect(root[name], `${name} at the root`).toBe(
248
+ (relevance as Record<string, unknown>)[name],
249
+ );
250
+ }
251
+ });
252
+
239
253
  it("exposes sourceSync.approval_pending in ACTION_ITEM_KINDS at the root", () => {
240
254
  expect(rootBarrel.ACTION_ITEM_KINDS).toContain(
241
255
  "sourceSync.approval_pending",
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The one file count for a candidate review.
3
+ *
4
+ * INVARIANTS TESTED:
5
+ * - Every total counts DISTINCT files (`sourceId`), never lines: a file routed to
6
+ * two documents is two lines and one file in every total.
7
+ * - A file is hidden only when EVERY line of it is hidden; one shown line shows it.
8
+ * - A file is not rediscovered / pending classification when ANY line of it is.
9
+ */
10
+
11
+ import { describe, expect, it } from "vitest";
12
+ import { candidateTotalsOf, type CandidateTotalsInput } from "../index";
13
+
14
+ function line(
15
+ sourceId: string,
16
+ overrides: Partial<CandidateTotalsInput> = {},
17
+ ): CandidateTotalsInput {
18
+ return {
19
+ sourceId,
20
+ included: true,
21
+ treatmentUnresolved: false,
22
+ visibility: "shown",
23
+ machineState: "fresh",
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ describe("candidateTotalsOf", () => {
29
+ it("counts distinct files, never lines", () => {
30
+ expect(
31
+ candidateTotalsOf([
32
+ line("a", { treatmentUnresolved: true }),
33
+ line("a"),
34
+ line("b", { included: false, treatmentUnresolved: true }),
35
+ line("c"),
36
+ ]),
37
+ ).toEqual({
38
+ files: 3,
39
+ filesIncluded: 2,
40
+ filesNeedingChoice: 1,
41
+ filesHidden: 0,
42
+ filesNotRediscovered: 0,
43
+ filesPendingClassification: 0,
44
+ });
45
+ });
46
+
47
+ it("a file is hidden only when every line of it is hidden", () => {
48
+ const hidden = { visibility: "hidden", included: false } as const;
49
+ const totals = candidateTotalsOf([
50
+ line("partly", hidden),
51
+ line("partly"),
52
+ line("wholly", hidden),
53
+ line("wholly", hidden),
54
+ line("shown"),
55
+ ]);
56
+ expect(totals.files).toBe(3);
57
+ expect(totals.filesHidden).toBe(1);
58
+ expect(totals.filesIncluded).toBe(2);
59
+ });
60
+
61
+ it("a file is not rediscovered or pending classification when any line of it is", () => {
62
+ const totals = candidateTotalsOf([
63
+ line("a", { machineState: "not_rediscovered" }),
64
+ line("a", { machineState: "refreshed" }),
65
+ line("b", { machineState: "pending_classification" }),
66
+ line("b"),
67
+ line("c", { machineState: "retired" }),
68
+ ]);
69
+ expect(totals.filesNotRediscovered).toBe(1);
70
+ expect(totals.filesPendingClassification).toBe(1);
71
+ });
72
+
73
+ it("a file routed to two documents counts once in every total", () => {
74
+ const facts = {
75
+ treatmentUnresolved: true,
76
+ visibility: "hidden",
77
+ machineState: "not_rediscovered",
78
+ included: false,
79
+ } as const;
80
+ const hiddenTwice = candidateTotalsOf([line("a", facts), line("a", facts)]);
81
+ expect(hiddenTwice).toEqual({
82
+ files: 1,
83
+ filesIncluded: 0,
84
+ filesNeedingChoice: 0,
85
+ filesHidden: 1,
86
+ filesNotRediscovered: 1,
87
+ filesPendingClassification: 0,
88
+ });
89
+ const shownTwice = candidateTotalsOf([
90
+ line("a", {
91
+ treatmentUnresolved: true,
92
+ machineState: "pending_classification",
93
+ }),
94
+ line("a", {
95
+ treatmentUnresolved: true,
96
+ machineState: "pending_classification",
97
+ }),
98
+ ]);
99
+ expect(shownTwice).toEqual({
100
+ files: 1,
101
+ filesIncluded: 1,
102
+ filesNeedingChoice: 1,
103
+ filesHidden: 0,
104
+ filesNotRediscovered: 0,
105
+ filesPendingClassification: 1,
106
+ });
107
+ });
108
+
109
+ it("counts nothing for an empty review", () => {
110
+ expect(candidateTotalsOf([])).toEqual({
111
+ files: 0,
112
+ filesIncluded: 0,
113
+ filesNeedingChoice: 0,
114
+ filesHidden: 0,
115
+ filesNotRediscovered: 0,
116
+ filesPendingClassification: 0,
117
+ });
118
+ });
119
+ });
@@ -11,11 +11,19 @@
11
11
  * - The wire carries a COUNT of people a source is shared with, never addresses.
12
12
  * - Money is integer micro-USD strings; a decimal or a JSON number is refused.
13
13
  * - A candidate set carries presented questions, never decision effects.
14
+ * - Relevance and treatment are two axes (control ADR source-discovery-relevance-funnel):
15
+ * a hidden line is never included; an unclassified line has no recommendation and
16
+ * no class; `selectionSource` is present exactly when `use` is.
17
+ * - A relevance reason's code belongs to its kind's closed set.
18
+ * - A candidate patch changes exactly one thing: bringing a line into review never
19
+ * also commits a treatment, and a patch can never hide a line.
20
+ * - The set response's discovery block is required and null before any completed run.
14
21
  */
15
22
 
16
23
  import { describe, expect, it } from "vitest";
17
24
  import {
18
- candidateTotalsOf,
25
+ RelevanceReasonSchema,
26
+ SourceSyncCandidateItemPatchSchema,
19
27
  SourceSyncCandidateItemSchema,
20
28
  SourceSyncCandidateSetResponseSchema,
21
29
  SourceSyncDiscoveryRunResponseSchema,
@@ -41,6 +49,8 @@ function candidateItem(overrides: Record<string, unknown> = {}) {
41
49
  scopeKey: "company",
42
50
  targetDocId: TARGET_DOC_ID,
43
51
  targetDocTitle: "Company overview",
52
+ visibility: "shown",
53
+ classificationState: "classified",
44
54
  sourceClass: "board_deck",
45
55
  periodLabel: "Q3 2026",
46
56
  seriesRole: "current",
@@ -51,6 +61,12 @@ function candidateItem(overrides: Record<string, unknown> = {}) {
51
61
  audienceFit: "fits",
52
62
  recommendedUse: "direction_only",
53
63
  use: null,
64
+ selectionSource: null,
65
+ personHoldReasons: [],
66
+ machineState: "fresh",
67
+ relevanceReasons: [],
68
+ targetAudience: "org_wide",
69
+ attention: { t1: null, t2: null },
54
70
  included: true,
55
71
  treatmentUnresolved: false,
56
72
  matchReason: "A board deck for the current quarter.",
@@ -98,13 +114,29 @@ function candidateSet(overrides: Record<string, unknown> = {}) {
98
114
  items: [candidateItem()],
99
115
  questions: [presentedQuestion()],
100
116
  withheldQuestionCount: 0,
101
- totals: { files: 1, filesIncluded: 1, filesNeedingChoice: 0 },
117
+ totals: {
118
+ files: 1,
119
+ filesIncluded: 1,
120
+ filesNeedingChoice: 0,
121
+ filesHidden: 0,
122
+ filesNotRediscovered: 0,
123
+ filesPendingClassification: 0,
124
+ },
102
125
  scopes: [],
103
126
  estimate: {
104
127
  lowMicroUsd: "250000",
105
128
  highMicroUsd: "1250000",
106
129
  capMicroUsd: "2000000",
107
130
  },
131
+ discovery: {
132
+ foundThisRun: 12,
133
+ retainedDecisions: 3,
134
+ notRediscovered: 1,
135
+ pendingClassification: 2,
136
+ filesDropped: 240,
137
+ coveragePercent: 87,
138
+ scanOutcome: "complete",
139
+ },
108
140
  ...overrides,
109
141
  };
110
142
  }
@@ -260,6 +292,208 @@ describe("SourceSyncCandidateSetResponseSchema", () => {
260
292
  });
261
293
  });
262
294
 
295
+ describe("candidate relevance", () => {
296
+ it("a hidden unclassified item has no recommendation, no class and is not included", () => {
297
+ const parsed = SourceSyncCandidateItemSchema.safeParse(
298
+ candidateItem({
299
+ visibility: "hidden",
300
+ classificationState: "unclassified",
301
+ recommendedUse: null,
302
+ sourceClass: null,
303
+ included: false,
304
+ targetAudience: null,
305
+ machineState: "pending_classification",
306
+ }),
307
+ );
308
+ expect(parsed.success).toBe(true);
309
+ expect(parsed.data?.recommendedUse).toBeNull();
310
+ expect(parsed.data?.sourceClass).toBeNull();
311
+ for (const claim of [
312
+ { recommendedUse: "attached" },
313
+ { sourceClass: "board_deck" },
314
+ ]) {
315
+ expect(
316
+ SourceSyncCandidateItemSchema.safeParse(
317
+ candidateItem({
318
+ visibility: "hidden",
319
+ classificationState: "unclassified",
320
+ recommendedUse: null,
321
+ sourceClass: null,
322
+ included: false,
323
+ ...claim,
324
+ }),
325
+ ).success,
326
+ ).toBe(false);
327
+ }
328
+ });
329
+
330
+ it("a classified item has a recommendation and a class", () => {
331
+ for (const missing of [{ recommendedUse: null }, { sourceClass: null }]) {
332
+ expect(
333
+ SourceSyncCandidateItemSchema.safeParse(candidateItem(missing)).success,
334
+ ).toBe(false);
335
+ }
336
+ });
337
+
338
+ it("a shown item carries a reason of every kind", () => {
339
+ const relevanceReasons = [
340
+ { kind: "lexicon", code: "title_term", term: "board", count: 2 },
341
+ { kind: "signal", code: "version_churn", value: 41 },
342
+ { kind: "signal", code: "owner_executive", value: null },
343
+ { kind: "evaluation", code: "not_evaluated_t2" },
344
+ { kind: "model", code: "title_path_score", score: 0.81 },
345
+ { kind: "model", code: "peek_confirms", score: null },
346
+ ];
347
+ const parsed = SourceSyncCandidateItemSchema.safeParse(
348
+ candidateItem({
349
+ relevanceReasons,
350
+ attention: { t1: "exploit", t2: "explore" },
351
+ personHoldReasons: ["relevance_override"],
352
+ }),
353
+ );
354
+ expect(parsed.success).toBe(true);
355
+ expect(parsed.data?.relevanceReasons.map((reason) => reason.kind)).toEqual([
356
+ "lexicon",
357
+ "signal",
358
+ "signal",
359
+ "evaluation",
360
+ "model",
361
+ "model",
362
+ ]);
363
+ });
364
+
365
+ it("refuses more than eight reasons", () => {
366
+ const reason = { kind: "evaluation", code: "explored_t1" };
367
+ expect(
368
+ SourceSyncCandidateItemSchema.safeParse(
369
+ candidateItem({ relevanceReasons: Array(9).fill(reason) }),
370
+ ).success,
371
+ ).toBe(false);
372
+ });
373
+
374
+ it("a reason whose code belongs to another kind is refused", () => {
375
+ for (const reason of [
376
+ { kind: "lexicon", code: "version_churn", term: "board", count: 1 },
377
+ { kind: "signal", code: "title_term", value: 1 },
378
+ { kind: "evaluation", code: "peek_confirms" },
379
+ { kind: "model", code: "folder_unresolved", score: 0.5 },
380
+ ]) {
381
+ expect(RelevanceReasonSchema.safeParse(reason).success).toBe(false);
382
+ }
383
+ });
384
+
385
+ it("refuses a lexicon reason without a term or a count, and a model score outside 0..1", () => {
386
+ for (const reason of [
387
+ { kind: "lexicon", code: "title_term", count: 1 },
388
+ { kind: "lexicon", code: "title_term", term: "", count: 1 },
389
+ { kind: "lexicon", code: "title_term", term: "board", count: 0 },
390
+ { kind: "model", code: "title_path_score", score: 1.2 },
391
+ ]) {
392
+ expect(RelevanceReasonSchema.safeParse(reason).success).toBe(false);
393
+ }
394
+ });
395
+
396
+ it("a hidden item that claims inclusion is refused", () => {
397
+ const parsed = SourceSyncCandidateItemSchema.safeParse(
398
+ candidateItem({ visibility: "hidden", included: true }),
399
+ );
400
+ expect(parsed.success).toBe(false);
401
+ expect(parsed.error?.issues[0]?.message).toBe(
402
+ "a hidden line is never included",
403
+ );
404
+ });
405
+
406
+ it("refuses the dropped tier on the wire item", () => {
407
+ expect(
408
+ SourceSyncCandidateItemSchema.safeParse(
409
+ candidateItem({ visibility: "dropped", included: false }),
410
+ ).success,
411
+ ).toBe(false);
412
+ });
413
+
414
+ it("carries a selection source exactly when a treatment is committed", () => {
415
+ const committed = { use: "attached", treatmentUnresolved: false };
416
+ expect(
417
+ SourceSyncCandidateItemSchema.safeParse(
418
+ candidateItem({ ...committed, selectionSource: "person" }),
419
+ ).success,
420
+ ).toBe(true);
421
+ expect(
422
+ SourceSyncCandidateItemSchema.safeParse(
423
+ candidateItem({ ...committed, selectionSource: "approval_default" }),
424
+ ).success,
425
+ ).toBe(true);
426
+ expect(
427
+ SourceSyncCandidateItemSchema.safeParse(
428
+ candidateItem({ ...committed, selectionSource: null }),
429
+ ).success,
430
+ ).toBe(false);
431
+ expect(
432
+ SourceSyncCandidateItemSchema.safeParse(
433
+ candidateItem({ use: null, selectionSource: "person" }),
434
+ ).success,
435
+ ).toBe(false);
436
+ });
437
+ });
438
+
439
+ describe("SourceSyncCandidateItemPatchSchema", () => {
440
+ it("bring into review is a patch of visibility alone", () => {
441
+ expect(
442
+ SourceSyncCandidateItemPatchSchema.safeParse({ visibility: "shown" })
443
+ .success,
444
+ ).toBe(true);
445
+ expect(
446
+ SourceSyncCandidateItemPatchSchema.safeParse({ visibility: "hidden" })
447
+ .success,
448
+ ).toBe(false);
449
+ });
450
+
451
+ it("a patch may not both promote and choose a treatment", () => {
452
+ for (const patch of [
453
+ { visibility: "shown", use: "attached" },
454
+ { visibility: "shown", targetDocId: TARGET_DOC_ID },
455
+ { use: "attached", sourceClass: "board_deck" },
456
+ {},
457
+ ]) {
458
+ expect(SourceSyncCandidateItemPatchSchema.safeParse(patch).success).toBe(
459
+ false,
460
+ );
461
+ }
462
+ });
463
+ });
464
+
465
+ describe("SourceSyncCandidateSetResponseSchema discovery", () => {
466
+ it("a set response before any completed run has a null discovery block", () => {
467
+ expect(
468
+ SourceSyncCandidateSetResponseSchema.safeParse(
469
+ candidateSet({ discovery: null }),
470
+ ).success,
471
+ ).toBe(true);
472
+ const { discovery: _omitted, ...withoutKey } = candidateSet();
473
+ expect(
474
+ SourceSyncCandidateSetResponseSchema.safeParse(withoutKey).success,
475
+ ).toBe(false);
476
+ });
477
+
478
+ it("accepts a discovery block and refuses a coverage above 100 or an unknown outcome", () => {
479
+ const set = candidateSet();
480
+ expect(SourceSyncCandidateSetResponseSchema.safeParse(set).success).toBe(
481
+ true,
482
+ );
483
+ for (const change of [
484
+ { coveragePercent: 101 },
485
+ { scanOutcome: "timed_out" },
486
+ { filesDropped: -1 },
487
+ ]) {
488
+ expect(
489
+ SourceSyncCandidateSetResponseSchema.safeParse(
490
+ candidateSet({ discovery: { ...set.discovery, ...change } }),
491
+ ).success,
492
+ ).toBe(false);
493
+ }
494
+ });
495
+ });
496
+
263
497
  describe("SourceSyncDiscoveryRunResponseSchema", () => {
264
498
  it("refuses a workflow type other than source-sync/discover", () => {
265
499
  expect(
@@ -281,16 +515,3 @@ describe("SourceSyncDiscoveryRunResponseSchema", () => {
281
515
  ).toBe(true);
282
516
  });
283
517
  });
284
-
285
- describe("candidateTotalsOf", () => {
286
- it("counts distinct files, never lines", () => {
287
- expect(
288
- candidateTotalsOf([
289
- { sourceId: "a", included: true, treatmentUnresolved: true },
290
- { sourceId: "a", included: true, treatmentUnresolved: false },
291
- { sourceId: "b", included: false, treatmentUnresolved: true },
292
- { sourceId: "c", included: true, treatmentUnresolved: false },
293
- ]),
294
- ).toEqual({ files: 3, filesIncluded: 2, filesNeedingChoice: 1 });
295
- });
296
- });
@@ -6,13 +6,31 @@
6
6
  * order.
7
7
  *
8
8
  * INVARIANTS TESTED:
9
- * - Each of the ten closed sets has exactly the members this release fixes
9
+ * - Each of the twenty-three closed sets (ten Source Sync, thirteen discovery
10
+ * relevance) has exactly the members this release fixes
10
11
  * - `decoy` is a source class; `unknown` is a sharing breadth
11
12
  * - No member string appears in two sets
13
+ * - The four relevance reason code sets are pairwise disjoint
12
14
  */
13
15
 
14
16
  import { describe, it, expect } from "vitest";
15
17
 
18
+ import {
19
+ ATTENTION_LANES,
20
+ CANDIDATE_MACHINE_STATES,
21
+ CLASSIFICATION_STATES,
22
+ EVALUATION_REASON_CODES,
23
+ LEXICON_REASON_CODES,
24
+ MODEL_REASON_CODES,
25
+ PERSON_HOLD_REASONS,
26
+ RELEVANCE_REASON_KINDS,
27
+ SCAN_OUTCOMES,
28
+ SELECTION_SOURCES,
29
+ SIGNAL_REASON_CODES,
30
+ SOURCE_RELEVANCE_TIERS,
31
+ TARGET_AUDIENCES,
32
+ } from "../relevance";
33
+
16
34
  import {
17
35
  AUDIENCE_FITS,
18
36
  SHARING_BREADTHS,
@@ -104,6 +122,108 @@ const VOCABULARIES = [
104
122
  tuple: SOURCE_SYNC_SCOPE_APPROVER_KINDS,
105
123
  members: ["org_admin", "unit_head"],
106
124
  },
125
+ {
126
+ name: "SOURCE_RELEVANCE_TIERS",
127
+ tuple: SOURCE_RELEVANCE_TIERS,
128
+ members: ["dropped", "hidden", "shown"],
129
+ },
130
+ {
131
+ name: "CLASSIFICATION_STATES",
132
+ tuple: CLASSIFICATION_STATES,
133
+ members: ["unclassified", "classified"],
134
+ },
135
+ {
136
+ name: "RELEVANCE_REASON_KINDS",
137
+ tuple: RELEVANCE_REASON_KINDS,
138
+ members: ["lexicon", "signal", "evaluation", "model"],
139
+ },
140
+ {
141
+ name: "LEXICON_REASON_CODES",
142
+ tuple: LEXICON_REASON_CODES,
143
+ members: [
144
+ "title_term",
145
+ "folder_term",
146
+ "negative_title_term",
147
+ "negative_folder_term",
148
+ ],
149
+ },
150
+ {
151
+ name: "SIGNAL_REASON_CODES",
152
+ tuple: SIGNAL_REASON_CODES,
153
+ members: [
154
+ "folder_probe_hit",
155
+ "title_probe_hit",
156
+ "version_churn",
157
+ "mime_class",
158
+ "size_band",
159
+ "dated_series",
160
+ "recent_edit",
161
+ "owner_unit_head",
162
+ "owner_executive",
163
+ "sharing_breadth",
164
+ "duplicate_title",
165
+ "personal_mime",
166
+ ],
167
+ },
168
+ {
169
+ name: "EVALUATION_REASON_CODES",
170
+ tuple: EVALUATION_REASON_CODES,
171
+ members: [
172
+ "not_evaluated_t1",
173
+ "not_evaluated_t2",
174
+ "folder_unresolved",
175
+ "folder_truncated",
176
+ "explored_t1",
177
+ "explored_t2",
178
+ ],
179
+ },
180
+ {
181
+ name: "MODEL_REASON_CODES",
182
+ tuple: MODEL_REASON_CODES,
183
+ members: ["title_path_score", "peek_confirms", "peek_contradicts"],
184
+ },
185
+ {
186
+ name: "TARGET_AUDIENCES",
187
+ tuple: TARGET_AUDIENCES,
188
+ members: ["restricted", "unit", "org_wide"],
189
+ },
190
+ {
191
+ name: "ATTENTION_LANES",
192
+ tuple: ATTENTION_LANES,
193
+ members: ["exploit", "explore"],
194
+ },
195
+ {
196
+ name: "CANDIDATE_MACHINE_STATES",
197
+ tuple: CANDIDATE_MACHINE_STATES,
198
+ members: [
199
+ "fresh",
200
+ "refreshed",
201
+ "filtered_out",
202
+ "not_rediscovered",
203
+ "pending_classification",
204
+ "retired",
205
+ ],
206
+ },
207
+ {
208
+ name: "SELECTION_SOURCES",
209
+ tuple: SELECTION_SOURCES,
210
+ members: ["person", "approval_default"],
211
+ },
212
+ {
213
+ name: "PERSON_HOLD_REASONS",
214
+ tuple: PERSON_HOLD_REASONS,
215
+ members: [
216
+ "relevance_override",
217
+ "treatment_selection",
218
+ "target_override",
219
+ "manual_add",
220
+ ],
221
+ },
222
+ {
223
+ name: "SCAN_OUTCOMES",
224
+ tuple: SCAN_OUTCOMES,
225
+ members: ["complete", "budget_exhausted", "file_ceiling", "stopped"],
226
+ },
107
227
  ] as const satisfies readonly {
108
228
  name: string;
109
229
  tuple: readonly string[];
@@ -142,4 +262,18 @@ describe("Source Sync closed sets", () => {
142
262
  const all = VOCABULARIES.flatMap(({ tuple }) => [...tuple]);
143
263
  expect(new Set(all).size).toBe(all.length);
144
264
  });
265
+
266
+ it("the four reason code sets are pairwise disjoint", () => {
267
+ const codeSets: readonly (readonly string[])[] = [
268
+ LEXICON_REASON_CODES,
269
+ SIGNAL_REASON_CODES,
270
+ EVALUATION_REASON_CODES,
271
+ MODEL_REASON_CODES,
272
+ ];
273
+ codeSets.forEach((a, i) => {
274
+ codeSets.slice(i + 1).forEach((b) => {
275
+ expect(a.filter((code) => b.includes(code))).toEqual([]);
276
+ });
277
+ });
278
+ });
145
279
  });
@@ -12,15 +12,19 @@ import type {
12
12
  SourceSyncCandidateTotals,
13
13
  } from "./schemas";
14
14
 
15
+ /** The relevance facts the count reads (control ADR source-discovery-relevance-funnel). */
16
+ type RelevanceFacts = "visibility" | "machineState";
17
+
15
18
  /** The item facts the count reads. */
16
19
  export type CandidateTotalsInput = Pick<
17
20
  SourceSyncCandidateItem,
18
- "sourceId" | "included" | "treatmentUnresolved"
21
+ "sourceId" | "included" | "treatmentUnresolved" | RelevanceFacts
19
22
  >;
20
23
 
21
24
  /**
22
25
  * Included when any of the file's lines is; in need of a choice when any
23
- * included line's treatment is unresolved.
26
+ * included line's treatment is unresolved. Hidden only when EVERY line of the
27
+ * file is hidden; not rediscovered / pending classification when ANY line is.
24
28
  */
25
29
  export function candidateTotalsOf(
26
30
  items: readonly CandidateTotalsInput[],
@@ -28,15 +32,29 @@ export function candidateTotalsOf(
28
32
  const files = new Set<string>();
29
33
  const included = new Set<string>();
30
34
  const needingChoice = new Set<string>();
35
+ const anyShown = new Set<string>();
36
+ const notRediscovered = new Set<string>();
37
+ const pendingClassification = new Set<string>();
31
38
  for (const item of items) {
32
39
  files.add(item.sourceId);
40
+ if (item.visibility === "shown") anyShown.add(item.sourceId);
41
+ if (item.machineState === "not_rediscovered") {
42
+ notRediscovered.add(item.sourceId);
43
+ }
44
+ if (item.machineState === "pending_classification") {
45
+ pendingClassification.add(item.sourceId);
46
+ }
33
47
  if (!item.included) continue;
34
48
  included.add(item.sourceId);
35
49
  if (item.treatmentUnresolved) needingChoice.add(item.sourceId);
36
50
  }
51
+ const hidden = [...files].filter((id) => !anyShown.has(id)).length;
37
52
  return {
38
53
  files: files.size,
39
54
  filesIncluded: included.size,
40
55
  filesNeedingChoice: needingChoice.size,
56
+ filesHidden: hidden,
57
+ filesNotRediscovered: notRediscovered.size,
58
+ filesPendingClassification: pendingClassification.size,
41
59
  };
42
60
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Re-exports the closed Source Sync vocabularies (the sets the backend persists
5
5
  * and mirrors as database CHECK constraints, and the app handles exhaustively)
6
- * the discovery and candidate-set response schemas, the candidate-edit and
6
+ * including the discovery relevance vocabulary, the discovery and candidate-set response schemas, the candidate-edit and
7
7
  * per-scope approval request and response schemas, the per-document change
8
8
  * plan and apply schemas, the source-resolution rule schemas, the staleness
9
9
  * finding and standing-order schemas, and the Drive-reach status and upgrade
@@ -44,6 +44,45 @@ export {
44
44
  SOURCE_SYNC_SCOPE_APPROVER_KINDS,
45
45
  } from "./vocabulary";
46
46
 
47
+ export type {
48
+ SourceRelevanceTier,
49
+ ClassificationState,
50
+ RelevanceReasonKind,
51
+ LexiconReasonCode,
52
+ SignalReasonCode,
53
+ EvaluationReasonCode,
54
+ ModelReasonCode,
55
+ TargetAudience,
56
+ AttentionLane,
57
+ CandidateMachineState,
58
+ SelectionSource,
59
+ PersonHoldReason,
60
+ ScanOutcome,
61
+ RelevanceReason,
62
+ CandidateAttention,
63
+ CandidateDiscoverySummary,
64
+ } from "./relevance";
65
+
66
+ export {
67
+ SOURCE_RELEVANCE_TIERS,
68
+ CLASSIFICATION_STATES,
69
+ RELEVANCE_REASON_KINDS,
70
+ LEXICON_REASON_CODES,
71
+ SIGNAL_REASON_CODES,
72
+ EVALUATION_REASON_CODES,
73
+ MODEL_REASON_CODES,
74
+ TARGET_AUDIENCES,
75
+ ATTENTION_LANES,
76
+ CANDIDATE_MACHINE_STATES,
77
+ SELECTION_SOURCES,
78
+ PERSON_HOLD_REASONS,
79
+ SCAN_OUTCOMES,
80
+ RelevanceReasonSchema,
81
+ CandidateAttentionSchema,
82
+ CandidateDiscoverySummarySchema,
83
+ refineCandidateRelevance,
84
+ } from "./relevance";
85
+
47
86
  export type {
48
87
  SourceSyncDiscoveryRunResponse,
49
88
  SourceSyncCandidateItem,
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Source discovery RELEVANCE vocabulary (control ADR source-discovery-relevance-funnel).
3
+ * Closed sets that go on the wire AND into backend CHECK constraints. Renaming a member is a migration.
4
+ *
5
+ * Two axes: relevance (dropped | hidden | shown — the funnel's verdict) and the disclosure
6
+ * treatment (SOURCE_USES — a person's). Neither changes the other.
7
+ */
8
+
9
+ import { z } from "zod";
10
+
11
+ /** The funnel's verdict. `dropped` is persisted (drop ledger / re-scored observation) and never reaches the wire item. */
12
+ export const SOURCE_RELEVANCE_TIERS = ["dropped", "hidden", "shown"] as const;
13
+ export type SourceRelevanceTier = (typeof SOURCE_RELEVANCE_TIERS)[number];
14
+
15
+ /** Whether a classifier has produced class/scope/period/sensitivity for the line. Separate from relevance. */
16
+ export const CLASSIFICATION_STATES = ["unclassified", "classified"] as const;
17
+ export type ClassificationState = (typeof CLASSIFICATION_STATES)[number];
18
+
19
+ /** RelevanceReason is a discriminated union on `kind`; each kind has its own disjoint code set. */
20
+ export const RELEVANCE_REASON_KINDS = [
21
+ "lexicon",
22
+ "signal",
23
+ "evaluation",
24
+ "model",
25
+ ] as const;
26
+ export type RelevanceReasonKind = (typeof RELEVANCE_REASON_KINDS)[number];
27
+
28
+ /** A product-owned lexicon TERM matched (the term travels; a filename slice never does). */
29
+ export const LEXICON_REASON_CODES = [
30
+ "title_term",
31
+ "folder_term",
32
+ "negative_title_term",
33
+ "negative_folder_term",
34
+ ] as const;
35
+ export type LexiconReasonCode = (typeof LEXICON_REASON_CODES)[number];
36
+
37
+ /** A metadata signal with a numeric value (or null). `version_churn` is Drive's server-side version, never an edit count. */
38
+ export const SIGNAL_REASON_CODES = [
39
+ "folder_probe_hit",
40
+ "title_probe_hit",
41
+ "version_churn",
42
+ "mime_class",
43
+ "size_band",
44
+ "dated_series",
45
+ "recent_edit",
46
+ "owner_unit_head",
47
+ "owner_executive",
48
+ "sharing_breadth",
49
+ "duplicate_title",
50
+ "personal_mime",
51
+ ] as const;
52
+ export type SignalReasonCode = (typeof SIGNAL_REASON_CODES)[number];
53
+
54
+ /** What was NOT evaluated, or was explored. Never negative evidence (INV-SOURCESYNC-MISSING-EVIDENCE-IS-NOT-EVIDENCE). */
55
+ export const EVALUATION_REASON_CODES = [
56
+ "not_evaluated_t1",
57
+ "not_evaluated_t2",
58
+ "folder_unresolved",
59
+ "folder_truncated",
60
+ "explored_t1",
61
+ "explored_t2",
62
+ ] as const;
63
+ export type EvaluationReasonCode = (typeof EVALUATION_REASON_CODES)[number];
64
+
65
+ /** A model tier's contribution. */
66
+ export const MODEL_REASON_CODES = [
67
+ "title_path_score",
68
+ "peek_confirms",
69
+ "peek_contradicts",
70
+ ] as const;
71
+ export type ModelReasonCode = (typeof MODEL_REASON_CODES)[number];
72
+
73
+ /** The audience Attach would disclose the source to. Mirrors the backend's TARGET_BREADTHS. `restricted` ≠ `restricted_few`. */
74
+ export const TARGET_AUDIENCES = ["restricted", "unit", "org_wide"] as const;
75
+ export type TargetAudience = (typeof TARGET_AUDIENCES)[number];
76
+
77
+ /** Why a tier got the model's attention (INV-SOURCESYNC-EXPLORATION-RESERVED). */
78
+ export const ATTENTION_LANES = ["exploit", "explore"] as const;
79
+ export type AttentionLane = (typeof ATTENTION_LANES)[number];
80
+
81
+ /** A candidate line's machine lifecycle (INV-SOURCESYNC-SET-IS-RECONCILED, INV-SOURCESYNC-DROP-IS-TWO-LIFECYCLES). `retired` = positive absence only. */
82
+ export const CANDIDATE_MACHINE_STATES = [
83
+ "fresh",
84
+ "refreshed",
85
+ "filtered_out",
86
+ "not_rediscovered",
87
+ "pending_classification",
88
+ "retired",
89
+ ] as const;
90
+ export type CandidateMachineState = (typeof CANDIDATE_MACHINE_STATES)[number];
91
+
92
+ /** Provenance of a committed treatment (INV-SOURCESYNC-RECOMMENDATION-IS-NOT-COMMITMENT). */
93
+ export const SELECTION_SOURCES = ["person", "approval_default"] as const;
94
+ export type SelectionSource = (typeof SELECTION_SOURCES)[number];
95
+
96
+ /** Field-level write barriers a person's action places on a line (INV-SOURCESYNC-HOLDS-ARE-FIELD-BARRIERS). */
97
+ export const PERSON_HOLD_REASONS = [
98
+ "relevance_override",
99
+ "treatment_selection",
100
+ "target_override",
101
+ "manual_add",
102
+ ] as const;
103
+ export type PersonHoldReason = (typeof PERSON_HOLD_REASONS)[number];
104
+
105
+ /** How a discovery scan ended. */
106
+ export const SCAN_OUTCOMES = [
107
+ "complete",
108
+ "budget_exhausted",
109
+ "file_ceiling",
110
+ "stopped",
111
+ ] as const;
112
+ export type ScanOutcome = (typeof SCAN_OUTCOMES)[number];
113
+
114
+ /**
115
+ * Why a candidate scored as it did. A discriminated union: the drop ledger keeps only {kind, code}
116
+ * and counts; product copy switches exhaustively on `kind` then `code`. A lexicon `term` is ALWAYS a
117
+ * product-owned lexicon entry, never a slice of the file name (INV-SOURCESYNC-POLICY-IS-RECORDED).
118
+ */
119
+ export const RelevanceReasonSchema = z.discriminatedUnion("kind", [
120
+ z.object({
121
+ kind: z.literal("lexicon"),
122
+ code: z.enum(LEXICON_REASON_CODES),
123
+ term: z.string().min(1).max(64),
124
+ count: z.number().int().min(1),
125
+ }),
126
+ z.object({
127
+ kind: z.literal("signal"),
128
+ code: z.enum(SIGNAL_REASON_CODES),
129
+ value: z.number().nullable(),
130
+ }),
131
+ z.object({
132
+ kind: z.literal("evaluation"),
133
+ code: z.enum(EVALUATION_REASON_CODES),
134
+ }),
135
+ z.object({
136
+ kind: z.literal("model"),
137
+ code: z.enum(MODEL_REASON_CODES),
138
+ score: z.number().min(0).max(1).nullable(),
139
+ }),
140
+ ]);
141
+ export type RelevanceReason = z.infer<typeof RelevanceReasonSchema>;
142
+
143
+ /** Which lane earned each model tier's attention for a line; null = that tier did not look at it. */
144
+ export const CandidateAttentionSchema = z.object({
145
+ t1: z.enum(ATTENTION_LANES).nullable(),
146
+ t2: z.enum(ATTENTION_LANES).nullable(),
147
+ });
148
+ export type CandidateAttention = z.infer<typeof CandidateAttentionSchema>;
149
+
150
+ /**
151
+ * The last completed discovery run's counts, so no surface shows one ambiguous total.
152
+ * The set response carries it NULL before any run has completed.
153
+ */
154
+ export const CandidateDiscoverySummarySchema = z.object({
155
+ foundThisRun: z.number().int().min(0),
156
+ retainedDecisions: z.number().int().min(0),
157
+ notRediscovered: z.number().int().min(0),
158
+ pendingClassification: z.number().int().min(0),
159
+ filesDropped: z.number().int().min(0),
160
+ coveragePercent: z.number().int().min(0).max(100),
161
+ scanOutcome: z.enum(SCAN_OUTCOMES),
162
+ });
163
+ export type CandidateDiscoverySummary = z.infer<
164
+ typeof CandidateDiscoverySummarySchema
165
+ >;
166
+
167
+ /** The item fields the relevance consistency rules read. */
168
+ type CandidateRelevanceFacts = {
169
+ visibility: SourceRelevanceTier;
170
+ included: boolean;
171
+ classificationState: ClassificationState;
172
+ recommendedUse: string | null;
173
+ sourceClass: string | null;
174
+ use: string | null;
175
+ selectionSource: SelectionSource | null;
176
+ };
177
+
178
+ /**
179
+ * The candidate item's cross-field rules (control ADR source-discovery-relevance-funnel):
180
+ * a hidden line is never included; classification decides whether a recommendation and a
181
+ * class exist; `selectionSource` is present exactly when a treatment is committed.
182
+ */
183
+ export function refineCandidateRelevance(
184
+ item: CandidateRelevanceFacts,
185
+ ctx: z.RefinementCtx,
186
+ ): void {
187
+ const unclassified = item.classificationState === "unclassified";
188
+ const hasRecommendation =
189
+ item.recommendedUse !== null || item.sourceClass !== null;
190
+ const missingRecommendation =
191
+ item.recommendedUse === null || item.sourceClass === null;
192
+ if (item.visibility === "hidden" && item.included) {
193
+ ctx.addIssue({
194
+ code: "custom",
195
+ message: "a hidden line is never included",
196
+ });
197
+ }
198
+ if (unclassified && hasRecommendation) {
199
+ ctx.addIssue({
200
+ code: "custom",
201
+ message: "an unclassified line has no recommendation and no class",
202
+ });
203
+ }
204
+ if (!unclassified && missingRecommendation) {
205
+ ctx.addIssue({
206
+ code: "custom",
207
+ message: "a classified line has a recommendation and a class",
208
+ });
209
+ }
210
+ if ((item.use === null) !== (item.selectionSource === null)) {
211
+ ctx.addIssue({
212
+ code: "custom",
213
+ message: "selectionSource is present exactly when use is",
214
+ });
215
+ }
216
+ }
@@ -30,6 +30,18 @@ import {
30
30
  SOURCE_SYNC_SCOPE_APPROVER_KINDS,
31
31
  SOURCE_USES,
32
32
  } from "./vocabulary";
33
+ import {
34
+ CANDIDATE_MACHINE_STATES,
35
+ CandidateAttentionSchema,
36
+ CandidateDiscoverySummarySchema,
37
+ CLASSIFICATION_STATES,
38
+ PERSON_HOLD_REASONS,
39
+ refineCandidateRelevance,
40
+ RelevanceReasonSchema,
41
+ SELECTION_SOURCES,
42
+ SOURCE_RELEVANCE_TIERS,
43
+ TARGET_AUDIENCES,
44
+ } from "./relevance";
33
45
 
34
46
  /** Integer micro-USD as a string — the billing domain's money convention on the wire. */
35
47
  const MicroUsd = z.string().regex(/^[0-9]+$/);
@@ -77,62 +89,86 @@ export const SourceSyncDiscoveryRunResponseSchema = z.object({
77
89
  });
78
90
 
79
91
  /** ONE source proposed for ONE target document. */
80
- export const SourceSyncCandidateItemSchema = z.object({
81
- itemId: z.string().uuid(),
82
- sourceId: z.string().uuid(),
83
- /**
84
- * Display facts about the source. Never its content. When `withheld`, `name` is
85
- * product copy naming only the source's class and period.
86
- */
87
- name: z.string().min(1).max(500),
88
- mimeType: z.string().min(1).max(255),
89
- /** Opens the source IN THE PROVIDER, which remains the ACL authority for it. */
90
- webViewLink: z.string().url().nullable(),
91
- ownerLabel: z.string().max(255).nullable(),
92
- modifiedTime: IsoDateTime.nullable(),
93
- /**
94
- * True when the caller may not open this source: its identifying fields are
95
- * withheld (`webViewLink`, `ownerLabel` and `sharedWithCount` are null, and
96
- * `name` and `matchReason` are product copy). What the approver decides stays.
97
- */
98
- withheld: z.boolean(),
99
- /** 'company' or 'unit:<uuid>'. */
100
- scopeKey: z.string().min(1).max(64),
101
- targetDocId: z.string().uuid(),
102
- targetDocTitle: z.string().min(1).max(255),
103
- sourceClass: z.enum(SOURCE_CLASSES),
104
- periodLabel: z.string().max(64).nullable(),
105
- seriesRole: z.enum(["current", "history"]).nullable(),
106
- relevance: z.number().min(0).max(1),
107
- sensitivityClass: z.enum(SOURCE_SENSITIVITY_CLASSES),
108
- sharingBreadth: z.enum(SHARING_BREADTHS),
109
- /** A COUNT of people the provider shares it with, when observed. Never addresses. */
110
- sharedWithCount: z.number().int().min(0).nullable(),
111
- /**
112
- * Whether attaching under THIS target would show the source to more people than the provider does.
113
- * A REQUIRED key with a NULLABLE value. NULL = not computed yet: discovery writes it null and the
114
- * server computes it per source x target when it builds the draft set item. Every consumer reads
115
- * null as UNRESOLVED (fail closed) — never as 'fits'.
116
- */
117
- audienceFit: z.enum(AUDIENCE_FITS).nullable(),
118
- recommendedUse: z.enum(SOURCE_USES),
119
- /** What the person has chosen; null until they (or an accepted default) decide. */
120
- use: z.enum(SOURCE_USES).nullable(),
121
- /**
122
- * Whether this line is in the set that approval would land — the SERVER's
123
- * answer, never derived from `use`. A decoy, a series' history or a line a
124
- * person left out is false while its `use` is still null.
125
- */
126
- included: z.boolean(),
127
- /** True while a `widens` item — or one whose fit is not computed yet — has no person-chosen use: approval is refused until none remain. */
128
- treatmentUnresolved: z.boolean(),
129
- /** Product-owned copy saying why this file was proposed. Never model-authored prose. */
130
- matchReason: z.string().min(1).max(500),
131
- /** Non-null when the source could not be read (a scanned PDF, an unsupported kind). */
132
- unreadableReason: z
133
- .enum(["unsupported_type", "needs_ocr", "unsupported_kind"])
134
- .nullable(),
135
- });
92
+ export const SourceSyncCandidateItemSchema = z
93
+ .object({
94
+ itemId: z.string().uuid(),
95
+ sourceId: z.string().uuid(),
96
+ /**
97
+ * Display facts about the source. Never its content. When `withheld`, `name` is
98
+ * product copy naming only the source's class and period.
99
+ */
100
+ name: z.string().min(1).max(500),
101
+ mimeType: z.string().min(1).max(255),
102
+ /** Opens the source IN THE PROVIDER, which remains the ACL authority for it. */
103
+ webViewLink: z.string().url().nullable(),
104
+ ownerLabel: z.string().max(255).nullable(),
105
+ modifiedTime: IsoDateTime.nullable(),
106
+ /**
107
+ * True when the caller may not open this source: its identifying fields are
108
+ * withheld (`webViewLink`, `ownerLabel` and `sharedWithCount` are null, and
109
+ * `name` and `matchReason` are product copy). What the approver decides stays.
110
+ */
111
+ withheld: z.boolean(),
112
+ /** 'company' or 'unit:<uuid>'. */
113
+ scopeKey: z.string().min(1).max(64),
114
+ targetDocId: z.string().uuid(),
115
+ targetDocTitle: z.string().min(1).max(255),
116
+ /** The funnel's verdict for the wire: `dropped` never reaches it. Hidden = behind the drawer, undecided. */
117
+ visibility: z.enum(SOURCE_RELEVANCE_TIERS).exclude(["dropped"]),
118
+ /** Separate from visibility: a shown line may still await classification (machineState pending_classification). */
119
+ classificationState: z.enum(CLASSIFICATION_STATES),
120
+ /** NULL until a classifier has run for this line. */
121
+ sourceClass: z.enum(SOURCE_CLASSES).nullable(),
122
+ periodLabel: z.string().max(64).nullable(),
123
+ seriesRole: z.enum(["current", "history"]).nullable(),
124
+ /** The FUSED relevance score (control ADR source-discovery-relevance-funnel), 0..1. */
125
+ relevance: z.number().min(0).max(1),
126
+ sensitivityClass: z.enum(SOURCE_SENSITIVITY_CLASSES),
127
+ sharingBreadth: z.enum(SHARING_BREADTHS),
128
+ /** A COUNT of people the provider shares it with, when observed. Never addresses. */
129
+ sharedWithCount: z.number().int().min(0).nullable(),
130
+ /**
131
+ * Whether attaching under THIS target would show the source to more people than the provider does.
132
+ * A REQUIRED key with a NULLABLE value. NULL = not computed yet: discovery writes it null and the
133
+ * server computes it per source x target when it builds the draft set item. Every consumer reads
134
+ * null as UNRESOLVED (fail closed) — never as 'fits'.
135
+ */
136
+ audienceFit: z.enum(AUDIENCE_FITS).nullable(),
137
+ /** Machine state: the sensitivity default. NULL until classified — never `excluded` as a stand-in. */
138
+ recommendedUse: z.enum(SOURCE_USES).nullable(),
139
+ /** The COMMITTED treatment: null until a person (or an accepted approval default) commits one. */
140
+ use: z.enum(SOURCE_USES).nullable(),
141
+ /** Provenance of `use`; null exactly when `use` is null (INV-SOURCESYNC-RECOMMENDATION-IS-NOT-COMMITMENT). */
142
+ selectionSource: z.enum(SELECTION_SOURCES).nullable(),
143
+ /** Field-level write barriers a person placed on this line (INV-SOURCESYNC-HOLDS-ARE-FIELD-BARRIERS). */
144
+ personHoldReasons: z.array(z.enum(PERSON_HOLD_REASONS)).max(4),
145
+ /** The line's machine lifecycle (INV-SOURCESYNC-SET-IS-RECONCILED). */
146
+ machineState: z.enum(CANDIDATE_MACHINE_STATES),
147
+ /** Structured reasons for `relevance`; `matchReason` is rendered from these by the server. */
148
+ relevanceReasons: z.array(RelevanceReasonSchema).max(8),
149
+ /** The audience Attach would disclose to. NULL = unknown — reads as MAY WIDEN (INV-SOURCESYNC-ATTACH-SHOWS-ITS-AUDIENCE). */
150
+ targetAudience: z.enum(TARGET_AUDIENCES).nullable(),
151
+ /** Which lane earned each model tier's attention (INV-SOURCESYNC-EXPLORATION-RESERVED). */
152
+ attention: CandidateAttentionSchema,
153
+ /**
154
+ * Whether this line is in the set that approval would land — the SERVER's
155
+ * answer, never derived from `use`. A decoy, a series' history or a line a
156
+ * person left out is false while its `use` is still null.
157
+ */
158
+ included: z.boolean(),
159
+ /** True while a `widens` item — or one whose fit is not computed yet — has no person-chosen use: approval is refused until none remain. */
160
+ treatmentUnresolved: z.boolean(),
161
+ /**
162
+ * Product-owned copy saying why this file was proposed, rendered by the server from
163
+ * `relevanceReasons`. Never model-authored prose. Deprecated: removed next release.
164
+ */
165
+ matchReason: z.string().min(1).max(500),
166
+ /** Non-null when the source could not be read (a scanned PDF, an unsupported kind). */
167
+ unreadableReason: z
168
+ .enum(["unsupported_type", "needs_ocr", "unsupported_kind"])
169
+ .nullable(),
170
+ })
171
+ .superRefine(refineCandidateRelevance);
136
172
 
137
173
  /** Distinct-file totals over every line of a candidate set the caller may see. */
138
174
  export const SourceSyncCandidateTotalsSchema = z.object({
@@ -142,6 +178,12 @@ export const SourceSyncCandidateTotalsSchema = z.object({
142
178
  filesIncluded: z.number().int().min(0),
143
179
  /** Files with at least one included line whose treatment is unresolved. */
144
180
  filesNeedingChoice: z.number().int().min(0),
181
+ /** Files every line of which is hidden (behind the drawer; control ADR source-discovery-relevance-funnel). */
182
+ filesHidden: z.number().int().min(0),
183
+ /** Files with at least one line the latest run did not see again. */
184
+ filesNotRediscovered: z.number().int().min(0),
185
+ /** Files with at least one line awaiting classification. */
186
+ filesPendingClassification: z.number().int().min(0),
145
187
  });
146
188
 
147
189
  /** The draft set under review. `revision` moves on every edit and is echoed on approve. */
@@ -170,14 +212,19 @@ export const SourceSyncCandidateSetResponseSchema = z.object({
170
212
  capMicroUsd: MicroUsd,
171
213
  })
172
214
  .nullable(),
215
+ /** The last completed discovery run's counts; null before any run completed. */
216
+ discovery: z.nullable(CandidateDiscoverySummarySchema),
173
217
  });
174
218
 
175
219
  /**
176
- * One edit to one candidate item. Every field optional: a PATCH names what changes.
220
+ * One edit to one candidate item: a PATCH names EXACTLY ONE thing that changes, so bringing
221
+ * a line into review never also commits a treatment.
177
222
  * The SERVER bumps the set's revision on every accepted edit; the client never sends one here.
178
223
  */
179
224
  export const SourceSyncCandidateItemPatchSchema = z
180
225
  .object({
226
+ /** Bring into review: hidden → shown. Changes VISIBILITY ONLY (INV-SOURCESYNC-RECOMMENDATION-IS-NOT-COMMITMENT). */
227
+ visibility: z.literal("shown").optional(),
181
228
  /** Attach, direction only, or leave out. Choosing one on a `widens` item RESOLVES its treatment. */
182
229
  use: z.enum(SOURCE_USES).optional(),
183
230
  /** Correct the class the classifier chose. */
@@ -186,8 +233,8 @@ export const SourceSyncCandidateItemPatchSchema = z
186
233
  targetDocId: z.string().uuid().optional(),
187
234
  })
188
235
  .strict()
189
- .refine((patch) => Object.keys(patch).length > 0, {
190
- message: "a patch changes something",
236
+ .refine((patch) => Object.keys(patch).length === 1, {
237
+ message: "a patch changes exactly one thing",
191
238
  });
192
239
 
193
240
  /** "Add one I missed": a file the person picked in the provider's own search. */