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