@cliwant/mcp-sam-gov 0.3.0 → 1.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.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. package/src/usitc.ts +420 -0
@@ -0,0 +1,928 @@
1
+ /**
2
+ * Integrity & teaming tier (keyless) — debarment screening + small-business
3
+ * discovery for teaming, both grounded on LIVE-VERIFIED public endpoints
4
+ * (2026-07-03).
5
+ *
6
+ * Two keyless tools:
7
+ * - sam_check_exclusions → SAM debarment/exclusion screening
8
+ * - usas_search_teaming_partners → award-derived small-business discovery,
9
+ * integrity-screened
10
+ *
11
+ * The defining truthfulness constraints of this tier:
12
+ *
13
+ * 1. SAM exclusions — an EMPTY result is a TRUE NEGATIVE with a narrow
14
+ * meaning ("no matching exclusion under these terms"), NOT a clean bill of
15
+ * health. We say so, loudly, in every `_meta.notes` so an AI never reads
16
+ * "0 records" as "responsible". The exclusions index is `ex` (NOT
17
+ * `ei`/`exclusion`), served keyless from sam.gov's frontend SGS with an
18
+ * `application/hal+json` Accept + a browser-y User-Agent. Deep paging is
19
+ * capped at 10,000 records server-side.
20
+ *
21
+ * 2. USAspending socioeconomic proxy — a BOGUS `recipient_type_names` value
22
+ * returns `0` results with HTTP 200 (VERIFIED — a silent accept). So the
23
+ * `cert` parameter MUST be a Zod enum of values confirmed live (see the
24
+ * server's TeamingPartnersInput); the runtime here ALSO re-validates the
25
+ * cert against `VERIFIED_CERTS` and throws a structured `invalid_input`
26
+ * rather than ever issuing a confident-empty list. And the cert is
27
+ * AWARD-DERIVED (recorded on the firm's federal awards), NOT the SBA
28
+ * certification of record (which needs a keyed SAM Entity call) — every
29
+ * response says so.
30
+ */
31
+
32
+ import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
33
+ import { withMeta } from "./meta.js";
34
+
35
+ // ─── Shared HTTP ─────────────────────────────────────────────────
36
+
37
+ // SAM's public frontend SGS endpoint gates on a browser-y User-Agent AND
38
+ // requires `Accept: application/hal+json`. Mirror the pricing tier's UA so
39
+ // behavior is consistent across the server.
40
+ const SAM_UA =
41
+ "Mozilla/5.0 (compatible; @cliwant/mcp-sam-gov; +https://github.com/cliwant/mcp-sam-gov)";
42
+ const SAM_HAL_HEADERS = {
43
+ Accept: "application/hal+json",
44
+ "User-Agent": SAM_UA,
45
+ } as const;
46
+
47
+ const SGS_BASE = "https://sam.gov/api/prod/sgs/v1/search";
48
+ const USAS = "https://api.usaspending.gov/api/v2";
49
+
50
+ // Server-side deep-paging cap on the SGS search index (LIVE-VERIFIED:
51
+ // page.maxAllowedRecords = 10000).
52
+ const SGS_MAX_RECORDS = 10_000;
53
+
54
+ async function getSgsJson<T>(url: string, label: string): Promise<T> {
55
+ const r = await fetchWithRetry(
56
+ url,
57
+ { headers: SAM_HAL_HEADERS, signal: AbortSignal.timeout(15_000) },
58
+ label,
59
+ );
60
+ return (await r.json()) as T;
61
+ }
62
+
63
+ async function postUsas<T>(
64
+ endpoint: string,
65
+ body: Record<string, unknown>,
66
+ ): Promise<T> {
67
+ const r = await fetchWithRetry(
68
+ `${USAS}/${endpoint}`,
69
+ {
70
+ method: "POST",
71
+ headers: { "Content-Type": "application/json" },
72
+ body: JSON.stringify(body),
73
+ signal: AbortSignal.timeout(15_000),
74
+ },
75
+ `usaspending:${endpoint}`,
76
+ );
77
+ return (await r.json()) as T;
78
+ }
79
+
80
+ // ─── 1. sam_check_exclusions ─────────────────────────────────────
81
+
82
+ const EXCLUSIONS_SOURCE = "sam.gov/api/prod (frontend HAL, keyless)";
83
+
84
+ /**
85
+ * The mandatory disclosure attached to EVERY exclusions response. An empty
86
+ * result is a narrow true-negative — never a general clearance. Kept as a
87
+ * constant so the smoke/edge tests can assert on it verbatim.
88
+ */
89
+ const NOT_PROOF_NOTE =
90
+ "An empty result means no matching exclusion was found (not currently excluded under these terms) — it is NOT proof of general responsibility.";
91
+
92
+ type ExclusionClassification =
93
+ | "Firm"
94
+ | "Individual"
95
+ | "Special Entity Designation"
96
+ | "any";
97
+
98
+ /** A single result row from the SGS `index=ex` exclusions index. */
99
+ type SgsExclusionResult = {
100
+ title?: string;
101
+ classification?: { code?: string; value?: string | null };
102
+ ueiSam?: string | null;
103
+ cageCode?: string | null;
104
+ samNumber?: string | null;
105
+ exclusionType?: string | null;
106
+ type?: { code?: string; value?: string | null };
107
+ exclusionProgram?: string | null;
108
+ excludingAgency?: string | null;
109
+ excludingAgencyDesc?: string | null;
110
+ ctCode?: string | null;
111
+ ctCodeDesc?: string | null;
112
+ isActive?: boolean | null;
113
+ activationDate?: string | null;
114
+ terminationDate?: string | null;
115
+ address?: Record<string, unknown> | null;
116
+ _id?: string | null;
117
+ };
118
+
119
+ type SgsExclusionResp = {
120
+ _embedded?: { results?: SgsExclusionResult[] };
121
+ page?: {
122
+ size?: number;
123
+ totalElements?: number;
124
+ totalPages?: number;
125
+ number?: number;
126
+ maxAllowedRecords?: number;
127
+ };
128
+ };
129
+
130
+ /** Normalize a UEI/CAGE for a case-insensitive post-filter compare. */
131
+ function norm(s: string | null | undefined): string {
132
+ return (s ?? "").trim().toUpperCase();
133
+ }
134
+
135
+ /**
136
+ * Normalize a legal entity NAME for a precise match: uppercase, strip
137
+ * punctuation, drop trailing entity suffixes (LLC/INC/CORP/…), and collapse
138
+ * whitespace. Used to decide whether an exclusion record genuinely names a
139
+ * given firm — SAM's free-text `q` tokenizes, so a raw "≥1 result" is NOT a
140
+ * match ("VISIONARY CONSULTING PARTNERS, LLC" would otherwise hit every
141
+ * unrelated "…CONSULTING…" exclusion, a dangerous false positive).
142
+ */
143
+ function normName(s: string | null | undefined): string {
144
+ return (s ?? "")
145
+ .toUpperCase()
146
+ .replace(/[.,/#!$%^&*;:{}=\-_`~()'"]/g, " ")
147
+ .replace(/\b(LLC|L L C|INC|INCORPORATED|CORP|CORPORATION|CO|COMPANY|LTD|LIMITED|LP|LLP|PLLC|PC)\b/g, " ")
148
+ .replace(/\s+/g, " ")
149
+ .trim();
150
+ }
151
+
152
+ /**
153
+ * Keyless SAM debarment / exclusion screening.
154
+ *
155
+ * Requires at least one of `query`/`uei`/`cage` (else structured
156
+ * invalid_input). `query` drives the server-side `q=`; `uei`/`cage` are
157
+ * POST-filtered on the returned rows (the frontend SGS has no dedicated
158
+ * uei/cage query param). `activeOnly` and `classification` are also applied as
159
+ * post-filters. The response distinguishes:
160
+ * - `excluded`: ≥1 ACTIVE record matched the (post-filtered) query,
161
+ * - `matchCount`: how many rows matched after post-filtering.
162
+ * An empty/false result is disclosed as a NARROW true-negative, never a
163
+ * general clearance (see NOT_PROOF_NOTE).
164
+ */
165
+ export async function checkExclusions(args: {
166
+ query?: string;
167
+ uei?: string;
168
+ cage?: string;
169
+ activeOnly?: boolean;
170
+ classification?: ExclusionClassification;
171
+ page?: number;
172
+ size?: number;
173
+ }) {
174
+ const query = args.query?.trim() || undefined;
175
+ const uei = args.uei?.trim() || undefined;
176
+ const cage = args.cage?.trim() || undefined;
177
+ const activeOnly = args.activeOnly ?? true;
178
+ const classification = args.classification ?? "any";
179
+ const page = Math.max(0, Math.floor(args.page ?? 0));
180
+ const size = Math.min(100, Math.max(1, Math.floor(args.size ?? 25)));
181
+
182
+ // At least one selector is required — an unbounded exclusions dump is never
183
+ // a meaningful screen.
184
+ if (!query && !uei && !cage) {
185
+ throw new ToolErrorCarrier({
186
+ kind: "invalid_input",
187
+ message:
188
+ "sam_check_exclusions requires at least one of query, uei, or cage. Pass the firm/individual name (query) and/or a UEI/CAGE to screen.",
189
+ retryable: false,
190
+ upstreamEndpoint: "sgs/v1/search?index=ex",
191
+ });
192
+ }
193
+
194
+ // Build the SGS query. `q` is the only server-side text selector on this
195
+ // index; uei/cage are post-filtered. When only a uei/cage is given (no
196
+ // name), use it as the `q` so the server still narrows.
197
+ const qValue = query ?? uei ?? cage ?? "";
198
+ const params = new URLSearchParams({
199
+ index: "ex",
200
+ q: qValue,
201
+ page: String(page),
202
+ size: String(size),
203
+ mode: "search",
204
+ });
205
+
206
+ const url = `${SGS_BASE}?${params.toString()}`;
207
+ const json = await getSgsJson<SgsExclusionResp>(url, "sam:sgs:ex");
208
+ const rawResults = json._embedded?.results ?? [];
209
+ const totalElements = json.page?.totalElements ?? null;
210
+
211
+ const filtersApplied: string[] = [];
212
+ const filtersDropped: string[] = [];
213
+ if (query) filtersApplied.push("query(q, server-side)");
214
+ else if (uei) filtersApplied.push("uei(as q, server-side)");
215
+ else if (cage) filtersApplied.push("cage(as q, server-side)");
216
+
217
+ // Post-filter on uei/cage/classification/activeOnly over the fetched page.
218
+ const ueiU = norm(uei);
219
+ const cageU = norm(cage);
220
+ let filtered = rawResults;
221
+ // NAME-MATCH GATE (truthfulness-critical): SAM's free-text `q` tokenizes, so a
222
+ // raw hit list is NOT a set of name matches — "VISIONARY CONSULTING PARTNERS"
223
+ // otherwise hits every unrelated "…PARTNERS…"/"…CONSULTING…" exclusion. When a
224
+ // name `query` is given, keep only records whose NORMALIZED name equals it
225
+ // (mirroring the teaming screen), so `excluded`/`records`/`matchCount` never
226
+ // flag someone else's exclusion. uei/cage-only selectors are gated below.
227
+ let looseTextHits = 0;
228
+ if (query) {
229
+ const target = normName(query);
230
+ if (target.length > 0) {
231
+ const before = filtered.length;
232
+ filtered = filtered.filter((r) => normName(r.title) === target);
233
+ looseTextHits = before - filtered.length;
234
+ filtersApplied.push("name(normalized exact match)");
235
+ }
236
+ }
237
+ if (uei) {
238
+ // Always narrow to the exact UEI when supplied (with or without a name
239
+ // query) — a UEI used as free-text `q` is otherwise a loose text hit.
240
+ filtered = filtered.filter((r) => norm(r.ueiSam) === ueiU);
241
+ filtersApplied.push("uei(exact post-filter)");
242
+ }
243
+ if (cage) {
244
+ filtered = filtered.filter((r) => norm(r.cageCode) === cageU);
245
+ filtersApplied.push("cage(exact post-filter)");
246
+ }
247
+ if (classification !== "any") {
248
+ filtered = filtered.filter(
249
+ (r) => (r.classification?.code ?? "") === classification,
250
+ );
251
+ filtersApplied.push(`classification(${classification})`);
252
+ }
253
+ if (activeOnly) {
254
+ filtered = filtered.filter((r) => r.isActive === true);
255
+ filtersApplied.push("activeOnly");
256
+ }
257
+
258
+ const records = filtered.map((r) => {
259
+ const ueiSam = r.ueiSam ?? null;
260
+ return {
261
+ name: r.title ?? "",
262
+ classification: r.classification?.code ?? null,
263
+ uei: ueiSam,
264
+ cage: r.cageCode ?? null,
265
+ samNumber: r.samNumber ?? null,
266
+ excludingAgency: r.excludingAgency ?? null,
267
+ excludingAgencyDesc: r.excludingAgencyDesc ?? null,
268
+ exclusionType: r.exclusionType ?? r.type?.value ?? null,
269
+ exclusionProgram: r.exclusionProgram ?? null,
270
+ ctCode: r.ctCode ?? null,
271
+ ctCodeDesc: r.ctCodeDesc ?? null,
272
+ isActive: r.isActive ?? null,
273
+ activationDate: r.activationDate ?? null,
274
+ terminationDate: r.terminationDate ?? null,
275
+ address: r.address ?? null,
276
+ // FAPIIS (the official exclusions/responsibility record) lookup URL.
277
+ samFapiisUrl: ueiSam
278
+ ? `https://sam.gov/search/?index=ex&q=${encodeURIComponent(ueiSam)}`
279
+ : `https://sam.gov/search/?index=ex&q=${encodeURIComponent(r.title ?? qValue)}`,
280
+ };
281
+ });
282
+
283
+ const excluded = records.some((r) => r.isActive === true);
284
+ const matchCount = records.length;
285
+
286
+ // truncated when the server total exceeds what a single page returned, OR
287
+ // when we hit the 10k deep-paging ceiling, OR when a post-filter means the
288
+ // fetched page may not contain every match.
289
+ const postFiltered =
290
+ Boolean(query) ||
291
+ Boolean(uei) ||
292
+ Boolean(cage) ||
293
+ classification !== "any";
294
+ const serverTruncated =
295
+ totalElements !== null && rawResults.length < totalElements;
296
+ const hitCap = totalElements !== null && totalElements > SGS_MAX_RECORDS;
297
+ const truncated = serverTruncated || hitCap || postFiltered;
298
+
299
+ const notes: string[] = [NOT_PROOF_NOTE];
300
+ notes.push(
301
+ "Exclusion screening is only as precise as the name/UEI/CAGE you pass. A name match is NOT identity-proof — confirm the UEI/CAGE, exclusion type, and dates against the FAPIIS record (samFapiisUrl) before acting on a hit.",
302
+ );
303
+ if (query && looseTextHits > 0) {
304
+ notes.push(
305
+ `SAM's free-text search returned ${looseTextHits} more record(s) sharing a word with "${query}" but NOT matching the normalized firm name — those are OTHER entities' exclusions and were dropped. \`excluded\` reflects ONLY records whose normalized name matches your query. Because this checked one page of text hits, a match under a name VARIANT could sit on a later page — if in doubt, raise \`size\` or verify the firm's UEI via samFapiisUrl.`,
306
+ );
307
+ }
308
+ if (postFiltered) {
309
+ notes.push(
310
+ "A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true match count for the combined filter may exceed this page. Narrow with a more specific `query` or raise `size`.",
311
+ );
312
+ }
313
+ if (hitCap) {
314
+ notes.push(
315
+ `SAM caps deep paging at ${SGS_MAX_RECORDS.toLocaleString()} records; this query is too broad to enumerate fully — narrow the query.`,
316
+ );
317
+ }
318
+ if (uei && !query) {
319
+ notes.push(
320
+ "You passed a UEI as the sole selector; it was used as the free-text `q` (the frontend exclusions index has no dedicated UEI field), so a match is a text hit, not a keyed UEI lookup — verify the returned uei equals the one you searched.",
321
+ );
322
+ }
323
+
324
+ return withMeta(
325
+ {
326
+ excluded,
327
+ matchCount,
328
+ records,
329
+ page,
330
+ size,
331
+ },
332
+ {
333
+ source: EXCLUSIONS_SOURCE,
334
+ keylessMode: true,
335
+ returned: records.length,
336
+ totalAvailable: totalElements,
337
+ truncated,
338
+ pagination: {
339
+ offset: page * size,
340
+ limit: size,
341
+ nextOffset: serverTruncated ? (page + 1) * size : null,
342
+ hasMore: serverTruncated,
343
+ },
344
+ filtersApplied,
345
+ filtersDropped,
346
+ fieldsUnavailable: [],
347
+ notes,
348
+ },
349
+ );
350
+ }
351
+
352
+ // ─── 1b. sam_integrity_lookup (keyless composition) ──────────────
353
+
354
+ const INTEGRITY_SOURCE =
355
+ "sam.gov exclusions (keyless) + FAPIIS/Responsibility-Qualification deep-link (record-level key-gated)";
356
+
357
+ /** The canonical, currently-resolving FAPIIS landing page (fapiis.gov 301s here). */
358
+ const FAPIIS_CONTENT_URL = "https://sam.gov/content/fapiis";
359
+
360
+ /**
361
+ * The mandatory disclosure attached to EVERY integrity-lookup response. Kept as
362
+ * a constant so smoke/edge/fault tests can assert it verbatim. It states, in
363
+ * order: (1) what keyless data this covers, (2) that FAPIIS
364
+ * Responsibility/Qualification records have NO keyless machine API, and (3) that
365
+ * `review_fapiis` is therefore NOT a clean bill of health.
366
+ */
367
+ const INTEGRITY_FAPIIS_NOTE =
368
+ "FAPIIS / Responsibility-Qualification records are publicly VIEWABLE at the linked SAM page but have no keyless machine API — record-level retrieval requires an optional SAM Entity key. This lookup covers the keyless government-wide EXCLUSION list only; it is not a full integrity clearance.";
369
+
370
+ /**
371
+ * Keyless one-call integrity screen — "any integrity red flags on this entity?"
372
+ *
373
+ * Composes the KEYLESS exclusion verdict (via {@link checkExclusions}, REUSED —
374
+ * exclusion fetching is not re-implemented here) with an HONEST pointer to the
375
+ * FAPIIS / Responsibility-Qualification record, which has NO keyless machine
376
+ * API. Requires at least one of `uei`/`cage`/`name` (uei preferred); `name`
377
+ * maps to the exclusion tool's `query`.
378
+ *
379
+ * TRUTHFULNESS (doc 07 §2.2):
380
+ * - `integrityFlag` is `"excluded"` when ≥1 ACTIVE matching exclusion is found,
381
+ * else `"review_fapiis"`. It NEVER emits `"clear"` keylessly — terminations
382
+ * for default/cause, non-responsibility determinations, and self-reported
383
+ * criminal/civil/administrative proceedings live in FAPIIS, which is not
384
+ * machine-readable without a key, so the absence of an exclusion does NOT
385
+ * prove integrity.
386
+ * - `fapiisRecords` is ALWAYS `null` (never faked), with
387
+ * `_meta.fieldsUnavailable: ["fapiisRecords"]` + INTEGRITY_FAPIIS_NOTE.
388
+ * - An upstream `checkExclusions` failure PROPAGATES as the classified error
389
+ * (the ToolErrorCarrier bubbles) — it is never masked as a "clear"/empty.
390
+ */
391
+ export async function integrityLookup(args: {
392
+ uei?: string;
393
+ cage?: string;
394
+ name?: string;
395
+ }) {
396
+ const uei = args.uei?.trim() || undefined;
397
+ const cage = args.cage?.trim() || undefined;
398
+ const name = args.name?.trim() || undefined;
399
+
400
+ // At least one identifier is required — an identity-less integrity screen is
401
+ // meaningless. Mirror checkExclusions' structured invalid_input.
402
+ if (!uei && !cage && !name) {
403
+ throw new ToolErrorCarrier({
404
+ kind: "invalid_input",
405
+ message:
406
+ "sam_integrity_lookup requires at least one of uei, cage, or name (uei preferred). Pass the entity's UEI/CAGE and/or legal name to screen.",
407
+ retryable: false,
408
+ upstreamEndpoint: "sgs/v1/search?index=ex",
409
+ });
410
+ }
411
+
412
+ // REUSE checkExclusions for the keyless exclusion verdict (map name→query).
413
+ // A failure here throws a classified ToolErrorCarrier that bubbles to the
414
+ // dispatcher — we never swallow it into a fake "clear".
415
+ const exclusionsRes = await checkExclusions({
416
+ query: name,
417
+ uei,
418
+ cage,
419
+ activeOnly: true,
420
+ });
421
+ const ex = exclusionsRes.data;
422
+ const excluded = ex.excluded === true;
423
+
424
+ // integrityFlag: "excluded" iff an ACTIVE matching exclusion was found; else
425
+ // "review_fapiis". NEVER "clear" — absence of an exclusion is not proof of
426
+ // integrity (the FAPIIS responsibility record is key-gated below).
427
+ const integrityFlag: "excluded" | "review_fapiis" = excluded
428
+ ? "excluded"
429
+ : "review_fapiis";
430
+
431
+ // Human deep-links: the always-valid FAPIIS content page, plus an
432
+ // entity-workspace Responsibility/Qualification deep-link when a UEI is known.
433
+ // These are NOT fetched by the tool — they are pointers for a human/agent.
434
+ const fapiisUrl = uei
435
+ ? `https://sam.gov/workspace/profile/${encodeURIComponent(uei)}/responsibilityInformation`
436
+ : FAPIIS_CONTENT_URL;
437
+
438
+ const data = {
439
+ entity: {
440
+ name: name ?? null,
441
+ uei: uei ?? null,
442
+ cage: cage ?? null,
443
+ },
444
+ exclusions: {
445
+ excluded,
446
+ activeCount: ex.matchCount,
447
+ records: ex.records,
448
+ },
449
+ // KEY-GATED — never faked. Record-level FAPIIS retrieval needs a SAM Entity
450
+ // key; keyless we can only point at the viewable page.
451
+ fapiisRecords: null,
452
+ fapiisContentUrl: FAPIIS_CONTENT_URL,
453
+ fapiisUrl,
454
+ integrityFlag,
455
+ };
456
+
457
+ const notes: string[] = [];
458
+ // Carry through the exclusion tool's own honesty first.
459
+ notes.push(NOT_PROOF_NOTE);
460
+ // Then the FAPIIS key-gating disclosure.
461
+ notes.push(INTEGRITY_FAPIIS_NOTE);
462
+ if (integrityFlag === "review_fapiis") {
463
+ notes.push(
464
+ "integrityFlag is 'review_fapiis' (NEVER 'clear' in keyless mode): no ACTIVE government-wide exclusion matched these identifiers, but that does NOT establish responsibility — review the FAPIIS / Responsibility-Qualification record at fapiisUrl (terminations for default/cause, non-responsibility determinations, and self-reported proceedings are not machine-readable keylessly).",
465
+ );
466
+ } else {
467
+ notes.push(
468
+ "integrityFlag is 'excluded' — at least one ACTIVE government-wide exclusion matched these identifiers (see exclusions.records). Confirm the UEI/CAGE, exclusion type, and dates against the FAPIIS record before acting; a name match is not identity-proof.",
469
+ );
470
+ }
471
+ if (!uei) {
472
+ notes.push(
473
+ "No UEI was supplied, so fapiisUrl points at the general FAPIIS page rather than the entity's Responsibility/Qualification profile — pass the UEI for an entity-specific deep-link and a keyed exclusion match.",
474
+ );
475
+ }
476
+
477
+ return withMeta(data, {
478
+ source: INTEGRITY_SOURCE,
479
+ keylessMode: true,
480
+ // A single composite verdict record — not a paged list.
481
+ returned: 1,
482
+ totalAvailable: 1,
483
+ // The exclusion verdict itself is complete for these terms; the FAPIIS
484
+ // record dimension is declared unavailable (not truncated).
485
+ truncated: false,
486
+ filtersApplied: [],
487
+ filtersDropped: [],
488
+ // FAPIIS record-level content is unavailable keylessly — declared, not faked.
489
+ fieldsUnavailable: ["fapiisRecords"],
490
+ notes,
491
+ });
492
+ }
493
+
494
+ // ─── 2. usas_search_teaming_partners ─────────────────────────────
495
+
496
+ const TEAMING_SOURCE =
497
+ "usaspending (award-derived socioeconomic proxy, keyless)";
498
+
499
+ /**
500
+ * The `recipient_type_names` vocabulary CONFIRMED live (2026-07-03) to narrow a
501
+ * known-populated NAICS (541512, 2023+) to a plausible non-zero, non-baseline
502
+ * count — the server SILENTLY accepts a bogus value and returns 0 with HTTP
503
+ * 200, so this allow-list is the guardrail. The server's Zod enum mirrors this
504
+ * set; this runtime re-check is defense-in-depth so a bad value can never yield
505
+ * a confident-empty list.
506
+ *
507
+ * Verified counts (NAICS 541512, action_date ≥ 2023-01-01):
508
+ * small_business ....................................... 11539
509
+ * 8a_program_participant ............................... 4902
510
+ * woman_owned_business ................................. 3251
511
+ * veteran_owned_business ............................... 2805
512
+ * service_disabled_veteran_owned_business ............. 2450
513
+ * women_owned_small_business .......................... 1931
514
+ * economically_disadvantaged_women_owned_small_business 1192
515
+ * historically_underutilized_business_firm (HUBZone) .. 1025
516
+ */
517
+ export const VERIFIED_CERTS = [
518
+ "small_business",
519
+ "8a_program_participant",
520
+ "woman_owned_business",
521
+ "women_owned_small_business",
522
+ "economically_disadvantaged_women_owned_small_business",
523
+ "service_disabled_veteran_owned_business",
524
+ "veteran_owned_business",
525
+ "historically_underutilized_business_firm",
526
+ ] as const;
527
+
528
+ export type VerifiedCert = (typeof VERIFIED_CERTS)[number];
529
+
530
+ const TEAMING_PROXY_NOTE =
531
+ "cert reflects socioeconomic categories recorded on the firm's federal awards, NOT the current SBA certification of record (which requires a SAM Entity key) — verify active certification in SAM/SBS before teaming.";
532
+
533
+ /** True total for a spending_by_award query via the companion count endpoint. */
534
+ async function teamingAwardCount(
535
+ filters: Record<string, unknown>,
536
+ ): Promise<number | null> {
537
+ try {
538
+ type CountResp = { results?: Record<string, number> };
539
+ const json = await postUsas<CountResp>("search/spending_by_award_count/", {
540
+ filters,
541
+ subawards: false,
542
+ });
543
+ const results = json.results;
544
+ if (!results) return null;
545
+ return Object.values(results).reduce(
546
+ (s, v) => s + (typeof v === "number" ? v : 0),
547
+ 0,
548
+ );
549
+ } catch {
550
+ return null;
551
+ }
552
+ }
553
+
554
+ type TeamingAwardRow = {
555
+ "Award ID"?: string | null;
556
+ "Recipient Name"?: string | null;
557
+ "Award Amount"?: number | null;
558
+ "Awarding Agency"?: string | null;
559
+ "Awarding Sub Agency"?: string | null;
560
+ NAICS?: { code?: string; description?: string } | null;
561
+ recipient_id?: string | null;
562
+ "Start Date"?: string | null;
563
+ "End Date"?: string | null;
564
+ // The award's BASE obligation date = when it was first awarded/obligated (the real
565
+ // "award date"). Always in the past; unlike "End Date" (PoP end, often FUTURE for
566
+ // ongoing contracts) it is the correct source for a recency signal.
567
+ "Base Obligation Date"?: string | null;
568
+ generated_internal_id?: string | null;
569
+ };
570
+
571
+ type TeamingSearchResp = {
572
+ results?: TeamingAwardRow[];
573
+ page_metadata?: { hasNext?: boolean; page?: number };
574
+ };
575
+
576
+ /**
577
+ * Small-business teaming-partner discovery by socioeconomic certification +
578
+ * NAICS + agency award history (keyless USAspending `spending_by_award`
579
+ * proxy), integrity-screened.
580
+ *
581
+ * MECHANISM: query `spending_by_award` filtered by `recipient_type_names:[cert]`
582
+ * (+ optional naics/agency/subagency + an action_date lookback), page a bounded
583
+ * number of award rows, then AGGREGATE client-side by `recipient_id`
584
+ * (spending_by_award is NOT pre-grouped by recipient — one firm spans many
585
+ * rows). Each candidate carries agencyAwardCount + agencyObligated +
586
+ * mostRecentAwardDate + sampleAwards, ranked by agencyObligated desc, with
587
+ * `minAwards` applied AFTER aggregation.
588
+ *
589
+ * INTEGRITY: when `excludeDebarred`, the top candidates (bounded by
590
+ * `screenCap`) are screened via checkExclusions and flagged/dropped on an
591
+ * ACTIVE exclusion. The screen is bounded and DISCLOSED (how many screened /
592
+ * removed / whether the screen was capped).
593
+ *
594
+ * HONESTY: the cert is AWARD-DERIVED, not the SBA registry of record
595
+ * (TEAMING_PROXY_NOTE, always in _meta). A bogus cert never reaches the network
596
+ * — it is rejected as invalid_input (the endpoint would silently return 0).
597
+ */
598
+ export async function searchTeamingPartners(args: {
599
+ cert: string;
600
+ naics?: string;
601
+ agency?: string;
602
+ subagency?: string;
603
+ lookbackYears?: number;
604
+ excludeDebarred?: boolean;
605
+ minAwards?: number;
606
+ limit?: number;
607
+ page?: number;
608
+ screenCap?: number;
609
+ scanPages?: number;
610
+ }) {
611
+ // --- Guardrail: the cert MUST be a verified value (defense-in-depth over
612
+ // the server's Zod enum). A bogus value would be SILENTLY accepted by the
613
+ // endpoint (HTTP 200, 0 results) — reject it loudly instead. ----------------
614
+ const cert = args.cert;
615
+ if (!(VERIFIED_CERTS as readonly string[]).includes(cert)) {
616
+ throw new ToolErrorCarrier({
617
+ kind: "invalid_input",
618
+ message: `Unknown socioeconomic cert '${cert}'. USAspending SILENTLY accepts an unrecognized recipient_type_names value and returns 0 results with HTTP 200, so an unverified value would yield a confident-but-empty list. Use one of: ${VERIFIED_CERTS.join(", ")}.`,
619
+ retryable: false,
620
+ upstreamEndpoint: "search/spending_by_award",
621
+ });
622
+ }
623
+
624
+ const lookbackYears = Math.min(
625
+ 20,
626
+ Math.max(1, Math.floor(args.lookbackYears ?? 3)),
627
+ );
628
+ const excludeDebarred = args.excludeDebarred ?? true;
629
+ const minAwards = Math.max(1, Math.floor(args.minAwards ?? 1));
630
+ const limit = Math.min(50, Math.max(1, Math.floor(args.limit ?? 25)));
631
+ const page = Math.max(1, Math.floor(args.page ?? 1));
632
+ const screenCap = Math.min(25, Math.max(1, Math.floor(args.screenCap ?? 10)));
633
+ const scanPages = Math.min(10, Math.max(1, Math.floor(args.scanPages ?? 4)));
634
+
635
+ // --- Build filters (only what we can send truthfully) ---------------------
636
+ const nowMs = Date.now();
637
+ const startDate = new Date(nowMs);
638
+ startDate.setUTCFullYear(startDate.getUTCFullYear() - lookbackYears);
639
+ const startIso = startDate.toISOString().slice(0, 10);
640
+ const todayIso = new Date(nowMs).toISOString().slice(0, 10);
641
+
642
+ const filters: Record<string, unknown> = {
643
+ award_type_codes: ["A", "B", "C", "D"],
644
+ recipient_type_names: [cert],
645
+ time_period: [{ start_date: startIso, end_date: todayIso }],
646
+ };
647
+ const filtersApplied: string[] = [
648
+ `cert(${cert})`,
649
+ `actionDateLookback(${lookbackYears}y)`,
650
+ ];
651
+ const filtersDropped: string[] = [];
652
+ if (args.naics) {
653
+ filters.naics_codes = [args.naics];
654
+ filtersApplied.push("naics");
655
+ }
656
+ // agency + optional subagency. `agencies` accepts a toptier `name` and,
657
+ // alongside it, a subtier entry when a subagency is given.
658
+ if (args.agency) {
659
+ const agencies: Record<string, unknown>[] = [
660
+ { type: "awarding", tier: "toptier", name: args.agency },
661
+ ];
662
+ if (args.subagency) {
663
+ agencies.push({ type: "awarding", tier: "subtier", name: args.subagency });
664
+ }
665
+ filters.agencies = agencies;
666
+ filtersApplied.push("agency");
667
+ if (args.subagency) filtersApplied.push("subagency");
668
+ } else if (args.subagency) {
669
+ // A subagency without a parent agency is ambiguous on this endpoint — do
670
+ // not send it silently; disclose it was dropped.
671
+ filtersDropped.push("subagency(requires agency)");
672
+ }
673
+
674
+ const fields = [
675
+ "Award ID",
676
+ "Recipient Name",
677
+ "Award Amount",
678
+ "Awarding Agency",
679
+ "Awarding Sub Agency",
680
+ "NAICS",
681
+ "recipient_id",
682
+ "Start Date",
683
+ "End Date",
684
+ "Base Obligation Date",
685
+ ];
686
+
687
+ // --- Scan a bounded number of award-value-sorted pages, then aggregate by
688
+ // recipient. spending_by_award is NOT grouped by recipient, so a firm spans
689
+ // multiple rows — we roll them up. --------------------------------------
690
+ type Candidate = {
691
+ recipientName: string;
692
+ recipient_id: string | null;
693
+ uei: string | null;
694
+ cert: string;
695
+ naicsMatched: Set<string>;
696
+ agencyAwardCount: number;
697
+ agencyObligated: number;
698
+ mostRecentAwardDate: string | null;
699
+ sampleAwards: {
700
+ awardId: string;
701
+ agency: string;
702
+ amount: number;
703
+ date: string | null;
704
+ }[];
705
+ excluded: boolean | null;
706
+ };
707
+
708
+ const byRecipient = new Map<string, Candidate>();
709
+ let rowsScanned = 0;
710
+ let scanTruncated = false;
711
+
712
+ for (let p = 1; p <= scanPages; p++) {
713
+ const resp = await postUsas<TeamingSearchResp>(
714
+ "search/spending_by_award",
715
+ {
716
+ filters,
717
+ fields,
718
+ sort: "Award Amount",
719
+ order: "desc",
720
+ limit: 100,
721
+ page: p,
722
+ subawards: false,
723
+ },
724
+ );
725
+ const rows = resp.results ?? [];
726
+ for (const row of rows) {
727
+ rowsScanned++;
728
+ const name = row["Recipient Name"] ?? "";
729
+ // Key by recipient_id when present, else fall back to the (uppercased)
730
+ // name so nameless-id rows still aggregate deterministically.
731
+ const key = row.recipient_id ?? `name:${norm(name)}`;
732
+ const amount =
733
+ typeof row["Award Amount"] === "number" ? row["Award Amount"] : 0;
734
+ // LEAD-8 / TEAM-1 fix (Codex dogfood C76): `mostRecentAwardDate` and each
735
+ // sample award's `date` are the AWARD date — sourced from Base Obligation
736
+ // Date (when the award was first obligated, always past — live-verified 40/40
737
+ // populated & 0 future for contract types A/B/C/D), NOT "End Date" (the PoP
738
+ // END, which is FUTURE for ongoing contracts — live-verified 13/40 future,
739
+ // producing "award dates" like 2027-05-16). "End Date" is DELIBERATELY NOT a
740
+ // fallback: it is the only future-capable field, so falling back to it would
741
+ // reintroduce the exact bug on a sparse row. Start Date (PoP start, ~always
742
+ // past) is the sole fallback; when both are absent the award date is unknown
743
+ // (null) rather than a fabricated/future value.
744
+ const date = row["Base Obligation Date"] ?? row["Start Date"] ?? null;
745
+ let c = byRecipient.get(key);
746
+ if (!c) {
747
+ c = {
748
+ recipientName: name,
749
+ recipient_id: row.recipient_id ?? null,
750
+ uei: null, // spending_by_award does not return UEI on the award row.
751
+ cert,
752
+ naicsMatched: new Set<string>(),
753
+ agencyAwardCount: 0,
754
+ agencyObligated: 0,
755
+ mostRecentAwardDate: null,
756
+ sampleAwards: [],
757
+ excluded: null,
758
+ };
759
+ byRecipient.set(key, c);
760
+ }
761
+ c.agencyAwardCount += 1;
762
+ c.agencyObligated += amount;
763
+ if (row.NAICS?.code) c.naicsMatched.add(row.NAICS.code);
764
+ if (date && (c.mostRecentAwardDate === null || date > c.mostRecentAwardDate)) {
765
+ c.mostRecentAwardDate = date;
766
+ }
767
+ if (c.sampleAwards.length < 3) {
768
+ c.sampleAwards.push({
769
+ awardId: row["Award ID"] ?? "",
770
+ agency: row["Awarding Agency"] ?? "",
771
+ amount,
772
+ date,
773
+ });
774
+ }
775
+ }
776
+ if (!resp.page_metadata?.hasNext) break;
777
+ if (p === scanPages && resp.page_metadata?.hasNext) scanTruncated = true;
778
+ }
779
+
780
+ // --- Rank by obligated desc, apply minAwards, page ------------------------
781
+ const ranked = [...byRecipient.values()]
782
+ .filter((c) => c.agencyAwardCount >= minAwards)
783
+ .sort((a, b) => {
784
+ if (b.agencyObligated !== a.agencyObligated)
785
+ return b.agencyObligated - a.agencyObligated;
786
+ if (b.agencyAwardCount !== a.agencyAwardCount)
787
+ return b.agencyAwardCount - a.agencyAwardCount;
788
+ return a.recipientName.localeCompare(b.recipientName);
789
+ });
790
+
791
+ const totalCandidates = ranked.length; // EXACT only when !scanTruncated
792
+ const startIdx = (page - 1) * limit;
793
+ const pageSlice = ranked.slice(startIdx, startIdx + limit);
794
+
795
+ // --- Integrity screen (bounded, disclosed) --------------------------------
796
+ let screenedCount = 0;
797
+ let removedCount = 0;
798
+ let screenFailed = false;
799
+ let screenCapped = false;
800
+ if (excludeDebarred && pageSlice.length > 0) {
801
+ const toScreen = pageSlice.slice(0, screenCap);
802
+ screenCapped = pageSlice.length > screenCap;
803
+ for (const c of toScreen) {
804
+ if (!c.recipientName) continue;
805
+ try {
806
+ const res = await checkExclusions({
807
+ query: c.recipientName,
808
+ activeOnly: true,
809
+ size: 25,
810
+ });
811
+ screenedCount++;
812
+ // PRECISION: `checkExclusions` returns every free-text hit (SAM's `q`
813
+ // tokenizes), so `res.data.excluded` alone is a false-positive trap —
814
+ // it is true if ANY active record shares a word with the firm name.
815
+ // Only flag this candidate excluded when a returned ACTIVE record's
816
+ // NAME actually matches the firm's (normalized). A non-matching hit is
817
+ // someone else's exclusion and must NOT drop a clean partner.
818
+ const target = normName(c.recipientName);
819
+ const nameMatch =
820
+ target.length > 0 &&
821
+ res.data.records.some(
822
+ (rec) => rec.isActive === true && normName(rec.name) === target,
823
+ );
824
+ c.excluded = nameMatch;
825
+ if (nameMatch) removedCount++;
826
+ } catch {
827
+ // A screen failure must NOT be read as "clean" — leave excluded:null
828
+ // and disclose that screening degraded.
829
+ screenFailed = true;
830
+ c.excluded = null;
831
+ }
832
+ }
833
+ }
834
+
835
+ // Materialize the candidate rows (after screening) — drop active exclusions
836
+ // when excludeDebarred, keep everyone otherwise.
837
+ const candidates = pageSlice
838
+ .filter((c) => !(excludeDebarred && c.excluded === true))
839
+ .map((c) => ({
840
+ recipientName: c.recipientName,
841
+ recipient_id: c.recipient_id,
842
+ uei: c.uei,
843
+ cert: c.cert,
844
+ naicsMatched: [...c.naicsMatched],
845
+ agencyAwardCount: c.agencyAwardCount,
846
+ agencyObligated: c.agencyObligated,
847
+ mostRecentAwardDate: c.mostRecentAwardDate,
848
+ sampleAwards: c.sampleAwards,
849
+ excluded: c.excluded,
850
+ }));
851
+
852
+ // --- True total (award count) via the companion endpoint. This is the
853
+ // number of AWARDS, not distinct recipients — the endpoint reports no
854
+ // distinct-recipient total, so recipient totalAvailable stays null. -------
855
+ const awardTotal = await teamingAwardCount(filters);
856
+
857
+ // --- Truthful _meta -------------------------------------------------------
858
+ const notes: string[] = [TEAMING_PROXY_NOTE];
859
+ notes.push(
860
+ `Candidates are aggregated client-side by recipient over a bounded ${scanPages}-page scan (${rowsScanned} award row(s), sorted by award amount desc). agencyObligated/agencyAwardCount reflect the SCANNED rows for this cert×filters slice, not necessarily the firm's entire history.`,
861
+ );
862
+ if (scanTruncated) {
863
+ notes.push(
864
+ `The ${scanPages}-page scan budget was exhausted with more award rows available, so the candidate ranking is a LOWER BOUND (a firm ranked lower here could have more awards on unscanned pages). totalAvailable (distinct recipients) is unknown — narrow with naics/agency/subagency or raise scanPages for a complete ranking.`,
865
+ );
866
+ }
867
+ if (awardTotal !== null) {
868
+ notes.push(
869
+ `The cert×filters slice covers ${awardTotal} award(s) total (via spending_by_award_count); the ${rowsScanned} scanned row(s) are the highest-value subset. This is an AWARD count, not a distinct-recipient count.`,
870
+ );
871
+ }
872
+ if (excludeDebarred) {
873
+ notes.push(
874
+ `Integrity screen: ${screenedCount} of the top ${pageSlice.length} ranked candidate(s) were screened for ACTIVE SAM exclusions${screenCapped ? ` (capped at ${screenCap}; lower-ranked candidates on this page were NOT screened)` : ""}; ${removedCount} with an active exclusion ${removedCount === 1 ? "was" : "were"} dropped. Matching is by NORMALIZED NAME (a firm is flagged only when an active exclusion record's name matches — a shared-word text hit does NOT drop a firm), and because UEI is unavailable on award rows this is a NAME match, not a keyed UEI match — confirm any borderline case in SAM. An unscreened candidate's excluded flag is null (unknown), NOT a clearance.`,
875
+ );
876
+ if (screenFailed) {
877
+ notes.push(
878
+ "At least one exclusion screen FAILED (upstream error) — those candidates show excluded:null and were NOT dropped; re-run to complete screening. A failed screen is not a clean result.",
879
+ );
880
+ }
881
+ } else {
882
+ notes.push(
883
+ "excludeDebarred is false — candidates were NOT screened for SAM exclusions (`excluded` is null for all). Screen with sam_check_exclusions before teaming.",
884
+ );
885
+ }
886
+ if (filtersDropped.includes("subagency(requires agency)")) {
887
+ notes.push(
888
+ "A subagency was requested without a parent agency and was NOT applied — pass `agency` (the toptier name) alongside `subagency`.",
889
+ );
890
+ }
891
+
892
+ const hasMore = scanTruncated ? true : startIdx + limit < totalCandidates;
893
+ const truncated = hasMore || scanTruncated;
894
+
895
+ return withMeta(
896
+ {
897
+ candidates,
898
+ cert,
899
+ page,
900
+ limit,
901
+ },
902
+ {
903
+ source: TEAMING_SOURCE,
904
+ keylessMode: true,
905
+ returned: candidates.length,
906
+ // The distinct-recipient total is unknown when the scan truncated; even
907
+ // when complete, the endpoint reports only an AWARD count (awardTotal),
908
+ // not a distinct-recipient total, so recipient totalAvailable is null.
909
+ totalAvailable: null,
910
+ truncated,
911
+ pagination: {
912
+ offset: startIdx,
913
+ limit,
914
+ nextOffset: hasMore ? startIdx + limit : null,
915
+ hasMore,
916
+ },
917
+ filtersApplied,
918
+ filtersDropped,
919
+ // UEI is not returned on the spending_by_award row (needs a recipient
920
+ // profile lookup); the SBA cert of record needs a keyed SAM Entity call.
921
+ fieldsUnavailable: [
922
+ "uei(needs usas_get_recipient_profile)",
923
+ "sbaCertificationOfRecord(needs keyed SAM Entity)",
924
+ ],
925
+ notes,
926
+ },
927
+ );
928
+ }