@cliwant/mcp-sam-gov 1.4.0 → 1.5.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 (82) hide show
  1. package/README.ja.md +11 -11
  2. package/README.ko.md +11 -11
  3. package/README.md +21 -13
  4. package/dist/cbp-border.d.ts +51 -0
  5. package/dist/cbp-border.d.ts.map +1 -0
  6. package/dist/cbp-border.js +123 -0
  7. package/dist/cbp-border.js.map +1 -0
  8. package/dist/datagov-catalog.d.ts.map +1 -1
  9. package/dist/datagov-catalog.js +16 -2
  10. package/dist/datagov-catalog.js.map +1 -1
  11. package/dist/ecfr.d.ts +2 -2
  12. package/dist/ecfr.d.ts.map +1 -1
  13. package/dist/ecfr.js +24 -10
  14. package/dist/ecfr.js.map +1 -1
  15. package/dist/edgar.d.ts.map +1 -1
  16. package/dist/edgar.js +26 -6
  17. package/dist/edgar.js.map +1 -1
  18. package/dist/epa-envirofacts.d.ts.map +1 -1
  19. package/dist/epa-envirofacts.js +14 -1
  20. package/dist/epa-envirofacts.js.map +1 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +11 -0
  23. package/dist/errors.js.map +1 -1
  24. package/dist/far.d.ts.map +1 -1
  25. package/dist/far.js +3 -1
  26. package/dist/far.js.map +1 -1
  27. package/dist/federal-register.d.ts +2 -2
  28. package/dist/federal-register.d.ts.map +1 -1
  29. package/dist/federal-register.js +26 -10
  30. package/dist/federal-register.js.map +1 -1
  31. package/dist/fema.d.ts +36 -0
  32. package/dist/fema.d.ts.map +1 -1
  33. package/dist/fema.js +124 -0
  34. package/dist/fema.js.map +1 -1
  35. package/dist/gov-domains.d.ts +66 -0
  36. package/dist/gov-domains.d.ts.map +1 -0
  37. package/dist/gov-domains.js +211 -0
  38. package/dist/gov-domains.js.map +1 -0
  39. package/dist/nist-controls.d.ts +48 -0
  40. package/dist/nist-controls.d.ts.map +1 -0
  41. package/dist/nist-controls.js +174 -0
  42. package/dist/nist-controls.js.map +1 -0
  43. package/dist/nws-weather.d.ts +57 -0
  44. package/dist/nws-weather.d.ts.map +1 -0
  45. package/dist/nws-weather.js +131 -0
  46. package/dist/nws-weather.js.map +1 -0
  47. package/dist/openfda-drugsfda.d.ts +72 -0
  48. package/dist/openfda-drugsfda.d.ts.map +1 -0
  49. package/dist/openfda-drugsfda.js +230 -0
  50. package/dist/openfda-drugsfda.js.map +1 -0
  51. package/dist/openfda.d.ts.map +1 -1
  52. package/dist/openfda.js +31 -8
  53. package/dist/openfda.js.map +1 -1
  54. package/dist/server.d.ts.map +1 -1
  55. package/dist/server.js +331 -10
  56. package/dist/server.js.map +1 -1
  57. package/dist/treasury.d.ts +2 -0
  58. package/dist/treasury.d.ts.map +1 -1
  59. package/dist/treasury.js +7 -0
  60. package/dist/treasury.js.map +1 -1
  61. package/dist/usaspending.d.ts +32 -1
  62. package/dist/usaspending.d.ts.map +1 -1
  63. package/dist/usaspending.js +143 -16
  64. package/dist/usaspending.js.map +1 -1
  65. package/package.json +2 -2
  66. package/src/cbp-border.ts +177 -0
  67. package/src/datagov-catalog.ts +18 -2
  68. package/src/ecfr.ts +27 -10
  69. package/src/edgar.ts +39 -7
  70. package/src/epa-envirofacts.ts +17 -1
  71. package/src/errors.ts +11 -0
  72. package/src/far.ts +3 -1
  73. package/src/federal-register.ts +29 -10
  74. package/src/fema.ts +139 -0
  75. package/src/gov-domains.ts +237 -0
  76. package/src/nist-controls.ts +219 -0
  77. package/src/nws-weather.ts +167 -0
  78. package/src/openfda-drugsfda.ts +313 -0
  79. package/src/openfda.ts +30 -7
  80. package/src/server.ts +352 -10
  81. package/src/treasury.ts +7 -0
  82. package/src/usaspending.ts +189 -17
