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