@cliwant/mcp-sam-gov 1.11.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 (77) hide show
  1. package/README.md +4 -0
  2. package/dist/bls.d.ts.map +1 -1
  3. package/dist/bls.js +6 -1
  4. package/dist/bls.js.map +1 -1
  5. package/dist/bonfire.d.ts.map +1 -1
  6. package/dist/bonfire.js +12 -1
  7. package/dist/bonfire.js.map +1 -1
  8. package/dist/census-economic.d.ts.map +1 -1
  9. package/dist/census-economic.js +10 -1
  10. package/dist/census-economic.js.map +1 -1
  11. package/dist/ckan.d.ts.map +1 -1
  12. package/dist/ckan.js +17 -3
  13. package/dist/ckan.js.map +1 -1
  14. package/dist/clinicaltrials.d.ts.map +1 -1
  15. package/dist/clinicaltrials.js +23 -6
  16. package/dist/clinicaltrials.js.map +1 -1
  17. package/dist/fdic.js +7 -7
  18. package/dist/fdic.js.map +1 -1
  19. package/dist/fema.d.ts.map +1 -1
  20. package/dist/fema.js +13 -1
  21. package/dist/fema.js.map +1 -1
  22. package/dist/grants.d.ts.map +1 -1
  23. package/dist/grants.js +13 -3
  24. package/dist/grants.js.map +1 -1
  25. package/dist/gsa-csv.d.ts +9 -0
  26. package/dist/gsa-csv.d.ts.map +1 -1
  27. package/dist/gsa-csv.js +37 -8
  28. package/dist/gsa-csv.js.map +1 -1
  29. package/dist/nppes.d.ts.map +1 -1
  30. package/dist/nppes.js +17 -4
  31. package/dist/nppes.js.map +1 -1
  32. package/dist/nsf.d.ts.map +1 -1
  33. package/dist/nsf.js +9 -0
  34. package/dist/nsf.js.map +1 -1
  35. package/dist/openfda-device.d.ts.map +1 -1
  36. package/dist/openfda-device.js +8 -5
  37. package/dist/openfda-device.js.map +1 -1
  38. package/dist/openfda-drugsfda.d.ts.map +1 -1
  39. package/dist/openfda-drugsfda.js +8 -5
  40. package/dist/openfda-drugsfda.js.map +1 -1
  41. package/dist/openfda.d.ts +21 -0
  42. package/dist/openfda.d.ts.map +1 -1
  43. package/dist/openfda.js +38 -4
  44. package/dist/openfda.js.map +1 -1
  45. package/dist/opengov.d.ts.map +1 -1
  46. package/dist/opengov.js +8 -4
  47. package/dist/opengov.js.map +1 -1
  48. package/dist/server.d.ts +7 -0
  49. package/dist/server.d.ts.map +1 -1
  50. package/dist/server.js +46 -3
  51. package/dist/server.js.map +1 -1
  52. package/dist/socrata.d.ts.map +1 -1
  53. package/dist/socrata.js +14 -7
  54. package/dist/socrata.js.map +1 -1
  55. package/dist/usaspending.d.ts +2 -2
  56. package/dist/usaspending.d.ts.map +1 -1
  57. package/dist/usaspending.js +21 -10
  58. package/dist/usaspending.js.map +1 -1
  59. package/package.json +1 -1
  60. package/src/bls.ts +8 -1
  61. package/src/bonfire.ts +12 -1
  62. package/src/census-economic.ts +13 -2
  63. package/src/ckan.ts +19 -3
  64. package/src/clinicaltrials.ts +25 -8
  65. package/src/fdic.ts +7 -7
  66. package/src/fema.ts +15 -1
  67. package/src/grants.ts +15 -2
  68. package/src/gsa-csv.ts +38 -7
  69. package/src/nppes.ts +18 -4
  70. package/src/nsf.ts +10 -0
  71. package/src/openfda-device.ts +10 -4
  72. package/src/openfda-drugsfda.ts +10 -4
  73. package/src/openfda.ts +47 -5
  74. package/src/opengov.ts +8 -4
  75. package/src/server.ts +51 -3
  76. package/src/socrata.ts +14 -7
  77. package/src/usaspending.ts +21 -10
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/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,8 +139,17 @@ 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
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.",
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
  }