@@ -0,0 +1,313 @@
1
+ /**
2
+ * openfda-drugsfda.ts — openFDA DRUG APPROVALS (Drugs@FDA, api.fda.gov) — the
3
+ * PHARMA REGULATORY-APPROVAL lane. FDA-approved drug applications (NDA/ANDA/BLA):
4
+ * the sponsor, the application number, each approved product (brand/generic name,
5
+ * dosage form, route, marketing status), and the submission/approval history.
6
+ * Answers "what drugs did sponsor X get approved, and which are still marketed" —
7
+ * pharma vendor product/approval intelligence.
8
+ *
9
+ * ★ SAME SOURCE + ENVELOPE + CRUX as openfda.ts / openfda-device.ts (ADR-0054/0056).
10
+ * Same host (api.fda.gov), same envelope `{ meta:{ results:{ skip,limit,total }},
11
+ * results:[…] }`, same ★no-match→HTTP-404-NOT_FOUND-as-honest-empty crux, same
12
+ * optional query-key K-test, same fixed-host SSRF idiom, same structured-only (no
13
+ * raw Lucene passthrough) search. REUSES openfda.ts's `fetchOpenfda`,
14
+ * `readOpenfdaError`, `luceneQuote`, `openfdaApiKey`, `OPENFDA_HOST` verbatim.
15
+ *
16
+ * GET https://api.fda.gov/drug/drugsfda.json?search=<lucene>&limit=&skip=[&api_key=]
17
+ * → { meta:{ results:{ skip,limit,total }}, results:[ { application_number,
18
+ * sponsor_name, products:[…], submissions:[…] } ] }.
19
+ *
20
+ * ★ HONESTY (mirrors the siblings exactly): [P1] totalAvailable = meta.results.total
21
+ * EXACT (never results.length); skip/limit offset pagination. [★P2] 404 NOT_FOUND
22
+ * ⇒ honest empty (never thrown); other 4xx ⇒ invalid_input surfacing openFDA's
23
+ * message; 5xx/429 ⇒ THROW. [P3] every scalar via `str` (null-never-""); nested
24
+ * products/submissions arrays default to [] and each field is str (never fabricated).
25
+ * [P4] meta.results / results absent-or-mis-shaped ⇒ driftError. [K-test] the
26
+ * OPTIONAL OPENFDA_API_KEY rides ONLY &api_key=; label/source name mode only.
27
+ * [SSRF] fixed host; all VALUES Lucene-escaped + phrase-quoted via URLSearchParams.
28
+ */
29
+
30
+ import { ToolErrorCarrier, errorFromResponse } from "./errors.js";
31
+ import { driftError } from "./datasource.js";
32
+ import { str } from "./coerce.js";
33
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
34
+ import {
35
+ OPENFDA_HOST,
36
+ fetchOpenfda,
37
+ readOpenfdaError,
38
+ luceneQuote,
39
+ openfdaApiKey,
40
+ } from "./openfda.js";
41
+
42
+ const DEFAULT_LIMIT = 25;
43
+ const MAX_LIMIT = 100;
44
+
45
+ /** host+path-only label (→ ToolError.upstreamEndpoint); NEVER carries the key. */
46
+ const DRUGSFDA_LABEL = "openfda:/drug/drugsfda";
47
+
48
+ // ─── Honesty notes ────────────────────────────────────────────────
49
+ const NOT_DETERMINATION_NOTE =
50
+ "openFDA Drugs@FDA records are FDA-published drug-application data; treat approval/marketing status as of the source's last publication. This is reference data, not a live regulatory determination.";
51
+ const KEYLESS_NOTE =
52
+ "Keyless: no OPENFDA_API_KEY is set. openFDA allows ~1000 requests/day without a key; a free key raises the rate limit (get one at https://open.fda.gov/apis/authentication/).";
53
+ const KEYED_NOTE =
54
+ "OPENFDA_API_KEY is set — it rides ONLY the &api_key= query parameter to api.fda.gov (openFDA has no header option), raising the rate limit. Its value is NEVER logged, echoed, or placed in this response.";
55
+ const NO_FILTER_NOTE =
56
+ "No structured filters were applied — this is an unscoped scan of the WHOLE Drugs@FDA collection. Add sponsorName / brandName / activeIngredient / applicationNumber to scope the result set.";
57
+ const MARKETING_NOTE =
58
+ "products[].marketingStatus is the FDA marketing category (e.g. 'Prescription', 'Over-the-counter', 'Discontinued', 'None (Tentative Approval)') — a 'Discontinued' product is NOT an approval revocation. submissions[] is the application's action history (ORIG approval + subsequent supplements).";
59
+
60
+ // ─── Curated row shape ────────────────────────────────────────────
61
+ export type DrugsfdaProduct = {
62
+ brandName: string | null;
63
+ genericIngredients: { name: string | null; strength: string | null }[];
64
+ dosageForm: string | null;
65
+ route: string | null;
66
+ marketingStatus: string | null;
67
+ };
68
+ export type DrugsfdaSubmission = {
69
+ submissionType: string | null; // ORIG / SUPPL
70
+ submissionNumber: string | null;
71
+ submissionStatus: string | null; // AP (approved) etc.
72
+ submissionStatusDate: string | null; // YYYYMMDD string, preserved (P3)
73
+ submissionClass: string | null;
74
+ };
75
+ export type DrugsfdaApplication = {
76
+ applicationNumber: string | null;
77
+ sponsorName: string | null;
78
+ products: DrugsfdaProduct[];
79
+ submissions: DrugsfdaSubmission[];
80
+ };
81
+
82
+ /** Map ONE Drugs@FDA result row → the curated shape. Every scalar via `str`. */
83
+ function mapApplication(row: unknown): DrugsfdaApplication {
84
+ const r = (row ?? {}) as Record<string, unknown>;
85
+ const products = Array.isArray(r.products) ? r.products : [];
86
+ const submissions = Array.isArray(r.submissions) ? r.submissions : [];
87
+ return {
88
+ applicationNumber: str(r.application_number),
89
+ sponsorName: str(r.sponsor_name),
90
+ products: products.map((p) => {
91
+ const pr = (p ?? {}) as Record<string, unknown>;
92
+ const ings = Array.isArray(pr.active_ingredients) ? pr.active_ingredients : [];
93
+ return {
94
+ brandName: str(pr.brand_name),
95
+ genericIngredients: ings.map((a) => {
96
+ const ai = (a ?? {}) as Record<string, unknown>;
97
+ return { name: str(ai.name), strength: str(ai.strength) };
98
+ }),
99
+ dosageForm: str(pr.dosage_form),
100
+ route: str(pr.route),
101
+ marketingStatus: str(pr.marketing_status),
102
+ };
103
+ }),
104
+ submissions: submissions.map((s) => {
105
+ const su = (s ?? {}) as Record<string, unknown>;
106
+ return {
107
+ submissionType: str(su.submission_type),
108
+ submissionNumber: str(su.submission_number),
109
+ submissionStatus: str(su.submission_status),
110
+ submissionStatusDate: str(su.submission_status_date),
111
+ submissionClass: str(su.submission_class_code_description),
112
+ };
113
+ }),
114
+ };
115
+ }
116
+
117
+ // ─── Lucene search assembly (structured-only; injection-safe) ─────
118
+ export type DrugsfdaFilters = {
119
+ sponsorName?: string; // → sponsor_name
120
+ brandName?: string; // → products.brand_name
121
+ activeIngredient?: string; // → products.active_ingredients.name
122
+ applicationNumber?: string; // → application_number
123
+ };
124
+
125
+ export function buildDrugsfdaSearch(f: DrugsfdaFilters): string {
126
+ const clauses: string[] = [];
127
+ if (f.sponsorName !== undefined)
128
+ clauses.push(`sponsor_name:${luceneQuote(f.sponsorName)}`);
129
+ if (f.brandName !== undefined)
130
+ clauses.push(`products.brand_name:${luceneQuote(f.brandName)}`);
131
+ if (f.activeIngredient !== undefined)
132
+ clauses.push(`products.active_ingredients.name:${luceneQuote(f.activeIngredient)}`);
133
+ if (f.applicationNumber !== undefined)
134
+ clauses.push(`application_number:${luceneQuote(f.applicationNumber)}`);
135
+ return clauses.join(" AND ");
136
+ }
137
+
138
+ // ─── Tool: openfda_drug_approvals ─────────────────────────────────
139
+ export type DrugApprovalsArgs = DrugsfdaFilters & {
140
+ limit?: number;
141
+ skip?: number;
142
+ };
143
+
144
+ /**
145
+ * Search openFDA Drugs@FDA drug-approval applications with structured filters →
146
+ * curated application rows (sponsor, application number, approved products, submission
147
+ * history) + honest `_meta`. KEYLESS (OPTIONAL OPENFDA_API_KEY only raises the rate
148
+ * limit). totalAvailable = meta.results.total (EXACT); skip/limit pagination. A
149
+ * no-match query (openFDA HTTP 404 NOT_FOUND) ⇒ an honest empty, never a throw.
150
+ */
151
+ export async function drugApprovals(args: DrugApprovalsArgs): Promise<MetaBundle> {
152
+ const limit = clampLimit(args.limit);
153
+ const skip = clampSkip(args.skip);
154
+
155
+ const filters: DrugsfdaFilters = {
156
+ sponsorName: args.sponsorName,
157
+ brandName: args.brandName,
158
+ activeIngredient: args.activeIngredient,
159
+ applicationNumber: args.applicationNumber,
160
+ };
161
+ const search = buildDrugsfdaSearch(filters);
162
+ const filtersApplied: string[] = [];
163
+ if (args.sponsorName !== undefined) filtersApplied.push("sponsorName");
164
+ if (args.brandName !== undefined) filtersApplied.push("brandName");
165
+ if (args.activeIngredient !== undefined) filtersApplied.push("activeIngredient");
166
+ if (args.applicationNumber !== undefined) filtersApplied.push("applicationNumber");
167
+
168
+ const params = new URLSearchParams();
169
+ if (search !== "") params.set("search", search);
170
+ params.set("limit", String(limit));
171
+ params.set("skip", String(skip));
172
+ const key = openfdaApiKey();
173
+ if (key !== undefined) params.set("api_key", key); // OPTIONAL — &api_key= ONLY
174
+
175
+ const url = `https://${OPENFDA_HOST}/drug/drugsfda.json?${params.toString()}`;
176
+
177
+ const res = await fetchOpenfda(url, DRUGSFDA_LABEL);
178
+
179
+ if (res.status === 404) {
180
+ const { code, message } = await readOpenfdaError(res);
181
+ if (code === "NOT_FOUND") {
182
+ return emptyResult(limit, skip, filtersApplied, key !== undefined);
183
+ }
184
+ throw new ToolErrorCarrier({
185
+ ...errorFromResponse(res, DRUGSFDA_LABEL),
186
+ message: message
187
+ ? `openFDA returned HTTP 404 at ${DRUGSFDA_LABEL}: ${message}`
188
+ : `Resource not found at ${DRUGSFDA_LABEL} (HTTP 404).`,
189
+ });
190
+ }
191
+
192
+ if (res.status >= 400 && res.status < 500 && res.status !== 429) {
193
+ const { message } = await readOpenfdaError(res);
194
+ throw new ToolErrorCarrier({
195
+ kind: "invalid_input",
196
+ retryable: false,
197
+ message: message
198
+ ? `openFDA rejected the request (HTTP ${res.status}) at ${DRUGSFDA_LABEL}: ${message}`
199
+ : `Bad request (HTTP ${res.status}) at ${DRUGSFDA_LABEL}.`,
200
+ upstreamStatus: res.status,
201
+ upstreamEndpoint: DRUGSFDA_LABEL,
202
+ });
203
+ }
204
+
205
+ if (!res.ok) {
206
+ throw new ToolErrorCarrier(errorFromResponse(res, DRUGSFDA_LABEL));
207
+ }
208
+
209
+ let body: unknown;
210
+ try {
211
+ body = await res.json();
212
+ } catch (e) {
213
+ if (e instanceof SyntaxError) {
214
+ throw driftError(
215
+ DRUGSFDA_LABEL,
216
+ `openFDA ${DRUGSFDA_LABEL} returned a non-JSON body at HTTP 200 — schema drift (never read as an empty result).`,
217
+ );
218
+ }
219
+ throw e;
220
+ }
221
+
222
+ const b = (body ?? {}) as { meta?: unknown; results?: unknown };
223
+ const meta = (b.meta ?? {}) as { results?: unknown };
224
+ const metaResults = meta.results as { total?: unknown } | undefined;
225
+ if (metaResults === undefined || metaResults === null || typeof metaResults !== "object") {
226
+ throw driftError(
227
+ DRUGSFDA_LABEL,
228
+ `openFDA ${DRUGSFDA_LABEL} shape drift — meta.results (the skip/limit/total carrier) is missing.`,
229
+ );
230
+ }
231
+ if (!Array.isArray(b.results)) {
232
+ throw driftError(
233
+ DRUGSFDA_LABEL,
234
+ `openFDA ${DRUGSFDA_LABEL} shape drift — results must be an array.`,
235
+ );
236
+ }
237
+
238
+ const applications = (b.results as unknown[]).map(mapApplication);
239
+ const returned = applications.length;
240
+
241
+ const rawTotal = metaResults.total;
242
+ const totalAvailable =
243
+ typeof rawTotal === "number" && Number.isFinite(rawTotal) ? rawTotal : null;
244
+ const hasMore = totalAvailable !== null && skip + returned < totalAvailable;
245
+ const nextOffset = hasMore ? skip + returned : null;
246
+
247
+ const notes: string[] = [NOT_DETERMINATION_NOTE, MARKETING_NOTE, keyNote(key !== undefined)];
248
+ if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
249
+
250
+ return withMeta(
251
+ { applications },
252
+ {
253
+ source: `${OPENFDA_HOST} /drug/drugsfda (openFDA Drugs@FDA approvals; ${
254
+ key !== undefined ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
255
+ })`,
256
+ keylessMode: true,
257
+ returned,
258
+ totalAvailable,
259
+ filtersApplied,
260
+ filtersDropped: [],
261
+ fieldsUnavailable: [],
262
+ pagination: { offset: skip, limit, hasMore, nextOffset },
263
+ notes,
264
+ } satisfies Partial<ResponseMeta>,
265
+ );
266
+ }
267
+
268
+ // ─── Small helpers ────────────────────────────────────────────────
269
+ function keyNote(hasKey: boolean): string {
270
+ return hasKey ? KEYED_NOTE : KEYLESS_NOTE;
271
+ }
272
+
273
+ function emptyResult(
274
+ limit: number,
275
+ skip: number,
276
+ filtersApplied: string[],
277
+ hasKey: boolean,
278
+ ): MetaBundle {
279
+ return withMeta(
280
+ { applications: [] as DrugsfdaApplication[] },
281
+ {
282
+ source: `${OPENFDA_HOST} /drug/drugsfda (openFDA Drugs@FDA approvals; ${
283
+ hasKey ? "OPENFDA_API_KEY rate-limit key applied" : "keyless"
284
+ })`,
285
+ keylessMode: true,
286
+ returned: 0,
287
+ totalAvailable: 0,
288
+ filtersApplied,
289
+ filtersDropped: [],
290
+ fieldsUnavailable: [],
291
+ pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
292
+ 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.",
294
+ NOT_DETERMINATION_NOTE,
295
+ keyNote(hasKey),
296
+ ],
297
+ } satisfies Partial<ResponseMeta>,
298
+ );
299
+ }
300
+
301
+ function clampLimit(v: unknown): number {
302
+ if (typeof v !== "number" || !Number.isFinite(v)) return DEFAULT_LIMIT;
303
+ const n = Math.floor(v);
304
+ if (n < 1) return 1;
305
+ if (n > MAX_LIMIT) return MAX_LIMIT;
306
+ return n;
307
+ }
308
+
309
+ function clampSkip(v: unknown): number {
310
+ if (typeof v !== "number" || !Number.isFinite(v)) return 0;
311
+ const n = Math.floor(v);
312
+ return n < 0 ? 0 : n;
313
+ }
package/src/openfda.ts CHANGED
@@ -95,6 +95,14 @@ const KEYED_NOTE =
95
95
  "OPENFDA_API_KEY is set — it rides ONLY the &api_key= query parameter to api.fda.gov (openFDA has no header option), raising the rate limit. Its value is NEVER logged, echoed, or placed in this response.";
