@cliwant/mcp-sam-gov 1.10.0 → 1.12.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 (120) hide show
  1. package/README.ja.md +5 -5
  2. package/README.ko.md +5 -5
  3. package/README.md +23 -7
  4. package/dist/bls.d.ts.map +1 -1
  5. package/dist/bls.js +6 -1
  6. package/dist/bls.js.map +1 -1
  7. package/dist/bonfire.d.ts.map +1 -1
  8. package/dist/bonfire.js +17 -2
  9. package/dist/bonfire.js.map +1 -1
  10. package/dist/cbp-border.d.ts +9 -5
  11. package/dist/cbp-border.d.ts.map +1 -1
  12. package/dist/cbp-border.js +31 -11
  13. package/dist/cbp-border.js.map +1 -1
  14. package/dist/census-economic.d.ts.map +1 -1
  15. package/dist/census-economic.js +10 -1
  16. package/dist/census-economic.js.map +1 -1
  17. package/dist/ckan.d.ts.map +1 -1
  18. package/dist/ckan.js +17 -3
  19. package/dist/ckan.js.map +1 -1
  20. package/dist/clinicaltrials.d.ts.map +1 -1
  21. package/dist/clinicaltrials.js +23 -6
  22. package/dist/clinicaltrials.js.map +1 -1
  23. package/dist/fdic.js +7 -7
  24. package/dist/fdic.js.map +1 -1
  25. package/dist/fema.d.ts.map +1 -1
  26. package/dist/fema.js +13 -1
  27. package/dist/fema.js.map +1 -1
  28. package/dist/gao.d.ts.map +1 -1
  29. package/dist/gao.js +25 -0
  30. package/dist/gao.js.map +1 -1
  31. package/dist/grants.d.ts.map +1 -1
  32. package/dist/grants.js +14 -4
  33. package/dist/grants.js.map +1 -1
  34. package/dist/gsa-csv.d.ts +9 -0
  35. package/dist/gsa-csv.d.ts.map +1 -1
  36. package/dist/gsa-csv.js +37 -8
  37. package/dist/gsa-csv.js.map +1 -1
  38. package/dist/gsa-perdiem.d.ts +16 -2
  39. package/dist/gsa-perdiem.d.ts.map +1 -1
  40. package/dist/gsa-perdiem.js +25 -4
  41. package/dist/gsa-perdiem.js.map +1 -1
  42. package/dist/integrity.d.ts.map +1 -1
  43. package/dist/integrity.js +18 -3
  44. package/dist/integrity.js.map +1 -1
  45. package/dist/lda.d.ts.map +1 -1
  46. package/dist/lda.js +17 -4
  47. package/dist/lda.js.map +1 -1
  48. package/dist/nhtsa.d.ts +9 -5
  49. package/dist/nhtsa.d.ts.map +1 -1
  50. package/dist/nhtsa.js +90 -26
  51. package/dist/nhtsa.js.map +1 -1
  52. package/dist/nist-controls.d.ts +3 -1
  53. package/dist/nist-controls.d.ts.map +1 -1
  54. package/dist/nist-controls.js +34 -12
  55. package/dist/nist-controls.js.map +1 -1
  56. package/dist/nppes.d.ts.map +1 -1
  57. package/dist/nppes.js +17 -4
  58. package/dist/nppes.js.map +1 -1
  59. package/dist/nsf.d.ts.map +1 -1
  60. package/dist/nsf.js +9 -0
  61. package/dist/nsf.js.map +1 -1
  62. package/dist/ofac.d.ts.map +1 -1
  63. package/dist/ofac.js +10 -1
  64. package/dist/ofac.js.map +1 -1
  65. package/dist/openfda-device.d.ts.map +1 -1
  66. package/dist/openfda-device.js +8 -5
  67. package/dist/openfda-device.js.map +1 -1
  68. package/dist/openfda-drugsfda.d.ts.map +1 -1
  69. package/dist/openfda-drugsfda.js +8 -5
  70. package/dist/openfda-drugsfda.js.map +1 -1
  71. package/dist/openfda.d.ts +21 -0
  72. package/dist/openfda.d.ts.map +1 -1
  73. package/dist/openfda.js +38 -4
  74. package/dist/openfda.js.map +1 -1
  75. package/dist/opengov.d.ts.map +1 -1
  76. package/dist/opengov.js +8 -4
  77. package/dist/opengov.js.map +1 -1
  78. package/dist/sam-gov/client.d.ts.map +1 -1
  79. package/dist/sam-gov/client.js +13 -4
  80. package/dist/sam-gov/client.js.map +1 -1
  81. package/dist/server.d.ts +11 -0
  82. package/dist/server.d.ts.map +1 -1
  83. package/dist/server.js +79 -10
  84. package/dist/server.js.map +1 -1
  85. package/dist/socrata.d.ts +1 -1
  86. package/dist/socrata.d.ts.map +1 -1
  87. package/dist/socrata.js +44 -7
  88. package/dist/socrata.js.map +1 -1
  89. package/dist/usaspending.d.ts +2 -2
  90. package/dist/usaspending.d.ts.map +1 -1
  91. package/dist/usaspending.js +21 -10
  92. package/dist/usaspending.js.map +1 -1
  93. package/package.json +11 -3
  94. package/src/bls.ts +8 -1
  95. package/src/bonfire.ts +17 -2
  96. package/src/cbp-border.ts +30 -9
  97. package/src/census-economic.ts +13 -2
  98. package/src/ckan.ts +19 -3
  99. package/src/clinicaltrials.ts +25 -8
  100. package/src/fdic.ts +7 -7
  101. package/src/fema.ts +15 -1
  102. package/src/gao.ts +27 -0
  103. package/src/grants.ts +16 -3
  104. package/src/gsa-csv.ts +38 -7
  105. package/src/gsa-perdiem.ts +28 -4
  106. package/src/integrity.ts +18 -3
  107. package/src/lda.ts +21 -5
  108. package/src/nhtsa.ts +92 -27
  109. package/src/nist-controls.ts +58 -9
  110. package/src/nppes.ts +18 -4
  111. package/src/nsf.ts +10 -0
  112. package/src/ofac.ts +12 -1
  113. package/src/openfda-device.ts +10 -4
  114. package/src/openfda-drugsfda.ts +10 -4
  115. package/src/openfda.ts +47 -5
  116. package/src/opengov.ts +8 -4
  117. package/src/sam-gov/client.ts +13 -4
  118. package/src/server.ts +86 -10
  119. package/src/socrata.ts +44 -7
  120. package/src/usaspending.ts +21 -10
