@cliwant/mcp-sam-gov 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +279 -67
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +92 -17
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +432 -15
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +114 -11
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3897 -295
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1664 -228
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +73 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +706 -191
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +290 -155
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4559 -327
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +2680 -925
  228. package/src/usitc.ts +420 -0
@@ -0,0 +1,575 @@
1
+ /**
2
+ * US Treasury — Fiscal Data API wrappers (keyless).
3
+ *
4
+ * First non-SAM macro/fiscal source (ADR-0002). Fully PUBLIC, KEYLESS — the
5
+ * Treasury Fiscal Data service (https://fiscaldata.treasury.gov) needs no API
6
+ * key and documents no numeric rate limit. Base:
7
+ * https://api.fiscaldata.treasury.gov/services/api/fiscal_service/
8
+ *
9
+ * Three clean layers (seed the future common DataSource port, R2):
10
+ * fetch — `getTreasury(path, query)` builds the URL + envelope, reusing
11
+ * errors.ts retry/timeout/taxonomy.
12
+ * map — per-dataset PURE mappers Row → domain object, coercing string
13
+ * amounts through the shared `num()`.
14
+ * meta — `treasuryMeta(...)` hands `{ totalAvailable, returned, pagination }`
15
+ * to `withMeta`; meta.ts's `buildMeta` DERIVES complete/truncated.
16
+ *
17
+ * HONESTY TRAPS (live-verified 2026-07-10 — see ADR-0002 Review outcome v2):
18
+ * F1/F9 — `meta.count` / `meta['total-count']` / `meta['total-pages']` are
19
+ * JSON NUMBERS (not strings). `totalAvailable = meta['total-count']`
20
+ * directly; never `num()` them.
21
+ * F2 — VALUE fields are inconsistently typed across datasets (debt/rate
22
+ * amounts are strings; even `rates_of_exchange.exchange_rate`, whose
23
+ * dataType metadata says NUMBER, arrives as a string on the wire).
24
+ * Row value fields are typed `string | number | null` and ALWAYS go
25
+ * through `num()`.
26
+ * F3 — `num(x)` returns null (NEVER 0) for absent values. The literal
27
+ * string "null" is common (early history + MTS parent/summary rows);
28
+ * returning 0 would be a data-absence-as-zero masquerade.
29
+ * F4 — `mts_table_1` mixes child line-items (real amounts) with
30
+ * fiscal-year PARENT/SUMMARY rows whose amounts are all "null".
31
+ * `monthlyStatement` excludes them by default via the LIVE-VERIFIED
32
+ * server-side filter `current_month_gross_outly_amt:gt:0` (3039 →
33
+ * 2769 rows; the 270 dropped rows are exactly the `parent_id="null"`
34
+ * header rows).
35
+ * F5 — `queryDataset` accepts only an ENUM of 5 confirmed paths (no free
36
+ * path) — removes the SSRF surface for this slice.
37
+ */
38
+
39
+ import {
40
+ getJson,
41
+ driftError,
42
+ throughPathChain,
43
+ CircuitBreaker,
44
+ type ResiliencePath,
45
+ type Provenance,
46
+ } from "./datasource.js";
47
+ import { snapshotPath } from "./snapshot.js";
48
+ import { num, str } from "./coerce.js";
49
+ import { memoize } from "./cache.js";
50
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
51
+
52
+ // Re-export the shared honesty coercion (single audited copy now lives in
53
+ // ./coerce.js — ADR-0005 v2 FIX-C) so existing importers and the fault suite's
54
+ // num-parity guard keep resolving `num` from this module.
55
+ export { num };
56
+
57
+ const BASE =
58
+ "https://api.fiscaldata.treasury.gov/services/api/fiscal_service";
59
+
60
+ // ─── Resilience wiring (ADR-0045 Phase 2 pilot — INERT by default) ─────────
61
+ // The Treasury live host, and a per-host circuit breaker keyed on the FIXED set
62
+ // {this host} (bounded — m3-regression). The breaker is CONSULTED only by
63
+ // `throughPathChain` for a ≥2-path chain; when no snapshot is configured the
64
+ // chain is single-path (live only) and the breaker is a pure no-op. See
65
+ // datasource.ts §"RESILIENCE PORT".
66
+ const TREASURY_HOST = "api.fiscaldata.treasury.gov";
67
+ let treasuryBreaker = new CircuitBreaker([TREASURY_HOST]);
68
+
69
+ /**
70
+ * Test-only: reset the resilience circuit breaker between OFFLINE fixtures (the
71
+ * breaker is module-level process state; a fresh instance isolates cases). Not a
72
+ * runtime API — mirrors the `_reset*Cache` convention in the fault suite.
73
+ */
74
+ export function _resetTreasuryBreakerForTests(): void {
75
+ treasuryBreaker = new CircuitBreaker([TREASURY_HOST]);
76
+ }
77
+
78
+ // ─── Confirmed dataset paths (F5 allowlist enum) ──────────────────
79
+ // Live-verified 2026-07-10: path + total-count + key fields (ADR-0002 §Context).
80
+ export const TREASURY_DATASETS = {
81
+ debt_to_penny: "/v2/accounting/od/debt_to_penny",
82
+ avg_interest_rates: "/v2/accounting/od/avg_interest_rates",
83
+ mts_table_1: "/v1/accounting/mts/mts_table_1",
84
+ rates_of_exchange: "/v1/accounting/od/rates_of_exchange",
85
+ debt_outstanding: "/v2/accounting/od/debt_outstanding",
86
+ } as const;
87
+
88
+ export type TreasuryDatasetKey = keyof typeof TREASURY_DATASETS;
89
+
90
+ // ─── Envelope types (F1 counts are numbers; F2 values string|number|null) ──
91
+ export type TreasuryValue = string | number | null;
92
+ export type TreasuryRow = Record<string, TreasuryValue>;
93
+
94
+ export type TreasuryEnvelopeMeta = {
95
+ /** rows in THIS page — a JSON number (F1). */
96
+ count: number;
97
+ /** the truthful grand total for the query — a JSON number (F1). */
98
+ "total-count": number;
99
+ /** a JSON number (F1). */
100
+ "total-pages": number;
101
+ labels?: Record<string, string>;
102
+ dataTypes?: Record<string, string>;
103
+ dataFormats?: Record<string, string>;
104
+ };
105
+
106
+ export type TreasuryEnvelope<Row = TreasuryRow> = {
107
+ data: Row[];
108
+ meta: TreasuryEnvelopeMeta;
109
+ links?: {
110
+ self?: string;
111
+ first?: string;
112
+ prev?: string | null;
113
+ next?: string | null;
114
+ last?: string;
115
+ };
116
+ };
117
+
118
+ type TreasuryQuery = {
119
+ fields?: string;
120
+ filter?: string;
121
+ sort?: string;
122
+ pageSize: number;
123
+ pageNumber: number;
124
+ };
125
+
126
+ // ─── HONESTY-CRITICAL coercions (F1/F2/F3) ────────────────────────
127
+ // `num`/`str` are the shared, audited null-never-0 coercions in ./coerce.js
128
+ // (imported above, `num` re-exported). F3: `num` returns null (NEVER 0) for
129
+ // absent values — the literal string "null" (early history + MTS parent rows),
130
+ // ""/whitespace (Number("") is 0!), and "(-)"/"-" all become null.
131
+
132
+ // ─── fetch layer ──────────────────────────────────────────────────
133
+ /**
134
+ * GET one Treasury Fiscal Data page. Reuses errors.ts (retry/backoff/timeout +
135
+ * the 429/5xx/404/400 taxonomy: an invalid `fields`/`filter` column ⇒ upstream
136
+ * 400 ⇒ `invalid_input`, surfaced as an error, never a silent drop). Throws a
137
+ * `schema_drift` ToolErrorCarrier if the envelope's `total-count` is not a
138
+ * number (so a future upstream retype can't silently corrupt `_meta`).
139
+ */
140
+ async function getTreasury<Row = TreasuryRow>(
141
+ path: string,
142
+ query: TreasuryQuery,
143
+ snapshotKey?: string,
144
+ ): Promise<{ env: TreasuryEnvelope<Row>; provenance: Provenance }> {
145
+ const params = new URLSearchParams();
146
+ if (query.fields) params.set("fields", query.fields);
147
+ if (query.filter) params.set("filter", query.filter);
148
+ if (query.sort) params.set("sort", query.sort);
149
+ params.set("page[size]", String(query.pageSize));
150
+ params.set("page[number]", String(query.pageNumber));
151
+ const url = `${BASE}${path}?${params.toString()}`;
152
+ const label = "treasury:" + path;
153
+ // ★ADR-0045 Phase 2 pilot — route the fetch through `throughPathChain` INSTEAD
154
+ // of a bare getJson. The LIVE path is byte-identical to the prior hand-rolled
155
+ // fetch (init === { signal }, no headers/redirect — the same getJson call).
156
+ const livePath: ResiliencePath<TreasuryEnvelope<Row>> = {
157
+ host: TREASURY_HOST,
158
+ provenance: { dataPath: "live" },
159
+ run: () => getJson<TreasuryEnvelope<Row>>(url, { label }),
160
+ };
161
+ // The snapshot fallback is added ONLY when (a) this call declares a canned
162
+ // snapshot key AND (b) SAMGOV_SNAPSHOT_BASE_URL is configured (else
163
+ // snapshotPath returns null). ★When either is absent the chain is SINGLE-ENTRY
164
+ // (live only) ⇒ throughPathChain fast-paths (no breaker consult, no overhead)
165
+ // ⇒ behavior byte-identical to before this ADR (the INERT guarantee).
166
+ const snap = snapshotKey
167
+ ? snapshotPath<TreasuryEnvelope<Row>>(snapshotKey)
168
+ : null;
169
+ const paths = snap ? [livePath, snap] : [livePath];
170
+ const { body: env, provenance } = await throughPathChain<
171
+ TreasuryEnvelope<Row>
172
+ >(paths, treasuryBreaker);
173
+ // Drift guard applies to BOTH paths (a malformed snapshot fails as loudly as a
174
+ // malformed live body) — schema_drift is non-retryable, so it does not fall
175
+ // through nor trip the breaker.
176
+ if (
177
+ !env ||
178
+ typeof env.meta !== "object" ||
179
+ env.meta === null ||
180
+ typeof env.meta["total-count"] !== "number" ||
181
+ !Array.isArray(env.data)
182
+ ) {
183
+ throw driftError(
184
+ label,
185
+ `treasury:${path} returned an unexpected envelope shape (meta['total-count'] must be a number and data an array).`,
186
+ );
187
+ }
188
+ return { env, provenance };
189
+ }
190
+
191
+ // ─── meta layer ───────────────────────────────────────────────────
192
+ const STRING_COERCION_NOTE =
193
+ "Treasury value/amount fields arrive as strings (or, for some datasets, numbers) and are coerced via num(): the literal string \"null\" and empty values become null (NOT 0) — a null amount means 'no value reported', never zero.";
194
+
195
+ /**
196
+ * Build the partial `_meta`. Passes `totalAvailable` (= the numeric
197
+ * `total-count`, NEVER data.length/count/page-size) + offset-pagination, and
198
+ * lets meta.ts's `buildMeta` DERIVE `complete`/`truncated` from those signals
199
+ * (do NOT recompute here).
200
+ */
201
+ function treasuryMeta(opts: {
202
+ env: TreasuryEnvelope;
203
+ path: string;
204
+ pageNumber: number;
205
+ pageSize: number;
206
+ notes: string[];
207
+ filtersApplied?: string[];
208
+ fieldsUnavailable?: string[];
209
+ /**
210
+ * The access path that served this response (ADR-0045 P5). When the live
211
+ * upstream answered (`dataPath:"live"`, the default and — with no snapshot
212
+ * configured — the ONLY path) the dataPath/asOf fields are OMITTED, so `_meta`
213
+ * is byte-identical to before this ADR. They are threaded ONLY for a NON-live
214
+ * (snapshot) body, which makes buildMeta emit a staleness note, gate `complete`
215
+ * off, and qualify `totalAvailable` as an as-of figure.
216
+ */
217
+ provenance?: Provenance;
218
+ }): Partial<ResponseMeta> {
219
+ const totalAvailable = opts.env.meta["total-count"];
220
+ const returned = opts.env.data.length;
221
+ const offset = (opts.pageNumber - 1) * opts.pageSize;
222
+ const hasMore = offset + returned < totalAvailable;
223
+ const meta: Partial<ResponseMeta> = {
224
+ source: `api.fiscaldata.treasury.gov (keyless) ${opts.path}`,
225
+ keylessMode: true,
226
+ returned,
227
+ totalAvailable,
228
+ pagination: {
229
+ offset,
230
+ limit: opts.pageSize,
231
+ hasMore,
232
+ nextOffset: hasMore ? offset + returned : null,
233
+ },
234
+ filtersApplied: opts.filtersApplied ?? [],
235
+ filtersDropped: [],
236
+ fieldsUnavailable: opts.fieldsUnavailable ?? [],
237
+ notes: opts.notes,
238
+ };
239
+ // P5 threading (ADR-0045 B2): surface dataPath/asOf ONLY when NON-live, so a
240
+ // live response omits the keys and stays byte-identical. No `??` default.
241
+ if (opts.provenance && opts.provenance.dataPath !== "live") {
242
+ meta.dataPath = opts.provenance.dataPath;
243
+ if (opts.provenance.asOf !== undefined) meta.asOf = opts.provenance.asOf;
244
+ }
245
+ return meta;
246
+ }
247
+
248
+ /** Build `record_date` gte/lte filter clauses from optional ISO dates. */
249
+ function dateFilters(startDate?: string, endDate?: string): string[] {
250
+ const f: string[] = [];
251
+ if (startDate) f.push(`record_date:gte:${startDate}`);
252
+ if (endDate) f.push(`record_date:lte:${endDate}`);
253
+ return f;
254
+ }
255
+
256
+ /** UTC "today minus N months" as YYYY-MM-DD, for trailing-window defaults. */
257
+ function isoMonthsAgo(months: number): string {
258
+ const d = new Date();
259
+ d.setUTCMonth(d.getUTCMonth() - months);
260
+ return d.toISOString().slice(0, 10);
261
+ }
262
+
263
+ /**
264
+ * The most recent `record_date` for a dataset (memoized 5-min — slow-changing
265
+ * reference read). Used to pin the "latest" period for the per-date-breakdown
266
+ * datasets (avg_interest_rates) where a single-row page would bleed across
267
+ * months.
268
+ */
269
+ async function latestRecordDate(path: string): Promise<string | null> {
270
+ return memoize(`treasury:latestdate:${path}`, async () => {
271
+ const { env } = await getTreasury(path, {
272
+ fields: "record_date",
273
+ sort: "-record_date",
274
+ pageSize: 1,
275
+ pageNumber: 1,
276
+ });
277
+ return str(env.data[0]?.record_date);
278
+ });
279
+ }
280
+
281
+ // ─── Tool: queryDataset (escape hatch, F5 enum-only) ──────────────
282
+ /**
283
+ * Generic escape hatch over the 5 confirmed datasets (covers debt_outstanding +
284
+ * rates_of_exchange without a dedicated tool). Rows are passed through RAW
285
+ * (value fields as strings exactly as upstream provides) — the `_meta` note
286
+ * warns that the literal string "null" means "no value", never zero.
287
+ */
288
+ export async function queryDataset(args: {
289
+ dataset: TreasuryDatasetKey;
290
+ fields?: string;
291
+ filter?: string;
292
+ sort?: string;
293
+ pageSize?: number;
294
+ pageNumber?: number;
295
+ }): Promise<MetaBundle> {
296
+ const path = TREASURY_DATASETS[args.dataset];
297
+ const pageSize = args.pageSize ?? 100;
298
+ const pageNumber = args.pageNumber ?? 1;
299
+ const { env, provenance } = await getTreasury(path, {
300
+ fields: args.fields,
301
+ filter: args.filter,
302
+ sort: args.sort,
303
+ pageSize,
304
+ pageNumber,
305
+ });
306
+ const filtersApplied: string[] = [];
307
+ if (args.filter) filtersApplied.push("filter");
308
+ if (args.fields) filtersApplied.push("fields");
309
+ return withMeta(
310
+ { dataset: args.dataset, path, rows: env.data },
311
+ treasuryMeta({
312
+ env,
313
+ path,
314
+ pageNumber,
315
+ pageSize,
316
+ filtersApplied,
317
+ provenance,
318
+ notes: [
319
+ "Raw pass-through: value/amount fields are the upstream strings (or numbers) verbatim — the literal string \"null\" or an empty value means 'no value reported', NOT zero. Parse client-side.",
320
+ ],
321
+ }),
322
+ );
323
+ }
324
+
325
+ // ─── Tool: debtToPenny ────────────────────────────────────────────
326
+ const DEBT_TO_PENNY_FIELDS =
327
+ "record_date,tot_pub_debt_out_amt,debt_held_public_amt,intragov_hold_amt";
328
+
329
+ /**
330
+ * Daily total US public debt ("Debt to the Penny"). `latest` (default true) ⇒
331
+ * the single most-recent day; else the `startDate/endDate` range, newest first.
332
+ * Amounts are USD, coerced via num() (null, never 0, for absent).
333
+ */
334
+ export async function debtToPenny(args: {
335
+ latest?: boolean;
336
+ startDate?: string;
337
+ endDate?: string;
338
+ pageSize?: number;
339
+ pageNumber?: number;
340
+ }): Promise<MetaBundle> {
341
+ const path = TREASURY_DATASETS.debt_to_penny;
342
+ const latest = args.latest ?? true;
343
+ const pageNumber = args.pageNumber ?? 1;
344
+ const pageSize = latest ? 1 : args.pageSize ?? 100;
345
+ const filter = latest
346
+ ? undefined
347
+ : dateFilters(args.startDate, args.endDate).join(",") || undefined;
348
+ // ★Snapshot fallback ONLY for the canned "latest" read (a single, well-defined
349
+ // most-recent-day snapshot the builder can pre-fetch). Range/paginated reads
350
+ // pass no key ⇒ live only. Even for latest, the snapshot is INERT unless
351
+ // SAMGOV_SNAPSHOT_BASE_URL is configured (snapshotPath returns null).
352
+ const snapshotKey = latest ? "treasury_debt_to_penny_latest" : undefined;
353
+ const { env, provenance } = await getTreasury(
354
+ path,
355
+ {
356
+ fields: DEBT_TO_PENNY_FIELDS,
357
+ filter,
358
+ sort: "-record_date",
359
+ pageSize,
360
+ pageNumber,
361
+ },
362
+ snapshotKey,
363
+ );
364
+ const data = {
365
+ records: env.data.map((r) => ({
366
+ recordDate: str(r.record_date),
367
+ totalPublicDebtOutstanding: num(r.tot_pub_debt_out_amt),
368
+ debtHeldByPublic: num(r.debt_held_public_amt),
369
+ intragovernmentalHoldings: num(r.intragov_hold_amt),
370
+ })),
371
+ };
372
+ const filtersApplied: string[] = [];
373
+ if (!latest && (args.startDate || args.endDate)) filtersApplied.push("recordDate");
374
+ return withMeta(
375
+ data,
376
+ treasuryMeta({
377
+ env,
378
+ path,
379
+ pageNumber,
380
+ pageSize,
381
+ filtersApplied,
382
+ provenance,
383
+ notes: [
384
+ latest
385
+ ? "latest=true returns only the single most-recent day (page[size]=1)."
386
+ : "Range mode: rows filtered by record_date, sorted newest-first.",
387
+ STRING_COERCION_NOTE,
388
+ ],
389
+ }),
390
+ );
391
+ }
392
+
393
+ // ─── Tool: monthlyStatement (MTS table 1) — F4 summary-row exclusion ──
394
+ const MTS_FIELDS =
395
+ "record_date,classification_desc,parent_id,line_code_nbr,current_month_gross_rcpt_amt,current_month_gross_outly_amt,current_month_dfct_sur_amt";
396
+
397
+ /**
398
+ * Monthly Treasury Statement (MTS) table 1 — federal receipts, outlays, and the
399
+ * deficit/surplus by month. `startDate/endDate` (ISO) filter record_date;
400
+ * default is the trailing ~12 months.
401
+ *
402
+ * F4 — SUMMARY-ROW EXCLUSION (live-verified 2026-07-10): mts_table_1 interleaves
403
+ * child line-items (real amounts) with fiscal-year PARENT/SUMMARY header rows
404
+ * whose `parent_id` is the string "null" and whose amount fields are all the
405
+ * string "null". `excludeSummaryRows` (default true) appends the server-side
406
+ * filter `current_month_gross_outly_amt:gt:0`, which drops EXACTLY those parent
407
+ * rows (total-count 3039 → 2769; the 270 dropped rows == the parent_id="null"
408
+ * set). Because the exclusion is server-side, `total-count` (and hence
409
+ * `_meta.totalAvailable` / pagination) reflect the child rows only — no
410
+ * client-side filtering that would desync `returned` from the envelope count.
411
+ */
412
+ export async function monthlyStatement(args: {
413
+ startDate?: string;
414
+ endDate?: string;
415
+ excludeSummaryRows?: boolean;
416
+ pageSize?: number;
417
+ pageNumber?: number;
418
+ }): Promise<MetaBundle> {
419
+ const path = TREASURY_DATASETS.mts_table_1;
420
+ const excludeSummary = args.excludeSummaryRows ?? true;
421
+ const pageNumber = args.pageNumber ?? 1;
422
+ const pageSize = args.pageSize ?? 100;
423
+ // Default to the trailing ~12 months when no explicit window is given.
424
+ const startDate =
425
+ args.startDate ?? (args.endDate ? undefined : isoMonthsAgo(12));
426
+ const filters = dateFilters(startDate, args.endDate);
427
+ if (excludeSummary) filters.push("current_month_gross_outly_amt:gt:0");
428
+ const filter = filters.length ? filters.join(",") : undefined;
429
+ const { env, provenance } = await getTreasury(path, {
430
+ fields: MTS_FIELDS,
431
+ filter,
432
+ sort: "-record_date,line_code_nbr",
433
+ pageSize,
434
+ pageNumber,
435
+ });
436
+ const data = {
437
+ records: env.data.map((r) => ({
438
+ recordDate: str(r.record_date),
439
+ classification: str(r.classification_desc),
440
+ grossReceipts: num(r.current_month_gross_rcpt_amt),
441
+ grossOutlays: num(r.current_month_gross_outly_amt),
442
+ deficitSurplus: num(r.current_month_dfct_sur_amt),
443
+ })),
444
+ };
445
+ const filtersApplied: string[] = [];
446
+ if (startDate || args.endDate) filtersApplied.push("recordDate");
447
+ if (excludeSummary) filtersApplied.push("excludeSummaryRows");
448
+ const notes: string[] = [
449
+ excludeSummary
450
+ ? "Summary/parent rows excluded (default): the server-side filter current_month_gross_outly_amt:gt:0 drops the fiscal-year header rows (parent_id=\"null\", all amounts \"null\"). totalAvailable reflects child line-item rows only. Pass excludeSummaryRows=false to include them."
451
+ : "excludeSummaryRows=false: fiscal-year parent/summary rows (parent_id=\"null\") ARE included; their amount fields are all null — do NOT read those null-amount rows as data.",
452
+ STRING_COERCION_NOTE,
453
+ ];
454
+ return withMeta(
455
+ data,
456
+ treasuryMeta({
457
+ env,
458
+ path,
459
+ pageNumber,
460
+ pageSize,
461
+ filtersApplied,
462
+ notes,
463
+ provenance,
464
+ }),
465
+ );
466
+ }
467
+
468
+ // ─── Tool: avgInterestRates ───────────────────────────────────────
469
+ const AVG_INTEREST_FIELDS =
470
+ "record_date,security_type_desc,security_desc,avg_interest_rate_amt";
471
+
472
+ /**
473
+ * Average interest rate the Treasury pays by security type/description.
474
+ * `latest` (default true) returns the most-recent month's full breakdown across
475
+ * security types (pinned to the latest record_date, memoized — a slow-changing
476
+ * reference read); else the `startDate/endDate` range. Optional `securityType`
477
+ * narrows by `security_type_desc` (e.g. "Marketable", "Non-marketable").
478
+ * `avg_interest_rate_amt` is a PERCENT, coerced via num() (null, never 0).
479
+ */
480
+ export async function avgInterestRates(args: {
481
+ securityType?: string;
482
+ latest?: boolean;
483
+ startDate?: string;
484
+ endDate?: string;
485
+ pageSize?: number;
486
+ pageNumber?: number;
487
+ }): Promise<MetaBundle> {
488
+ const latest = args.latest ?? true;
489
+ if (latest) {
490
+ const key = `treasury:avg_interest_rates:latest:${args.securityType ?? "*"}`;
491
+ return memoize(key, () => avgInterestRatesLatest(args.securityType));
492
+ }
493
+ return avgInterestRatesQuery({
494
+ securityType: args.securityType,
495
+ startDate: args.startDate,
496
+ endDate: args.endDate,
497
+ pageSize: args.pageSize ?? 100,
498
+ pageNumber: args.pageNumber ?? 1,
499
+ latest: false,
500
+ });
501
+ }
502
+
503
+ async function avgInterestRatesLatest(
504
+ securityType?: string,
505
+ ): Promise<MetaBundle> {
506
+ const path = TREASURY_DATASETS.avg_interest_rates;
507
+ const maxDate = await latestRecordDate(path);
508
+ return avgInterestRatesQuery({
509
+ securityType,
510
+ startDate: maxDate ?? undefined,
511
+ endDate: maxDate ?? undefined,
512
+ pageSize: 100,
513
+ pageNumber: 1,
514
+ latest: true,
515
+ latestDate: maxDate,
516
+ });
517
+ }
518
+
519
+ async function avgInterestRatesQuery(opts: {
520
+ securityType?: string;
521
+ startDate?: string;
522
+ endDate?: string;
523
+ pageSize: number;
524
+ pageNumber: number;
525
+ latest: boolean;
526
+ latestDate?: string | null;
527
+ }): Promise<MetaBundle> {
528
+ const path = TREASURY_DATASETS.avg_interest_rates;
529
+ const filters = dateFilters(opts.startDate, opts.endDate);
530
+ if (opts.securityType) {
531
+ filters.push(`security_type_desc:eq:${opts.securityType}`);
532
+ }
533
+ const filter = filters.length ? filters.join(",") : undefined;
534
+ const { env, provenance } = await getTreasury(path, {
535
+ fields: AVG_INTEREST_FIELDS,
536
+ filter,
537
+ sort: "-record_date,security_type_desc",
538
+ pageSize: opts.pageSize,
539
+ pageNumber: opts.pageNumber,
540
+ });
541
+ const data = {
542
+ records: env.data.map((r) => ({
543
+ recordDate: str(r.record_date),
544
+ securityType: str(r.security_type_desc),
545
+ securityDescription: str(r.security_desc),
546
+ avgInterestRatePercent: num(r.avg_interest_rate_amt),
547
+ })),
548
+ };
549
+ const filtersApplied: string[] = [];
550
+ if (opts.startDate || opts.endDate) filtersApplied.push("recordDate");
551
+ if (opts.securityType) filtersApplied.push("securityType");
552
+ const notes: string[] = [];
553
+ if (opts.latest) {
554
+ notes.push(
555
+ opts.latestDate
556
+ ? `latest=true: pinned to the most-recent record_date (${opts.latestDate}); served from a 5-minute TTL cache.`
557
+ : "latest=true: could not resolve the most-recent record_date (upstream returned no rows).",
558
+ );
559
+ } else {
560
+ notes.push("Range mode: rows filtered by record_date, sorted newest-first.");
561
+ }
562
+ notes.push(STRING_COERCION_NOTE);
563
+ return withMeta(
564
+ data,
565
+ treasuryMeta({
566
+ env,
567
+ path,
568
+ pageNumber: opts.pageNumber,
569
+ pageSize: opts.pageSize,
570
+ filtersApplied,
571
+ notes,
572
+ provenance,
573
+ }),
574
+ );
575
+ }