96
96
  const NO_FILTER_NOTE =
97
97
  "No structured filters were applied — this is an unscoped scan of the WHOLE recall/enforcement category. Add firm / product / reason / classification / status / state to scope the result set.";
98
+ // dogfooding 2026-07-16: `category` defaults to 'drug'. A DEVICE- or FOOD-intent
99
+ // caller who omits it silently searches the DRUG dataset (a separate openFDA index
100
+ // with its own, much smaller total — e.g. "pacemaker" returns 1 drug recall vs 197
101
+ // device recalls). The default is convenient but load-bearing, so when it is TAKEN
102
+ // (not explicitly chosen) we say so and point to the alternatives. `category` is
103
+ // also echoed in filtersApplied so the drug/device/food choice is never invisible.
104
+ const CATEGORY_DEFAULT_NOTE =
105
+ "category was NOT specified and DEFAULTED to 'drug' — this searched the DRUG recall dataset (/drug/enforcement) ONLY. For medical DEVICES pass category:'device'; for FOOD pass category:'food'. Each category is a SEPARATE openFDA dataset with its own total, so a device/food-intent query left on the default MISSES all of those recalls.";
98
106
 
99
107
  // ─── The OPTIONAL key seam (value NEVER leaked past the &api_key= param) ──
100
108
  /** Read OPENFDA_API_KEY from env; trim; return the value or undefined (unset/blank). */