package/src/bonfire.ts CHANGED
@@ -153,7 +153,18 @@ async function getBonfireRss(org: string): Promise<string> {
153
153
  upstreamEndpoint: bonfireLabel(org),
154
154
  });
155
155
  }
156
- return getText(url, { label: bonfireLabel(org), redirect: "error" });
156
+ // Single-fetch path (retry:false) so a redirect is classified as a NON-retryable
157
+ // schema_drift, NOT a retryable upstream_unavailable. A drifted seed slug whose
158
+ // Bonfire portal has moved returns a 3xx (live-observed 307 on sanantonio /
159
+ // kingcounty / rutgers); routed through fetchWithRetry it would masquerade as a
160
+ // transient outage ("retry in 30s") that never recovers. A 5xx/429/404/timeout
161
+ // still THROWS honestly on the single attempt (never a fake empty).
162
+ return getText(url, {
163
+ label: bonfireLabel(org),
164
+ redirect: "error",
165
+ retry: false,
166
+ redirectMessage: `Bonfire org ${JSON.stringify(org)} redirected off ${host} (an HTTP 3xx) — this seed slug's portal has moved or been retired, so the entry is drifted (retrying will not help). Re-discover the current slug via bonfire_list_organizations or probe {slug}.bonfirehub.com/opportunities/rss.`,
167
+ });
157
168
  }
158
169
 
159
170
  // ─── Tool 1: bonfire_list_organizations (curated seed directory) ──
@@ -218,7 +229,11 @@ export async function searchOpportunities(args: BonfireSearchArgs): Promise<Meta
218
229
  throw driftError(bonfireLabel(org), "Bonfire returned a non-RSS body at HTTP 200 — schema drift (expected an <rss><channel> feed).");
219
230
  }
220
231
 
221
- const items = body.match(/<item>[\s\S]*?<\/item>/gi) ?? [];
232
+ // Tolerate attributes on the opening <item …> tag (consistent with the <channel[\s>]
233
+ // drift guard and the inner tag() matcher). RSS 2.0 <item> has no standard attributes,
234
+ // but a namespaced/extended feed could add them — a bare-<item> regex would silently
235
+ // DROP such items and undercount totalAvailable (a latent P1 risk caught in dogfooding).
236
+ const items = body.match(/<item(?:\s[^>]*)?>[\s\S]*?<\/item>/gi) ?? [];
222
237
  const all = items.map(mapItem);
223
238
  // P1: the RSS is the COMPLETE open set ⇒ totalAvailable = item count (true total).
224
239
  const totalAvailable = all.length;
package/src/cbp-border.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * cbp-border.ts — CBP Border Wait Times (bwt.cbp.gov, KEYLESS) — the FREIGHT /
3
- * LOGISTICS lane. Live commercial-vehicle (and passenger) wait times at every US
3
+ * LOGISTICS lane. Live COMMERCIAL-VEHICLE (freight-truck) wait times at every US
4
4
  * land border port (Canadian + Mexican): per-port lane delays, operational status,
5
- * and open-lane counts. Answers "what's the current commercial-truck delay at port
5
+ * and open-lane counts. (The raw feed also carries passenger/pedestrian lanes, but
6
+ * this tool surfaces ONLY the commercial-vehicle lanes — the freight lane.)
7
+ * Answers "what's the current commercial-truck delay at port
6
8
  * X" — real-time freight-crossing situational awareness for logistics/trade vendors.
7
9
  *
8
10
  * SOURCE: CBP's official Border Wait Times API (bwt.cbp.gov/api/bwtnew) — a .gov host,