package/src/nppes.ts CHANGED
@@ -793,16 +793,29 @@ export async function lookupProvider(
793
793
  const candidateNext = skip + returned;
794
794
  const nextSkip = pageFull && candidateNext <= NPPES_MAX_SKIP ? candidateNext : null;
795
795
  const hasMore = nextSkip !== null;
796
- const totalAvailable = skip + returned;
796
+ // P1: NPPES exposes no grand total. `skip + returned` is the total ONLY when the
797
+ // page actually held rows — a partial/last page ⇒ EXACT, a full page ⇒ a lower
798
+ // bound (flagged below). An EMPTY page past skip 0 proves only that the true total
799
+ // is ≤ skip (an UPPER bound), NEVER a count: reporting skip + 0 = skip would
800
+ // fabricate an arbitrary total (e.g. skip 56 over a 6-match query ⇒ a false "56").
801
+ // At skip 0 an empty page is the genuine zero.
802
+ const overSkipped = returned === 0 && skip > 0;
803
+ const totalAvailable = overSkipped ? null : skip + returned;
797
804
 
798
805
  const notes: string[] = [];
799
806
  if (pageFull) {
800
- notes.push(lowerBoundNote(totalAvailable));
807
+ // pageFull ⇒ returned === limit ≥ 1 ⇒ overSkipped false ⇒ totalAvailable is the
808
+ // numeric lower bound skip + returned (pass it directly so the type stays number).
809
+ notes.push(lowerBoundNote(skip + returned));
801
810
  if (nextSkip === null) {
802
811
  notes.push(
803
812
  `A full page was returned but the next page would exceed the skip ≤ ${NPPES_MAX_SKIP} policy cap — additional matches exist but are NOT reachable via this tool. Narrow your filters for a complete set.`,
804
813
  );
805
814
  }
815
+ } else if (overSkipped) {
816
+ notes.push(
817
+ `No providers at skip=${skip}: the skip offset is past the end of the result set. An empty page proves only that the true total is ≤ ${skip} (an upper bound), not a count — so totalAvailable is unknown (null). Page back (a lower skip) to reach the last populated page and its exact total.`,
818
+ );
806
819
  } else if (returned === 0) {
807
820
  notes.push(
808
821
  `No NPPES providers matched (found:false / empty — an honest zero, distinct from an outage or a body-level error). Not a fitness determination.`,
@@ -813,8 +826,9 @@ export async function lookupProvider(
813
826
  const meta: Partial<ResponseMeta> = {
814
827
  source: SOURCE,
815
828
  keylessMode: true,
816
- // A full page is never complete (more may exist, reachable or not).
817
- truncated: pageFull,
829
+ // A full page (more may exist) OR an over-skip empty page (matches exist at a
830
+ // LOWER skip, not here) is not complete; only skip-0 / a partial last page is.
831
+ truncated: pageFull || overSkipped,
818
832
  returned,
819
833
  totalAvailable,
820
834
  filtersApplied,
package/src/nsf.ts CHANGED
@@ -158,6 +158,13 @@ const AMOUNTS_NOTE =
158
158
  const DATA_CURRENCY_NOTE =
159
159
  "NSF updates awards on a rolling basis; per-record refresh lag is not API-verifiable.";
160
160
 
161
+ /** Unstable-order disclosure: NSF exposes no stable server-side sort/tiebreaker, so
162
+ * paging by offset can overlap (duplicate) or skip rows across pages. Fires whenever
163
+ * the result set spans more than one page (hasMore, or the caller is already past
164
+ * offset 0). Live-verified: repeated identical requests reorder; adjacent windows overlap. */
165
+ const NSF_ORDER_UNSTABLE_NOTE =
166
+ "NSF returns results in an order that is NOT stably sorted (no server-side stable sort/tiebreaker), so paging by offset across multiple pages can DUPLICATE or SKIP rows (live-verified: repeated identical requests reorder, and adjacent offset windows overlap). totalAvailable is the reliable COUNT — do NOT enumerate by walking offsets; to list an exact set, narrow the query (state / keyword / PI / date / UEI) so the whole result fits one page (offset 0).";
167
+
161
168
  /** Live-verified date-filter semantics (Open-Q6 — do not assume). */
162
169
  const DATE_SEMANTICS_NOTE =
163
170
  "dateStart / dateEnd filter on the award ACTION date (the initial award / obligation date — the `date` / initAmendmentDate field, live-verified 2026-07-12), NOT the project startDate or expDate. Format is STRICT mm/dd/yyyy; a yyyy-mm-dd (or any other format) is silently mis-parsed by NSF (not an error), so it is rejected client-side.";
@@ -618,6 +625,9 @@ export async function searchAwards(args: NsfSearchArgs): Promise<MetaBundle> {
618
625
  const notes: string[] = [NSF_GRANT_CAVEAT, UEI_JOIN_NOTE, AMOUNTS_NOTE];
619
626
  if (multiWordKeyword) notes.push(orSemanticsNote(multiWordKeyword)); // M1
620
627
  if (totalIsLowerBound) notes.push(LOWER_BOUND_NOTE);
628
+ // Unstable-order disclosure: fire whenever the result spans >1 page (offset paging is
629
+ // in play), so an agent never silently double-counts/skips rows by walking offsets.
630
+ if (hasMore || offset > 0) notes.push(NSF_ORDER_UNSTABLE_NOTE);
621
631
  if (rpp < limit) notes.push(clampNote(rpp));
622
632
  if (args.dateStart !== undefined || args.dateEnd !== undefined)
623
633
  notes.push(DATE_SEMANTICS_NOTE);
@@ -53,6 +53,10 @@ import {
53
53
  readOpenfdaError,
54
54
  luceneQuote,
55
55
  openfdaApiKey,
56
+ openfdaPageMeta,
57
+ openfdaEmptyTotal,
58
+ OPENFDA_CEILING_NOTE,
59
+ OPENFDA_OVERSKIP_NOTE,
56
60
  } from "./openfda.js";
57
61
 
58
62
  // state filter charclass (a 2-letter US state/territory postal code).
@@ -282,10 +286,10 @@ export async function deviceClearances(
282
286
  const rawTotal = metaResults.total;
283
287
  const totalAvailable =
284
288
  typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
285
- const hasMore = totalAvailable !== null && skip + returned < totalAvailable;
286
- const nextOffset = hasMore ? skip + returned : null;
289
+ const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
287
290
 
288
291
  const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
292
+ if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
289
293
  if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
290
294
 
291
295
  return withMeta(
@@ -327,13 +331,15 @@ function emptyResult(
327
331
  })`,
328
332
  keylessMode: true,
329
333
  returned: 0,
330
- totalAvailable: 0,
334
+ totalAvailable: openfdaEmptyTotal(skip),
331
335
  filtersApplied,
332
336
  filtersDropped: [],
333
337
  fieldsUnavailable: [],
334
338
  pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
335
339
  notes: [
336
- "No 510(k) device clearances matched this query (openFDA returned HTTP 404 NOT_FOUND — the source's honest no-match). This is an exact empty, not an error.",
340
+ skip === 0
341
+ ? "No 510(k) device clearances matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
342
+ : OPENFDA_OVERSKIP_NOTE,
337
343
  NOT_DETERMINATION_NOTE,
338
344
  keyNote(hasKey),
339
345
  ],
@@ -37,6 +37,10 @@ import {
37
37
  readOpenfdaError,
38
38
  luceneQuote,
39
39
  openfdaApiKey,
40
+ openfdaPageMeta,
41
+ openfdaEmptyTotal,
42
+ OPENFDA_CEILING_NOTE,
43
+ OPENFDA_OVERSKIP_NOTE,
40
44
  } from "./openfda.js";
41
45
 
42
46
  const DEFAULT_LIMIT = 25;
@@ -241,10 +245,10 @@ export async function drugApprovals(args: DrugApprovalsArgs): Promise<MetaBundle
241
245
  const rawTotal = metaResults.total;
242
246
  const totalAvailable =
243
247
  typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
244
- const hasMore = totalAvailable !== null && skip + returned < totalAvailable;
245
- const nextOffset = hasMore ? skip + returned : null;
248
+ const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
246
249
 
247
250
  const notes: string[] = [NOT_DETERMINATION_NOTE, MARKETING_NOTE, keyNote(key !== undefined)];
251
+ if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
248
252
  if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
249
253
 
250
254
  return withMeta(
@@ -284,13 +288,15 @@ function emptyResult(
284
288
  })`,
285
289
  keylessMode: true,
286
290
  returned: 0,
287
- totalAvailable: 0,
291
+ totalAvailable: openfdaEmptyTotal(skip),
288
292
  filtersApplied,
289
293
  filtersDropped: [],
290
294
  fieldsUnavailable: [],
291
295
  pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
292
296
  notes: [
293
- "No Drugs@FDA applications matched this query (openFDA returned HTTP 404 NOT_FOUND — the source's honest no-match). This is an exact empty, not an error.",
297
+ skip === 0
298
+ ? "No Drugs@FDA applications matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
299
+ : OPENFDA_OVERSKIP_NOTE,
294
300
  NOT_DETERMINATION_NOTE,
295
301
  keyNote(hasKey),
296
302
  ],
package/src/openfda.ts CHANGED
@@ -435,11 +435,10 @@ export async function enforcement(
435
435
  const rawTotal = metaResults.total;
436
436
  const totalAvailable =
437
437
  typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
438
- const hasMore =
439
- totalAvailable !== null && skip + returned < totalAvailable;
440
- const nextOffset = hasMore ? skip + returned : null;
438
+ const { hasMore, nextOffset, ceilingHit } = openfdaPageMeta(skip, returned, totalAvailable);
441
439
 
442
440
  const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
441
+ if (ceilingHit) notes.push(OPENFDA_CEILING_NOTE);
443
442
  if (!hasStructuredFilter) notes.push(NO_FILTER_NOTE);
444
443
  if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
445
444
 
@@ -476,8 +475,11 @@ function emptyResult(
476
475
  hasKey: boolean,
477
476
  categoryDefaulted: boolean,
478
477
  ): MetaBundle {
478
+ const emptyTotal = openfdaEmptyTotal(skip);
479
479
  const notes = [
480
- "No recall/enforcement records matched this query (openFDA returned HTTP 404 NOT_FOUND — the source's honest no-match). This is an exact empty, not an error.",
480
+ skip === 0
481
+ ? "No recall/enforcement records matched this query (openFDA returned HTTP 404 NOT_FOUND at skip 0 — the source's honest no-match). This is an exact empty (total 0), not an error."
482
+ : OPENFDA_OVERSKIP_NOTE,
481
483
  NOT_DETERMINATION_NOTE,
482
484
  keyNote(hasKey),
483
485
  ];
@@ -493,7 +495,7 @@ function emptyResult(
493
495
  })`,
494
496
  keylessMode: true,
495
497
  returned: 0,
496
- totalAvailable: 0,
498
+ totalAvailable: emptyTotal,
497
499
  filtersApplied,
498
500
  filtersDropped: [],
499
501
  fieldsUnavailable: [],
@@ -516,3 +518,43 @@ function clampSkip(v: unknown): number {
516
518
  const n = Math.floor(v);
517
519
  return n < 0 ? 0 : n;
518
520
  }
521
+
522
+ // ─── Shared pagination-honesty helpers (used by all three openFDA modules) ──
523
+ /** openFDA caps `skip` at 25000 (skip>25000 → HTTP 400 "Skip value must 25000 or less").
524
+ * The last reachable page is skip=25000, so at most ~25000+limit records are enumerable
525
+ * via offset paging — datasets larger than that have an UNREACHABLE tail. */
526
+ export const OPENFDA_MAX_SKIP = 25000;
527
+
528
+ /** Ceiling- and empty-aware pagination. `hasMore` is false on an empty page (returned:0,
529
+ * the fema/nvd guard) OR when the next offset would exceed OPENFDA_MAX_SKIP (advertising a
530
+ * skip>25000 nextOffset is a poison cursor — following it 400s). `ceilingHit` is true when
531
+ * more rows exist upstream but lie beyond the reachable skip window (must be disclosed). */
532
+ export function openfdaPageMeta(
533
+ skip: number,
534
+ returned: number,
535
+ totalAvailable: number | null,
536
+ ): { hasMore: boolean; nextOffset: number | null; ceilingHit: boolean } {
537
+ const candidateNext = skip + returned;
538
+ const moreUpstream = totalAvailable !== null && candidateNext < totalAvailable;
539
+ const withinWindow = candidateNext <= OPENFDA_MAX_SKIP;
540
+ const hasMore = returned > 0 && moreUpstream && withinWindow;
541
+ return {
542
+ hasMore,
543
+ nextOffset: hasMore ? candidateNext : null,
544
+ ceilingHit: returned > 0 && moreUpstream && !withinWindow,
545
+ };
546
+ }
547
+
548
+ /** The ceiling disclosure (records beyond the skip window are permanently unreachable). */
549
+ export const OPENFDA_CEILING_NOTE =
550
+ `openFDA caps pagination at skip=${OPENFDA_MAX_SKIP}: records beyond ~${OPENFDA_MAX_SKIP}+limit are NOT reachable via this API, so totalAvailable exceeds the paginable window here and the tail is unreachable. Narrow the query (add filters or a date range) to bring the matching set within the first ${OPENFDA_MAX_SKIP} records.`;
551
+
552
+ /** Skip-aware total for a 404 NOT_FOUND: openFDA returns the SAME 404 for a genuine
553
+ * zero-match (only provable at skip 0) and an OVER-SKIP (skip past the end). So skip===0
554
+ * ⇒ a true total of 0; a skip>0 404 is AMBIGUOUS ⇒ totalAvailable unknown (null). */
555
+ export function openfdaEmptyTotal(skip: number): number | null {
556
+ return skip === 0 ? 0 : null;
557
+ }
558
+ /** Disclosure for a skip>0 404 (ambiguous over-skip vs no-match; total unknown). */
559
+ export const OPENFDA_OVERSKIP_NOTE =
560
+ "Returned 0 at this offset: the skip is at/past the end of the result set (or a genuine no-match). openFDA returns an IDENTICAL HTTP 404 for both, so the true total is UNKNOWN at a non-zero skip (totalAvailable is null, not 0) — re-query at skip:0 for the exact total. This is NOT a confirmed empty result set.";
package/src/opengov.ts CHANGED
@@ -238,10 +238,14 @@ export async function searchSolicitations(
238
238
  });
239
239
  }
240
240
 
241
- // The API paginates by 0-based `page` + `limit`. Map our offset → page.
242
- const page = Math.floor(offset / limit);
243
- const servedOffset = page * limit;
244
- const payload = JSON.stringify({ governmentCode: code, publicView: true, page, limit });
241
+ // The OpenGov /project/list `page` param is 1-BASED — `page=0` CLAMPS to page 1
242
+ // (live-verified 2026-07-20: phoenix offset 0 and offset<limit both returned the
243
+ // SAME first page → a 0-based send duplicates page 1 and shifts every later page,
244
+ // silently dropping the tail while totalAvailable still claims the full count).
245
+ // Map our row `offset` → the 1-based page; servedOffset stays offset-aligned.
246
+ const apiPage = Math.floor(offset / limit) + 1;
247
+ const servedOffset = (apiPage - 1) * limit;
248
+ const payload = JSON.stringify({ governmentCode: code, publicView: true, page: apiPage, limit });
245
249
 
246
250
  let body: unknown;
247
251
  try {