@cliwant/mcp-sam-gov 0.3.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +226 -64
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +2 -0
  14. package/dist/cache.d.ts.map +1 -1
  15. package/dist/cache.js +4 -0
  16. package/dist/cache.js.map +1 -1
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +74 -1
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +26 -0
  70. package/dist/errors.d.ts.map +1 -1
  71. package/dist/errors.js +69 -1
  72. package/dist/errors.js.map +1 -1
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +419 -3
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +111 -6
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3889 -296
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1629 -201
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +5 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +98 -1
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +84 -1
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +512 -3
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +139 -7
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4551 -328
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +1946 -214
  228. package/src/usitc.ts +420 -0
package/src/datagov.ts ADDED
@@ -0,0 +1,907 @@
1
+ /**
2
+ * datagov.ts — the api.data.gov KEYED trio (slice 1: Regulations.gov +
3
+ * Congress.gov). ADR-0007.
4
+ *
5
+ * This is the project's FIRST KEYED (non-keyless) source — every prior source
6
+ * (Treasury, EDGAR, Socrata, CKAN) is anonymous. It exercises policy② ("keyless-
7
+ * first, but when a free public key is required, research + automate its
8
+ * issuance"), so the load-bearing concern is a SAFE env-key adapter whose secret
9
+ * can NEVER leak into a ToolError, a URL, a log, or `_meta`. Built on the R2
10
+ * `DataSource` port (ADR-0005) exactly like CKAN — it writes ZERO fetch/coercion/
11
+ * error/meta code (reuses `getJson`/`driftError`/`num`·`str`/`withMeta`) and
12
+ * COPIES (does not import) the Socrata/CKAN SSRF + honesty PATTERN.
13
+ *
14
+ * ★ THE KEY-SECURITY DISCIPLINE (ADR-0007 §2 — the load-bearing rules):
15
+ * 1. The key travels in `headers:{ "X-Api-Key": <key> }` ONLY — NEVER in the
16
+ * URL/query (no `?api_key=`). Live-verified: the `X-Api-Key` header works for
17
+ * BOTH api.regulations.gov and api.congress.gov, so the key is structurally
18
+ * absent from the request URL (→ never in a CDN/proxy/access log, never in
19
+ * `getJson`'s `label`, never in `ToolError.upstreamEndpoint`).
20
+ * 2. `label` is HOST+PATH only (e.g. "regulations:/v4/documents",
21
+ * "congress:/v3/bill") — never the full URL, never a token. This is what
22
+ * reaches `ToolError.upstreamEndpoint`.
23
+ * 3. `_meta.source`/`notes` are host + key-MODE only ("…(DEMO_KEY)" /
24
+ * "…(DATA_GOV_API_KEY)") — never the URL, never the key value, never the
25
+ * `X-Api-Key` header.
26
+ * 4. Headers are never logged/echoed; upstream `next`/`pagination.next` URLs are
27
+ * NEVER surfaced verbatim — pagination is re-derived NUMERICALLY.
28
+ * 5. Never commit the key — read from env only; the DEMO_KEY fallback is a
29
+ * literal public constant (safe in source), the real key never is.
30
+ *
31
+ * ★ KEYLESS-FIRST UX (policy② / §3): DATA_GOV_API_KEY from env, else the public
32
+ * literal "DEMO_KEY" + a `_meta.notes` disclosure of the ~10 req/hr shared ceiling
33
+ * and the free-key signup path. `keylessMode:false` (this is the FIRST source to
34
+ * report it — it is genuinely keyed).
35
+ *
36
+ * ★ SSRF (§4): two fixed hosts (constants, no free host param); all caller params
37
+ * via URLSearchParams; path segments that interpolate caller input (Congress
38
+ * `/v3/bill/{congress}/{billType}/{billNumber}`) are Zod-constrained to int/enum;
39
+ * post-construction `hostname===host && https` assertion; `redirect:"error"`.
40
+ *
41
+ * ★ HONESTY (§5): totals are EXACT integers read from the PRIMARY container
42
+ * (`meta.totalElements` / `pagination.count`) with container guards (a null/absent
43
+ * container or a non-number total → `driftError`, NOT a TypeError/upstream_unavail);
44
+ * the primary array (`data`/`bills`) must be an array or → `driftError`; a
45
+ * genuine-empty (0 results) → complete:true/total:0; an outage/5xx/timeout throws
46
+ * (never a fake empty). Regulations.gov's 40-page/10,000-record HARD CAP is
47
+ * handled with the EDGAR window-cap pattern (B1): `hasMore:true` (more genuinely
48
+ * exists) BUT `nextOffset:null` (no reachable continuation) + a disclosing note.
49
+ */
50
+
51
+ import { ToolErrorCarrier } from "./errors.js";
52
+ import { getJson, driftError } from "./datasource.js";
53
+ import { num, str } from "./coerce.js";
54
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
55
+ // ADR-0010 §2 — the api.data.gov KEY seam is now a single audited home shared with
56
+ // govinfo.ts (the 2nd consumer). This is a pure behavior-identical extraction of
57
+ // datagov's former module-private key helpers (they read process.env at call time,
58
+ // so the move changes nothing — datagov's key handling / _meta / snapshot / K-test
59
+ // all stay green). govinfo.ts imports from the SAME module; neither imports the other.
60
+ import { keyHeader, keyModeLabel, pushKeyNote } from "./datagovKey.js";
61
+
62
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js —
63
+ // ADR-0005 v2 FIX-C) so the fault suite's num-parity guard resolves the SAME
64
+ // `num` from this module (datagov.num === coerce.num === ckan.num === …).
65
+ export { num };
66
+
67
+ // ─── Fixed hosts (SSRF core — no free host param) ─────────────────
68
+ export const REGULATIONS_HOST = "api.regulations.gov";
69
+ export const CONGRESS_HOST = "api.congress.gov";
70
+
71
+ // ─── Regulations.gov hard pagination cap (§1c / B1, EDGAR-pattern) ─
72
+ // page[number] is hard-capped at 40; the max reachable window is 40 × 250 =
73
+ // 10,000 records. `meta.totalElements` is the REAL total (~1.97M) — far larger.
74
+ // We never trust meta.totalPages/hasNextPage/lastPage (they are cap-relative
75
+ // liars). Mirrors edgar.ts's FTS_WINDOW / FTS_MAX_FROM window cap.
76
+ const REG_MAX_PAGE = 40;
77
+ const REG_MAX_PAGE_SIZE = 250;
78
+ const REG_MAX_RECORDS = REG_MAX_PAGE * REG_MAX_PAGE_SIZE; // 10_000
79
+
80
+ // Live-verified accepted Regulations.gov sort fields (non-exhaustive — the API
81
+ // accepts more; these are the ones confirmed with DEMO_KEY, ADR-0007 §1b/m8).
82
+ export const REGULATIONS_SORTS = [
83
+ "-postedDate",
84
+ "postedDate",
85
+ "-lastModifiedDate",
86
+ "lastModifiedDate",
87
+ "-commentEndDate",
88
+ ] as const;
89
+ export type RegulationsSort = (typeof REGULATIONS_SORTS)[number];
90
+
91
+ // Regulations.gov documentType facet (§4 rule 5 — Zod enum, bad values fail
92
+ // locally before fetch).
93
+ export const REGULATIONS_DOCUMENT_TYPES = [
94
+ "Rule",
95
+ "Proposed Rule",
96
+ "Notice",
97
+ "Supporting & Related Material",
98
+ "Other",
99
+ ] as const;
100
+ export type RegulationsDocumentType = (typeof REGULATIONS_DOCUMENT_TYPES)[number];
101
+
102
+ // Regulations.gov docket-type facet (ADR-0044 §4 rule 5 — Zod enum; a bad value
103
+ // fails LOCALLY as invalid_input before any fetch). Dockets are the rulemaking/
104
+ // nonrulemaking CONTAINER that groups documents + comments under one action.
105
+ export const REGULATIONS_DOCKET_TYPES = ["Rulemaking", "Nonrulemaking"] as const;
106
+ export type RegulationsDocketType = (typeof REGULATIONS_DOCKET_TYPES)[number];
107
+
108
+ // Docket sort fields (ADR-0044 S5). `-lastModifiedDate` and `title` are
109
+ // DEMO_KEY-verified; `lastModifiedDate` (asc) and `-title` are assumed by JSON:API
110
+ // asc/desc symmetry — LIVE-VERIFY when a non-throttled key is available. An
111
+ // unattested value the API rejects yields an honest invalid_input THROW, so
112
+ // keeping them is safe (mirrors the REGULATIONS_SORTS non-exhaustive note above).
113
+ export const REGULATIONS_DOCKET_SORTS = [
114
+ "-lastModifiedDate",
115
+ "lastModifiedDate",
116
+ "title",
117
+ "-title",
118
+ ] as const;
119
+ export type RegulationsDocketSort = (typeof REGULATIONS_DOCKET_SORTS)[number];
120
+
121
+ // Congress.gov bill-type path enum (§4 — constrains the /v3/bill/{congress}/{type}
122
+ // path segment; a bad value fails locally before any fetch).
123
+ export const CONGRESS_BILL_TYPES = [
124
+ "hr",
125
+ "s",
126
+ "hjres",
127
+ "sjres",
128
+ "hconres",
129
+ "sconres",
130
+ "hres",
131
+ "sres",
132
+ ] as const;
133
+ export type CongressBillType = (typeof CONGRESS_BILL_TYPES)[number];
134
+
135
+ // ─── Key handling (the load-bearing secret discipline) ────────────
136
+ // `keyHeader`/`keyModeLabel`/`pushKeyNote` (+ `usingDemoKey`, the DEMO_KEY literal,
137
+ // and the disclosure notes) now live in the SHARED `./datagovKey.js` seam (ADR-0010
138
+ // §2) — imported above, byte-identical behavior. GovInfo is their 2nd consumer, so
139
+ // the single-audited-home promotion mirrors the `coerce.ts` precedent for `num`.
140
+
141
+ // ─── SSRF-guarded fetch (§4 — fixed host + hostname assertion + redirect) ──
142
+ /**
143
+ * GET one api.data.gov JSON resource. `host` is a fixed module constant; `path`
144
+ * is a fixed base path (or a Zod-constrained interpolation); all caller params go
145
+ * through `params` (URLSearchParams, encoded). Asserts the CONSTRUCTED URL's
146
+ * hostname === host over https (belt-and-suspenders, copied from Socrata/CKAN),
147
+ * sets `redirect:"error"` (a 3xx off an api.data.gov host is anomalous and must
148
+ * NOT be followed — it could carry the X-Api-Key header to a foreign host), and
149
+ * attaches the key ONLY in the X-Api-Key header. `label` is host+path only.
150
+ */
151
+ async function getDatagov(
152
+ host: string,
153
+ path: string,
154
+ label: string,
155
+ params: URLSearchParams,
156
+ ): Promise<unknown> {
157
+ const qs = params.toString();
158
+ const url = `https://${host}${path}${qs ? `?${qs}` : ""}`;
159
+ const built = new URL(url);
160
+ if (built.hostname !== host || built.protocol !== "https:") {
161
+ throw new ToolErrorCarrier({
162
+ kind: "invalid_input",
163
+ message: `Constructed api.data.gov URL host ${JSON.stringify(built.hostname)} (${built.protocol}) does not match the fixed host ${JSON.stringify(host)} over https — refusing to fetch (SSRF safety).`,
164
+ retryable: false,
165
+ });
166
+ }
167
+ // Shared fetch envelope (ADR-0005): the key rides in headers ONLY (§2 rule 1);
168
+ // redirect:"error" (§4); host-only label → ToolError.upstreamEndpoint (§2 rule 2).
169
+ return getJson(url, { label, headers: keyHeader(), redirect: "error" });
170
+ }
171
+
172
+ // ═══════════════════ Regulations.gov (JSON:API) ═══════════════════
173
+
174
+ const REG_DOC_SOURCE = (mode: string) =>
175
+ `${REGULATIONS_HOST} via Regulations.gov API (${mode})`;
176
+
177
+ type RegulationsSearchArgs = {
178
+ searchTerm?: string;
179
+ query?: string; // alias for searchTerm
180
+ agencyId?: string;
181
+ docketId?: string;
182
+ documentType?: RegulationsDocumentType; // documents only
183
+ withinCommentPeriod?: boolean; // documents only
184
+ postedDateGe?: string;
185
+ postedDateLe?: string;
186
+ sort?: RegulationsSort;
187
+ pageNumber?: number;
188
+ pageSize?: number;
189
+ };
190
+
191
+ /** Map one JSON:API `data[]` document into a flat, honesty-coerced row. */
192
+ function mapRegDocument(item: unknown): Record<string, unknown> {
193
+ const it = (item ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
194
+ const a = (it.attributes ?? {}) as Record<string, unknown>;
195
+ return {
196
+ id: str(it.id),
197
+ documentType: str(a.documentType),
198
+ title: str(a.title),
199
+ agencyId: str(a.agencyId),
200
+ docketId: str(a.docketId),
201
+ postedDate: str(a.postedDate),
202
+ commentStartDate: str(a.commentStartDate),
203
+ commentEndDate: str(a.commentEndDate),
204
+ openForComment:
205
+ typeof a.openForComment === "boolean" ? a.openForComment : null,
206
+ withinCommentPeriod:
207
+ typeof a.withinCommentPeriod === "boolean" ? a.withinCommentPeriod : null,
208
+ frDocNum: str(a.frDocNum),
209
+ objectId: str(a.objectId),
210
+ };
211
+ }
212
+
213
+ /** Map one JSON:API `data[]` comment into a flat, honesty-coerced row. */
214
+ function mapRegComment(item: unknown): Record<string, unknown> {
215
+ const it = (item ?? {}) as { id?: unknown; attributes?: Record<string, unknown> };
216
+ const a = (it.attributes ?? {}) as Record<string, unknown>;
217
+ return {
218
+ id: str(it.id),
219
+ documentType: str(a.documentType),
220
+ title: str(a.title),
221
+ agencyId: str(a.agencyId),
222
+ docketId: str(a.docketId),
223
+ postedDate: str(a.postedDate),
224
+ objectId: str(a.objectId),
225
+ };
226
+ }
227
+
228
+ /**
229
+ * The shared Regulations.gov JSON:API search core for `/v4/documents` and
230
+ * `/v4/comments` (identical envelope + identical page[number]≤40 hard cap).
231
+ *
232
+ * ⚠ CAP-VERIFICATION NOTE (m5): the `/v4/documents` 40-page cap (HTTP 400
233
+ * "Maximum value is 40") is LIVE-VERIFIED (ADR-0007 §1c). The `/v4/comments` cap
234
+ * was NOT live-verifiable at build time (the shared DEMO_KEY was rate-limited /
235
+ * HTTP 429 on both hosts), so the SAME cap guard is applied here on the well-
236
+ * founded assumption that the two endpoints share the identical JSON:API paging
237
+ * contract (same host, same envelope, same page[number]/page[size] model).
238
+ * LIVE-VERIFY `GET /v4/comments?page[number]=41` when a non-throttled key is
239
+ * available and adjust REG_MAX_PAGE if it ever differs.
240
+ */
241
+ async function regulationsSearch(
242
+ endpoint: "/v4/documents" | "/v4/comments",
243
+ kind: "documents" | "comments",
244
+ args: RegulationsSearchArgs,
245
+ ): Promise<MetaBundle> {
246
+ const label = `regulations:${endpoint}`;
247
+ const pageNumber = args.pageNumber ?? 1;
248
+ const pageSize = args.pageSize ?? 25;
249
+ const sort = args.sort ?? "-postedDate";
250
+
251
+ // B1 PRE-FETCH window guard (mirror edgar.ts `from >= FTS_MAX_FROM`): reject a
252
+ // beyond-cap page BEFORE any fetch, so a naive agent that computes page 41 gets
253
+ // a clean LOCAL invalid_input, never the upstream HTTP 400. (Zod also caps
254
+ // pageNumber≤40 / pageSize≤250 at the tool boundary — this is defense-in-depth
255
+ // for a direct call.)
256
+ if (pageNumber > REG_MAX_PAGE || pageNumber * pageSize > REG_MAX_RECORDS) {
257
+ throw new ToolErrorCarrier({
258
+ kind: "invalid_input",
259
+ message: `Regulations.gov page[number] (${pageNumber}) × page[size] (${pageSize}) exceeds the API's hard ${REG_MAX_RECORDS}-record / ${REG_MAX_PAGE}-page pagination ceiling. Narrow filters (agencyId/docketId/postedDate) or seek by lastModifiedDate instead of paging past ${REG_MAX_RECORDS} results.`,
260
+ retryable: false,
261
+ upstreamEndpoint: label,
262
+ });
263
+ }
264
+
265
+ const searchTerm = args.searchTerm ?? args.query;
266
+ const params = new URLSearchParams();
267
+ const filtersApplied: string[] = [];
268
+ // D3 (no-silent-filter): documentType/withinCommentPeriod are documents-ONLY
269
+ // facets. RegulationsSearchInput is SHARED by documents + comments, so a caller
270
+ // can supply them to regulations_search_comments — where /v4/comments does not
271
+ // accept them. Rather than silently ignore them (returning comments as if the
272
+ // facet applied), DISCLOSE them in filtersDropped + a note.
273
+ const filtersDropped: string[] = [];
274
+ if (searchTerm) {
275
+ params.set("filter[searchTerm]", searchTerm);
276
+ filtersApplied.push("searchTerm");
277
+ }
278
+ if (args.agencyId) {
279
+ params.set("filter[agencyId]", args.agencyId);
280
+ filtersApplied.push("agencyId");
281
+ }
282
+ if (args.docketId) {
283
+ params.set("filter[docketId]", args.docketId);
284
+ filtersApplied.push("docketId");
285
+ }
286
+ if (kind === "documents" && args.documentType) {
287
+ params.set("filter[documentType]", args.documentType);
288
+ filtersApplied.push("documentType");
289
+ } else if (kind === "comments" && args.documentType !== undefined) {
290
+ // Comments are themselves "Public Submission" documents; /v4/comments exposes
291
+ // NO filter[documentType]. Not applied → disclosed, never silently dropped.
292
+ filtersDropped.push("documentType");
293
+ }
294
+ if (kind === "documents" && args.withinCommentPeriod !== undefined) {
295
+ params.set("filter[withinCommentPeriod]", String(args.withinCommentPeriod));
296
+ filtersApplied.push("withinCommentPeriod");
297
+ } else if (kind === "comments" && args.withinCommentPeriod !== undefined) {
298
+ // withinCommentPeriod = whether a DOCUMENT is currently open for comment — a
299
+ // documents-only concept; /v4/comments does not accept it.
300
+ filtersDropped.push("withinCommentPeriod");
301
+ }
302
+ if (args.postedDateGe) {
303
+ params.set("filter[postedDate][ge]", args.postedDateGe);
304
+ filtersApplied.push("postedDateGe");
305
+ }
306
+ if (args.postedDateLe) {
307
+ params.set("filter[postedDate][le]", args.postedDateLe);
308
+ filtersApplied.push("postedDateLe");
309
+ }
310
+ params.set("sort", sort);
311
+ params.set("page[number]", String(pageNumber));
312
+ params.set("page[size]", String(pageSize));
313
+
314
+ // M1 — the typed catch ladder (fema.ts:262-275 shape) — IDENTICAL to its
315
+ // searchDockets/getDocket siblings. Preserve the 429/404/5xx/400/timeout
316
+ // ToolErrorCarrier taxonomy FIRST (LOAD-BEARING: the DEMO_KEY-10/hr 429→rate_limited
317
+ // frontier would regress to schema_drift under a broader catch); reclassify a 200
318
+ // non-JSON `.json()` SyntaxError to schema_drift SECOND; bare-rethrow LAST.
319
+ let body: unknown;
320
+ try {
321
+ body = await getDatagov(REGULATIONS_HOST, endpoint, label, params);
322
+ } catch (e) {
323
+ if (e instanceof ToolErrorCarrier) throw e;
324
+ if (e instanceof SyntaxError)
325
+ throw driftError(
326
+ label,
327
+ "Regulations.gov returned a non-JSON body at HTTP 200 — schema drift.",
328
+ );
329
+ throw e;
330
+ }
331
+
332
+ const b = (body ?? {}) as {
333
+ data?: unknown;
334
+ meta?: { totalElements?: unknown } | null;
335
+ };
336
+
337
+ // M2 — `data` MUST be an array (a missing/string/null data is drift, never []).
338
+ if (!Array.isArray(b.data)) {
339
+ throw driftError(
340
+ label,
341
+ `regulations shape drift — ${endpoint} response.data must be an array.`,
342
+ );
343
+ }
344
+ // M3 — CONTAINER-guarded total: a null/absent `meta` or a non-number
345
+ // `meta.totalElements` → driftError (NOT a TypeError that would mask drift as
346
+ // upstream_unavailable).
347
+ if (!b.meta || typeof b.meta.totalElements !== "number") {
348
+ throw driftError(
349
+ label,
350
+ `regulations shape drift — ${endpoint} meta.totalElements missing/non-number.`,
351
+ );
352
+ }
353
+
354
+ const rows = (b.data as unknown[]).map(
355
+ kind === "documents" ? mapRegDocument : mapRegComment,
356
+ );
357
+ const returned = rows.length;
358
+ // EXACT real total — NEVER meta.totalPages*pageSize (§1c: totalPages is capped
359
+ // at 40 = a lie). totalElements is typeof-guarded to be a number above.
360
+ const totalAvailable = num(b.meta.totalElements);
361
+ const offset = (pageNumber - 1) * pageSize;
362
+
363
+ // B1 EDGAR-pattern cap: `hasMore` reflects whether MORE genuinely exists (from
364
+ // the real total). `nextOffset` is the next page's record offset ONLY when the
365
+ // next page is still inside the 40-page/10,000-record window; at the ceiling it
366
+ // is null (no actionable continuation) + a disclosing note. Never use
367
+ // meta.hasNextPage/lastPage/totalPages (cap-relative liars).
368
+ const moreExist =
369
+ totalAvailable !== null && pageNumber * pageSize < totalAvailable;
370
+ const nextPageNumber = pageNumber + 1;
371
+ const nextPageReachable =
372
+ nextPageNumber <= REG_MAX_PAGE &&
373
+ nextPageNumber * pageSize <= REG_MAX_RECORDS;
374
+ const hasMore = moreExist;
375
+ const nextOffset = moreExist && nextPageReachable ? pageNumber * pageSize : null;
376
+
377
+ const notes: string[] = [];
378
+ pushKeyNote(notes);
379
+ if (filtersDropped.length > 0) {
380
+ notes.push(
381
+ `${filtersDropped.join("/")} do not apply to the ${endpoint} (comments) endpoint and were ignored (they are documents-only facets) — results are UNFILTERED on those facets. Use regulations_search_documents to filter by documentType / withinCommentPeriod.`,
382
+ );
383
+ }
384
+ if (moreExist && !nextPageReachable) {
385
+ notes.push(
386
+ `Reached the API's ${REG_MAX_RECORDS}-record / ${REG_MAX_PAGE}-page pagination ceiling (totalElements=${totalAvailable} total). ~${totalAvailable - REG_MAX_RECORDS} more records exist but are UNREACHABLE via page[number] — narrow filters (agencyId/docketId/postedDate) or seek by lastModifiedDate to reach the rest.`,
387
+ );
388
+ }
389
+
390
+ const key = kind === "documents" ? "documents" : "comments";
391
+ return withMeta(
392
+ { [key]: rows },
393
+ {
394
+ source: REG_DOC_SOURCE(keyModeLabel()),
395
+ keylessMode: false,
396
+ returned,
397
+ totalAvailable,
398
+ filtersApplied,
399
+ filtersDropped,
400
+ fieldsUnavailable: [],
401
+ pagination: { offset, limit: pageSize, hasMore, nextOffset },
402
+ notes,
403
+ } satisfies Partial<ResponseMeta>,
404
+ );
405
+ }
406
+
407
+ /** Tool: regulations_search_documents. */
408
+ export async function searchDocuments(
409
+ args: RegulationsSearchArgs,
410
+ ): Promise<MetaBundle> {
411
+ return regulationsSearch("/v4/documents", "documents", args);
412
+ }
413
+
414
+ /** Tool: regulations_search_comments. */
415
+ export async function searchComments(
416
+ args: RegulationsSearchArgs,
417
+ ): Promise<MetaBundle> {
418
+ return regulationsSearch("/v4/comments", "comments", args);
419
+ }
420
+
421
+ // ─── Regulations.gov dockets (ADR-0044 — within-source DEPTH) ──────
422
+ // Siblings of regulationsSearch that reuse getDatagov + REG_MAX_* + the
423
+ // totalElements-exact block + pushKeyNote. The docket is the rulemaking CONTAINER
424
+ // grouping documents + comments, and (in DETAIL) it carries `rin` — the cross-
425
+ // source join key to the Federal Register / Unified Agenda (NULL in list rows,
426
+ // PRESENT in detail). regulationsSearch/mapRegDocument/mapRegComment are UNTOUCHED.
427
+
428
+ type RegulationsSearchDocketsArgs = {
429
+ searchTerm?: string;
430
+ query?: string; // alias for searchTerm
431
+ agencyId?: string;
432
+ docketType?: RegulationsDocketType;
433
+ lastModifiedDateGe?: string;
434
+ lastModifiedDateLe?: string;
435
+ sort?: RegulationsDocketSort;
436
+ limit?: number; // caller's requested count (min-5 client-slice control)
437
+ pageNumber?: number;
438
+ };
439
+
440
+ type RegulationsGetDocketArgs = {
441
+ docketId: string;
442
+ };
443
+
444
+ /**
445
+ * Map one JSON:API `/v4/dockets` LIST row into a flat, honesty-coerced record
446
+ * (all scalars via `str`, null-never-empty). `highlightedContent` (a search-
447
+ * snippet artifact) is surfaced ONLY when a searchTerm was sent. There is NO
448
+ * `rin` field: the list envelope never carries it (captured fact 4) — surfacing a
449
+ * null `rin` here would read as "no RIN"; `rin` lives ONLY in mapDocketDetail.
450
+ */
451
+ function mapDocketListRow(
452
+ item: unknown,
453
+ withSearchTerm: boolean,
454
+ ): Record<string, unknown> {
455
+ const it = (item ?? {}) as {
456
+ id?: unknown;
457
+ attributes?: Record<string, unknown>;
458
+ };
459
+ const a = (it.attributes ?? {}) as Record<string, unknown>;
460
+ const row: Record<string, unknown> = {
461
+ docketId: str(a.docketId),
462
+ title: str(a.title),
463
+ agencyId: str(a.agencyId),
464
+ docketType: str(a.docketType),
465
+ lastModifiedDate: str(a.lastModifiedDate),
466
+ objectId: str(a.objectId),
467
+ id: str(it.id),
468
+ };
469
+ if (withSearchTerm) row.highlightedContent = str(a.highlightedContent);
470
+ return row;
471
+ }
472
+
473
+ /**
474
+ * Map the `/v4/dockets/{docketId}` DETAIL object into the compliance-relevant
475
+ * subset (captured fact 5). `rin` is null-when-absent (never "") — the cross-
476
+ * source join key to the Federal Register / Unified Agenda. `keywords` → string[]
477
+ * (via `str`+filter; [] only when genuinely absent). DROPS internal/rarely-
478
+ * populated fields (displayProperties/generic/field1/field2/subType/subType2/
479
+ * category/petitionNbr/organization/legacyId) to keep the row focused.
480
+ */
481
+ function mapDocketDetail(data: unknown): Record<string, unknown> {
482
+ const it = (data ?? {}) as {
483
+ id?: unknown;
484
+ attributes?: Record<string, unknown>;
485
+ };
486
+ const a = (it.attributes ?? {}) as Record<string, unknown>;
487
+ const keywords = Array.isArray(a.keywords)
488
+ ? (a.keywords as unknown[])
489
+ .map((k) => str(k))
490
+ .filter((k): k is string => k !== null)
491
+ : [];
492
+ return {
493
+ docketId: str(a.docketId),
494
+ title: str(a.title),
495
+ agencyId: str(a.agencyId),
496
+ docketType: str(a.docketType),
497
+ // rin — the cross-source join key; null-when-absent, NEVER "".
498
+ rin: str(a.rin),
499
+ dkAbstract: str(a.dkAbstract),
500
+ keywords,
501
+ program: str(a.program),
502
+ shortTitle: str(a.shortTitle),
503
+ effectiveDate: str(a.effectiveDate),
504
+ modifyDate: str(a.modifyDate),
505
+ objectId: str(a.objectId),
506
+ id: str(it.id),
507
+ };
508
+ }
509
+
510
+ /**
511
+ * Tool: regulations_search_dockets (`GET /v4/dockets`). Lists rulemaking/
512
+ * nonrulemaking docket CONTAINERS with the same 40-page/10,000-record ceiling and
513
+ * `totalElements`-exact total doctrine as regulationsSearch.
514
+ *
515
+ * ★ min-5 floor (ADR-0044): the API 400s on page[size]<5. The friendly `limit`
516
+ * exposes 1..250; the wire page[size] is `max(5, limit)` and a `limit<5` returns
517
+ * the first `limit` of the fetched rows client-side (disclosed) — `totalAvailable`
518
+ * stays the EXACT server total.
519
+ */
520
+ export async function searchDockets(
521
+ args: RegulationsSearchDocketsArgs,
522
+ ): Promise<MetaBundle> {
523
+ const label = "regulations:/v4/dockets";
524
+ const limit = args.limit ?? 20;
525
+ const pageNumber = args.pageNumber ?? 1;
526
+ const sort = args.sort ?? "-lastModifiedDate";
527
+ const fetchSize = Math.max(5, limit); // the wire page[size] — NEVER < 5
528
+ const clientSlice = limit < 5;
529
+
530
+ // S6 — pre-fetch window guard (mirror regulationsSearch): reject a beyond-ceiling
531
+ // page BEFORE any fetch, so a DIRECT call bypassing the Zod caps gets a clean
532
+ // LOCAL invalid_input rather than burning a scarce DEMO_KEY call on an upstream
533
+ // 400. (Zod also caps pageNumber≤40 / limit≤250 at the tool boundary.)
534
+ if (pageNumber > REG_MAX_PAGE || pageNumber * fetchSize > REG_MAX_RECORDS) {
535
+ throw new ToolErrorCarrier({
536
+ kind: "invalid_input",
537
+ message: `Regulations.gov page[number] (${pageNumber}) × page[size] (${fetchSize}) exceeds the API's hard ${REG_MAX_RECORDS}-record / ${REG_MAX_PAGE}-page pagination ceiling. Narrow filters (agencyId/docketType/lastModifiedDate) or seek by lastModifiedDate instead of paging past ${REG_MAX_RECORDS} results.`,
538
+ retryable: false,
539
+ upstreamEndpoint: label,
540
+ });
541
+ }
542
+
543
+ const searchTerm = args.searchTerm ?? args.query;
544
+ const params = new URLSearchParams();
545
+ const filtersApplied: string[] = [];
546
+ if (searchTerm) {
547
+ params.set("filter[searchTerm]", searchTerm);
548
+ filtersApplied.push("searchTerm");
549
+ }
550
+ if (args.agencyId) {
551
+ params.set("filter[agencyId]", args.agencyId);
552
+ filtersApplied.push("agencyId");
553
+ }
554
+ if (args.docketType) {
555
+ params.set("filter[docketType]", args.docketType);
556
+ filtersApplied.push("docketType");
557
+ }
558
+ if (args.lastModifiedDateGe) {
559
+ params.set("filter[lastModifiedDate][ge]", args.lastModifiedDateGe);
560
+ filtersApplied.push("lastModifiedDateGe");
561
+ }
562
+ if (args.lastModifiedDateLe) {
563
+ params.set("filter[lastModifiedDate][le]", args.lastModifiedDateLe);
564
+ filtersApplied.push("lastModifiedDateLe");
565
+ }
566
+ params.set("sort", sort);
567
+ params.set("page[number]", String(pageNumber));
568
+ params.set("page[size]", String(fetchSize));
569
+
570
+ // M1 — the typed catch ladder (fema.ts:262-275 shape). Preserve the 429/404/5xx/
571
+ // 400/timeout ToolErrorCarrier taxonomy FIRST (LOAD-BEARING: a broader catch
572
+ // would regress the DEMO_KEY-10/hr 429→rate_limited frontier to schema_drift);
573
+ // reclassify a 200 non-JSON `.json()` SyntaxError to schema_drift SECOND; bare-
574
+ // rethrow LAST.
575
+ let body: unknown;
576
+ try {
577
+ body = await getDatagov(REGULATIONS_HOST, "/v4/dockets", label, params);
578
+ } catch (e) {
579
+ if (e instanceof ToolErrorCarrier) throw e;
580
+ if (e instanceof SyntaxError)
581
+ throw driftError(
582
+ label,
583
+ "Regulations.gov returned a non-JSON body at HTTP 200 — schema drift.",
584
+ );
585
+ throw e;
586
+ }
587
+
588
+ const b = (body ?? {}) as {
589
+ data?: unknown;
590
+ meta?: { totalElements?: unknown } | null;
591
+ };
592
+
593
+ // data[] guard + meta.totalElements container guard (datagov.ts:288-302 idiom).
594
+ if (!Array.isArray(b.data)) {
595
+ throw driftError(
596
+ label,
597
+ "regulations shape drift — /v4/dockets response.data must be an array.",
598
+ );
599
+ }
600
+ if (!b.meta || typeof b.meta.totalElements !== "number") {
601
+ throw driftError(
602
+ label,
603
+ "regulations shape drift — /v4/dockets meta.totalElements missing/non-number.",
604
+ );
605
+ }
606
+
607
+ const rawRows = (b.data as unknown[]).map((row) =>
608
+ mapDocketListRow(row, Boolean(searchTerm)),
609
+ );
610
+ const rows = clientSlice ? rawRows.slice(0, limit) : rawRows;
611
+ const returned = rows.length;
612
+ // EXACT real total — NEVER meta.totalPages (a capped-40 sentinel). Typeof-guarded
613
+ // above. UNAFFECTED by the client-slice.
614
+ const totalAvailable = num(b.meta.totalElements);
615
+ const offset = (pageNumber - 1) * fetchSize;
616
+
617
+ const moreExist =
618
+ totalAvailable !== null && offset + returned < totalAvailable;
619
+ const nextPageNumber = pageNumber + 1;
620
+ const nextPageReachable =
621
+ nextPageNumber <= REG_MAX_PAGE &&
622
+ nextPageNumber * fetchSize <= REG_MAX_RECORDS;
623
+ const hasMore = moreExist;
624
+ // nextOffset is null in the client-slice case (paging by pageNumber would skip
625
+ // the unshown rows — an honest "increase limit to page reliably") and at the
626
+ // ceiling (no reachable continuation) — both surface hasMore:true/nextOffset:null.
627
+ const nextOffset =
628
+ moreExist && nextPageReachable && !clientSlice ? pageNumber * fetchSize : null;
629
+
630
+ const notes: string[] = [];
631
+ pushKeyNote(notes);
632
+ if (moreExist && !nextPageReachable) {
633
+ notes.push(
634
+ `Reached the API's ${REG_MAX_RECORDS}-record / ${REG_MAX_PAGE}-page pagination ceiling (totalElements=${totalAvailable} total). ~${totalAvailable - REG_MAX_RECORDS} more records exist but are UNREACHABLE via page[number] — narrow filters (agencyId/docketType/lastModifiedDate) or seek by lastModifiedDate to reach the rest.`,
635
+ );
636
+ }
637
+ if (clientSlice) {
638
+ notes.push(
639
+ `limit<5 requested; page[size] was floored to 5 upstream (the API rejects page[size]<5) and the first ${limit} of the ${rawRows.length} fetched rows are returned — totalAvailable is still the EXACT server total. For reliable pagination use limit>=5.`,
640
+ );
641
+ }
642
+
643
+ return withMeta(
644
+ { dockets: rows },
645
+ {
646
+ source: REG_DOC_SOURCE(keyModeLabel()),
647
+ keylessMode: false, // M2 — genuinely keyed
648
+ returned,
649
+ totalAvailable,
650
+ filtersApplied,
651
+ filtersDropped: [],
652
+ fieldsUnavailable: [],
653
+ // S3 — pagination.limit is the CALLER's requested count (the honest effective
654
+ // window; the wire floor of 5 is disclosed only in the min-5 note). offset
655
+ // uses fetchSize.
656
+ pagination: { offset, limit, hasMore, nextOffset },
657
+ notes,
658
+ } satisfies Partial<ResponseMeta>,
659
+ );
660
+ }
661
+
662
+ /**
663
+ * Tool: regulations_get_docket (`GET /v4/dockets/{docketId}`). Single-docket
664
+ * detail — the ONLY view carrying `rin`. `docketId` is charclass-validated at the
665
+ * Zod layer (S1); it is the only caller value reaching a path segment.
666
+ */
667
+ export async function getDocket(
668
+ args: RegulationsGetDocketArgs,
669
+ ): Promise<MetaBundle> {
670
+ const label = "regulations:/v4/dockets/{id}";
671
+ const path = `/v4/dockets/${args.docketId}`;
672
+ const params = new URLSearchParams();
673
+
674
+ // M1 — the IDENTICAL typed catch ladder as searchDockets (fema.ts:262-275). A
675
+ // nonexistent id is EXPECTED to 404 → not_found; the missing-`data` driftError
676
+ // guard below is the mandatory fallback if a bad id instead yields a 200 error-
677
+ // envelope (S2).
678
+ let body: unknown;
679
+ try {
680
+ body = await getDatagov(REGULATIONS_HOST, path, label, params);
681
+ } catch (e) {
682
+ if (e instanceof ToolErrorCarrier) throw e;
683
+ if (e instanceof SyntaxError)
684
+ throw driftError(
685
+ label,
686
+ "Regulations.gov returned a non-JSON body at HTTP 200 — schema drift.",
687
+ );
688
+ throw e;
689
+ }
690
+
691
+ const b = (body ?? {}) as { data?: unknown };
692
+
693
+ // S2 — MANDATORY fabrication guard: a missing/non-object `data` → schema_drift,
694
+ // NEVER a fabricated {docketId, ...nulls} (mirror getBill's bill guard).
695
+ if (!b.data || typeof b.data !== "object") {
696
+ throw driftError(
697
+ label,
698
+ "regulations shape drift — /v4/dockets/{docketId} response.data missing/not-an-object.",
699
+ );
700
+ }
701
+
702
+ const notes: string[] = [];
703
+ pushKeyNote(notes);
704
+ notes.push(
705
+ "rin (Regulatory Identifier Number) is the join key to the Federal Register (fed_register_search_documents) and the Unified Agenda; null when this docket has no assigned RIN (e.g. many Nonrulemaking dockets).",
706
+ );
707
+
708
+ return withMeta(
709
+ { docket: mapDocketDetail(b.data) },
710
+ {
711
+ source: REG_DOC_SOURCE(keyModeLabel()),
712
+ keylessMode: false, // M2 — genuinely keyed
713
+ returned: 1,
714
+ totalAvailable: null, // S4 — single-record detail convention (rely on complete:true)
715
+ filtersApplied: [],
716
+ filtersDropped: [],
717
+ fieldsUnavailable: [],
718
+ notes,
719
+ } satisfies Partial<ResponseMeta>,
720
+ );
721
+ }
722
+
723
+ // ═══════════════════ Congress.gov ═════════════════════════════════
724
+
725
+ const CONGRESS_SOURCE = (mode: string) =>
726
+ `${CONGRESS_HOST} via Congress.gov API (${mode})`;
727
+
728
+ /** Map one Congress.gov `bills[]` item into a flat, honesty-coerced row. */
729
+ function mapBill(item: unknown): Record<string, unknown> {
730
+ const it = (item ?? {}) as Record<string, unknown>;
731
+ const latest = (it.latestAction ?? null) as Record<string, unknown> | null;
732
+ return {
733
+ congress: num(it.congress),
734
+ type: str(it.type),
735
+ number: str(it.number),
736
+ title: str(it.title),
737
+ originChamber: str(it.originChamber),
738
+ latestAction: latest
739
+ ? { actionDate: str(latest.actionDate), text: str(latest.text) }
740
+ : null,
741
+ updateDate: str(it.updateDate),
742
+ // `url` is the API's canonical resource locator; under header auth it is
743
+ // key-FREE (the key is never in any URL). Passed through as data.
744
+ url: str(it.url),
745
+ };
746
+ }
747
+
748
+ type CongressSearchBillsArgs = {
749
+ query?: string;
750
+ congress?: number;
751
+ billType?: CongressBillType;
752
+ fromDateTime?: string;
753
+ toDateTime?: string;
754
+ offset?: number;
755
+ limit?: number;
756
+ };
757
+
758
+ /**
759
+ * Tool: congress_search_bills. Lists bills via `/v3/bill`, `/v3/bill/{congress}`,
760
+ * or `/v3/bill/{congress}/{billType}` (congress/billType are Zod-constrained PATH
761
+ * segments — §4). Numeric offset/limit pagination; `totalAvailable =
762
+ * pagination.count` (EXACT). `nextOffset` is re-derived NUMERICALLY (never the
763
+ * upstream `pagination.next` URL — §2 rule 4).
764
+ *
765
+ * HONESTY on `query`: the Congress.gov `/v3/bill` endpoint has NO keyword-search
766
+ * parameter, so a supplied `query` is NOT sent and is disclosed in
767
+ * `filtersDropped` (a filter we cannot honor is surfaced, never silently ignored).
768
+ */
769
+ export async function searchBills(
770
+ args: CongressSearchBillsArgs,
771
+ ): Promise<MetaBundle> {
772
+ const label = "congress:/v3/bill";
773
+ const limit = args.limit ?? 20;
774
+ const offset = args.offset ?? 0;
775
+
776
+ if (args.billType && args.congress === undefined) {
777
+ throw new ToolErrorCarrier({
778
+ kind: "invalid_input",
779
+ message:
780
+ "congress_search_bills: `billType` requires `congress` (the path is /v3/bill/{congress}/{billType}). Provide `congress`, or omit `billType`.",
781
+ retryable: false,
782
+ upstreamEndpoint: label,
783
+ });
784
+ }
785
+
786
+ let path = "/v3/bill";
787
+ if (args.congress !== undefined) {
788
+ path += `/${args.congress}`;
789
+ if (args.billType) path += `/${args.billType}`;
790
+ }
791
+
792
+ const params = new URLSearchParams();
793
+ params.set("format", "json");
794
+ params.set("limit", String(limit));
795
+ params.set("offset", String(offset));
796
+ const filtersApplied: string[] = [];
797
+ const filtersDropped: string[] = [];
798
+ if (args.congress !== undefined) filtersApplied.push("congress");
799
+ if (args.billType) filtersApplied.push("billType");
800
+ if (args.fromDateTime) {
801
+ params.set("fromDateTime", args.fromDateTime);
802
+ filtersApplied.push("fromDateTime");
803
+ }
804
+ if (args.toDateTime) {
805
+ params.set("toDateTime", args.toDateTime);
806
+ filtersApplied.push("toDateTime");
807
+ }
808
+ if (args.query) filtersDropped.push("query");
809
+
810
+ const body = await getDatagov(CONGRESS_HOST, path, label, params);
811
+ const b = (body ?? {}) as {
812
+ bills?: unknown;
813
+ pagination?: { count?: unknown } | null;
814
+ };
815
+
816
+ // M2 — `bills` MUST be an array (missing/string/null → drift, never []).
817
+ if (!Array.isArray(b.bills)) {
818
+ throw driftError(
819
+ label,
820
+ "congress shape drift — /v3/bill response.bills must be an array.",
821
+ );
822
+ }
823
+ // M3 — CONTAINER-guarded total: null/absent `pagination` or non-number
824
+ // `pagination.count` → driftError (not a TypeError masked as upstream_unavailable).
825
+ if (!b.pagination || typeof b.pagination.count !== "number") {
826
+ throw driftError(
827
+ label,
828
+ "congress shape drift — /v3/bill pagination.count missing/non-number.",
829
+ );
830
+ }
831
+
832
+ const bills = (b.bills as unknown[]).map(mapBill);
833
+ const returned = bills.length;
834
+ const totalAvailable = num(b.pagination.count); // EXACT
835
+ const hasMore =
836
+ totalAvailable !== null ? offset + returned < totalAvailable : false;
837
+ const nextOffset = hasMore ? offset + returned : null;
838
+
839
+ const notes: string[] = [];
840
+ pushKeyNote(notes);
841
+ if (filtersDropped.includes("query")) {
842
+ notes.push(
843
+ "The `query` filter was NOT applied: Congress.gov /v3/bill has no keyword-search parameter. Results are UNFILTERED on keyword — narrow with `congress`, `billType`, `fromDateTime`/`toDateTime` instead.",
844
+ );
845
+ }
846
+
847
+ return withMeta(
848
+ { bills },
849
+ {
850
+ source: CONGRESS_SOURCE(keyModeLabel()),
851
+ keylessMode: false,
852
+ returned,
853
+ totalAvailable,
854
+ filtersApplied,
855
+ filtersDropped,
856
+ fieldsUnavailable: [],
857
+ pagination: { offset, limit, hasMore, nextOffset },
858
+ notes,
859
+ } satisfies Partial<ResponseMeta>,
860
+ );
861
+ }
862
+
863
+ type CongressGetBillArgs = {
864
+ congress: number;
865
+ billType: CongressBillType;
866
+ billNumber: number;
867
+ };
868
+
869
+ /**
870
+ * Tool: congress_get_bill. Fetches one bill via
871
+ * `/v3/bill/{congress}/{billType}/{billNumber}` (all three path segments are
872
+ * Zod-constrained to int/enum — §4, no injection). Single-record honest `_meta`
873
+ * (complete:true). A 404 (nonexistent bill) → not_found (never fabricated).
874
+ */
875
+ export async function getBill(args: CongressGetBillArgs): Promise<MetaBundle> {
876
+ const label = "congress:/v3/bill";
877
+ const path = `/v3/bill/${args.congress}/${args.billType}/${args.billNumber}`;
878
+ const params = new URLSearchParams();
879
+ params.set("format", "json");
880
+
881
+ const body = await getDatagov(CONGRESS_HOST, path, label, params);
882
+ const b = (body ?? {}) as { bill?: unknown };
883
+
884
+ if (!b.bill || typeof b.bill !== "object") {
885
+ throw driftError(
886
+ label,
887
+ "congress shape drift — /v3/bill/{congress}/{type}/{number} response.bill missing/not-an-object.",
888
+ );
889
+ }
890
+
891
+ const notes: string[] = [];
892
+ pushKeyNote(notes);
893
+
894
+ return withMeta(
895
+ { bill: b.bill },
896
+ {
897
+ source: CONGRESS_SOURCE(keyModeLabel()),
898
+ keylessMode: false,
899
+ returned: 1,
900
+ totalAvailable: null,
901
+ filtersApplied: [],
902
+ filtersDropped: [],
903
+ fieldsUnavailable: [],
904
+ notes,
905
+ } satisfies Partial<ResponseMeta>,
906
+ );
907
+ }