@@ -127,9 +129,11 @@ async function loadPorts(): Promise<CbpPort[]> {
127
129
 
128
130
  // ─── Tool: cbp_border_wait_times ──────────────────────────────────
129
131
  /**
130
- * List CBP land-border-port commercial-vehicle (+ passenger) wait times, optionally
131
- * filtered by border (Canadian/Mexican) and/or port name (substring). Client-side
132
- * filter over the live feed; honest `_meta` (exact match total + real-time freshness).
132
+ * List CBP land-border-port COMMERCIAL-VEHICLE (freight-truck) wait times, optionally
133
+ * filtered by border (Canadian/Mexican) and/or port name (substring, applied
134
+ * CLIENT-SIDE over the full fetched set and disclosed as such). Passenger/pedestrian
135
+ * lanes are NOT surfaced (freight lane only). Honest `_meta` (exact match total +
136
+ * real-time freshness).
133
137
  */
134
138
  export async function borderWaitTimes(args: {
135
139
  border?: string;
@@ -142,10 +146,20 @@ export async function borderWaitTimes(args: {
142
146
  const all = await loadPorts();
143
147
 
144
148
  const filtersApplied: string[] = [];
149
+ const filtersDropped: string[] = [];
145
150
  const borderQ = args.border?.trim().toLowerCase();
146
151
  const portQ = args.portName?.trim().toLowerCase();
147
- if (args.border !== undefined) filtersApplied.push("border");
148
- if (args.portName !== undefined) filtersApplied.push("portName");
152
+ // [filter honesty] Only claim a filter APPLIED when its query is non-empty — a
153
+ // border:"" / portName:"" (or whitespace) narrows nothing, so reporting it as
154
+ // applied while returning every port would be a false filtersApplied.
155
+ if (args.border !== undefined) {
156
+ if (borderQ) filtersApplied.push("border");
157
+ else filtersDropped.push("border(empty)");
158
+ }
159
+ if (args.portName !== undefined) {
160
+ if (portQ) filtersApplied.push("portName");
161
+ else filtersDropped.push("portName(empty)");
162
+ }
149
163
 
150
164
  const matched = all.filter((p) => {
151
165
  if (borderQ && !(p.border ?? "").toLowerCase().includes(borderQ)) return false;
@@ -159,6 +173,13 @@ export async function borderWaitTimes(args: {
159
173
  const hasMore = offset + returned < totalAvailable;
160
174
  const nextOffset = hasMore ? offset + returned : null;
161
175
 
176
+ const notes: string[] = [PROVENANCE_NOTE, FRESHNESS_NOTE];
177
+ if (filtersApplied.length > 0) {
178
+ notes.push(
179
+ "border / portName are applied CLIENT-SIDE over the full live port set (fetched in ONE request — the CBP feed has no server-side filter); totalAvailable is the EXACT matched count over that full set.",
180
+ );
181
+ }
182
+
162
183
  return withMeta(
163
184
  { ports: page },
164
185
  {
@@ -168,10 +189,10 @@ export async function borderWaitTimes(args: {
168
189
  totalAvailable,
169
190
  truncated: hasMore,
170
191
  filtersApplied,
171
- filtersDropped: [],
192
+ filtersDropped,
172
193
  fieldsUnavailable: [],
173
194
  pagination: { offset, limit, hasMore, nextOffset },
174
- notes: [PROVENANCE_NOTE, FRESHNESS_NOTE],
195
+ notes,
175
196
  } satisfies Partial<ResponseMeta>,
176
197
  );
177
198
  }
@@ -94,7 +94,12 @@ const GEOGRAPHIES = new Set(["us", "state", "county"]);
94
94
  // (no CENSUS_API_KEY was available at build time). See SUPPRESSED_NOTE.
95
95
  const CENSUS_SENTINEL_FLOOR = -100000000;
96
96
 
97
- const DEFAULT_YEAR = "2022"; // the latest confirmed CBP vintage (ADR-0047)
97
+ // Latest PUBLISHED CBP vintage (live-verified 2026-07-20:
98
+ // api.census.gov/data/2023/cbp/variables.json → 200; /data/2024 → 404). CBP is
99
+ // released with a ~2-year lag and irregularly, so a dynamic "current year − N" is
100
+ // unsafe (it would query an unpublished vintage) — this is a hard-coded latest;
101
+ // bump it (and re-verify) when a newer /data/{year}/cbp appears. ADR-0047.
102
+ const DEFAULT_YEAR = "2023";
98
103
 
99
104
  // ─── Honesty notes (ADR-0047 required set) ────────────────────────
100
105
  const KEY_REQUIRED_NOTE =
@@ -145,7 +150,7 @@ export type CensusBusinessPatternsArgs = {
145
150
  naics?: string;
146
151
  geography?: string; // us | state | county (default us)
147
152
  state?: string; // 2-digit FIPS (required for county; optional filter for state)
148
- year?: string; // ^\d{4}$ (default 2022)
153
+ year?: string; // ^\d{4}$ (default 2023)
149
154
  limit?: number; // OPTIONAL client-side top-N slice (CBP has no server pagination)
150
155
  };
151
156
 
@@ -172,6 +177,7 @@ export async function businessPatterns(
172
177
 
173
178
  // ── Validate + default the inputs (belt-and-suspenders behind the server Zod;
174
179
  // a DIRECT handler call bypasses Zod). ──
180
+ const yearWasDefaulted = args.year === undefined;
175
181
  const year = args.year ?? DEFAULT_YEAR;
176
182
  if (!YEAR_RE.test(year)) {
177
183
  throw new ToolErrorCarrier({
@@ -395,6 +401,11 @@ export async function businessPatterns(
395
401
  SUPPRESSED_NOTE,
396
402
  NO_PAGINATION_NOTE,
397
403
  ];
404
+ if (yearWasDefaulted) {
405
+ notes.push(
406
+ `No \`year\` was supplied, so it defaulted to ${DEFAULT_YEAR} — the latest PUBLISHED CBP vintage as of the last verification (CBP is released with a ~2-year lag). Pass an explicit \`year\` for a different vintage.`,
407
+ );
408
+ }
398
409
 
399
410
  let rows = allRows;
400
411
  if (
package/src/ckan.ts CHANGED
@@ -441,7 +441,7 @@ export async function discoverDatasets(args: {
441
441
  params.set("rows", String(limit));
442
442
 
443
443
  const key = `ckan:package_search:${args.host}:${args.q}:${limit}`;
444
- const { totalAvailable, results } = await memoize(
444
+ const { totalAvailable, results, packagesReturned } = await memoize(
445
445
  key,
446
446
  async () => {
447
447
  const body = await getCkanPackageSearch(args.host, params);
@@ -468,16 +468,31 @@ export async function discoverDatasets(args: {
468
468
  const total = num(b.result.count);
469
469
  const packages = Array.isArray(b.result.results) ? b.result.results : [];
470
470
  const rows = packages.flatMap(mapPackageResources);
471
- return { totalAvailable: total, results: rows };
471
+ // packagesReturned = the number of DATASETS on this page. Truncation is a
472
+ // DATASET question (totalAvailable is a dataset count), NOT a resource-row
473
+ // one — the two units differ because a dataset can expose several resources.
474
+ return { totalAvailable: total, results: rows, packagesReturned: packages.length };
472
475
  },
473
476
  10 * 60 * 1000,
474
477
  );
475
478
 
476
479
  const returned = results.length;
480
+ // [truncation honesty] complete/truncated is a DATASET question — compare the
481
+ // DATASETS returned (packagesReturned) against the dataset total, NOT the
482
+ // per-resource row count (which can EXCEED the dataset total, e.g. 2 datasets →
483
+ // 14 resource rows vs count 10, and would spoof complete:true when only 2 of 10
484
+ // datasets were shown). This tool returns the first `limit` datasets and has no
485
+ // offset param, so nextOffset is null (raise `limit` to see more).
486
+ const hasMore = totalAvailable !== null && packagesReturned < totalAvailable;
477
487
  const notes: string[] = [
478
488
  `package_search over ${args.host} (rows=${limit}). Feed a datastoreActive:true result's resourceId to ckan_query; a datastoreActive:false resource is a raw file blob (CSV/PDF/…) NOT in the datastore and is NOT queryable.`,
479
- "totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned may differ from totalAvailable.",
489
+ "totalAvailable is the count of matching DATASETS (packages); the rows are per-RESOURCE (a dataset may expose several resources), so returned (resource rows) may differ from totalAvailable (datasets). complete/truncated tracks DATASETS shown vs matched.",
480
490
  ];
491
+ if (hasMore) {
492
+ notes.push(
493
+ `Only the first ${packagesReturned} of ${totalAvailable} matching datasets are shown (this tool returns the first \`limit\` datasets and does not page); raise \`limit\` to retrieve more.`,
494
+ );
495
+ }
481
496
 
482
497
  return withMeta(
483
498
  { host: args.host, query: args.q, results },
@@ -489,6 +504,7 @@ export async function discoverDatasets(args: {
489
504
  filtersApplied: ["q"],
490
505
  filtersDropped: [],
491
506
  fieldsUnavailable: [],
507
+ pagination: { offset: 0, limit, hasMore, nextOffset: null },
492
508
  notes,
493
509
  } satisfies Partial<ResponseMeta>,
494
510
  );
@@ -543,19 +543,31 @@ export async function searchStudies(args: CtSearchArgs): Promise<MetaBundle> {
543
543
  "clinicaltrials shape drift — /studies response.studies must be an array.",
544
544
  );
545
545
  }
546
- // countTotal=true was sent, so totalCount MUST be a finite number — its absence
547
- // is drift, NEVER a silently-null total, NEVER studies.length (§Honesty #1).
548
- if (typeof b.totalCount !== "number" || !Number.isFinite(b.totalCount)) {
546
+ // §Honesty #1: totalCount is returned ONLY on the FIRST page of a cursor sequence —
547
+ // ClinicalTrials.gov v2 OMITS it on every pageToken CONTINUATION, even with
548
+ // countTotal=true (live-verified: page 1 carries totalCount, every subsequent
549
+ // pageToken call omits it). So its absence is drift ONLY on the first page; on a
550
+ // continuation an absent total is EXPECTED (handled at the totalAvailable step below).
551
+ const isContinuation = args.pageToken !== undefined;
552
+
553
+ const studies = (b.studies as unknown[]).map((s) => mapStudy(s, false));
554
+ const returned = studies.length;
555
+ // First page: totalCount MUST be a finite number (typeof-checked BEFORE num() so a
556
+ // non-number can't silently parse; NEVER studies.length) — its absence is drift.
557
+ // Continuation page: totalCount is legitimately omitted ⇒ totalAvailable:null (the
558
+ // first page's total still describes the whole set; disclosed in a note).
559
+ let totalAvailable: number | null;
560
+ if (typeof b.totalCount === "number" && Number.isFinite(b.totalCount)) {
561
+ totalAvailable = num(b.totalCount) as number; // EXACT (genuine 0 → 0)
562
+ } else if (isContinuation) {
563
+ totalAvailable = null;
564
+ } else {
549
565
  throw driftError(
550
566
  CT_LABEL,
551
- "clinicaltrials shape drift — /studies totalCount missing/non-number on a countTotal=true request (typeof-checked BEFORE num() so a non-number can't silently parse; NEVER fall back to studies.length).",
567
+ "clinicaltrials shape drift — /studies totalCount missing/non-number on a FIRST-page countTotal=true request (typeof-checked BEFORE num(); NEVER fall back to studies.length).",
552
568
  );
553
569
  }
554
570
 
555
- const studies = (b.studies as unknown[]).map((s) => mapStudy(s, false));
556
- const returned = studies.length;
557
- const totalAvailable = num(b.totalCount) as number; // EXACT (genuine 0 → 0)
558
-
559
571
  // ── Opaque-cursor honesty (§Honesty #2). Terminal = token ABSENT. The token is
560
572
  // surfaced VERBATIM (never fabricated/derived). Phantom-empty guard: 0
561
573
  // studies WITH a token ⇒ terminal (never advertise a continuation into an
@@ -576,6 +588,11 @@ export async function searchStudies(args: CtSearchArgs): Promise<MetaBundle> {
576
588
  // ── Notes: the mandatory caveat + cursor + data-currency always; the
577
589
  // conditional facet/tokenization disclosures; the unscoped recommendation. ──
578
590
  const notes: string[] = [CT_TRIAL_CAVEAT, CT_CURSOR_NOTE];
591
+ if (isContinuation && totalAvailable === null) {
592
+ notes.push(
593
+ "totalAvailable is null on this continuation page — ClinicalTrials.gov reports the study total ONLY on the first page of a cursor sequence (it is omitted on every pageToken continuation, even with countTotal=true). The total from the first page still describes the whole result set.",
594
+ );
595
+ }
579
596
  notes.push(...andNotes);
580
597
  // Emit the sponsor-broadening note ONLY for a SINGLE-token sponsor. For a
581
598
  // MULTI-token sponsor CT AND-split and NARROWED (the andNotes AND-note fired),
package/src/fdic.ts CHANGED
@@ -523,7 +523,7 @@ export async function searchInstitutions(args: {
523
523
  const records = env.records.map(mapInstitution);
524
524
  const returned = records.length;
525
525
  const totalAvailable = env.totalAvailable;
526
- const hasMore = offset + returned < totalAvailable;
526
+ const hasMore = returned > 0 && offset + returned < totalAvailable;
527
527
  const nextOffset = hasMore ? offset + returned : null;
528
528
 
529
529
  const filtersApplied: string[] = [];
@@ -618,7 +618,7 @@ export async function institutionFinancials(args: {
618
618
  const records = env.records.map(mapFinancials);
619
619
  const returned = records.length;
620
620
  const totalAvailable = env.totalAvailable;
621
- const hasMore = offset + returned < totalAvailable;
621
+ const hasMore = returned > 0 && offset + returned < totalAvailable;
622
622
  const nextOffset = hasMore ? offset + returned : null;
623
623
 
624
624
  const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
@@ -862,7 +862,7 @@ export async function bankFailures(args: {
862
862
  const records = env.records.map(mapFailure);
863
863
  const returned = records.length;
864
864
  const totalAvailable = env.totalAvailable;
865
- const hasMore = offset + returned < totalAvailable;
865
+ const hasMore = returned > 0 && offset + returned < totalAvailable;
866
866
  const nextOffset = hasMore ? offset + returned : null;
867
867
 
868
868
  const filtersApplied: string[] = [];
@@ -1184,7 +1184,7 @@ export async function institutionHistory(args: {
1184
1184
  const records = env.records.map(mapHistory);
1185
1185
  const returned = records.length;
1186
1186
  const totalAvailable = env.totalAvailable;
1187
- const hasMore = offset + returned < totalAvailable;
1187
+ const hasMore = returned > 0 && offset + returned < totalAvailable;
1188
1188
  const nextOffset = hasMore ? offset + returned : null;
1189
1189
 
1190
1190
  const filtersApplied: string[] = [];
@@ -1524,7 +1524,7 @@ export async function industrySummary(args: {
1524
1524
  const records = env.records.map(mapSummary);
1525
1525
  const returned = records.length;
1526
1526
  const totalAvailable = env.totalAvailable;
1527
- const hasMore = offset + returned < totalAvailable;
1527
+ const hasMore = returned > 0 && offset + returned < totalAvailable;
1528
1528
  const nextOffset = hasMore ? offset + returned : null;
1529
1529
 
1530
1530
  const filtersApplied: string[] = [];
@@ -1820,7 +1820,7 @@ export async function riskRatios(args: {
1820
1820
  const records = env.records.map(mapRiskRatios);
1821
1821
  const returned = records.length;
1822
1822
  const totalAvailable = env.totalAvailable;
1823
- const hasMore = offset + returned < totalAvailable;
1823
+ const hasMore = returned > 0 && offset + returned < totalAvailable;
1824
1824
  const nextOffset = hasMore ? offset + returned : null;
1825
1825
 
1826
1826
  const filtersApplied: string[] = ["cert"];
@@ -2013,7 +2013,7 @@ export async function branchDeposits(args: {
2013
2013
  const records = env.records.map(mapBranchDeposit);
2014
2014
  const returned = records.length;
2015
2015
  const totalAvailable = env.totalAvailable;
2016
- const hasMore = offset + returned < totalAvailable;
2016
+ const hasMore = returned > 0 && offset + returned < totalAvailable;
2017
2017
  const nextOffset = hasMore ? offset + returned : null;
2018
2018
 
2019
2019
  const filtersApplied: string[] = [];
package/src/fema.ts CHANGED
@@ -407,8 +407,14 @@ function shapeResponse(args: {
407
407
 
408
408
  const rows = coerceAmounts(rawRows as FemaRow[], def.amountFields);
409
409
  const returned = rows.length;
410
+ // `returned > 0` guard (matches nvd/usaspending): an EMPTY page MUST terminate the
411
+ // walk. OpenFEMA's metadata.count can EXCEED the rows it will actually serve via
412
+ // $skip/$top (live: the ~822k-row public_assistance set stops serving rows well
413
+ // before its count), so without this guard a near-end offset yields returned:0 while
414
+ // offset < count ⇒ hasMore:true, nextOffset === offset — a non-advancing cursor an
415
+ // agent following nextOffset re-requests forever (empty page, re-scan, repeat).
410
416
  const hasMore =
411
- totalAvailable !== null && args.offset + returned < totalAvailable;
417
+ returned > 0 && totalAvailable !== null && args.offset + returned < totalAvailable;
412
418
  const nextOffset = hasMore ? args.offset + returned : null;
413
419
 
414
420
  const notes: string[] = [SHAPE_NOTE];
@@ -421,6 +427,14 @@ function shapeResponse(args: {
421
427
  "This page returned fewer rows than the requested limit while more remain (OpenFEMA byte-truncates a wide page below $top); metadata.count is authoritative — page with a larger offset ($skip).",
422
428
  );
423
429
  }
430
+ // Phantom tail: OpenFEMA returned 0 rows though metadata.count is higher — its count
431
+ // can exceed the rows it will serve via pagination, so the walk is COMPLETE here even
432
+ // though returned:0 < count. Disclose it so the count is not read as a reachable target.
433
+ if (returned === 0 && totalAvailable !== null && args.offset > 0 && args.offset < totalAvailable) {
434
+ notes.push(
435
+ `OpenFEMA served 0 rows at offset ${args.offset} although metadata.count is ${totalAvailable} — its count can exceed the rows actually pageable via $skip/$top, so pagination is COMPLETE here (do not treat count as a reachable row target; narrow the filter for an exact set).`,
436
+ );
437
+ }
424
438
  // Deep-offset caveat on the ~800k PA set (ADR-0016 OQ2).
425
439
  if (args.offset > 100000) {
426
440
  notes.push(
package/src/gao.ts CHANGED
@@ -593,6 +593,21 @@ export async function gaoProtestLookup(args: {
593
593
 
594
594
  // ── Feed path ──────────────────────────────────────────────────────
595
595
  const xml = await getText(RSS_URL, "gao:rss");
596
+ // [P2/P4] A 200 body that isn't the RSS feed (GAO's edge is Cloudflare/WAF — a
597
+ // challenge or maintenance interstitial returns 200 HTML, NOT the feed) must
598
+ // NOT be parsed into an empty decision list: parseFeed's <item> regex yields []
599
+ // on any non-RSS body, which would read as "no recent bid protests" — an
600
+ // outage-as-empty lie. A real feed always carries <rss>/<channel>/<item> (a
601
+ // genuinely EMPTY feed still has <channel>, so a 0-item feed passes). Guard the
602
+ // shape and surface a retryable outage instead of a fabricated empty result.
603
+ if (!/<(rss|feed|channel|item)\b/i.test(xml)) {
604
+ throw new ToolErrorCarrier({
605
+ kind: "upstream_unavailable",
606
+ retryable: true,
607
+ message: `GAO RSS returned a 200 body with no <rss>/<channel>/<item> markup (${xml.length} bytes) — GAO's Cloudflare/WAF edge most likely served a challenge or maintenance interstitial instead of the feed. Surfaced as a retryable outage rather than a fabricated empty decision list (which would falsely read as "no recent bid protests"). Retry shortly.`,
608
+ upstreamEndpoint: "gao:rss",
609
+ });
610
+ }
596
611
  const rawItems = parseFeed(xml);
597
612
  const protests = rawItems.filter(isBidProtest);
598
613
 
@@ -653,9 +668,16 @@ export async function gaoProtestLookup(args: {
653
668
 
654
669
  // Apply the outcome filter now that we (may) have parsed outcomes, then cap.
655
670
  const filtersDropped: string[] = [];
671
+ let outcomeUndetermined = 0;
656
672
  if (outcomeFilter) {
657
673
  if (enrich) {
658
674
  filtersApplied.push("outcome(from decision page)");
675
+ // [filter honesty] A decision whose page could not be enriched has
676
+ // outcome=null — UNDETERMINED, not confirmed "≠ outcomeFilter". Dropping
677
+ // those silently makes returned:0 read as "no <outcome> protests exist"
678
+ // when the truth is "no outcome could be read" (GAO per-decision pages are
679
+ // intermittently WAF-blocked). Count them so it is disclosed, not swallowed.
680
+ outcomeUndetermined = decisions.filter((d) => d.outcome === null).length;
659
681
  decisions = decisions.filter((d) => d.outcome === outcomeFilter);
660
682
  } else {
661
683
  // Can't determine outcome without enrichment → don't silently pretend to.
@@ -672,6 +694,11 @@ export async function gaoProtestLookup(args: {
672
694
  `${enrichFailures} decision(s) returned feed-level fields only; per-decision page enrichment failed (agency/outcome/solicitation may be null for those).`,
673
695
  );
674
696
  }
697
+ if (outcomeFilter && enrich && outcomeUndetermined > 0) {
698
+ notes.push(
699
+ `${outcomeUndetermined} recent decision(s) were EXCLUDED from the outcome='${outcomeFilter}' filter because their decision page could not be read (enrichment failed) — their outcome is UNDETERMINED, not confirmed to differ. Treat a 0 or low result here as "outcome could not be determined for those", NOT "none exist"; retry (GAO per-decision pages are intermittently WAF-blocked) or drop the outcome filter to list all recent decisions.`,
700
+ );
701
+ }
675
702
  if (!enrich) {
676
703
  notes.push(
677
704
  "enrich=false: only RSS feed-level fields were returned (protester/title/date/summary). Agency, outcome, solicitation number, and the decision PDF require the per-decision page — call again with enrich=true or a specific bNumber.",
package/src/grants.ts CHANGED
@@ -45,13 +45,17 @@ export async function searchGrants(args: {
45
45
  oppStatuses?: GrantStatus[];
46
46
  rows?: number;
47
47
  }) {
48
+ // The tool ALWAYS sends oppStatuses — default forecasted|posted when the caller
49
+ // omits it — so it is ALWAYS an applied server-side filter (disclosed below).
50
+ const effOppStatuses = args.oppStatuses ?? ["forecasted", "posted"];
51
+ const statusDefaulted = args.oppStatuses == null;
48
52
  const body: Record<string, unknown> = {
49
53
  rows: args.rows ?? 10,
50
54
  keyword: args.keyword ?? "",
51
55
  cfda: args.cfda ?? "",
52
56
  agencies: args.agency ?? "",
53
57
  oppNum: args.oppNum ?? "",
54
- oppStatuses: (args.oppStatuses ?? ["forecasted", "posted"]).join("|"),
58
+ oppStatuses: effOppStatuses.join("|"),
55
59
  };
56
60
  type Resp = {
57
61
  errorcode?: number;
@@ -135,11 +139,20 @@ export async function searchGrants(args: {
135
139
  if (args.cfda) sent.push("cfda");
136
140
  if (args.agency) sent.push("agency");
137
141
  if (args.oppNum) sent.push("oppNum");
138
- if (args.oppStatuses?.length) sent.push("oppStatuses");
142
+ // oppStatuses is ALWAYS sent (default or caller-supplied), so ALWAYS disclose the
143
+ // EFFECTIVE set — omitting it when defaulted made totalAvailable (a status-filtered
144
+ // subset) read as unfiltered (live: keyword=cybersecurity → 235 forecasted|posted vs
145
+ // 2008 all-statuses, a 1773-record closed/archived bucket hidden).
146
+ sent.push(`oppStatuses(${effOppStatuses.join("|") || "all-statuses"})`);
139
147
  const notes: string[] = [];
148
+ if (statusDefaulted) {
149
+ notes.push(
150
+ "No oppStatuses supplied — DEFAULTED to forecasted|posted, so CLOSED and ARCHIVED opportunities are EXCLUDED from both the results AND totalAvailable (this total is the open/forecasted subset, not all statuses). To count or return every status, pass oppStatuses including 'closed'/'archived'.",
151
+ );
152
+ }
140
153
  if (args.agency || args.cfda) {
141
154
  notes.push(
142
- "Grants.gov silently ignores an unknown agency code or CFDA number (it returns the UNFILTERED result set instead of an error) and does not confirm which filters were honored — if the result count looks too broad, verify the agency/CFDA value.",
155
+ "Grants.gov applies the agency/CFDA filter server-side (live-verified 2026-07-20): a bogus or misspelled value returns 0 results, NOT an error and NOT the unfiltered set — so an unexpectedly EMPTY filtered search most often means the agency/CFDA value is invalid, not that no grants exist. `filtersApplied` reflects that the filter was sent. Verify the agency code / CFDA number if a filtered result is surprisingly empty.",
143
156
  );
144
157
  }
145
158
  // VQ-1 (C82 dogfooding): Grants.gov OR-tokenizes multi-word keywords (matches ANY
package/src/gsa-csv.ts CHANGED
@@ -57,10 +57,12 @@
57
57
  import { createReadStream, createWriteStream } from "node:fs";
58
58
  import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
59
59
  import { createInterface } from "node:readline";
60
+ import { once } from "node:events";
60
61
  import { pipeline } from "node:stream/promises";
61
62
  import * as os from "node:os";
62
63
  import * as path from "node:path";
63
64
  import { fetchWithRetry, ToolErrorCarrier } from "./errors.js";
65
+ import { driftError } from "./datasource.js";
64
66
  import { withMeta } from "./meta.js";
65
67
 
66
68
  // ─── Source + config ─────────────────────────────────────────────
@@ -309,20 +311,34 @@ function fieldsFromRecord(rec: string[]): NoticeFields {
309
311
  * loads the whole file into memory — readline yields one physical line at a
310
312
  * time and the assembler holds at most one in-progress (quote-spanning) record.
311
313
  */
312
- async function buildIndexFromFile(
314
+ export async function buildIndexFromFile(
313
315
  csvPath: string,
314
316
  ): Promise<{ notices: Record<string, NoticeFields>; rowCount: number }> {
315
317
  const notices: Record<string, NoticeFields> = Object.create(null);
316
318
  let headerSeen = false;
317
319
  let rowCount = 0;
318
320
 
319
- const rl = createInterface({
320
- input: createReadStream(csvPath, { encoding: "utf8" }),
321
- crlfDelay: Infinity,
322
- });
321
+ const stream = createReadStream(csvPath, { encoding: "utf8" });
322
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
323
323
  const asm = makeRecordAssembler((rec) => {
324
324
  if (!headerSeen) {
325
325
  headerSeen = true; // first logical record is the 47-column header
326
+ // [P4] The column contract is POSITIONAL (fixed COL indices). If GSA
327
+ // reorders / inserts / renames a column, trusting the old positions would
328
+ // serve WRONG-POSITION data as authoritative (or, if NoticeId shifts, skip
329
+ // every row → a false "not in the current CSV snapshot"). Neither throws
330
+ // today. Assert the expected header NAME at each COL index before indexing
331
+ // any data row — a mismatch is schema drift, never a fake-empty (mirrors the
332
+ // census-economic.ts header guard). Each COL key IS its expected header name.
333
+ for (const [expectedName, idx] of Object.entries(COL)) {
334
+ const got = (rec[idx] ?? "").trim();
335
+ if (got !== expectedName) {
336
+ throw driftError(
337
+ "gsa:csv",
338
+ `GSA Contract-Opportunities CSV header drift — column ${idx} is ${JSON.stringify(got)}, expected ${JSON.stringify(expectedName)}. The positional column contract changed; refusing to index (a silent column shift would serve wrong-position data as authoritative, or skip every row as a false "not in the current snapshot").`,
339
+ );
340
+ }
341
+ }
326
342
  return;
327
343
  }
328
344
  rowCount++;
@@ -333,8 +349,23 @@ async function buildIndexFromFile(
333
349
  notices[noticeId.toLowerCase()] = fieldsFromRecord(rec);
334
350
  });
335
351
 
336
- for await (const line of rl) asm.push(line);
337
- asm.flush();
352
+ try {
353
+ for await (const line of rl) asm.push(line);
354
+ asm.flush();
355
+ } finally {
356
+ // Release the OS file handle on ALL exit paths (including the header-drift
357
+ // throw, which aborts mid-stream). destroy() is ASYNC — the fd is not freed
358
+ // until the 'close' event — so AWAIT it; otherwise a caller deleting the file
359
+ // immediately after (Windows holds a lock on an open read stream) hits
360
+ // ENOTEMPTY/EBUSY. On the normal EOF path the stream auto-destroys, so guard
361
+ // on `.destroyed` and set the listener BEFORE destroy() to catch its 'close'.
362
+ rl.close();
363
+ if (!stream.destroyed) {
364
+ const closed = once(stream, "close");
365
+ stream.destroy();
366
+ await closed.catch(() => {});
367
+ }
368
+ }
338
369
 
339
370
  return { notices, rowCount };
340
371
  }
@@ -57,8 +57,26 @@ const GSA_PERDIEM_LABEL = "gsa-perdiem:/travel/perdiem/v2/rates";
57
57
  const GSA_PERDIEM_SOURCE = (mode: string) =>
58
58
  `${GSA_PERDIEM_HOST} via GSA Federal Travel Per-Diem API (${mode})`;
59
59
 
60
- // The default per-diem fiscal year (ADR-0050 — the current confirmed vintage).
61
- export const DEFAULT_PERDIEM_YEAR = "2025";
60
+ /**
61
+ * The US federal fiscal year for a date (ADR-0050). The FY begins Oct 1, so
62
+ * Oct–Dec belong to the NEXT calendar year's FY (e.g. 2025-11 → FY2026) while
63
+ * Jan–Sep stay in the current (e.g. 2026-07 → FY2026). Pure + UTC-based so it is
64
+ * deterministic and timezone-independent (the intra-day rollover instant is
65
+ * immaterial — per-diem rates do not change within a day).
66
+ */
67
+ export function federalFiscalYear(d: Date): number {
68
+ return d.getUTCMonth() >= 9 ? d.getUTCFullYear() + 1 : d.getUTCFullYear();
69
+ }
70
+
71
+ /**
72
+ * The default per-diem year when the caller omits `year`: the CURRENT federal
73
+ * fiscal year. GSA publishes rates per FY; a hard-coded default silently serves
74
+ * an EXPIRED vintage once the FY rolls over (the drift this replaces — a no-year
75
+ * lookup must track the live FY, not a frozen year).
76
+ */
77
+ export function defaultPerdiemYear(): string {
78
+ return String(federalFiscalYear(new Date()));
79
+ }
62
80
 
63
81
  // ─── Validation charclasses (SSRF + "verify the input" honesty) ───
64
82
  // Each rides in a single PATH segment (encodeURIComponent-escaped), so these are
@@ -158,7 +176,7 @@ async function getGsaPerdiem(path: string): Promise<unknown> {
158
176
 
159
177
  /**
160
178
  * Look up GSA Federal Travel per-diem rates by EITHER (city + state) OR zip, for a
161
- * given `year` (default 2025). Returns flattened rate rows (each outer state/year
179
+ * given `year` (default: the current U.S. federal fiscal year). Returns flattened rate rows (each outer state/year
162
180
  * group × inner city/rate) + honest `_meta`: totalAvailable = the row count (no
163
181
  * pagination — P1), lodging/meals as null-never-0 dollars (P3), standardRate/isOconus
164
182
  * as real booleans, the months array preserved as-is. The DEMO_KEY rate disclosure
@@ -168,7 +186,8 @@ export async function perdiemRates(
168
186
  args: GsaPerdiemRatesArgs,
169
187
  ): Promise<MetaBundle> {
170
188
  const label = GSA_PERDIEM_LABEL;
171
- const year = args.year ?? DEFAULT_PERDIEM_YEAR;
189
+ const yearWasDefaulted = args.year === undefined;
190
+ const year = args.year ?? defaultPerdiemYear();
172
191
 
173
192
  // ── [INPUT] EITHER (city + state) OR zip — never both, never neither. This is a
174
193
  // caller-shape check (0 fetch): an ambiguous or empty lookup is invalid_input,
@@ -341,6 +360,11 @@ export async function perdiemRates(
341
360
 
342
361
  const returned = rows.length;
343
362
  const notes: string[] = [RATE_MEANING_NOTE, STANDARD_RATE_NOTE, NO_PAGINATION_NOTE];
363
+ if (yearWasDefaulted) {
364
+ notes.push(
365
+ `No \`year\` was supplied, so it defaulted to the CURRENT U.S. federal fiscal year (FY${year}). GSA per-diem ceilings are set per fiscal year (Oct 1–Sep 30); pass an explicit \`year\` for a prior or upcoming FY.`,
366
+ );
367
+ }
344
368
  pushKeyNote(notes);
345
369
 
346
370
  return withMeta(
package/src/integrity.ts CHANGED
@@ -294,7 +294,22 @@ export async function checkExclusions(args: {
294
294
  const serverTruncated =
295
295
  totalElements !== null && rawResults.length < totalElements;
296
296
  const hitCap = totalElements !== null && totalElements > SGS_MAX_RECORDS;
297
- const truncated = serverTruncated || hitCap || postFiltered;
297
+ // totalAvailable HONESTY: the raw free-text `totalElements` counts every record
298
+ // that merely shares a WORD with the query — NOT the name-gated matches this
299
+ // tool reports. Surfacing it read as "0 of 252 matches, incomplete" for a firm
300
+ // with ZERO real matches (a vetting tool must not overstate match availability).
301
+ // When the result is name-gated (the normal case — a selector is always
302
+ // required), the true count of name-MATCHING exclusions is genuinely unknown
303
+ // from one page of text hits, so report null (never the free-text total); the
304
+ // per-page match count stays in data.matchCount.
305
+ const totalAvailable = postFiltered ? null : totalElements;
306
+ // truncated only when there is genuinely more to see: more server pages, the
307
+ // deep-paging cap, or this page dropped loose text hits (so a name VARIANT
308
+ // could match on a later page). The old `|| postFiltered` forced truncated:true
309
+ // even over a genuinely empty result set (0 text hits) — asserting incompleteness
310
+ // over an empty set, a contradiction. A fully-consumed name-gated result is a
311
+ // complete, honest empty.
312
+ const truncated = serverTruncated || hitCap || (postFiltered && looseTextHits > 0);
298
313
 
299
314
  const notes: string[] = [NOT_PROOF_NOTE];
300
315
  notes.push(
@@ -307,7 +322,7 @@ export async function checkExclusions(args: {
307
322
  }
308
323
  if (postFiltered) {
309
324
  notes.push(
310
- "A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true match count for the combined filter may exceed this page. Narrow with a more specific `query` or raise `size`.",
325
+ "A uei/cage/classification/activeOnly post-filter was applied over the fetched page only — the true name-matched total is unknown from one page, so `totalAvailable` is null (the raw free-text hit count is NOT the match count). `matchCount` is this page's name-gated matches; narrow with a more specific `query` or raise `size`.",
311
326
  );
312
327
  }
313
328
  if (hitCap) {
@@ -333,7 +348,7 @@ export async function checkExclusions(args: {
333
348
  source: EXCLUSIONS_SOURCE,
334
349
  keylessMode: true,
335
350
  returned: records.length,
336
- totalAvailable: totalElements,
351
+ totalAvailable,
337
352
  truncated,
338
353
  pagination: {
339
354
  offset: page * size,