@@ -322,6 +330,14 @@ export async function enforcement(
322
330
  if (args.classification !== undefined) filtersApplied.push("classification");
323
331
  if (args.status !== undefined) filtersApplied.push("status");
324
332
  if (args.state !== undefined) filtersApplied.push("state");
333
+ // NO_FILTER_NOTE gates on the STRUCTURED filters only — capture that BEFORE the
334
+ // always-present category selector is appended below.
335
+ const hasStructuredFilter = filtersApplied.length > 0;
336
+ // The category (drug|device|food) is the ENDPOINT selector — always applied and
337
+ // consequential — so echo it in filtersApplied to make the choice VISIBLE (a
338
+ // device query left on the drug default must not look identical to a device query).
339
+ const categoryDefaulted = args.category === undefined;
340
+ filtersApplied.push(`category:${category}`);
325
341
 
326
342
  const params = new URLSearchParams();
327
343
  if (search !== "") params.set("search", search);
@@ -342,7 +358,7 @@ export async function enforcement(
342
358
  const { code, message } = await readOpenfdaError(res);
343
359
  if (code === "NOT_FOUND") {
344
360
  // Honest empty — NOT thrown, NOT not_found (the openFDA no-match idiom).
345
- return emptyResult(category, limit, skip, filtersApplied, key !== undefined);
361
+ return emptyResult(category, limit, skip, filtersApplied, key !== undefined, categoryDefaulted);
346
362
  }
347
363
  // A non-NOT_FOUND 404 ⇒ the shared not_found taxonomy (never a fake-empty).
348
364
  throw new ToolErrorCarrier({
@@ -424,7 +440,8 @@ export async function enforcement(
424
440
  const nextOffset = hasMore ? skip + returned : null;
425
441
 
426
442
  const notes: string[] = [NOT_DETERMINATION_NOTE, keyNote(key !== undefined)];
427
- if (filtersApplied.length === 0) notes.push(NO_FILTER_NOTE);
443
+ if (!hasStructuredFilter) notes.push(NO_FILTER_NOTE);
444
+ if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
428
445
 
429
446
  return withMeta(
430
447
  { recalls },
@@ -457,7 +474,17 @@ function emptyResult(
457
474
  skip: number,
458
475
  filtersApplied: string[],
459
476
  hasKey: boolean,
477
+ categoryDefaulted: boolean,
460
478
  ): MetaBundle {
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.",
481
+ NOT_DETERMINATION_NOTE,
482
+ keyNote(hasKey),
483
+ ];
484
+ // A defaulted category on an EMPTY result is the most misleading case — a device
485
+ // analyst reads "0 recalls" as "clean" when they actually searched the wrong
486
+ // dataset. Surface the default + the alternatives.
487
+ if (categoryDefaulted) notes.push(CATEGORY_DEFAULT_NOTE);
461
488
  return withMeta(
462
489
  { recalls: [] as OpenfdaRecall[] },
463
490
  {
@@ -471,11 +498,7 @@ function emptyResult(
471
498
  filtersDropped: [],
472
499
  fieldsUnavailable: [],
473
500
  pagination: { offset: skip, limit, hasMore: false, nextOffset: null },
474
- notes: [
475
- "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.",
476
- NOT_DETERMINATION_NOTE,
477
- keyNote(hasKey),
478
- ],
501
+ notes,
479
502
  } satisfies Partial<ResponseMeta>,
480
503
  );
481
504
  }