@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
@@ -66,8 +66,259 @@ function buildFilters(args: {
66
66
  return filters;
67
67
  }
68
68
 
69
- import { fetchWithRetry } from "./errors.js";
69
+ /**
70
+ * VQ-6 (C79 dogfooding): derive the honest `_meta.filtersApplied` labels from the
71
+ * ACTUAL filter object sent upstream. Reading the built `filters` (not the args)
72
+ * means it can NEVER drift from what was really applied. `award_type_codes` is
73
+ * always present (contracts-only A/B/C/D). Every buildFilters-based tool previously
74
+ * reported `filtersApplied:[]` even when naics/agency/fiscalYear WAS applied — an
75
+ * agent verifying via `_meta` could not confirm its filter took effect.
76
+ */
77
+ function filtersAppliedFromFilters(f: UsasFilters): string[] {
78
+ // UsasFilters is Record<string, unknown>, so narrow each value to a non-empty
79
+ // array before treating it as "applied".
80
+ const has = (k: string): boolean => {
81
+ const v = f[k];
82
+ return Array.isArray(v) && v.length > 0;
83
+ };
84
+ const applied: string[] = [];
85
+ // Label the ACTUAL award-type scope. A/B/C/D = contracts; 02/03/04/05 = grants
86
+ // (searchCfdaSpending). Adversarial-review SHIP-BLOCKER: a value-blind label
87
+ // made the grants tool falsely claim "contracts A/B/C/D".
88
+ const atc = f["award_type_codes"];
89
+ if (Array.isArray(atc) && atc.length > 0) {
90
+ const codes = atc.map(String);
91
+ applied.push(
92
+ codes.includes("A")
93
+ ? "awardType(contracts A/B/C/D)"
94
+ : `awardType(${codes.join("/")})`,
95
+ );
96
+ }
97
+ if (has("agencies")) applied.push("agency");
98
+ if (has("naics_codes")) applied.push("naics");
99
+ if (has("time_period")) applied.push("fiscalYear");
100
+ if (has("set_aside_type_codes")) applied.push("setAside");
101
+ if (has("psc_codes")) applied.push("pscCodes");
102
+ if (has("recipient_search_text")) applied.push("recipientSearchText");
103
+ return applied;
104
+ }
105
+
106
+ import { fetchWithRetry, ToolErrorCarrier, errorFromResponse } from "./errors.js";
107
+ import {
108
+ throughPathChain,
109
+ CircuitBreaker,
110
+ type ResiliencePath,
111
+ type Provenance,
112
+ } from "./datasource.js";
113
+ import { snapshotPath, provenanceMeta } from "./snapshot.js";
70
114
  import { memoize } from "./cache.js";
115
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
116
+
117
+ // ─── Resilience wiring (ADR-0045 pilot expansion — INERT by default) ───────
118
+ // The USAspending live host + a per-host circuit breaker keyed on the FIXED set
119
+ // {this host} (bounded — m3-regression). CONSULTED only by `throughPathChain`
120
+ // for a ≥2-path chain; when SAMGOV_SNAPSHOT_BASE_URL is unset the chain is
121
+ // single-path (live only), the breaker is a pure no-op, and every opted-in
122
+ // reference tool is BYTE-IDENTICAL to before this ADR. Only the three
123
+ // SLOW-CHANGING, canonical/queryless REFERENCE reads opt in (toptier agencies,
124
+ // the top-level NAICS tree, the glossary); the search/aggregate tools stay
125
+ // live-only (a snapshot can't cover free queries). See datasource.ts §"RESILIENCE
126
+ // PORT" and the policy boundary (no route-around / public-only / no-proxy).
127
+ const USAS_HOST = "api.usaspending.gov";
128
+ let usasBreaker = new CircuitBreaker([USAS_HOST]);
129
+
130
+ /**
131
+ * Test-only: reset the resilience circuit breaker between OFFLINE fixtures (the
132
+ * breaker is module-level process state; a fresh instance isolates cases).
133
+ * Mirrors treasury.ts's `_resetTreasuryBreakerForTests`.
134
+ */
135
+ export function _resetUsasBreakerForTests(): void {
136
+ usasBreaker = new CircuitBreaker([USAS_HOST]);
137
+ }
138
+
139
+ const SPENDING_BY_AWARD_SOURCE =
140
+ "usaspending.gov/api/v2 search/spending_by_award";
141
+ const SPENDING_BY_CATEGORY_RECIPIENT_SOURCE =
142
+ "usaspending.gov/api/v2 search/spending_by_category/recipient";
143
+ const SPENDING_OVER_TIME_SOURCE =
144
+ "usaspending.gov/api/v2 search/spending_over_time";
145
+
146
+ /**
147
+ * `page_metadata` shape for the `spending_by_category/*` endpoints. These are
148
+ * cursor/page-paginated: the block carries `hasNext` but NO grand total
149
+ * (empirically verified 2026-07-03 for psc/state_territory/cfda/federal_account/
150
+ * awarding_agency/awarding_subagency — every one returns only
151
+ * `{page, next, previous, hasNext, hasPrevious}`). So a truthful aggregate
152
+ * `_meta` uses `hasNext` as the truncation signal and sets
153
+ * `totalAvailable: null` — never the page length (spec §3.3).
154
+ */
155
+ type CategoryPageMeta = {
156
+ page?: number;
157
+ next?: number | null;
158
+ hasNext?: boolean;
159
+ };
160
+
161
+ /**
162
+ * Build the `_meta` for a top-N `spending_by_category/*` aggregate. These
163
+ * category endpoints report no grand total, so `totalAvailable` is always
164
+ * `null` (honest "unknown" — never the returned count). Truncation is the
165
+ * endpoint's own `hasNext` when present, else `returned >= limit`.
166
+ */
167
+ function categoryAggregateMeta(opts: {
168
+ source: string;
169
+ returned: number;
170
+ limit: number;
171
+ hasNext?: boolean;
172
+ fieldsUnavailable?: string[];
173
+ extraNotes?: string[];
174
+ filters?: UsasFilters;
175
+ }): Partial<ResponseMeta> {
176
+ const truncated = opts.hasNext ?? opts.returned >= opts.limit;
177
+ const notes: string[] = [];
178
+ if (truncated) {
179
+ notes.push(
180
+ `Capped at the top ${opts.limit} categories by amount; more categories may exist. This endpoint reports no grand total, so the true number of categories is unknown (totalAvailable is null, NOT the returned count). These extra categories are NOT page-reachable — all six callers post page:1 with NO offset/page input (nextOffset is null). Raise limit (up to 50) or narrow filters to see the rest.`,
181
+ );
182
+ }
183
+ if (opts.extraNotes) notes.push(...opts.extraNotes);
184
+ return {
185
+ source: opts.source,
186
+ keylessMode: true,
187
+ returned: opts.returned,
188
+ // spec §3.3: the spending_by_category/* endpoints expose no total → null.
189
+ totalAvailable: null,
190
+ truncated,
191
+ pagination: {
192
+ offset: 0,
193
+ limit: opts.limit,
194
+ // W3-8 (honesty; mirrors awardPagination :488 / searchRecipients M1): all SIX
195
+ // callers (searchPscSpending / searchStateSpending / searchCfdaSpending /
196
+ // searchFederalAccountSpending / searchAgencySpending / searchSubAgencySpending)
197
+ // hardcode page:1 and expose ONLY `limit` (no offset/page arg) — so `nextOffset`
198
+ // is NOT consumable. Emitting `opts.returned` (the page length) made an agent
199
+ // re-fetch the SAME top-N forever while ranked-below-`limit` categories stayed
200
+ // unreachable. Emit null unconditionally; `truncated`/`hasMore` stay honest — the
201
+ // extras are reachable ONLY by raising `limit` (≤50) or narrowing filters.
202
+ nextOffset: null,
203
+ hasMore: truncated,
204
+ },
205
+ filtersApplied: opts.filters ? filtersAppliedFromFilters(opts.filters) : [],
206
+ filtersDropped: [],
207
+ fieldsUnavailable: opts.fieldsUnavailable ?? [],
208
+ notes,
209
+ };
210
+ }
211
+
212
+ /**
213
+ * Build the `_meta` for a reference / autocomplete tool. These are the
214
+ * anti-hallucination lookups (NAICS/recipient autocomplete, NAICS hierarchy,
215
+ * glossary, toptier agencies). Completeness rule (spec §2.3): a page that
216
+ * came back SHORT of the requested `limit` is the whole result set
217
+ * (`truncated:false`); a FULL page means more may exist (`truncated:true`).
218
+ * When the endpoint reports a real total (glossary), pass it so truncation is
219
+ * derived from `returned < total` instead. All are served from a 5-min TTL
220
+ * cache (see cache.ts) — noted so the AI knows the data may be up to 5 min old.
221
+ * [가설] we can't tell a cache HIT from a MISS here, so the note is
222
+ * unconditional rather than hit-specific.
223
+ */
224
+ function referenceMeta(opts: {
225
+ source: string;
226
+ returned: number;
227
+ limit: number;
228
+ totalAvailable: number | null;
229
+ limitHonored?: boolean; // false ⇒ endpoint ignores `limit` (e.g. toptier)
230
+ extraNotes?: string[];
231
+ }): Partial<ResponseMeta> {
232
+ const { source, returned, limit, totalAvailable } = opts;
233
+ const limitHonored = opts.limitHonored ?? true;
234
+ let truncated: boolean;
235
+ let hasMore: boolean;
236
+ if (totalAvailable !== null) {
237
+ truncated = returned < totalAvailable;
238
+ hasMore = truncated;
239
+ } else if (!limitHonored) {
240
+ // Endpoint ignores `limit` and returns the full set → complete.
241
+ truncated = false;
242
+ hasMore = false;
243
+ } else {
244
+ truncated = returned >= limit;
245
+ hasMore = truncated;
246
+ }
247
+ const notes: string[] = [
248
+ "Reference lookup served from a 5-minute TTL cache; values may be up to 5 minutes stale.",
249
+ ];
250
+ if (truncated) {
251
+ notes.push(
252
+ totalAvailable !== null
253
+ ? `Showing ${returned} of ${totalAvailable} total; raise limit to see more.`
254
+ : `A full page of ${returned} was returned; more matches may exist — raise limit to widen the result.`,
255
+ );
256
+ }
257
+ if (opts.extraNotes) notes.push(...opts.extraNotes);
258
+ return {
259
+ source,
260
+ keylessMode: true,
261
+ returned,
262
+ totalAvailable,
263
+ truncated,
264
+ pagination: {
265
+ // W3-8 (honesty; mirrors govinfo.ts:406-408 "no numeric offset"): the three
266
+ // callers (autocompleteNaics / autocompleteRecipient / glossary) forward only
267
+ // searchText/search + limit — NO offset/page input — so these reference lookups
268
+ // are not offset-pageable AT ALL. Emit BOTH offset:null and nextOffset:null;
269
+ // emitting `returned` for nextOffset made an agent re-fetch the SAME page forever.
270
+ // `hasMore`/`truncated` stay honest — the truncation notes already say "raise
271
+ // limit" (the only real way to widen), so no page cursor is implied.
272
+ offset: null,
273
+ limit,
274
+ nextOffset: null,
275
+ hasMore,
276
+ },
277
+ filtersApplied: [],
278
+ filtersDropped: [],
279
+ fieldsUnavailable: [],
280
+ notes,
281
+ };
282
+ }
283
+
284
+ /**
285
+ * True total for a `spending_by_award` query, via the companion
286
+ * `spending_by_award_count` endpoint.
287
+ *
288
+ * WHY a companion query (not `page_metadata.total`): the `spending_by_award`
289
+ * response uses cursor-style pagination — its `page_metadata` carries only
290
+ * `page`/`hasNext`/`last_record_*`, NOT a `total` (empirically verified
291
+ * 2026-07-03; the spec's assumption that it mirrors `recipient/`'s
292
+ * `page_metadata.total` was wrong for this endpoint). The only honest source
293
+ * of a real count is `spending_by_award_count`, which returns per-award-type
294
+ * buckets; we sum them. Returns `null` on any failure — NEVER a page length
295
+ * (spec §3.3: never substitute page size for an unknown total).
296
+ */
297
+ async function awardCount(
298
+ filters: UsasFilters,
299
+ mode: "awards" | "subawards",
300
+ ): Promise<number | null> {
301
+ try {
302
+ type CountResp = { results?: Record<string, number> };
303
+ const body: Record<string, unknown> = { filters };
304
+ if (mode === "subawards") body.subawards = true;
305
+ const json = await postUsas<CountResp>(
306
+ "search/spending_by_award_count/",
307
+ body,
308
+ );
309
+ const results = json.results;
310
+ if (!results) return null;
311
+ // Awards → contracts+idvs+direct_payments+grants+loans+other.
312
+ // Subawards → subcontracts+subgrants. Sum every numeric bucket so we stay
313
+ // correct if the endpoint adds categories.
314
+ return Object.values(results).reduce(
315
+ (s, v) => s + (typeof v === "number" ? v : 0),
316
+ 0,
317
+ );
318
+ } catch {
319
+ return null;
320
+ }
321
+ }
71
322
 
72
323
  async function postUsas<T>(
73
324
  endpoint: string,
@@ -95,6 +346,34 @@ async function getUsas<T>(endpoint: string): Promise<T> {
95
346
  return (await r.json()) as T;
96
347
  }
97
348
 
349
+ /**
350
+ * getUsas THROUGH the resilience path-chain (ADR-0045 pilot expansion). The LIVE
351
+ * path is byte-identical to a bare `getUsas(endpoint)` — same URL, same init
352
+ * ({signal} only), same label — so with no snapshot configured the chain is
353
+ * SINGLE-ENTRY and `throughPathChain` fast-paths (no breaker consult, no
354
+ * overhead) ⇒ output BYTE-IDENTICAL to before this ADR (the INERT guarantee).
355
+ *
356
+ * A snapshot fallback is added ONLY when (a) `snapshotKey` is provided (the
357
+ * caller declares this read is a canonical/queryless reference the builder can
358
+ * pre-fetch) AND (b) SAMGOV_SNAPSHOT_BASE_URL is configured (else `snapshotPath`
359
+ * returns null). When either is absent the chain stays live-only. The snapshot's
360
+ * `data` is the RAW upstream JSON (same shape as the live body), so the caller's
361
+ * existing mapping applies unchanged to both paths.
362
+ */
363
+ async function getUsasResilient<T>(
364
+ endpoint: string,
365
+ snapshotKey?: string,
366
+ ): Promise<{ body: T; provenance: Provenance }> {
367
+ const livePath: ResiliencePath<T> = {
368
+ host: USAS_HOST,
369
+ provenance: { dataPath: "live" },
370
+ run: () => getUsas<T>(endpoint),
371
+ };
372
+ const snap = snapshotKey ? snapshotPath<T>(snapshotKey) : null;
373
+ const paths = snap ? [livePath, snap] : [livePath];
374
+ return throughPathChain<T>(paths, usasBreaker);
375
+ }
376
+
98
377
  // ─── Aggregate share-of-wallet ───────────────────────────────────
99
378
 
100
379
  export async function searchAwards(args: {
@@ -106,21 +385,45 @@ export async function searchAwards(args: {
106
385
  const filters = buildFilters(args);
107
386
  type Resp = {
108
387
  results?: { name?: string; amount?: number; count?: number }[];
388
+ page_metadata?: { total?: number; count?: number };
109
389
  };
390
+ const limit = 10;
110
391
  const json = await postUsas<Resp>(
111
392
  "search/spending_by_category/recipient",
112
- { filters, limit: 10, page: 1 },
393
+ { filters, limit, page: 1 },
113
394
  );
114
395
  const results = json.results ?? [];
115
- return {
116
- totalAwards: results.reduce((s, r) => s + (r.count ?? 0), 0),
396
+ // B1 (spec §1.3, §3.4): the spending_by_category/recipient endpoint returns
397
+ // `amount` but NOT a per-recipient award `count`. The old code defaulted the
398
+ // missing count to 0, so every recipient reported `awards:0` and
399
+ // `totalAwards:0` while `totalValue` was billions — a self-contradictory lie
400
+ // ("0 contracts worth $3.45B"). Emit `null` (explicit "not available"), NOT
401
+ // 0, and flag it in `_meta.fieldsUnavailable`. `amount`/`value` unchanged.
402
+ const data = {
403
+ totalAwards: null as number | null,
117
404
  totalValue: results.reduce((s, r) => s + (r.amount ?? 0), 0),
118
405
  topRecipients: results.map((r) => ({
119
406
  name: r.name ?? "—",
120
407
  value: r.amount ?? 0,
121
- awards: r.count ?? 0,
408
+ awards: null as number | null,
122
409
  })),
123
410
  };
411
+ return withMeta(data, {
412
+ source: SPENDING_BY_CATEGORY_RECIPIENT_SOURCE,
413
+ keylessMode: true,
414
+ returned: results.length,
415
+ // This is a landscape/top-N aggregate: the recipient tail is capped at
416
+ // `limit` and the category endpoint reports no grand total → unknown.
417
+ totalAvailable: null,
418
+ truncated: results.length >= limit,
419
+ filtersApplied: filtersAppliedFromFilters(filters),
420
+ filtersDropped: [],
421
+ fieldsUnavailable: ["awards", "totalAwards"],
422
+ notes: [
423
+ "Per-recipient award COUNTS are not available from the spending_by_category/recipient endpoint (it returns obligated amount only) — `awards` and `totalAwards` are null, not 0. For a real contract count use usas_search_awards_by_recipient (its _meta.totalAvailable) or usas_get_recipient_profile.",
424
+ "Only contract award types (A/B/C/D) are included; grants/IDVs are excluded from this share-of-wallet view.",
425
+ ],
426
+ });
124
427
  }
125
428
 
126
429
  // ─── Line-item awards ─────────────────────────────────────────────
@@ -140,38 +443,123 @@ export async function searchIndividualAwards(args: {
140
443
  "Award Amount"?: number;
141
444
  "Awarding Agency"?: string;
142
445
  "Awarding Sub Agency"?: string;
446
+ NAICS?: { code?: string; description?: string };
143
447
  "Place of Performance State Code"?: string;
448
+ "Place of Performance City Code"?: string;
449
+ "Place of Performance Country Code"?: string;
450
+ "Place of Performance Zip5"?: string;
144
451
  Description?: string;
145
452
  generated_internal_id?: string;
146
453
  }[];
454
+ page_metadata?: { hasNext?: boolean };
147
455
  };
148
- const json = await postUsas<Resp>("search/spending_by_award", {
149
- filters,
150
- fields: [
151
- "Award ID",
152
- "Recipient Name",
153
- "Award Amount",
154
- "Awarding Agency",
155
- "Awarding Sub Agency",
156
- "Place of Performance State Code",
157
- "Description",
158
- ],
159
- limit: args.limit ?? 10,
160
- page: 1,
161
- subawards: false,
162
- });
163
- return {
164
- awards: (json.results ?? []).map((r) => ({
456
+ const limit = args.limit ?? 10;
457
+ // D1/D2 field-parity (spec §3.2): "NAICS" and the "Place of Performance …"
458
+ // fields are valid `spending_by_award` field names (empirically verified
459
+ // 2026-07-03 + confirmed against the API contract) and cost NO extra request
460
+ // — USAspending returns whatever fields you ask for. Set-aside is NOT a
461
+ // requestable field on this endpoint (filter-only) → documented as
462
+ // detail-only in `_meta.fieldsUnavailable`.
463
+ const [json, total] = await Promise.all([
464
+ postUsas<Resp>("search/spending_by_award", {
465
+ filters,
466
+ fields: [
467
+ "Award ID",
468
+ "Recipient Name",
469
+ "Award Amount",
470
+ "Awarding Agency",
471
+ "Awarding Sub Agency",
472
+ "NAICS",
473
+ "Place of Performance State Code",
474
+ "Place of Performance City Code",
475
+ "Place of Performance Country Code",
476
+ "Place of Performance Zip5",
477
+ "Description",
478
+ ],
479
+ limit,
480
+ page: 1,
481
+ subawards: false,
482
+ }),
483
+ awardCount(filters, "awards"),
484
+ ]);
485
+ const results = json.results ?? [];
486
+ const data = {
487
+ awards: results.map((r) => ({
165
488
  awardId: r["Award ID"] ?? "",
166
- recipient: r["Recipient Name"] ?? "",
167
- amount: r["Award Amount"] ?? 0,
168
- awardingAgency: r["Awarding Agency"] ?? "",
489
+ recipient: r["Recipient Name"] || null,
490
+ // F2 (P3 null-never-0): an ABSENT Award Amount null, NEVER a fabricated
491
+ // $0 (a null-amount IDV/loan-type row must not read as "a $0 award"). A
492
+ // genuine 0 still survives — `??` fires only on null/undefined.
493
+ amount: r["Award Amount"] ?? null,
494
+ awardingAgency: r["Awarding Agency"] || null,
169
495
  awardingSubAgency: r["Awarding Sub Agency"],
496
+ // D1: NAICS now returned (parity with usas_search_awards_by_recipient).
497
+ naicsCode: r.NAICS?.code,
498
+ naicsDescription: r.NAICS?.description,
170
499
  placeOfPerformanceState: r["Place of Performance State Code"],
500
+ placeOfPerformanceCity: r["Place of Performance City Code"],
501
+ placeOfPerformanceCountry: r["Place of Performance Country Code"],
502
+ placeOfPerformanceZip: r["Place of Performance Zip5"],
171
503
  description: r.Description,
172
504
  generatedInternalId: r.generated_internal_id ?? "",
173
505
  })),
174
506
  };
507
+ const pagination = awardPagination(
508
+ 0,
509
+ limit,
510
+ results.length,
511
+ total,
512
+ json.page_metadata?.hasNext ?? false,
513
+ );
514
+ const notes = [
515
+ "Set-aside type is NOT available from the spending_by_award search endpoint (it can only be FILTERED, not returned) — call usas_get_award_detail (setAsideType/setAsideDescription) per award via generatedInternalId.",
516
+ ];
517
+ // W3-7: when more matches exist than this single ranked page holds, say so AND
518
+ // that they are not page-reachable (nextOffset is null — no offset input here).
519
+ if (pagination.hasMore) {
520
+ notes.push(
521
+ `Showing ${limit} of ${total ?? "more"} matching awards. These extra matches are NOT page-reachable — this tool returns one ranked page with no offset input (nextOffset is null). Raise limit (up to 50) or narrow the filters to see more.`,
522
+ );
523
+ }
524
+ return withMeta(data, {
525
+ source: SPENDING_BY_AWARD_SOURCE,
526
+ keylessMode: true,
527
+ returned: results.length,
528
+ totalAvailable: total,
529
+ pagination,
530
+ filtersApplied: filtersAppliedFromFilters(filters),
531
+ filtersDropped: [],
532
+ // Set-aside is not a `spending_by_award` output field; PoP city is often a
533
+ // numeric code (or null) rather than a name. Both live in detail.
534
+ fieldsUnavailable: ["setAside", "setAsideDescription"],
535
+ notes,
536
+ });
537
+ }
538
+
539
+ /** hasMore for cursor-paginated award search: prefer the real total. */
540
+ function awardPagination(
541
+ offset: number,
542
+ limit: number,
543
+ returned: number,
544
+ total: number | null,
545
+ upstreamHasNext: boolean,
546
+ ): NonNullable<MetaBundle["meta"]["pagination"]> {
547
+ const hasMore =
548
+ total !== null ? offset + returned < total : upstreamHasNext;
549
+ return {
550
+ offset,
551
+ limit,
552
+ // W3-7 (honesty; mirrors searchRecipients M1): ALL THREE callers
553
+ // (searchIndividualAwards / searchAwardsByRecipient / searchSubawards) request
554
+ // the upstream `page:1` with NO offset/page input in their tool schemas — so
555
+ // `nextOffset` is NOT consumable. Emitting `offset + returned` (the page length)
556
+ // made an agent re-fetch the SAME top-N forever while ranked-below-`limit` rows
557
+ // stayed unjoinable. Emit null unconditionally; `hasMore` stays honest (more
558
+ // matches DO exist) — the extras are reachable ONLY by raising `limit` (≤50) or
559
+ // narrowing the filters. (No offset-capable caller exists — verified: all 3 pass 0.)
560
+ nextOffset: null,
561
+ hasMore,
562
+ };
175
563
  }
176
564
 
177
565
  // ─── Recipient win history ────────────────────────────────────────
@@ -193,40 +581,92 @@ export async function searchAwardsByRecipient(args: {
193
581
  "Awarding Agency"?: string;
194
582
  "Awarding Sub Agency"?: string;
195
583
  NAICS?: { code?: string; description?: string };
584
+ "Place of Performance State Code"?: string;
585
+ "Place of Performance City Code"?: string;
586
+ "Place of Performance Country Code"?: string;
587
+ "Place of Performance Zip5"?: string;
196
588
  Description?: string;
197
589
  generated_internal_id?: string;
198
590
  }[];
591
+ page_metadata?: { hasNext?: boolean };
199
592
  };
200
- const json = await postUsas<Resp>("search/spending_by_award", {
201
- filters,
202
- fields: [
203
- "Award ID",
204
- "Recipient Name",
205
- "Award Amount",
206
- "Awarding Agency",
207
- "Awarding Sub Agency",
208
- "NAICS",
209
- "Description",
210
- ],
211
- limit: args.limit ?? 15,
212
- page: 1,
213
- subawards: false,
214
- });
593
+ const limit = args.limit ?? 15;
594
+ const [json, total] = await Promise.all([
595
+ postUsas<Resp>("search/spending_by_award", {
596
+ filters,
597
+ fields: [
598
+ "Award ID",
599
+ "Recipient Name",
600
+ "Award Amount",
601
+ "Awarding Agency",
602
+ "Awarding Sub Agency",
603
+ "NAICS",
604
+ "Place of Performance State Code",
605
+ "Place of Performance City Code",
606
+ "Place of Performance Country Code",
607
+ "Place of Performance Zip5",
608
+ "Description",
609
+ ],
610
+ limit,
611
+ page: 1,
612
+ subawards: false,
613
+ }),
614
+ awardCount(filters, "awards"),
615
+ ]);
215
616
  const results = json.results ?? [];
216
- return {
617
+ const data = {
217
618
  awards: results.map((r) => ({
218
619
  awardId: r["Award ID"] ?? "",
219
- recipient: r["Recipient Name"] ?? "",
220
- amount: r["Award Amount"] ?? 0,
221
- awardingAgency: r["Awarding Agency"] ?? "",
620
+ recipient: r["Recipient Name"] || null,
621
+ // F2 (P3 null-never-0): an ABSENT Award Amount null, NEVER a fabricated
622
+ // $0 (a null-amount IDV/loan-type row must not read as "a $0 award"). A
623
+ // genuine 0 still survives — `??` fires only on null/undefined.
624
+ amount: r["Award Amount"] ?? null,
625
+ awardingAgency: r["Awarding Agency"] || null,
222
626
  awardingSubAgency: r["Awarding Sub Agency"],
223
627
  naicsCode: r.NAICS?.code,
224
628
  naicsDescription: r.NAICS?.description,
629
+ placeOfPerformanceState: r["Place of Performance State Code"],
630
+ placeOfPerformanceCity: r["Place of Performance City Code"],
631
+ placeOfPerformanceCountry: r["Place of Performance Country Code"],
632
+ placeOfPerformanceZip: r["Place of Performance Zip5"],
225
633
  description: r.Description,
226
634
  generatedInternalId: r.generated_internal_id ?? "",
227
635
  })),
228
- totalRecords: results.length,
636
+ // C5 (spec §1.4, §3.3): the OLD value was `results.length` — the PAGE SIZE,
637
+ // not the true count. A recipient with 400 awards but a 15-row page
638
+ // reported `totalRecords:15` (an order-of-magnitude lie). Now the REAL
639
+ // upstream total (via spending_by_award_count), or null if that companion
640
+ // query failed — never the page length.
641
+ totalRecords: total,
229
642
  };
643
+ const pagination = awardPagination(
644
+ 0,
645
+ limit,
646
+ results.length,
647
+ total,
648
+ json.page_metadata?.hasNext ?? false,
649
+ );
650
+ const notes = [
651
+ "Set-aside type is NOT available from the spending_by_award search endpoint (filter-only) — call usas_get_award_detail per award for setAsideType/setAsideDescription.",
652
+ ];
653
+ // W3-7: extra matches beyond this ranked page are not page-reachable (nextOffset null).
654
+ if (pagination.hasMore) {
655
+ notes.push(
656
+ `Showing ${limit} of ${total ?? "more"} matching awards for this recipient. These extra matches are NOT page-reachable — this tool returns one ranked page with no offset input (nextOffset is null). Raise limit (up to 50) or narrow the filters to see more.`,
657
+ );
658
+ }
659
+ return withMeta(data, {
660
+ source: SPENDING_BY_AWARD_SOURCE,
661
+ keylessMode: true,
662
+ returned: results.length,
663
+ totalAvailable: total,
664
+ pagination,
665
+ filtersApplied: filtersAppliedFromFilters(filters),
666
+ filtersDropped: [],
667
+ fieldsUnavailable: ["setAside", "setAsideDescription"],
668
+ notes,
669
+ });
230
670
  }
231
671
 
232
672
  // ─── Subaward enumeration ─────────────────────────────────────────
@@ -248,47 +688,140 @@ export async function searchSubawards(args: {
248
688
  "Sub-Award Recipient"?: string;
249
689
  "Sub-Award Amount"?: number;
250
690
  "Sub-Award Date"?: string;
691
+ NAICS?: { code?: string; description?: string };
251
692
  prime_award_generated_internal_id?: string;
252
693
  }[];
694
+ page_metadata?: { hasNext?: boolean };
253
695
  };
254
- const json = await postUsas<Resp>("search/spending_by_award", {
255
- filters,
256
- fields: [
257
- "Sub-Award ID",
258
- "Sub-Award Recipient",
259
- "Sub-Award Amount",
260
- "Sub-Award Date",
261
- "Sub-Award NAICS",
262
- ],
263
- limit: args.limit ?? 15,
264
- page: 1,
265
- subawards: true,
266
- });
267
- return {
268
- subawards: (json.results ?? []).map((r) => ({
696
+ const limit = args.limit ?? 15;
697
+ // A3 (spec §1.2, §3.2): the OLD code requested "Sub-Award NAICS", which is
698
+ // NOT a valid field name on this endpoint — `spending_by_award` echoes an
699
+ // unknown field back as `null` (verified 2026-07-03), so the arg looked
700
+ // honored but silently returned nothing. The valid field for subaward NAICS
701
+ // is "NAICS" (returns {code,description} — the PRIME award's NAICS, which is
702
+ // what USAspending exposes on subaward rows). Swap to it and map it.
703
+ const [json, total] = await Promise.all([
704
+ postUsas<Resp>("search/spending_by_award", {
705
+ filters,
706
+ fields: [
707
+ "Sub-Award ID",
708
+ "Sub-Award Recipient",
709
+ "Sub-Award Amount",
710
+ "Sub-Award Date",
711
+ "NAICS",
712
+ ],
713
+ limit,
714
+ page: 1,
715
+ subawards: true,
716
+ }),
717
+ awardCount(filters, "subawards"),
718
+ ]);
719
+ const results = json.results ?? [];
720
+ const data = {
721
+ subawards: results.map((r) => ({
269
722
  subAwardId: r["Sub-Award ID"] ?? "",
270
- subRecipient: r["Sub-Award Recipient"] ?? "(name redacted)",
271
- amount: r["Sub-Award Amount"] ?? 0,
723
+ // minor m2 (W3-1 honesty): an ABSENT Sub-Award Recipient null, NOT a
724
+ // fabricated "(name redacted)". The old sentinel asserted a specific PRIVACY
725
+ // reason on ANY nullish value (schema gap / null echo / genuine redaction
726
+ // alike) — internally inconsistent with the null-never-fabricate discipline
727
+ // this same function applies to `amount` below. Honest null; the caller
728
+ // reads absence, not an invented redaction cause.
729
+ subRecipient: r["Sub-Award Recipient"] ?? null,
730
+ // F2 (P3 null-never-0): an ABSENT Sub-Award Amount → null, NEVER a
731
+ // fabricated $0. A genuine 0 still survives (`??` fires only on null).
732
+ amount: r["Sub-Award Amount"] ?? null,
272
733
  actionDate: r["Sub-Award Date"] ?? "",
734
+ // A3: prime-award NAICS on the subaward row (the only NAICS the endpoint
735
+ // exposes for subawards). null when the row genuinely lacks it.
736
+ naicsCode: r.NAICS?.code,
737
+ naicsDescription: r.NAICS?.description,
273
738
  primeAwardId: r.prime_award_generated_internal_id ?? "",
274
739
  })),
275
740
  };
741
+ const pagination = awardPagination(
742
+ 0,
743
+ limit,
744
+ results.length,
745
+ total,
746
+ json.page_metadata?.hasNext ?? false,
747
+ );
748
+ const notes = [
749
+ "The `naicsCode`/`naicsDescription` on each subaward is the PRIME award's NAICS (USAspending does not expose a distinct sub-award NAICS on this endpoint). A subaward-specific NAICS is not available keyless.",
750
+ ];
751
+ // W3-7: extra subawards beyond this ranked page are not page-reachable (nextOffset null).
752
+ if (pagination.hasMore) {
753
+ notes.push(
754
+ `Showing ${limit} of ${total ?? "more"} matching subawards. These extra matches are NOT page-reachable — this tool returns one ranked page with no offset input (nextOffset is null). Raise limit (up to 50) or narrow the filters to see more.`,
755
+ );
756
+ }
757
+ return withMeta(data, {
758
+ source: SPENDING_BY_AWARD_SOURCE,
759
+ keylessMode: true,
760
+ returned: results.length,
761
+ totalAvailable: total,
762
+ pagination,
763
+ filtersApplied: filtersAppliedFromFilters(filters),
764
+ filtersDropped: [],
765
+ fieldsUnavailable: [],
766
+ notes,
767
+ });
276
768
  }
277
769
 
278
770
  // ─── Per-award detail ─────────────────────────────────────────────
279
771
 
772
+ /**
773
+ * Parse USAspending's `number_of_offers_received` to a real number|null.
774
+ *
775
+ * LIVE-VERIFIED 2026-07-03: this field is a STRING on competed awards (e.g.
776
+ * "1", "2", "3") but is genuinely `null` on some delivery orders — so the
777
+ * previous typing/mapping (`number_of_offers_received?: string`, passed
778
+ * through raw) exposed a string where a numeric compare was expected. Coerce
779
+ * to a number; return null for null/empty/non-numeric so a missing value is
780
+ * an honest "unknown", never 0 or "".
781
+ */
782
+ function parseOffers(raw: unknown): number | null {
783
+ if (raw === null || raw === undefined) return null;
784
+ const n = typeof raw === "number" ? raw : Number.parseInt(String(raw), 10);
785
+ return Number.isFinite(n) ? n : null;
786
+ }
787
+
788
+ /** A parent-award / IDV linkage as returned on awards/{id} (all optional). */
789
+ export type AwardParentIdv = {
790
+ piid: string | null;
791
+ generatedUniqueAwardId: string | null;
792
+ idvTypeDescription: string | null;
793
+ multipleOrSingleAwardDescription: string | null;
794
+ };
795
+
280
796
  export async function getAwardDetail(generatedInternalId: string) {
281
797
  try {
282
798
  const r = await fetch(
283
799
  `${USAS}/awards/${encodeURIComponent(generatedInternalId)}/`,
284
800
  { signal: AbortSignal.timeout(10_000) },
285
801
  );
286
- if (!r.ok) return null;
802
+ // 404 = the id genuinely doesn't resolve → null (a real "not found").
803
+ // 429/5xx = a RETRYABLE upstream fault, NOT a missing award → throw a
804
+ // classified error so callers never mislabel an outage as not_found.
805
+ if (r.status === 404) return null;
806
+ if (!r.ok) {
807
+ throw new ToolErrorCarrier({
808
+ kind: r.status === 429 ? "rate_limited" : "upstream_unavailable",
809
+ message: `usaspending awards/{id} returned ${r.status}`,
810
+ retryable: true,
811
+ upstreamStatus: r.status,
812
+ upstreamEndpoint: `awards/${generatedInternalId}`,
813
+ });
814
+ }
287
815
  type Resp = {
288
816
  piid?: string;
289
817
  description?: string;
290
818
  total_obligation?: number;
291
819
  base_and_all_options?: number;
820
+ base_exercised_options?: number;
821
+ subaward_count?: number;
822
+ type?: string;
823
+ type_description?: string;
824
+ category?: string;
292
825
  period_of_performance?: {
293
826
  start_date?: string;
294
827
  end_date?: string;
@@ -298,10 +831,20 @@ export async function getAwardDetail(generatedInternalId: string) {
298
831
  type_set_aside?: string;
299
832
  type_set_aside_description?: string;
300
833
  extent_competed?: string;
301
- number_of_offers_received?: string;
834
+ extent_competed_description?: string;
835
+ number_of_offers_received?: string | number | null;
302
836
  naics?: string;
303
837
  naics_description?: string;
838
+ product_or_service_code?: string;
839
+ product_or_service_description?: string;
304
840
  };
841
+ psc_hierarchy?: { base_code?: { code?: string; description?: string } };
842
+ parent_award?: {
843
+ piid?: string;
844
+ generated_unique_award_id?: string;
845
+ idv_type_description?: string;
846
+ multiple_or_single_aw_desc?: string;
847
+ } | null;
305
848
  awarding_agency?: {
306
849
  toptier_agency?: { name?: string };
307
850
  subtier_agency?: { name?: string };
@@ -310,11 +853,43 @@ export async function getAwardDetail(generatedInternalId: string) {
310
853
  };
311
854
  const json = (await r.json()) as Resp;
312
855
  const ltc = json.latest_transaction_contract_data ?? {};
856
+ // PSC: prefer the ltc code, fall back to the psc_hierarchy base code (the
857
+ // ltc-level product_or_service_code is often absent while the hierarchy
858
+ // carries it — LIVE-VERIFIED 2026-07-03).
859
+ const pscCode =
860
+ ltc.product_or_service_code ?? json.psc_hierarchy?.base_code?.code ?? null;
861
+ const pscDescription =
862
+ ltc.product_or_service_description ??
863
+ json.psc_hierarchy?.base_code?.description ??
864
+ null;
865
+ const parent = json.parent_award ?? null;
866
+ const parentIdv: AwardParentIdv | null = parent
867
+ ? {
868
+ piid: parent.piid ?? null,
869
+ generatedUniqueAwardId: parent.generated_unique_award_id ?? null,
870
+ idvTypeDescription: parent.idv_type_description ?? null,
871
+ multipleOrSingleAwardDescription:
872
+ parent.multiple_or_single_aw_desc ?? null,
873
+ }
874
+ : null;
313
875
  return {
314
876
  awardId: json.piid ?? "",
315
- recipient: json.recipient?.recipient_name ?? "",
316
- totalObligation: json.total_obligation ?? 0,
317
- baseAndAllOptions: json.base_and_all_options ?? 0,
877
+ // Identity field is null (UNKNOWN) when absent OR blank — never "" (which
878
+ // reads as "no recipient"). `|| null` catches both a missing recipient_name
879
+ // and a present-but-empty one. Consistent with analyzeIncumbent's incumbent
880
+ // field (#43) and the money fields below.
881
+ recipient: json.recipient?.recipient_name || null,
882
+ // Money fields are null (UNKNOWN) when USAspending omits them — never 0.
883
+ // A null base_and_all_options is common and legitimate (IDVs/BPAs carry
884
+ // the ceiling at the vehicle level, grants/loans have no ceiling concept);
885
+ // rendering it as 0 would read as "a $0 ceiling", a data-absence-as-present
886
+ // masquerade. Consistent with baseExercisedOptions, which already nulls.
887
+ totalObligation: json.total_obligation ?? null,
888
+ baseAndAllOptions: json.base_and_all_options ?? null,
889
+ baseExercisedOptions: json.base_exercised_options ?? null,
890
+ subawardCount: json.subaward_count ?? null,
891
+ // Award type + human description (e.g. "C" / "DELIVERY ORDER").
892
+ contractAwardType: json.type_description ?? json.type ?? null,
318
893
  periodOfPerformance: {
319
894
  startDate: json.period_of_performance?.start_date ?? null,
320
895
  endDate: json.period_of_performance?.end_date ?? null,
@@ -324,87 +899,751 @@ export async function getAwardDetail(generatedInternalId: string) {
324
899
  setAsideType: ltc.type_set_aside,
325
900
  setAsideDescription: ltc.type_set_aside_description,
326
901
  competitionExtent: ltc.extent_competed,
327
- numberOfOffers: ltc.number_of_offers_received,
902
+ competitionExtentDescription: ltc.extent_competed_description ?? null,
903
+ // E-type-hygiene fix: number_of_offers_received is now a parsed
904
+ // number|null, not the raw string it arrives as.
905
+ numberOfOffers: parseOffers(ltc.number_of_offers_received),
328
906
  awardingAgency: json.awarding_agency?.toptier_agency?.name,
329
907
  awardingSubAgency: json.awarding_agency?.subtier_agency?.name,
330
908
  naicsCode: ltc.naics,
331
909
  naicsDescription: ltc.naics_description,
910
+ pscCode,
911
+ pscDescription,
912
+ parentIdv,
332
913
  };
914
+ } catch (e) {
915
+ // A classified upstream error (429/5xx) must propagate so the caller can
916
+ // retry and never mislabel it as not_found. A network/timeout/parse fault
917
+ // is likewise retryable — surface it, don't collapse it to a false null.
918
+ if (e instanceof ToolErrorCarrier) throw e;
919
+ throw new ToolErrorCarrier({
920
+ kind: "upstream_unavailable",
921
+ message: `usaspending awards/{id} fetch failed: ${e instanceof Error ? e.message : String(e)}`,
922
+ retryable: true,
923
+ upstreamEndpoint: `awards/${generatedInternalId}`,
924
+ });
925
+ }
926
+ }
927
+
928
+ // ─── Per-award transaction (modification) count ───────────────────
929
+
930
+ /**
931
+ * Bounded modification-count for a single award via the keyless
932
+ * `POST transactions/` endpoint (`{award_id, limit:100, page:1}`).
933
+ *
934
+ * WHY bounded, not paged: this endpoint's `page_metadata` carries only
935
+ * `hasNext` — there is NO grand total (LIVE-VERIFIED 2026-07-03). So we read
936
+ * ONE 100-row page and return its length. If `hasNext` is true the true count
937
+ * exceeds 100, so we return `{ count: <len>, atLeast: true }` — a LOWER BOUND,
938
+ * never an unbounded fan-out. `modification_number` on the latest transaction
939
+ * is an unreliable proxy (it was `undefined` on the test award) and is not
940
+ * used. Returns `null` count on failure so the caller degrades honestly.
941
+ */
942
+ async function transactionsCount(
943
+ generatedInternalId: string,
944
+ ): Promise<{ count: number | null; atLeast: boolean }> {
945
+ try {
946
+ type Resp = {
947
+ results?: unknown[];
948
+ page_metadata?: { hasNext?: boolean };
949
+ };
950
+ const json = await postUsas<Resp>("transactions/", {
951
+ award_id: generatedInternalId,
952
+ limit: 100,
953
+ page: 1,
954
+ });
955
+ const rows = json.results ?? [];
956
+ return { count: rows.length, atLeast: json.page_metadata?.hasNext === true };
333
957
  } catch {
334
- return null;
958
+ return { count: null, atLeast: false };
335
959
  }
336
960
  }
337
961
 
962
+ // ─── Per-award incumbent + public recompete-pressure analysis ─────
963
+
964
+ const ANALYZE_INCUMBENT_SOURCE =
965
+ "usaspending.gov awards/{id} + transactions + spending_by_award (keyless)";
966
+
967
+ /**
968
+ * The public fields that are decision-relevant for a recompete but are NOT
969
+ * in any keyless (or any public) source — declared in `_meta.fieldsUnavailable`
970
+ * so the AI hedges instead of inventing a vulnerability score.
971
+ */
972
+ const ANALYZE_FIELDS_UNAVAILABLE = [
973
+ "past_performance_cpars",
974
+ "protest_history",
975
+ "option_exercise_intent",
976
+ ];
977
+
978
+ /**
979
+ * Per-award incumbent + PUBLIC recompete-pressure analysis (design doc 04
980
+ * §5.2). Given ONE award (`generatedInternalId`) it assembles, from keyless
981
+ * data only:
982
+ * - the incumbent identity + the award's agency/NAICS/PSC/vehicle,
983
+ * - PUBLIC recompete-pressure SIGNALS (obligated-vs-ceiling consumption, mod
984
+ * count, competition extent + number of offers, set-aside, days to the
985
+ * current PoP end, and option-extendable days), and
986
+ * - (optionally) the incumbent's other awards in the same agency.
987
+ *
988
+ * DESIGN — bounded & keyless, NO N+1 fan-out:
989
+ * 1 `awards/{id}` detail + 1 `transactions/` page (mod count, capped at
990
+ * 100 → lower bound) + (optional) 1 `searchAwardsByRecipient` call. That is
991
+ * at most 3 upstream calls regardless of award size.
992
+ *
993
+ * HONEST CEILING (mandatory): it emits INDIVIDUAL public signals + `pressureHints`
994
+ * (e.g. "single_offer", "ceiling_nearly_exhausted", "hard_stop_no_options") that
995
+ * are HINTS, never a score. It NEVER emits a composite "vulnerability score" —
996
+ * the most decision-relevant input (past performance / CPARS), protest history,
997
+ * and the incumbent's option-exercise intent are not public, and are declared
998
+ * in `_meta.fieldsUnavailable`. A not-found award raises a structured not_found
999
+ * error (never `{ok:true, data:null}`).
1000
+ */
1001
+ export async function analyzeIncumbent(args: {
1002
+ generatedInternalId: string;
1003
+ includeOtherAwards?: boolean;
1004
+ otherAwardsLimit?: number;
1005
+ }) {
1006
+ const includeOtherAwards = args.includeOtherAwards ?? true;
1007
+ const otherAwardsLimit = Math.min(
1008
+ 50,
1009
+ Math.max(1, Math.floor(args.otherAwardsLimit ?? 15)),
1010
+ );
1011
+
1012
+ // --- 1. Award detail (throws not_found if the id doesn't resolve) ------
1013
+ const detail = await getAwardDetail(args.generatedInternalId);
1014
+ if (!detail) {
1015
+ throw new ToolErrorCarrier({
1016
+ kind: "not_found",
1017
+ message: `No award found for generatedInternalId '${args.generatedInternalId}' on usaspending.gov awards/{id}. Resolve a valid id via usas_search_individual_awards or usas_search_awards_by_recipient (each result carries a generatedInternalId).`,
1018
+ retryable: false,
1019
+ upstreamEndpoint: `awards/${args.generatedInternalId}`,
1020
+ });
1021
+ }
1022
+
1023
+ const nowMs = Date.now();
1024
+ let enrichmentCalls = 1; // the detail fetch
1025
+
1026
+ // --- 2. Bounded mod count (1 transactions page) -----------------------
1027
+ const mods = await transactionsCount(args.generatedInternalId);
1028
+ enrichmentCalls++;
1029
+
1030
+ // --- 3. Signals (all PUBLIC, individual — never combined into a score) -
1031
+ const obligated = detail.totalObligation;
1032
+ const ceiling = detail.baseAndAllOptions;
1033
+ // pctConsumed only when BOTH obligated is a number AND the ceiling is a usable
1034
+ // positive number; a null/absent obligated or a 0/absent/negative ceiling → null
1035
+ // (never a divide-by-zero, a null-coerced-to-0 ratio, or a nonsensical negative).
1036
+ const pctConsumed =
1037
+ typeof obligated === "number" && typeof ceiling === "number" && ceiling > 0
1038
+ ? obligated / ceiling
1039
+ : null;
1040
+
1041
+ const currentEndDate = detail.periodOfPerformance.endDate;
1042
+ const potentialEndDate = detail.periodOfPerformance.potentialEndDate;
1043
+ const daysUntilCurrentEnd = daysUntil(currentEndDate, nowMs);
1044
+ const daysUntilPotentialEnd = daysUntil(potentialEndDate, nowMs);
1045
+ // extendableDays = runway the unexercised options would add. Null when
1046
+ // either end date is unusable.
1047
+ const extendableDays =
1048
+ daysUntilPotentialEnd !== null && daysUntilCurrentEnd !== null
1049
+ ? daysUntilPotentialEnd - daysUntilCurrentEnd
1050
+ : null;
1051
+
1052
+ const numberOfOffers = detail.numberOfOffers; // already number|null
1053
+
1054
+ const signals = {
1055
+ obligatedVsCeiling: {
1056
+ obligated,
1057
+ baseAndAllOptions: ceiling,
1058
+ baseExercisedOptions: detail.baseExercisedOptions,
1059
+ pctConsumed,
1060
+ },
1061
+ modCount: mods.count,
1062
+ modCountAtLeast: mods.atLeast,
1063
+ setAside: detail.setAsideType ?? null,
1064
+ setAsideDescription: detail.setAsideDescription ?? null,
1065
+ extentCompeted: detail.competitionExtent ?? null,
1066
+ extentCompetedDescription: detail.competitionExtentDescription ?? null,
1067
+ numberOfOffers,
1068
+ currentEndDate,
1069
+ potentialEndDate,
1070
+ extendableDays,
1071
+ daysUntilCurrentEnd,
1072
+ vehicle: {
1073
+ contractAwardType: detail.contractAwardType,
1074
+ parentIdvPiid: detail.parentIdv?.piid ?? null,
1075
+ idvType: detail.parentIdv?.idvTypeDescription ?? null,
1076
+ singleOrMultiple:
1077
+ detail.parentIdv?.multipleOrSingleAwardDescription ?? null,
1078
+ },
1079
+ };
1080
+
1081
+ // --- pressureHints: individual PUBLIC flags — HINTS, never a score -----
1082
+ const pressureHints: string[] = [];
1083
+ if (numberOfOffers === 1) pressureHints.push("single_offer");
1084
+ if (pctConsumed !== null && pctConsumed >= 0.9)
1085
+ pressureHints.push("ceiling_nearly_exhausted");
1086
+ if (extendableDays !== null && extendableDays <= 0)
1087
+ pressureHints.push("hard_stop_no_options");
1088
+
1089
+ // --- 4. Incumbent's other awards in the same agency (1 bounded call) ---
1090
+ let incumbentOtherAwards:
1091
+ | Awaited<ReturnType<typeof searchAwardsByRecipient>>["data"]["awards"]
1092
+ | undefined;
1093
+ let otherAwardsFailed = false;
1094
+ if (includeOtherAwards && detail.recipient) {
1095
+ enrichmentCalls++; // count the attempt (whether or not it succeeds)
1096
+ try {
1097
+ const other = await searchAwardsByRecipient({
1098
+ recipientName: detail.recipient,
1099
+ agency: detail.awardingAgency,
1100
+ naics: detail.naicsCode,
1101
+ limit: otherAwardsLimit,
1102
+ });
1103
+ // Drop the award we're analyzing from its own "other awards" list.
1104
+ incumbentOtherAwards = other.data.awards.filter(
1105
+ (a) => a.generatedInternalId !== args.generatedInternalId,
1106
+ );
1107
+ } catch {
1108
+ // Non-fatal, but MUST be disclosed: an empty list here means "the search
1109
+ // failed", NOT "the incumbent has no other awards". (D1)
1110
+ incumbentOtherAwards = [];
1111
+ otherAwardsFailed = true;
1112
+ }
1113
+ }
1114
+
1115
+ // The award record carries no recipient_name → the incumbent identity is
1116
+ // UNKNOWN. Two masquerades to avoid: (1) `incumbent: ""` reads as "none"
1117
+ // rather than "unknown"; (2) with includeOtherAwards, the recipient search is
1118
+ // SKIPPED (the `detail.recipient` guard above is falsy) so `incumbentOtherAwards`
1119
+ // stays undefined → `?? []` emits an empty list that reads as "no other awards"
1120
+ // when the search never ran. Same class as the D1 otherAwardsFailed disclosure.
1121
+ const incumbentUnknown = !detail.recipient;
1122
+
1123
+ const data = {
1124
+ award: {
1125
+ awardId: detail.awardId,
1126
+ incumbent: detail.recipient || null,
1127
+ awardingAgency: detail.awardingAgency ?? null,
1128
+ awardingSubAgency: detail.awardingSubAgency ?? null,
1129
+ naicsCode: detail.naicsCode ?? null,
1130
+ pscCode: detail.pscCode ?? null,
1131
+ contractAwardType: detail.contractAwardType,
1132
+ startDate: detail.periodOfPerformance.startDate,
1133
+ currentEndDate,
1134
+ potentialEndDate,
1135
+ },
1136
+ signals,
1137
+ pressureHints,
1138
+ ...(includeOtherAwards ? { incumbentOtherAwards: incumbentOtherAwards ?? [] } : {}),
1139
+ };
1140
+
1141
+ // --- Truthful _meta ---------------------------------------------------
1142
+ // The offers value being null means single_offer could not be evaluated —
1143
+ // declare number_of_offers_received unavailable so the AI knows.
1144
+ const fieldsUnavailable = [...ANALYZE_FIELDS_UNAVAILABLE];
1145
+ if (numberOfOffers === null) {
1146
+ fieldsUnavailable.push("number_of_offers_received");
1147
+ }
1148
+ if (incumbentUnknown) {
1149
+ fieldsUnavailable.push("recipient_name");
1150
+ }
1151
+ // Null money fields are UNKNOWN, not $0 — disclose so an AI never cites a
1152
+ // fabricated zero (the values themselves are now null in obligatedVsCeiling).
1153
+ if (obligated === null) fieldsUnavailable.push("total_obligation");
1154
+ if (ceiling === null) fieldsUnavailable.push("base_and_all_options");
1155
+
1156
+ // List ONLY the calls actually attempted — never assert a recipient search
1157
+ // that failed or was skipped (D1). enrichmentCalls stays in lockstep with
1158
+ // this list: detail + transactions are always attempted; the recipient call
1159
+ // is attempted iff includeOtherAwards && a recipient name exists.
1160
+ const callList = ["awards/{id} detail", "1 transactions page"];
1161
+ if (includeOtherAwards && detail.recipient) callList.push("1 recipient search");
1162
+ const transactionsFailed = mods.count === null;
1163
+
1164
+ const notes: string[] = [
1165
+ "HONEST CEILING: PUBLIC signals only; no composite vulnerability score. Past-performance/CPARS ratings, protest history, and the incumbent's option-exercise intent are NOT public — judge the recompete with off-platform intelligence.",
1166
+ `Bounded keyless design: ${enrichmentCalls} upstream call(s) (${callList.join(" + ")}); no per-record fan-out.`,
1167
+ ];
1168
+ if (mods.atLeast) {
1169
+ notes.push(
1170
+ `modCount is a LOWER BOUND: this award has more than 100 transactions (the transactions endpoint reports no total, so only one 100-row page is read). modCountAtLeast is true.`,
1171
+ );
1172
+ }
1173
+ if (transactionsFailed) {
1174
+ notes.push(
1175
+ "modCount is null because the transactions call FAILED (not because the award has no modifications) — the modification count is unknown, not zero.",
1176
+ );
1177
+ }
1178
+ if (otherAwardsFailed) {
1179
+ notes.push(
1180
+ "incumbentOtherAwards could not be retrieved (the recipient search FAILED) and is shown as an EMPTY list — this is NOT a confirmation that the incumbent has no other awards.",
1181
+ );
1182
+ }
1183
+ if (incumbentUnknown) {
1184
+ notes.push(
1185
+ "The award record carries no recipient_name — the incumbent identity is UNKNOWN (returned as null), NOT 'none'. Incumbent-specific analysis (identity, other awards) cannot be performed on this record.",
1186
+ );
1187
+ if (includeOtherAwards) {
1188
+ notes.push(
1189
+ "incumbentOtherAwards is an EMPTY list because there is no recipient name to search by — the recipient search was SKIPPED, not run and found empty. This is NOT a confirmation that the incumbent has no other awards.",
1190
+ );
1191
+ }
1192
+ }
1193
+ if (pctConsumed === null) {
1194
+ notes.push(
1195
+ "obligatedVsCeiling.pctConsumed is null because the obligated amount or the award's ceiling (base_and_all_options) is absent (null), zero, or a negative data-entry value — consumption cannot be computed.",
1196
+ );
1197
+ }
1198
+ if (numberOfOffers === null) {
1199
+ notes.push(
1200
+ "number_of_offers_received is null on this award, so the 'single_offer' hint could not be evaluated (absence of the hint does NOT imply competition).",
1201
+ );
1202
+ }
1203
+
1204
+ // A failed secondary enrichment means this is NOT the complete picture →
1205
+ // force complete:false so an AI never reads partial data as complete (D1/D2).
1206
+ // A blank recipient with includeOtherAwards is the same class: the emitted
1207
+ // empty incumbentOtherAwards would otherwise read as complete.
1208
+ const degraded =
1209
+ transactionsFailed ||
1210
+ otherAwardsFailed ||
1211
+ (includeOtherAwards && incumbentUnknown);
1212
+
1213
+ return withMeta(data, {
1214
+ source: ANALYZE_INCUMBENT_SOURCE,
1215
+ keylessMode: true,
1216
+ complete: degraded ? false : undefined,
1217
+ returned: 1,
1218
+ totalAvailable: 1,
1219
+ truncated: false,
1220
+ filtersApplied: [],
1221
+ filtersDropped: [],
1222
+ fieldsUnavailable,
1223
+ enrichedCount: enrichmentCalls,
1224
+ notes,
1225
+ });
1226
+ }
1227
+
338
1228
  // ─── Recompete radar ──────────────────────────────────────────────
339
1229
 
1230
+ /**
1231
+ * Set-aside → USAspending `set_aside_type_codes` filter code. The
1232
+ * `spending_by_award` endpoint DOES honor `set_aside_type_codes` server-side
1233
+ * (LIVE-VERIFIED 2026-07-03: VA×541512 base 696 → SDVOSBC 182, SBA 19, WOSB 1
1234
+ * — genuine reductions; the wrong keys `type_set_aside`/`set_aside` are
1235
+ * silently IGNORED, returning the unfiltered 696). Set-aside is a FILTER only,
1236
+ * never a requestable output field (verified: it comes back absent). So we
1237
+ * filter by it but cannot read a per-row set-aside VALUE from search — that
1238
+ * lives in usas_get_award_detail.
1239
+ */
1240
+ const SET_ASIDE_CODES = new Set([
1241
+ "SBA",
1242
+ "8A",
1243
+ "HZS",
1244
+ "SDVOSBC",
1245
+ "WOSB",
1246
+ "EDWOSB",
1247
+ "VSA",
1248
+ "VSS",
1249
+ ]);
1250
+
1251
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
1252
+
1253
+ /**
1254
+ * A `spending_by_award` row as returned when we request the recompete field
1255
+ * set. Every value is optional/nullable — USAspending echoes unknown fields
1256
+ * back as `null`, and PoP end dates are legitimately null on some rows.
1257
+ */
1258
+ type RecompeteRow = {
1259
+ "Award ID"?: string | null;
1260
+ "Recipient Name"?: string | null;
1261
+ "Award Amount"?: number | null;
1262
+ "Awarding Agency"?: string | null;
1263
+ "Awarding Sub Agency"?: string | null;
1264
+ "Start Date"?: string | null;
1265
+ "End Date"?: string | null;
1266
+ NAICS?: { code?: string; description?: string } | null;
1267
+ PSC?: { code?: string; description?: string } | null;
1268
+ "Contract Award Type"?: string | null;
1269
+ "Last Modified Date"?: string | null;
1270
+ "Period of Performance Potential End Date"?: string | null;
1271
+ generated_internal_id?: string | null;
1272
+ };
1273
+
1274
+ /**
1275
+ * Parse a PoP end date to "whole days from today" (UTC midnight). Returns
1276
+ * `null` for null/empty/unparseable/absurd values so the caller can COUNT the
1277
+ * row (never silently drop it) and treat it as out-of-window. Guards against
1278
+ * the far-future data-entry errors USAspending carries (e.g. year 2108).
1279
+ */
1280
+ function daysUntil(dateStr: string | null | undefined, nowMs: number): number | null {
1281
+ if (!dateStr) return null;
1282
+ const t = new Date(dateStr).getTime();
1283
+ if (Number.isNaN(t)) return null;
1284
+ const year = new Date(t).getUTCFullYear();
1285
+ // Sanity clamp: PoP end dates outside [1990, 2200] are data errors.
1286
+ if (year < 1990 || year > 2200) return null;
1287
+ return Math.ceil((t - nowMs) / MS_PER_DAY);
1288
+ }
1289
+
1290
+ /** UTC "today minus N years" as YYYY-MM-DD, for the action_date lower bound. */
1291
+ function isoYearsAgo(nowMs: number, years: number): string {
1292
+ const d = new Date(nowMs);
1293
+ d.setUTCFullYear(d.getUTCFullYear() - years);
1294
+ return d.toISOString().slice(0, 10);
1295
+ }
1296
+
1297
+ const RECOMPETE_SOURCE =
1298
+ "usaspending.gov spending_by_award (keyless)";
1299
+
1300
+ const RECOMPETE_FIELDS_UNAVAILABLE = [
1301
+ "past_performance_cpars",
1302
+ "protest_history",
1303
+ "option_exercise_intent",
1304
+ ];
1305
+
1306
+ /**
1307
+ * Recompete radar — federal contracts whose current period of performance
1308
+ * ends inside a window around today, so you can see what's coming up for
1309
+ * recompete. Replaces the broken `searchExpiringContracts` internals.
1310
+ *
1311
+ * MECHANISM (LIVE-VERIFIED 2026-07-03 across VA×541512 and DoD×541330):
1312
+ * `spending_by_award` returns the current PoP end date directly under the
1313
+ * field ALIAS `"End Date"` (the canonical string
1314
+ * "Period of Performance Current End Date" is NOT a recognized field — it
1315
+ * comes back always null, and is not in the sort mappings → HTTP 400 if you
1316
+ * sort by it). Gold-standard confirmed: search `"End Date"` ===
1317
+ * `awards/{generated_internal_id}`.period_of_performance.end_date.
1318
+ *
1319
+ * We CANNOT filter by PoP end date server-side (`time_period.date_type` only
1320
+ * supports action_date/date_signed/last_modified_date/new_awards_only). So:
1321
+ * 1. server-side SORT by `"End Date"` DESC (the alias — the only PoP-end
1322
+ * value in the sort mappings),
1323
+ * 2. an action_date `time_period` lower bound (LOAD-BEARING: prunes inactive
1324
+ * records and much of the far-future data-entry garbage so DESC reaches
1325
+ * the window sooner),
1326
+ * 3. a CLIENT-SIDE window filter with pagination + a safe early-stop (DESC ⇒
1327
+ * once a row is earlier than the window start, every later row is earlier
1328
+ * too), bounded by `scanBudgetPages`.
1329
+ *
1330
+ * TRUTHFULNESS: rows with a null `"End Date"` are COUNTED (`missingEndDate`),
1331
+ * never silently dropped. If the scan budget is exhausted before the early-stop
1332
+ * fires, `scanTruncated` is set and `totalAvailable` becomes null (the returned
1333
+ * set is a lower bound, not the complete window). This tool emits PUBLIC
1334
+ * signals only — it never fabricates a composite "vulnerability" score;
1335
+ * past-performance/CPARS, protest history, and option-exercise intent are not
1336
+ * public and are declared in `_meta.fieldsUnavailable`.
1337
+ */
1338
+ export async function searchRecompetes(args: {
1339
+ agency?: string;
1340
+ naics?: string;
1341
+ pscCodes?: string[];
1342
+ setAside?: string;
1343
+ windowStartDays?: number;
1344
+ windowEndDays?: number;
1345
+ minAwardValue?: number;
1346
+ includePotentialEnd?: boolean;
1347
+ actionDateLookbackYears?: number;
1348
+ page?: number;
1349
+ pageSize?: number;
1350
+ scanBudgetPages?: number;
1351
+ }) {
1352
+ const nowMs = Date.now();
1353
+ const windowStartDays = args.windowStartDays ?? -90;
1354
+ const windowEndDays = args.windowEndDays ?? 548; // ~18 months
1355
+ const minAwardValue = args.minAwardValue ?? 0;
1356
+ const includePotentialEnd = args.includePotentialEnd ?? false;
1357
+ const actionDateLookbackYears = args.actionDateLookbackYears ?? 3;
1358
+ const page = Math.max(1, Math.floor(args.page ?? 1));
1359
+ const pageSize = Math.min(100, Math.max(1, Math.floor(args.pageSize ?? 25)));
1360
+ const scanBudgetPages = Math.min(20, Math.max(1, Math.floor(args.scanBudgetPages ?? 8)));
1361
+
1362
+ // --- Build filters (only what we can send truthfully) -----------------
1363
+ const filters: UsasFilters = { award_type_codes: ["A", "B", "C", "D"] };
1364
+ const filtersApplied: string[] = ["awardType(contracts A/B/C/D)"];
1365
+ const filtersDropped: string[] = [];
1366
+ if (args.agency) {
1367
+ filters.agencies = [{ type: "awarding", tier: "toptier", name: args.agency }];
1368
+ filtersApplied.push("agency");
1369
+ }
1370
+ if (args.naics) {
1371
+ filters.naics_codes = [args.naics];
1372
+ filtersApplied.push("naics");
1373
+ }
1374
+ if (args.pscCodes?.length) {
1375
+ filters.psc_codes = args.pscCodes;
1376
+ filtersApplied.push("pscCodes");
1377
+ }
1378
+ // Set-aside: `set_aside_type_codes` is honored server-side (verified). Only
1379
+ // send a code we know the endpoint recognizes; otherwise record it dropped.
1380
+ if (args.setAside) {
1381
+ if (SET_ASIDE_CODES.has(args.setAside)) {
1382
+ filters.set_aside_type_codes = [args.setAside];
1383
+ filtersApplied.push("setAside");
1384
+ } else {
1385
+ filtersDropped.push("setAside");
1386
+ }
1387
+ }
1388
+ // action_date lower bound — the default date_type is action_date, so no
1389
+ // explicit date_type is needed (and passing one is optional).
1390
+ const lookbackStart = isoYearsAgo(nowMs, actionDateLookbackYears);
1391
+ const todayIso = new Date(nowMs).toISOString().slice(0, 10);
1392
+ filters.time_period = [{ start_date: lookbackStart, end_date: todayIso }];
1393
+ filtersApplied.push(`actionDateLookback(${actionDateLookbackYears}y)`);
1394
+
1395
+ const fields = [
1396
+ "Award ID",
1397
+ "Recipient Name",
1398
+ "Award Amount",
1399
+ "Awarding Agency",
1400
+ "Awarding Sub Agency",
1401
+ "Start Date",
1402
+ "End Date",
1403
+ "NAICS",
1404
+ "PSC",
1405
+ "Contract Award Type",
1406
+ "Last Modified Date",
1407
+ "generated_internal_id",
1408
+ ];
1409
+ if (includePotentialEnd) {
1410
+ fields.push("Period of Performance Potential End Date");
1411
+ }
1412
+
1413
+ type SearchResp = {
1414
+ results?: RecompeteRow[];
1415
+ page_metadata?: { hasNext?: boolean; page?: number };
1416
+ };
1417
+
1418
+ // --- Scan pages (DESC by End Date) with early-stop + budget ----------
1419
+ type Shaped = {
1420
+ awardId: string;
1421
+ generatedInternalId: string;
1422
+ incumbent: string;
1423
+ amount: number;
1424
+ currentEndDate: string;
1425
+ daysUntilCurrentEnd: number;
1426
+ potentialEndDate?: string | null;
1427
+ extendableDays?: number | null;
1428
+ awardingAgency: string;
1429
+ awardingSubAgency: string | null;
1430
+ naicsCode: string | null;
1431
+ pscCode: string | null;
1432
+ contractAwardType: string | null;
1433
+ setAsideDescription: string | null;
1434
+ startDate: string | null;
1435
+ description: string | null;
1436
+ };
1437
+
1438
+ const results: Shaped[] = [];
1439
+ let scanned = 0;
1440
+ let missingEndDate = 0;
1441
+ let pastWindow = false;
1442
+ let scanTruncated = false;
1443
+
1444
+ for (let p = 1; p <= scanBudgetPages; p++) {
1445
+ const resp = await postUsas<SearchResp>("search/spending_by_award", {
1446
+ filters,
1447
+ fields,
1448
+ sort: "End Date",
1449
+ order: "desc",
1450
+ limit: 100,
1451
+ page: p,
1452
+ subawards: false,
1453
+ });
1454
+ const rows = resp.results ?? [];
1455
+ for (const row of rows) {
1456
+ scanned++;
1457
+ const end = row["End Date"] ?? null;
1458
+ const d = daysUntil(end, nowMs);
1459
+ if (d === null) {
1460
+ // Null/unparseable/absurd end date — COUNT it, never silently drop.
1461
+ missingEndDate++;
1462
+ continue;
1463
+ }
1464
+ if (d > windowEndDays) continue; // far future (incl. data errors) → skip
1465
+ if (d < windowStartDays) {
1466
+ // DESC ⇒ everything after this row is earlier ⇒ safe to stop.
1467
+ pastWindow = true;
1468
+ break;
1469
+ }
1470
+ const amount = row["Award Amount"] ?? 0;
1471
+ if (amount < minAwardValue) continue;
1472
+ const potentialEnd = includePotentialEnd
1473
+ ? row["Period of Performance Potential End Date"] ?? null
1474
+ : undefined;
1475
+ let extendableDays: number | null | undefined;
1476
+ if (includePotentialEnd) {
1477
+ const pd = daysUntil(potentialEnd ?? null, nowMs);
1478
+ extendableDays = pd === null ? null : pd - d;
1479
+ }
1480
+ results.push({
1481
+ awardId: row["Award ID"] ?? "",
1482
+ generatedInternalId: row.generated_internal_id ?? "",
1483
+ incumbent: row["Recipient Name"] ?? "",
1484
+ amount,
1485
+ currentEndDate: end as string,
1486
+ daysUntilCurrentEnd: d,
1487
+ ...(includePotentialEnd
1488
+ ? { potentialEndDate: potentialEnd ?? null, extendableDays }
1489
+ : {}),
1490
+ awardingAgency: row["Awarding Agency"] ?? "",
1491
+ awardingSubAgency: row["Awarding Sub Agency"] ?? null,
1492
+ naicsCode: row.NAICS?.code ?? null,
1493
+ pscCode: row.PSC?.code ?? null,
1494
+ contractAwardType: row["Contract Award Type"] ?? null,
1495
+ // Set-aside VALUE is not a search output field (filter-only) → null
1496
+ // here; the caller reads it per-award via usas_get_award_detail.
1497
+ setAsideDescription: null,
1498
+ startDate: row["Start Date"] ?? null,
1499
+ description: null,
1500
+ });
1501
+ }
1502
+ if (pastWindow) break;
1503
+ if (!resp.page_metadata?.hasNext) break;
1504
+ if (p === scanBudgetPages && !pastWindow) scanTruncated = true;
1505
+ }
1506
+
1507
+ // Deterministic order: current end date ascending (soonest recompete first),
1508
+ // tiebreak by descending amount then awardId so paging is stable.
1509
+ results.sort((a, b) => {
1510
+ if (a.daysUntilCurrentEnd !== b.daysUntilCurrentEnd)
1511
+ return a.daysUntilCurrentEnd - b.daysUntilCurrentEnd;
1512
+ if (b.amount !== a.amount) return b.amount - a.amount;
1513
+ return a.awardId.localeCompare(b.awardId);
1514
+ });
1515
+
1516
+ const totalInWindow = results.length; // EXACT iff not scanTruncated
1517
+ const startIdx = (page - 1) * pageSize;
1518
+ const pageSlice = results.slice(startIdx, startIdx + pageSize);
1519
+
1520
+ // --- Truthful _meta ---------------------------------------------------
1521
+ // totalAvailable is a REAL count only when we scanned the whole window
1522
+ // (early-stop fired). If the scan budget truncated, it is unknown → null,
1523
+ // and the returned set is a lower bound.
1524
+ const totalAvailable = scanTruncated ? null : totalInWindow;
1525
+ const nextOffset = startIdx + pageSize;
1526
+ const hasMore = scanTruncated
1527
+ ? true // more may exist beyond the scanned pages
1528
+ : nextOffset < totalInWindow;
1529
+ const truncated = hasMore || scanTruncated;
1530
+
1531
+ const notes: string[] = [
1532
+ `Completeness boundary: only contracts with a recorded action in the last ${actionDateLookbackYears} year(s) are included (an action_date lower bound is required to make the End-Date sort reach the window; contracts with no action in that span are not returned).`,
1533
+ "Recompete window is applied client-side on the current period-of-performance END date; the API cannot filter by PoP end date server-side, so results are sorted by End Date (desc) and windowed here.",
1534
+ "HONEST CEILING: this tool emits PUBLIC signals only. Past-performance/CPARS ratings, protest history, and the incumbent's option-exercise intent are NOT public — it never emits a composite 'recompete vulnerability' score. Judge each row with off-platform intelligence.",
1535
+ ];
1536
+ if (missingEndDate > 0) {
1537
+ notes.push(
1538
+ `${missingEndDate} scanned award(s) had no usable current PoP end date and were counted but excluded from the window (never silently dropped).`,
1539
+ );
1540
+ }
1541
+ if (scanTruncated) {
1542
+ notes.push(
1543
+ `Scan budget of ${scanBudgetPages} page(s) (${scanned} awards) was exhausted before reaching the end of the window, so totalAvailable is unknown (null) and the returned recompetes are a LOWER BOUND. This agency×NAICS slice has a very large tail of long-duration/far-future contracts — narrow it (add pscCodes, a higher minAwardValue, a set-aside, or a tighter agency/sub-agency) or raise scanBudgetPages to get an exact window count.`,
1544
+ );
1545
+ }
1546
+ if (filtersDropped.includes("setAside")) {
1547
+ notes.push(
1548
+ `The requested set-aside code is not a recognized USAspending set_aside_type_code and was NOT applied (results are unfiltered on set-aside). Valid codes: ${[...SET_ASIDE_CODES].join(", ")}.`,
1549
+ );
1550
+ }
1551
+
1552
+ const data = {
1553
+ recompetes: pageSlice,
1554
+ page,
1555
+ pageSize,
1556
+ };
1557
+
1558
+ return withMeta(data, {
1559
+ source: RECOMPETE_SOURCE,
1560
+ keylessMode: true,
1561
+ returned: pageSlice.length,
1562
+ totalAvailable,
1563
+ truncated,
1564
+ pagination: {
1565
+ offset: startIdx,
1566
+ limit: pageSize,
1567
+ nextOffset: hasMore ? nextOffset : null,
1568
+ hasMore,
1569
+ },
1570
+ filtersApplied,
1571
+ filtersDropped,
1572
+ fieldsUnavailable: [
1573
+ ...RECOMPETE_FIELDS_UNAVAILABLE,
1574
+ "setAsideDescription(search-omits; use usas_get_award_detail)",
1575
+ ],
1576
+ notes,
1577
+ });
1578
+ }
1579
+
1580
+ /**
1581
+ * DEPRECATED alias — kept working so existing callers of
1582
+ * `usas_search_expiring_contracts` don't break. Maps the old params onto
1583
+ * `searchRecompetes` and re-shapes the output to the legacy `{ contracts,
1584
+ * searchedCount }` keys the smoke/edge tests assert on. Prefer
1585
+ * `usas_search_recompetes`.
1586
+ */
340
1587
  export async function searchExpiringContracts(args: {
341
1588
  agency?: string;
342
1589
  naics?: string;
343
- fiscalYear?: number;
1590
+ // M2 (W3-1 honesty): `fiscalYear` is deliberately ABSENT. The recompete radar
1591
+ // windows on the current PoP end date around TODAY, not an obligation FY, so a
1592
+ // fiscalYear was inapplicable — it was validated by the input schema then
1593
+ // silently discarded (never forwarded to searchRecompetes), with empty
1594
+ // filtersDropped, i.e. zero signal to the caller. Removed from BOTH this arg
1595
+ // type and UsasExpiringInput (server.ts) so it can no longer be
1596
+ // validated-then-dropped. Use usas_search_recompetes' window controls instead.
344
1597
  monthsUntilExpiry?: number;
345
1598
  minAwardValue?: number;
346
1599
  limit?: number;
347
1600
  }) {
348
- const filters = buildFilters(args);
349
- type SearchResp = {
350
- results?: {
351
- "Award ID"?: string;
352
- "Recipient Name"?: string;
353
- "Award Amount"?: number;
354
- generated_internal_id?: string;
355
- }[];
356
- };
357
- const search = await postUsas<SearchResp>("search/spending_by_award", {
358
- filters,
359
- fields: ["Award ID", "Recipient Name", "Award Amount"],
360
- limit: 50,
1601
+ const windowEndDays = Math.round((args.monthsUntilExpiry ?? 12) * 30.44);
1602
+ const pageSize = args.limit ?? 10;
1603
+ const bundle = await searchRecompetes({
1604
+ agency: args.agency,
1605
+ naics: args.naics,
1606
+ windowStartDays: -30, // legacy tool dropped rows expired > 30d ago
1607
+ windowEndDays,
1608
+ minAwardValue: args.minAwardValue ?? 100_000,
1609
+ pageSize,
361
1610
  page: 1,
362
- subawards: false,
363
- sort: "Award Amount",
364
- order: "desc",
365
1611
  });
366
1612
 
367
- const candidates = (search.results ?? []).filter(
368
- (r) =>
369
- (r["Award Amount"] ?? 0) >= (args.minAwardValue ?? 100_000) &&
370
- r.generated_internal_id,
371
- );
372
-
373
- // Enrich up to 8 in parallel — be polite to USAspending.
374
- const enrich = candidates.slice(0, 8);
375
- const details = await Promise.all(
376
- enrich.map((r) => getAwardDetail(r.generated_internal_id!)),
377
- );
1613
+ // Re-shape to the legacy contract row + keep the truthful _meta, appending a
1614
+ // deprecation note.
1615
+ type Recompete = (typeof bundle.data.recompetes)[number];
1616
+ const contracts = bundle.data.recompetes.map((r: Recompete) => ({
1617
+ awardId: r.awardId,
1618
+ recipient: r.incumbent,
1619
+ amount: r.amount,
1620
+ endDate: r.currentEndDate,
1621
+ potentialEndDate: r.potentialEndDate ?? null,
1622
+ awardingAgency: r.awardingAgency,
1623
+ awardingSubAgency: r.awardingSubAgency ?? undefined,
1624
+ naicsCode: r.naicsCode ?? undefined,
1625
+ setAsideDescription: r.setAsideDescription ?? undefined,
1626
+ description: r.description ?? undefined,
1627
+ daysUntilExpiry: r.daysUntilCurrentEnd,
1628
+ generatedInternalId: r.generatedInternalId,
1629
+ }));
378
1630
 
379
- const now = Date.now();
380
- const cutoffDays = (args.monthsUntilExpiry ?? 12) * 30;
381
- const contracts = details
382
- .map((d, idx) => {
383
- if (!d || !d.periodOfPerformance.endDate) return null;
384
- const end = new Date(d.periodOfPerformance.endDate).getTime();
385
- if (Number.isNaN(end)) return null;
386
- const days = Math.ceil((end - now) / (24 * 60 * 60 * 1000));
387
- if (days < -30 || days > cutoffDays) return null;
388
- const orig = enrich[idx] ?? {};
389
- return {
390
- awardId: d.awardId || orig["Award ID"] || "",
391
- recipient: d.recipient || orig["Recipient Name"] || "",
392
- amount: d.totalObligation || orig["Award Amount"] || 0,
393
- endDate: d.periodOfPerformance.endDate,
394
- potentialEndDate: d.periodOfPerformance.potentialEndDate,
395
- awardingAgency: d.awardingAgency ?? "",
396
- awardingSubAgency: d.awardingSubAgency,
397
- naicsCode: d.naicsCode,
398
- setAsideDescription: d.setAsideDescription,
399
- description: d.description,
400
- daysUntilExpiry: days,
401
- };
402
- })
403
- .filter((x): x is NonNullable<typeof x> => x !== null)
404
- .slice(0, args.limit ?? 10)
405
- .sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
1631
+ const data = {
1632
+ contracts,
1633
+ // Legacy field: previously the count of value-filtered candidates. Now the
1634
+ // number of in-window recompetes returned on this page (honest, non-zero
1635
+ // where data exists).
1636
+ searchedCount: contracts.length,
1637
+ };
406
1638
 
407
- return { contracts, searchedCount: candidates.length };
1639
+ const meta: Partial<ResponseMeta> = {
1640
+ ...bundle.meta,
1641
+ notes: [
1642
+ "deprecated: use usas_search_recompetes — this alias re-shapes the corrected recompete-radar output onto the legacy { contracts, searchedCount } keys.",
1643
+ ...(bundle.meta.notes ?? []),
1644
+ ],
1645
+ };
1646
+ return withMeta(data, meta);
408
1647
  }
409
1648
 
410
1649
  // ─── Aggregate analysis: time series ──────────────────────────────
@@ -426,20 +1665,89 @@ export async function spendingOverTime(args: {
426
1665
  Idv_Obligations?: number;
427
1666
  }[];
428
1667
  };
1668
+ const group = args.group ?? "fiscal_year";
429
1669
  const json = await postUsas<Resp>("search/spending_over_time/", {
430
- group: args.group ?? "fiscal_year",
1670
+ group,
431
1671
  filters,
432
1672
  });
433
- return {
434
- group: json.group,
435
- timeline: (json.results ?? []).map((r) => ({
436
- timePeriod: r.time_period ?? {},
437
- total: r.aggregated_amount ?? 0,
438
- contractObligations: r.Contract_Obligations ?? 0,
439
- grantObligations: r.Grant_Obligations ?? 0,
440
- idvObligations: r.Idv_Obligations ?? 0,
441
- })),
1673
+ const results = json.results ?? [];
1674
+
1675
+ const timeline = results.map((r) => ({
1676
+ timePeriod: r.time_period ?? {},
1677
+ // `total` and `contractObligations` are IDENTICAL: buildFilters restricts to
1678
+ // contract award types A/B/C/D, so aggregated_amount IS the contract
1679
+ // obligation (LIVE-VERIFIED: aggregated_amount === Contract_Obligations for
1680
+ // every bucket). The `?? r.aggregated_amount` fallback keeps that identity true
1681
+ // ONLY while buildFilters enforces contracts-only — if this tool ever sends a
1682
+ // wider award-type filter, revisit (aggregated_amount would be a MIXED total,
1683
+ // not the contract figure). `total`'s `?? 0` is a genuine zero (endpoint silent
1684
+ // on a bucket ⇒ no contract spend that period), NOT a masked-out category.
1685
+ total: r.aggregated_amount ?? 0,
1686
+ contractObligations: r.Contract_Obligations ?? r.aggregated_amount ?? 0,
1687
+ // Grant/IDV obligations are EXCLUDED by that contract-only filter — they are
1688
+ // NOT zero. The endpoint returns 0 for them here purely because A/B/C/D omits
1689
+ // grants (award types 02–05) and IDVs (IDV_*); an agency's real grant/IDV
1690
+ // spend can be billions per period (LIVE: DoD grants ~$4.8B in FY2008). Emit
1691
+ // null, never a fabricated 0 that reads as "no grant/IDV spending" (DA-1 class).
1692
+ grantObligations: null as number | null,
1693
+ idvObligations: null as number | null,
1694
+ }));
1695
+
1696
+ // spending_over_time returns the FULL timeline for the filter (no cursor/total),
1697
+ // so what we return IS the complete set. Read the span for disclosure. Format
1698
+ // each period label unambiguously per grouping (FY2024 / FY2024-Q1 / FY2024-M10)
1699
+ // so the span note can't be misread ("2024 1" as month-vs-quarter).
1700
+ const fmtPeriod = (
1701
+ p: { fiscal_year?: string; quarter?: string; month?: string } | undefined,
1702
+ ): string | null => {
1703
+ if (!p) return null;
1704
+ const fy = p.fiscal_year ? `FY${p.fiscal_year}` : null;
1705
+ if (p.quarter) return fy ? `${fy}-Q${p.quarter}` : `Q${p.quarter}`;
1706
+ if (p.month) return fy ? `${fy}-M${p.month}` : `M${p.month}`;
1707
+ return fy;
442
1708
  };
1709
+ const spanStart = fmtPeriod(timeline[0]?.timePeriod);
1710
+ const spanEnd = fmtPeriod(timeline[timeline.length - 1]?.timePeriod);
1711
+
1712
+ const filtersApplied = ["awardType(contracts A/B/C/D)"];
1713
+ if (args.agency) filtersApplied.push("agency");
1714
+ if (args.naics) filtersApplied.push("naics");
1715
+ if (args.setAside) filtersApplied.push("setAside");
1716
+
1717
+ const notes: string[] = [
1718
+ "This timeline counts CONTRACT obligations only (award types A/B/C/D), matching the other usas_search_*_spending tools — so `total` equals `contractObligations`.",
1719
+ "Grant, IDV, loan, direct, and other obligation types are EXCLUDED by that filter. grantObligations/idvObligations are null (NOT 0) so an agency's real grant/IDV spending — which can run billions per period — is never misread as zero here.",
1720
+ spanStart && spanEnd
1721
+ ? `The timeline spans the full range the endpoint returned for this filter (${spanStart} … ${spanEnd}); a period showing 0 is a genuine zero for CONTRACT obligations in that period.`
1722
+ : "A period showing 0 is a genuine zero for CONTRACT obligations in that period.",
1723
+ ];
1724
+ // Completeness caveat: no-cap is LIVE-VERIFIED only for fiscal_year (19 buckets,
1725
+ // FY2008–2026). spending_over_time carries NO pagination envelope, so a very long
1726
+ // month/quarter series could hit a silent server cap we could not detect — disclose
1727
+ // that rather than assert a completeness we can't prove for those granularities.
1728
+ if (group !== "fiscal_year") {
1729
+ notes.push(
1730
+ `Completeness for group='${group}': this endpoint returns no pagination envelope, and no-truncation is verified only for fiscal_year granularity — a very long ${group} series could in principle be capped server-side without a signal. Confirm the span (${spanStart ?? "?"} … ${spanEnd ?? "?"}) covers your expected range.`,
1731
+ );
1732
+ }
1733
+
1734
+ return withMeta(
1735
+ { group: json.group ?? group, timeline },
1736
+ {
1737
+ source: SPENDING_OVER_TIME_SOURCE,
1738
+ keylessMode: true,
1739
+ returned: timeline.length,
1740
+ totalAvailable: timeline.length,
1741
+ truncated: false,
1742
+ filtersApplied,
1743
+ filtersDropped: [],
1744
+ fieldsUnavailable: [
1745
+ "grantObligations (excluded by the contract-only A/B/C/D filter — not zero)",
1746
+ "idvObligations (excluded by the contract-only A/B/C/D filter — not zero)",
1747
+ ],
1748
+ notes,
1749
+ },
1750
+ );
443
1751
  }
444
1752
 
445
1753
  // ─── Aggregate analysis: PSC spending ─────────────────────────────
@@ -453,18 +1761,31 @@ export async function searchPscSpending(args: {
453
1761
  const filters = buildFilters(args);
454
1762
  type Resp = {
455
1763
  results?: { code?: string; name?: string; amount?: number }[];
1764
+ page_metadata?: CategoryPageMeta;
456
1765
  };
1766
+ const limit = args.limit ?? 10;
457
1767
  const json = await postUsas<Resp>(
458
1768
  "search/spending_by_category/psc",
459
- { filters, limit: args.limit ?? 10, page: 1 },
1769
+ { filters, limit, page: 1 },
460
1770
  );
461
- return {
462
- psc: (json.results ?? []).map((r) => ({
1771
+ const results = json.results ?? [];
1772
+ const data = {
1773
+ psc: results.map((r) => ({
463
1774
  pscCode: r.code ?? "",
464
1775
  pscName: r.name ?? "",
465
1776
  amount: r.amount ?? 0,
466
1777
  })),
467
1778
  };
1779
+ return withMeta(
1780
+ data,
1781
+ categoryAggregateMeta({
1782
+ source: "usaspending.gov/api/v2 search/spending_by_category/psc",
1783
+ returned: results.length,
1784
+ limit,
1785
+ hasNext: json.page_metadata?.hasNext,
1786
+ filters,
1787
+ }),
1788
+ );
468
1789
  }
469
1790
 
470
1791
  // ─── Aggregate analysis: state / territory ─────────────────────────
@@ -478,18 +1799,35 @@ export async function searchStateSpending(args: {
478
1799
  const filters = buildFilters(args);
479
1800
  type Resp = {
480
1801
  results?: { code?: string; name?: string; amount?: number }[];
1802
+ page_metadata?: CategoryPageMeta;
481
1803
  };
1804
+ const limit = args.limit ?? 10;
482
1805
  const json = await postUsas<Resp>(
483
1806
  "search/spending_by_category/state_territory",
484
- { filters, limit: args.limit ?? 10, page: 1 },
1807
+ { filters, limit, page: 1 },
485
1808
  );
486
- return {
487
- states: (json.results ?? []).map((r) => ({
1809
+ const results = json.results ?? [];
1810
+ const data = {
1811
+ states: results.map((r) => ({
488
1812
  stateCode: r.code ?? "",
489
1813
  stateName: r.name ?? "",
490
1814
  amount: r.amount ?? 0,
491
1815
  })),
492
1816
  };
1817
+ return withMeta(
1818
+ data,
1819
+ categoryAggregateMeta({
1820
+ source:
1821
+ "usaspending.gov/api/v2 search/spending_by_category/state_territory",
1822
+ returned: results.length,
1823
+ limit,
1824
+ hasNext: json.page_metadata?.hasNext,
1825
+ filters,
1826
+ extraNotes: [
1827
+ "There are ~59 U.S. states/territories total; a capped result is a top-N by amount, not all places that received funding.",
1828
+ ],
1829
+ }),
1830
+ );
493
1831
  }
494
1832
 
495
1833
  // ─── Aggregate analysis: CFDA (grants) ─────────────────────────────
@@ -518,18 +1856,34 @@ export async function searchCfdaSpending(args: {
518
1856
  }
519
1857
  type Resp = {
520
1858
  results?: { code?: string; name?: string; amount?: number }[];
1859
+ page_metadata?: CategoryPageMeta;
521
1860
  };
1861
+ const limit = args.limit ?? 10;
522
1862
  const json = await postUsas<Resp>(
523
1863
  "search/spending_by_category/cfda",
524
- { filters, limit: args.limit ?? 10, page: 1 },
1864
+ { filters, limit, page: 1 },
525
1865
  );
526
- return {
527
- programs: (json.results ?? []).map((r) => ({
1866
+ const results = json.results ?? [];
1867
+ const data = {
1868
+ programs: results.map((r) => ({
528
1869
  cfdaCode: r.code ?? "",
529
1870
  programName: r.name ?? "",
530
1871
  amount: r.amount ?? 0,
531
1872
  })),
532
1873
  };
1874
+ return withMeta(
1875
+ data,
1876
+ categoryAggregateMeta({
1877
+ source: "usaspending.gov/api/v2 search/spending_by_category/cfda",
1878
+ returned: results.length,
1879
+ limit,
1880
+ hasNext: json.page_metadata?.hasNext,
1881
+ filters,
1882
+ extraNotes: [
1883
+ "This is a grants view (award types 02/03/04/05); contracts are excluded.",
1884
+ ],
1885
+ }),
1886
+ );
533
1887
  }
534
1888
 
535
1889
  // ─── Aggregate analysis: federal account (TAS) ─────────────────────
@@ -543,18 +1897,32 @@ export async function searchFederalAccountSpending(args: {
543
1897
  const filters = buildFilters(args);
544
1898
  type Resp = {
545
1899
  results?: { code?: string; name?: string; amount?: number }[];
1900
+ page_metadata?: CategoryPageMeta;
546
1901
  };
1902
+ const limit = args.limit ?? 10;
547
1903
  const json = await postUsas<Resp>(
548
1904
  "search/spending_by_category/federal_account",
549
- { filters, limit: args.limit ?? 10, page: 1 },
1905
+ { filters, limit, page: 1 },
550
1906
  );
551
- return {
552
- accounts: (json.results ?? []).map((r) => ({
1907
+ const results = json.results ?? [];
1908
+ const data = {
1909
+ accounts: results.map((r) => ({
553
1910
  tasCode: r.code ?? "",
554
1911
  accountName: r.name ?? "",
555
1912
  amount: r.amount ?? 0,
556
1913
  })),
557
1914
  };
1915
+ return withMeta(
1916
+ data,
1917
+ categoryAggregateMeta({
1918
+ source:
1919
+ "usaspending.gov/api/v2 search/spending_by_category/federal_account",
1920
+ returned: results.length,
1921
+ limit,
1922
+ hasNext: json.page_metadata?.hasNext,
1923
+ filters,
1924
+ }),
1925
+ );
558
1926
  }
559
1927
 
560
1928
  // ─── Aggregate analysis: awarding agency ──────────────────────────
@@ -573,19 +1941,33 @@ export async function searchAgencySpending(args: {
573
1941
  amount?: number;
574
1942
  agency_slug?: string;
575
1943
  }[];
1944
+ page_metadata?: CategoryPageMeta;
576
1945
  };
1946
+ const limit = args.limit ?? 10;
577
1947
  const json = await postUsas<Resp>(
578
1948
  "search/spending_by_category/awarding_agency",
579
- { filters, limit: args.limit ?? 10, page: 1 },
1949
+ { filters, limit, page: 1 },
580
1950
  );
581
- return {
582
- agencies: (json.results ?? []).map((r) => ({
1951
+ const results = json.results ?? [];
1952
+ const data = {
1953
+ agencies: results.map((r) => ({
583
1954
  name: r.name ?? "",
584
1955
  code: r.code ?? "",
585
1956
  slug: r.agency_slug ?? "",
586
1957
  amount: r.amount ?? 0,
587
1958
  })),
588
1959
  };
1960
+ return withMeta(
1961
+ data,
1962
+ categoryAggregateMeta({
1963
+ source:
1964
+ "usaspending.gov/api/v2 search/spending_by_category/awarding_agency",
1965
+ returned: results.length,
1966
+ limit,
1967
+ hasNext: json.page_metadata?.hasNext,
1968
+ filters,
1969
+ }),
1970
+ );
589
1971
  }
590
1972
 
591
1973
  // ─── Sub-agency breakdown ─────────────────────────────────────────
@@ -597,18 +1979,42 @@ export async function searchSubAgencySpending(args: {
597
1979
  const filters = buildFilters(args);
598
1980
  type Resp = {
599
1981
  results?: { name?: string; amount?: number; count?: number }[];
1982
+ page_metadata?: CategoryPageMeta;
600
1983
  };
1984
+ const limit = 10;
601
1985
  const json = await postUsas<Resp>(
602
1986
  "search/spending_by_category/awarding_subagency",
603
- { filters, limit: 10, page: 1 },
1987
+ { filters, limit, page: 1 },
604
1988
  );
605
- return {
606
- subAgencies: (json.results ?? []).map((r) => ({
1989
+ const results = json.results ?? [];
1990
+ // The awarding_subagency endpoint returns `amount` but NOT a per-subagency
1991
+ // award `count` (verified 2026-07-03). Emitting `awards: 0` would be a
1992
+ // FABRICATED count (0 reads as "zero contracts", not "unknown") — the exact B1
1993
+ // class. So `awards` is `null` (honest "unavailable"), consistent with
1994
+ // searchAwards' B1 fix, AND flagged in `_meta.fieldsUnavailable` + a note. An
1995
+ // AI that ignores `_meta` still sees null, never a fake 0.
1996
+ const data = {
1997
+ subAgencies: results.map((r) => ({
607
1998
  name: r.name ?? "",
608
1999
  amount: r.amount ?? 0,
609
- awards: r.count ?? 0,
2000
+ awards: null as number | null,
610
2001
  })),
611
2002
  };
2003
+ return withMeta(
2004
+ data,
2005
+ categoryAggregateMeta({
2006
+ source:
2007
+ "usaspending.gov/api/v2 search/spending_by_category/awarding_subagency",
2008
+ returned: results.length,
2009
+ limit,
2010
+ hasNext: json.page_metadata?.hasNext,
2011
+ filters,
2012
+ fieldsUnavailable: ["awards"],
2013
+ extraNotes: [
2014
+ "Per-subagency award COUNTS are not returned by this endpoint — the `awards` field is null for every row (unavailable, NOT a real count and NOT 0). Use amount for ranking; do not report `awards` as a contract count.",
2015
+ ],
2016
+ }),
2017
+ );
612
2018
  }
613
2019
 
614
2020
  // ─── Agency profile ───────────────────────────────────────────────
@@ -625,6 +2031,18 @@ export async function getAgencyProfile(toptierCode: string) {
625
2031
  congressional_justification_url?: string;
626
2032
  };
627
2033
  const json = await getUsas<Resp>(`agency/${toptierCode}/`);
2034
+ // minor m1 (W3-1 honesty) hollow-200 guard — mirror getRecipientProfile: a real
2035
+ // agency/{code} 200 always echoes a toptier_code and/or name. A 200 with NEITHER
2036
+ // is a degraded/hollow response (CDN/WAF interstitial, upstream hiccup) — do NOT
2037
+ // map it into a fabricated { name:undefined } "complete" profile; throw schema_drift.
2038
+ if (!json.toptier_code && !json.name) {
2039
+ throw new ToolErrorCarrier({
2040
+ kind: "schema_drift",
2041
+ message: `usaspending agency/${toptierCode} returned a 200 with no toptier_code or name — a hollow/degraded response, not a real agency profile. Retry, or resolve the code via usas_lookup_agency.`,
2042
+ retryable: true,
2043
+ upstreamEndpoint: `agency/${toptierCode}`,
2044
+ });
2045
+ }
628
2046
  return {
629
2047
  fiscalYear: json.fiscal_year,
630
2048
  toptierCode: json.toptier_code,
@@ -652,13 +2070,47 @@ export async function getAgencyAwardsSummary(args: {
652
2070
  const json = await getUsas<Resp>(
653
2071
  `agency/${args.toptierCode}/awards/?fiscal_year=${fy}`,
654
2072
  );
655
- return {
2073
+ // M3 (W3-1 honesty) hollow-200 guard — mirror getRecipientProfile: a real
2074
+ // agency/{code}/awards 200 always carries transaction_count and/or obligations
2075
+ // as numbers. A valid-JSON but degraded/renamed 200 with NEITHER as a number is
2076
+ // a hollow response — do NOT map it into a confident false "$0 obligations, 0
2077
+ // transactions". Surface it as retryable schema_drift (a typed carrier).
2078
+ if (
2079
+ typeof json.transaction_count !== "number" &&
2080
+ typeof json.obligations !== "number"
2081
+ ) {
2082
+ throw new ToolErrorCarrier({
2083
+ kind: "schema_drift",
2084
+ message: `usaspending agency/${args.toptierCode}/awards returned a 200 with neither transaction_count nor obligations for FY${fy} — a hollow/degraded response, not a real summary. Do NOT read it as $0; retry, or verify the toptier code via usas_lookup_agency.`,
2085
+ retryable: true,
2086
+ upstreamEndpoint: `agency/${args.toptierCode}/awards`,
2087
+ });
2088
+ }
2089
+ const data = {
656
2090
  fiscalYear: json.fiscal_year,
657
2091
  toptierCode: json.toptier_code,
658
- transactionCount: json.transaction_count ?? 0,
659
- obligations: json.obligations ?? 0,
2092
+ // null-never-0: an individually-absent field is null (unknown), NOT a
2093
+ // fabricated 0. A genuine numeric 0 survives (`??` fires only on nullish).
2094
+ transactionCount: json.transaction_count ?? null,
2095
+ obligations: json.obligations ?? null,
660
2096
  latestActionDate: json.latest_action_date,
661
2097
  };
2098
+ // VQ-2 (C80 dogfooding): `obligations`/`transactionCount` from agency/{code}/awards
2099
+ // span ALL award types, NOT contracts only. Disclose scope so an agent doesn't
2100
+ // misread it as the procurement market (live: VA FY2024 = $238B all-awards vs
2101
+ // $66.9B contracts — a benefit-heavy agency's figure is dominated by direct
2102
+ // benefit payments, not procurement).
2103
+ return withMeta(data, {
2104
+ source: "usaspending.gov/api/v2 agency/{code}/awards",
2105
+ keylessMode: true,
2106
+ returned: 1,
2107
+ // FILT-1 consistency (adversarial review): this tool filters by URL params, not
2108
+ // buildFilters, so filtersAppliedFromFilters can't reach it — declare them here.
2109
+ filtersApplied: ["toptierCode", "fiscalYear"],
2110
+ notes: [
2111
+ "SCOPE: `obligations` and `transactionCount` cover ALL award types (contracts, grants, direct payments incl. benefits, loans) for this agency — NOT prime contracts only. For a benefit-heavy agency (VA/SSA/HHS) direct benefit payments DOMINATE this figure (VA FY2024: ~$238B all-awards vs ~$67B prime contract awards A/B/C/D); for a procurement-heavy agency (DoD/DHS) obligations closely tracks contract spending. For the CONTRACTS-only obligation use usas_spending_over_time (contractObligations) or usas_search_*_spending — those filter by the agency's canonical NAME, so first resolve it from this toptierCode via usas_get_agency_profile (→ name).",
2112
+ ],
2113
+ });
662
2114
  }
663
2115
 
664
2116
  export async function getAgencyBudgetFunction(args: {
@@ -667,6 +2119,7 @@ export async function getAgencyBudgetFunction(args: {
667
2119
  limit?: number;
668
2120
  }) {
669
2121
  const fy = args.fiscalYear ?? new Date().getUTCFullYear();
2122
+ const limit = args.limit ?? 10;
670
2123
  type Resp = {
671
2124
  toptier_code?: string;
672
2125
  fiscal_year?: number;
@@ -678,14 +2131,32 @@ export async function getAgencyBudgetFunction(args: {
678
2131
  gross_outlay_amount?: number;
679
2132
  }[];
680
2133
  }[];
2134
+ // Unlike the spending_by_category/* endpoints, agency/budget_function
2135
+ // DOES report a real grand total in page_metadata.total (verified
2136
+ // 2026-07-03: e.g. DoD → total:6 while a 3-row page has hasNext:true).
2137
+ page_metadata?: { page?: number; total?: number; hasNext?: boolean };
681
2138
  };
682
2139
  const json = await getUsas<Resp>(
683
- `agency/${args.toptierCode}/budget_function/?fiscal_year=${fy}&limit=${args.limit ?? 10}`,
2140
+ `agency/${args.toptierCode}/budget_function/?fiscal_year=${fy}&limit=${limit}`,
684
2141
  );
685
- return {
2142
+ const results = json.results ?? [];
2143
+ // minor m1 (W3-1 honesty) hollow-200 guard — a real budget_function 200 echoes
2144
+ // toptier_code + fiscal_year and carries results. A 200 with NONE of the three is
2145
+ // a degraded/hollow response — do NOT map it into a fabricated empty budget
2146
+ // (which reads as "this agency has no budget functions"); throw schema_drift.
2147
+ if (!json.toptier_code && !json.fiscal_year && results.length === 0) {
2148
+ throw new ToolErrorCarrier({
2149
+ kind: "schema_drift",
2150
+ message: `usaspending agency/${args.toptierCode}/budget_function returned a 200 with no toptier_code, fiscal_year, or results for FY${fy} — a hollow/degraded response, not a real budget. Retry.`,
2151
+ retryable: true,
2152
+ upstreamEndpoint: `agency/${args.toptierCode}/budget_function`,
2153
+ });
2154
+ }
2155
+ const total = json.page_metadata?.total ?? null;
2156
+ const data = {
686
2157
  toptierCode: json.toptier_code,
687
2158
  fiscalYear: json.fiscal_year,
688
- functions: (json.results ?? []).map((r) => ({
2159
+ functions: results.map((r) => ({
689
2160
  name: r.name ?? "",
690
2161
  programs: (r.children ?? []).map((c) => ({
691
2162
  name: c.name ?? "",
@@ -694,6 +2165,34 @@ export async function getAgencyBudgetFunction(args: {
694
2165
  })),
695
2166
  })),
696
2167
  };
2168
+ const hasMore =
2169
+ total !== null ? results.length < total : (json.page_metadata?.hasNext ?? false);
2170
+ return withMeta(data, {
2171
+ source: "usaspending.gov/api/v2 agency/{code}/budget_function",
2172
+ keylessMode: true,
2173
+ returned: results.length,
2174
+ // Real total from the endpoint (budget-function count for the FY).
2175
+ totalAvailable: total,
2176
+ truncated: hasMore,
2177
+ pagination: {
2178
+ offset: 0,
2179
+ limit,
2180
+ // W3-7 (honesty; mirrors searchRecipients M1 / awardPagination): this tool has
2181
+ // NO offset/page input (page hardcoded to the `limit` slice) — so `nextOffset`
2182
+ // is NOT consumable. Emitting `results.length` made an agent re-fetch the SAME
2183
+ // top-N forever. Emit null; `hasMore` stays honest (more functions DO exist).
2184
+ nextOffset: null,
2185
+ hasMore,
2186
+ },
2187
+ filtersApplied: [],
2188
+ filtersDropped: [],
2189
+ fieldsUnavailable: [],
2190
+ notes: hasMore
2191
+ ? [
2192
+ `Showing the top ${limit} budget functions; ${total ?? "more"} exist for FY${fy}. These extra functions are NOT page-reachable — this tool has no offset input (nextOffset is null). Raise limit to see the rest.`,
2193
+ ]
2194
+ : [],
2195
+ });
697
2196
  }
698
2197
 
699
2198
  // ─── Recipient list + profile ─────────────────────────────────────
@@ -714,18 +2213,28 @@ export async function searchRecipients(args: {
714
2213
  amount?: number;
715
2214
  }[];
716
2215
  };
2216
+ const limit = args.limit ?? 10;
717
2217
  const body: Record<string, unknown> = {
718
2218
  keyword: args.keyword,
719
- limit: args.limit ?? 10,
2219
+ limit,
720
2220
  page: 1,
721
2221
  };
722
2222
  if (args.recipientLevel) {
723
2223
  body.recipient_level = args.recipientLevel;
724
2224
  }
725
2225
  const json = await postUsas<Resp>("recipient/", body);
726
- return {
727
- totalRecords: json.page_metadata?.total ?? 0,
728
- recipients: (json.results ?? []).map((r) => ({
2226
+ const results = json.results ?? [];
2227
+ // recipient/ DOES report a real grand total in page_metadata.total
2228
+ // (verified 2026-07-03: "booz" → total:512). Keep it in _meta.totalAvailable
2229
+ // and derive truncation from returned < total. null (not 0) when absent so
2230
+ // we never claim a total the endpoint didn't give.
2231
+ const total = json.page_metadata?.total ?? null;
2232
+ const data = {
2233
+ // F3 (P1): mirror the honest null `_meta.totalAvailable` — an OMITTED total
2234
+ // must NOT default to 0 in the data field (a 200-with-rows-but-no-total then
2235
+ // says "0 records" while rows exist and _meta says null: a self-contradiction).
2236
+ totalRecords: total,
2237
+ recipients: results.map((r) => ({
729
2238
  id: r.id ?? "",
730
2239
  duns: r.duns,
731
2240
  uei: r.uei,
@@ -734,6 +2243,35 @@ export async function searchRecipients(args: {
734
2243
  totalAmount: r.amount ?? 0,
735
2244
  })),
736
2245
  };
2246
+ const hasMore = total !== null ? results.length < total : results.length >= limit;
2247
+ return withMeta(data, {
2248
+ source: "usaspending.gov/api/v2 recipient/",
2249
+ keylessMode: true,
2250
+ returned: results.length,
2251
+ totalAvailable: total,
2252
+ truncated: hasMore,
2253
+ pagination: {
2254
+ offset: 0,
2255
+ limit,
2256
+ // M1 (W3-1 honesty): this tool has NO offset/page input — page is hardcoded
2257
+ // to 1 (see body above) — so `nextOffset` is NOT consumable. Emitting
2258
+ // `results.length` made an agent re-fetch the SAME top-N forever while
2259
+ // ranked-below-`limit` recipients stayed unjoinable. Emit null
2260
+ // unconditionally (mirror edgar.ts:656 / the FTS beyond-window pattern:
2261
+ // hasMore stays true while nextOffset is null). The extra matches are
2262
+ // reachable ONLY by raising `limit` (≤50) or narrowing the keyword.
2263
+ nextOffset: null,
2264
+ hasMore,
2265
+ },
2266
+ filtersApplied: [],
2267
+ filtersDropped: [],
2268
+ fieldsUnavailable: [],
2269
+ notes: hasMore
2270
+ ? [
2271
+ `Showing the top ${limit} recipients by amount; ${total ?? "more"} match the keyword. These extra matches are NOT page-reachable — this tool has no offset input (nextOffset is null). Raise limit (up to 50) or narrow the keyword to see more.`,
2272
+ ]
2273
+ : [],
2274
+ });
737
2275
  }
738
2276
 
739
2277
  export async function getRecipientProfile(recipientId: string) {
@@ -757,9 +2295,64 @@ export async function getRecipientProfile(recipientId: string) {
757
2295
  total_transaction_amount?: number;
758
2296
  total_transactions?: number;
759
2297
  };
760
- const json = await getUsas<Resp>(
761
- `recipient/${encodeURIComponent(recipientId)}/`,
762
- );
2298
+ // NOT via getUsas: USAspending signals a NONEXISTENT recipient with HTTP 400 +
2299
+ // `detail: "Recipient ID not found: '...'"` (LIVE-VERIFIED 2026-07-06 — NOT a
2300
+ // 404, and NOT a malformed-input 400). Through fetchWithRetry/errorFromResponse
2301
+ // that 400 becomes `invalid_input` ("Bad request"), telling a caller its
2302
+ // recipient_id was MALFORMED when the recipient simply does not exist. Read the
2303
+ // body to distinguish that not-found from a genuine bad-input 400 and classify
2304
+ // honestly (mirrors getAwardDetail's explicit status handling).
2305
+ let r: Response;
2306
+ try {
2307
+ r = await fetch(`${USAS}/recipient/${encodeURIComponent(recipientId)}/`, {
2308
+ signal: AbortSignal.timeout(15_000),
2309
+ });
2310
+ } catch (e) {
2311
+ // A network-level fault (DNS/reset/timeout) is an OUTAGE, not a missing
2312
+ // recipient — classify as retryable rather than surfacing the generic `unknown`.
2313
+ if (e instanceof ToolErrorCarrier) throw e;
2314
+ throw new ToolErrorCarrier({
2315
+ kind: "upstream_unavailable",
2316
+ message: `usaspending recipient/{id} fetch failed: ${e instanceof Error ? e.message : String(e)}. This is an outage, not a missing recipient. Retry.`,
2317
+ retryable: true,
2318
+ upstreamEndpoint: `recipient/${recipientId}`,
2319
+ });
2320
+ }
2321
+ if (!r.ok) {
2322
+ let detail = "";
2323
+ try {
2324
+ detail = ((await r.json()) as { detail?: string }).detail ?? "";
2325
+ } catch {
2326
+ /* non-JSON error body → detail stays "" and we fall through to errorFromResponse */
2327
+ }
2328
+ if (
2329
+ r.status === 404 ||
2330
+ (r.status === 400 && /recipient\s*(id\s+)?not found/i.test(detail))
2331
+ ) {
2332
+ throw new ToolErrorCarrier({
2333
+ kind: "not_found",
2334
+ message: `No recipient profile found for recipient_id '${recipientId}' on usaspending.gov${detail ? ` (upstream: ${detail})` : ""}. Resolve a valid recipient_id via usas_search_recipients — each result carries an id. This is a genuine not-found, NOT a malformed input.`,
2335
+ retryable: false,
2336
+ upstreamStatus: r.status,
2337
+ upstreamEndpoint: `recipient/${recipientId}`,
2338
+ });
2339
+ }
2340
+ // Any other non-2xx (a genuine bad-input 400, 429, 5xx) → the shared matrix.
2341
+ throw new ToolErrorCarrier(errorFromResponse(r, `recipient/${recipientId}`));
2342
+ }
2343
+ const json = (await r.json()) as Resp;
2344
+ // Defensive hollow-200 guard (cf. sam_lookup_organization / grants / opportunity):
2345
+ // a real recipient 200 always carries a name and/or recipient_id. A 200 with
2346
+ // NEITHER is a degraded/hollow response (CDN/WAF interstitial, upstream hiccup) —
2347
+ // do NOT map it into a fabricated { name:"" } profile; surface it as schema_drift.
2348
+ if (!json.recipient_id && !json.name) {
2349
+ throw new ToolErrorCarrier({
2350
+ kind: "schema_drift",
2351
+ message: `usaspending recipient/{id} returned a 200 with no recipient_id or name for '${recipientId}' — a hollow/degraded response, not a real profile. Retry, or verify the id via usas_search_recipients.`,
2352
+ retryable: true,
2353
+ upstreamEndpoint: `recipient/${recipientId}`,
2354
+ });
2355
+ }
763
2356
  return {
764
2357
  name: json.name ?? "",
765
2358
  alternateNames: json.alternate_names ?? [],
@@ -782,14 +2375,6 @@ export async function lookupAgency(searchText: string) {
782
2375
  // Cache: agency lookups are extremely repeat-prone (`VA`, `DHS`, etc.)
783
2376
  // and effectively static across a session.
784
2377
  return memoize(`usas:agency:${searchText.toLowerCase()}`, async () => {
785
- try {
786
- const r = await fetch(`${USAS}/autocomplete/funding_agency/`, {
787
- method: "POST",
788
- headers: { "Content-Type": "application/json" },
789
- body: JSON.stringify({ search_text: searchText, limit: 5 }),
790
- signal: AbortSignal.timeout(10_000),
791
- });
792
- if (!r.ok) return { matches: [] };
793
2378
  type Resp = {
794
2379
  results?: {
795
2380
  toptier_flag?: boolean;
@@ -800,7 +2385,15 @@ export async function lookupAgency(searchText: string) {
800
2385
  };
801
2386
  }[];
802
2387
  };
803
- const json = (await r.json()) as Resp;
2388
+ // Via postUsas (fetchWithRetry) so a DOWN service THROWS upstream_unavailable
2389
+ // instead of returning `{ matches: [] }` — which an AI reads as "no such
2390
+ // agency" when the endpoint is merely down. This was the last silent-empty-
2391
+ // on-outage in the codebase; now consistent with autocompleteNaics/Recipient
2392
+ // (a GENUINE no-match still returns an honest empty `matches`).
2393
+ const json = await postUsas<Resp>("autocomplete/funding_agency/", {
2394
+ search_text: searchText,
2395
+ limit: 5,
2396
+ });
804
2397
  return {
805
2398
  matches: (json.results ?? []).map((r) => ({
806
2399
  name: r.toptier_agency?.name ?? "",
@@ -809,9 +2402,6 @@ export async function lookupAgency(searchText: string) {
809
2402
  isToptier: !!r.toptier_flag,
810
2403
  })),
811
2404
  };
812
- } catch {
813
- return { matches: [] };
814
- }
815
2405
  });
816
2406
  }
817
2407
 
@@ -819,8 +2409,9 @@ export async function autocompleteNaics(args: {
819
2409
  searchText: string;
820
2410
  limit?: number;
821
2411
  }) {
2412
+ const limit = args.limit ?? 10;
822
2413
  return memoize(
823
- `usas:naics:${args.searchText.toLowerCase()}:${args.limit ?? 10}`,
2414
+ `usas:naics:${args.searchText.toLowerCase()}:${limit}`,
824
2415
  async () => {
825
2416
  type Resp = {
826
2417
  results?: {
@@ -831,15 +2422,24 @@ export async function autocompleteNaics(args: {
831
2422
  };
832
2423
  const json = await postUsas<Resp>("autocomplete/naics/", {
833
2424
  search_text: args.searchText,
834
- limit: args.limit ?? 10,
2425
+ limit,
835
2426
  });
836
- return {
837
- naics: (json.results ?? []).map((r) => ({
2427
+ const results = json.results ?? [];
2428
+ const data = {
2429
+ naics: results.map((r) => ({
838
2430
  code: r.naics ?? "",
839
2431
  description: r.naics_description ?? "",
840
2432
  retired: !!r.year_retired,
841
2433
  })),
842
2434
  };
2435
+ return withMeta(data, referenceMeta({
2436
+ source: "usaspending.gov/api/v2 autocomplete/naics",
2437
+ returned: results.length,
2438
+ limit,
2439
+ // autocomplete/naics returns only {results} — no total (verified
2440
+ // 2026-07-03). A full page means more matches likely exist.
2441
+ totalAvailable: null,
2442
+ }));
843
2443
  },
844
2444
  );
845
2445
  }
@@ -848,10 +2448,16 @@ export async function autocompleteRecipient(args: {
848
2448
  searchText: string;
849
2449
  limit?: number;
850
2450
  }) {
2451
+ const limit = args.limit ?? 10;
851
2452
  return memoize(
852
- `usas:recipient:${args.searchText.toLowerCase()}:${args.limit ?? 10}`,
2453
+ `usas:recipient:${args.searchText.toLowerCase()}:${limit}`,
853
2454
  async () => {
854
2455
  type Resp = {
2456
+ // NOTE: this endpoint's top-level `count` equals the RETURNED row
2457
+ // count (verified 2026-07-03: 5 asked → count:5), NOT a grand total —
2458
+ // so it is NOT a usable totalAvailable (spec §3.3: never substitute
2459
+ // page size for an unknown total). Left as null.
2460
+ count?: number;
855
2461
  results?: {
856
2462
  recipient_name?: string;
857
2463
  uei?: string;
@@ -860,67 +2466,168 @@ export async function autocompleteRecipient(args: {
860
2466
  };
861
2467
  const json = await postUsas<Resp>("autocomplete/recipient/", {
862
2468
  search_text: args.searchText,
863
- limit: args.limit ?? 10,
2469
+ limit,
864
2470
  });
865
- return {
866
- recipients: (json.results ?? []).map((r) => ({
2471
+ const results = json.results ?? [];
2472
+ const data = {
2473
+ recipients: results.map((r) => ({
867
2474
  name: r.recipient_name ?? "",
868
2475
  uei: r.uei,
869
2476
  duns: r.duns,
870
2477
  })),
871
2478
  };
2479
+ return withMeta(data, referenceMeta({
2480
+ source: "usaspending.gov/api/v2 autocomplete/recipient",
2481
+ returned: results.length,
2482
+ limit,
2483
+ totalAvailable: null,
2484
+ }));
872
2485
  },
873
2486
  );
874
2487
  }
875
2488
 
876
2489
  export async function naicsHierarchy(args: { naicsFilter?: string }) {
877
2490
  return memoize(`usas:naics-hierarchy:${args.naicsFilter ?? ""}`, async () => {
878
- type Resp = {
879
- results?: {
880
- naics?: string;
881
- naics_description?: string;
882
- count?: number;
883
- children?: unknown[];
884
- }[];
2491
+ type NaicsNode = {
2492
+ naics?: string;
2493
+ naics_description?: string;
2494
+ count?: number;
2495
+ children?: NaicsNode[];
885
2496
  };
2497
+ type Resp = { results?: NaicsNode[] };
2498
+ // VQ-4 (C81 dogfooding): DRILL-DOWN is the PATH param references/naics/{code}/
2499
+ // (returns the node WITH its `children`), NOT `?filter=` — `filter` is a keyword
2500
+ // search (live: filter=54 fuzzy-matched sectors 32/45/48/54, and the top-level
2501
+ // response omits `children` so hasChildren was ALWAYS false). Unfiltered returns
2502
+ // the top-level 2-digit sectors.
886
2503
  const path = args.naicsFilter
887
- ? `references/naics/?filter=${encodeURIComponent(args.naicsFilter)}`
2504
+ ? `references/naics/${encodeURIComponent(args.naicsFilter)}/`
888
2505
  : "references/naics/";
889
- const json = await getUsas<Resp>(path);
890
- return {
891
- hierarchy: (json.results ?? []).map((r) => ({
892
- code: r.naics ?? "",
893
- description: r.naics_description ?? "",
894
- count: r.count ?? 0,
895
- hasChildren: !!(r.children && r.children.length > 0),
896
- })),
2506
+ // Snapshot ONLY the canonical UNFILTERED top-level NAICS tree (a single,
2507
+ // well-defined queryless read the builder can pre-fetch). A drill-down
2508
+ // (`naicsFilter` set) passes no key ⇒ live only. Even unfiltered, the
2509
+ // snapshot is INERT unless SAMGOV_SNAPSHOT_BASE_URL is configured.
2510
+ const snapshotKey = args.naicsFilter ? undefined : "usas_naics_hierarchy";
2511
+ const { body: json, provenance } = await getUsasResilient<Resp>(
2512
+ path,
2513
+ snapshotKey,
2514
+ );
2515
+ const results = json.results ?? [];
2516
+ // NAICS levels are 2→4→6 digit (USAspending skips 3/5); only a 6-digit code is a
2517
+ // leaf. The top-level response carries no `children`, so hasChildren is derived
2518
+ // from code length — an honest structural signal, never a blanket false.
2519
+ const toRow = (r: NaicsNode) => ({
2520
+ code: r.naics ?? "",
2521
+ description: r.naics_description ?? "",
2522
+ count: r.count ?? 0,
2523
+ hasChildren: (r.naics ?? "").length < 6,
2524
+ });
2525
+ // Filtered: results[0] is the requested node; its `children` are the drill-down
2526
+ // level. Unfiltered: results ARE the top-level sectors.
2527
+ const node = args.naicsFilter ? results[0] : undefined;
2528
+ const level = args.naicsFilter ? (node?.children ?? []) : results;
2529
+ const notes: string[] = [];
2530
+ if (args.naicsFilter && !node) {
2531
+ notes.push(
2532
+ `NAICS '${args.naicsFilter}' was NOT found in the USAspending reference dataset — no hierarchy returned (nothing fabricated). Confirm it is a current 2/4/6-digit NAICS code.`,
2533
+ );
2534
+ } else if (args.naicsFilter && node && level.length === 0) {
2535
+ // Adversarial review F2: derive the digit count instead of hardcoding "6-digit"
2536
+ // (a 4-digit code with no children in USAspending's data would else mislabel).
2537
+ const digits = (node.naics ?? args.naicsFilter).length;
2538
+ notes.push(
2539
+ `NAICS ${node.naics ?? args.naicsFilter} is a leaf (${digits}-digit — no child codes in USAspending's reference). The node's own row is in \`parent\`.`,
2540
+ );
2541
+ } else {
2542
+ notes.push(
2543
+ "Drill into any row with hasChildren:true by calling again with its `code` as naicsFilter. Levels are 2→4→6 digit (USAspending skips 3/5-digit).",
2544
+ );
2545
+ }
2546
+ const data = {
2547
+ filter: args.naicsFilter ?? null,
2548
+ // Adversarial review F3: structured found signal — a leaf (found, no children)
2549
+ // and a nonexistent code both yield hierarchy:[], so `returned` alone can't
2550
+ // distinguish them. found:true/false disambiguates; null when unfiltered.
2551
+ found: args.naicsFilter ? node !== undefined : null,
2552
+ parent: node
2553
+ ? { code: node.naics ?? "", description: node.naics_description ?? "", count: node.count ?? 0 }
2554
+ : null,
2555
+ hierarchy: level.map(toRow),
897
2556
  };
2557
+ // references/naics has NO limit param and NO total — the returned level IS the
2558
+ // complete set for that node.
2559
+ return withMeta(data, {
2560
+ ...referenceMeta({
2561
+ source: "usaspending.gov/api/v2 references/naics",
2562
+ returned: data.hierarchy.length,
2563
+ limit: data.hierarchy.length,
2564
+ totalAvailable: data.hierarchy.length,
2565
+ limitHonored: false,
2566
+ extraNotes: notes,
2567
+ }),
2568
+ filtersApplied: args.naicsFilter ? ["naicsFilter(direct-children)"] : [],
2569
+ // P5 provenance: threaded ONLY when NON-live (snapshot) — live ⇒ {} ⇒
2570
+ // byte-identical _meta (provenanceMeta returns the empty object).
2571
+ ...provenanceMeta(provenance),
2572
+ });
898
2573
  });
899
2574
  }
900
2575
 
2576
+ const GLOSSARY_DEFAULT_LIMIT = 25;
2577
+
901
2578
  export async function glossary(args: { limit?: number; search?: string }) {
902
- return memoize(`usas:glossary:${args.search ?? ""}:${args.limit ?? 25}`, async () => {
2579
+ const limit = args.limit ?? GLOSSARY_DEFAULT_LIMIT;
2580
+ return memoize(`usas:glossary:${args.search ?? ""}:${limit}`, async () => {
903
2581
  type Resp = {
2582
+ // references/glossary DOES report a real grand total in
2583
+ // page_metadata.count (verified 2026-07-03: 151, stable across limits).
904
2584
  page_metadata?: { count?: number };
905
2585
  results?: { term?: string; slug?: string; plain?: string }[];
906
2586
  };
907
2587
  const params = new URLSearchParams();
908
- params.set("limit", String(args.limit ?? 25));
2588
+ params.set("limit", String(limit));
909
2589
  if (args.search) params.set("search", args.search);
910
- const json = await getUsas<Resp>(`references/glossary/?${params.toString()}`);
911
- return {
912
- totalRecords: json.page_metadata?.count ?? 0,
913
- terms: (json.results ?? []).map((r) => ({
2590
+ // Snapshot ONLY the canonical read — NO search term AND the default limit —
2591
+ // which is the exact query the builder pre-fetches (references/glossary/
2592
+ // ?limit=25). A search or a non-default limit passes no key ⇒ live only, so
2593
+ // the served snapshot can never desync `returned` from what was asked. INERT
2594
+ // unless SAMGOV_SNAPSHOT_BASE_URL is configured.
2595
+ const snapshotKey =
2596
+ !args.search && limit === GLOSSARY_DEFAULT_LIMIT
2597
+ ? "usas_glossary"
2598
+ : undefined;
2599
+ const { body: json, provenance } = await getUsasResilient<Resp>(
2600
+ `references/glossary/?${params.toString()}`,
2601
+ snapshotKey,
2602
+ );
2603
+ const results = json.results ?? [];
2604
+ const total = json.page_metadata?.count ?? null;
2605
+ const data = {
2606
+ // F3 (P1): mirror the honest null `_meta.totalAvailable` — an OMITTED count
2607
+ // must NOT default to 0 in the data field (that would contradict the null meta).
2608
+ totalRecords: total,
2609
+ terms: results.map((r) => ({
914
2610
  term: r.term ?? "",
915
2611
  slug: r.slug ?? "",
916
2612
  definition: r.plain ?? "",
917
2613
  })),
918
2614
  };
2615
+ return withMeta(data, {
2616
+ ...referenceMeta({
2617
+ source: "usaspending.gov/api/v2 references/glossary",
2618
+ returned: results.length,
2619
+ limit,
2620
+ totalAvailable: total,
2621
+ }),
2622
+ // P5 provenance — threaded ONLY when NON-live ⇒ live stays byte-identical.
2623
+ ...provenanceMeta(provenance),
2624
+ });
919
2625
  });
920
2626
  }
921
2627
 
922
2628
  export async function listToptierAgencies(args: { limit?: number }) {
923
- return memoize(`usas:toptier:${args.limit ?? 50}`, async () => {
2629
+ const limit = args.limit ?? 50;
2630
+ return memoize(`usas:toptier:${limit}`, async () => {
924
2631
  type Resp = {
925
2632
  results?: {
926
2633
  agency_name?: string;
@@ -931,11 +2638,17 @@ export async function listToptierAgencies(args: { limit?: number }) {
931
2638
  obligated_amount?: number;
932
2639
  }[];
933
2640
  };
934
- const json = await getUsas<Resp>(
935
- `references/toptier_agencies/?limit=${args.limit ?? 50}`,
2641
+ // The toptier list is canonical + the endpoint IGNORES `limit` (returns the
2642
+ // COMPLETE ~111-agency set for any limit verified 2026-07-03), so the
2643
+ // snapshot is limit-independent and always opted in (key stable). INERT
2644
+ // unless SAMGOV_SNAPSHOT_BASE_URL is configured.
2645
+ const { body: json, provenance } = await getUsasResilient<Resp>(
2646
+ `references/toptier_agencies/?limit=${limit}`,
2647
+ "usas_toptier_agencies",
936
2648
  );
937
- return {
938
- agencies: (json.results ?? []).map((r) => ({
2649
+ const results = json.results ?? [];
2650
+ const data = {
2651
+ agencies: results.map((r) => ({
939
2652
  name: r.agency_name ?? "",
940
2653
  abbreviation: r.abbreviation,
941
2654
  toptierCode: r.toptier_code,
@@ -944,5 +2657,24 @@ export async function listToptierAgencies(args: { limit?: number }) {
944
2657
  obligatedAmount: r.obligated_amount ?? 0,
945
2658
  })),
946
2659
  };
2660
+ // IMPORTANT: this endpoint IGNORES the `limit` param (verified 2026-07-03:
2661
+ // limit=3 AND limit=1000 both return all 111 toptier agencies). So the
2662
+ // response is ALWAYS the complete set — truncated:false, and the returned
2663
+ // count IS the total. Deriving truncation from `returned >= limit` would be
2664
+ // a false positive, so limitHonored:false forces complete.
2665
+ return withMeta(data, {
2666
+ ...referenceMeta({
2667
+ source: "usaspending.gov/api/v2 references/toptier_agencies",
2668
+ returned: results.length,
2669
+ limit,
2670
+ totalAvailable: results.length,
2671
+ limitHonored: false,
2672
+ extraNotes: [
2673
+ "The toptier_agencies endpoint returns the COMPLETE list of ~111 toptier agencies regardless of the `limit` value (limit is ignored upstream).",
2674
+ ],
2675
+ }),
2676
+ // P5 provenance — threaded ONLY when NON-live ⇒ live stays byte-identical.
2677
+ ...provenanceMeta(provenance),
2678
+ });
947
2679
  });
948
2680
  }