@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/fdic.ts ADDED
@@ -0,0 +1,2052 @@
1
+ /**
2
+ * FDIC BankFind Suite — keyless FDIC-insured-institution directory + regulated-
3
+ * entity financials (ADR-0028). The FIRST OFF-EDGAR entity source: an
4
+ * FDIC-insured-institution directory (`/banks/institutions`) + quarterly
5
+ * financial time-series (`/banks/financials`) for B2G counterparty / entity
6
+ * due-diligence. Source 22 → 23; tool snapshot 87 → 89. Fully PUBLIC, KEYLESS —
7
+ * no key param, no auth, no PII (institution NAME is an ORG name; the curated
8
+ * projections exclude every officer/contact field).
9
+ *
10
+ * R2 consumer — reuses the shipped `DataSource` port EXACTLY like ckan.ts /
11
+ * socrata.ts: `getJson` / `driftError` / `throughGate` (datasource.ts), `num`·
12
+ * `str` (coerce.ts), `withMeta` / `buildMeta` (meta.ts). It writes ZERO fetch /
13
+ * coercion / error / pagination code, and COPIES (does not import) the SSRF +
14
+ * honesty PATTERN.
15
+ * Directory: https://api.fdic.gov/banks/institutions?filters=…&search=…&fields=…
16
+ * Financials: https://api.fdic.gov/banks/financials?filters=CERT:<int>&fields=…
17
+ *
18
+ * SUCCESS envelope (live-verified 2026-07-13, SEC-style UA, HTTP 200 JSON):
19
+ * { "meta": { "total": <EXACT int>, "parameters": {…}, "index": { "name":…,
20
+ * "createTimestamp":… } }, "data": [ { "data": {<record>}, "score":… } ] }
21
+ * `meta.total` is the EXACT match count, STABLE across `offset` (honest exact-
22
+ * total pagination). Records are nested under `data[].data`. Two OTHER envelopes
23
+ * exist: a QUERY-ERROR `{ "errors":[{status,detail}] }` (HTTP 400) and a
24
+ * ROUTING `{ "message","statusCode" }` (HTTP 404) — both handled by the port's
25
+ * taxonomy (they THROW before the drift-guard ever runs).
26
+ *
27
+ * ★ SSRF GUARD (policy① — the central design risk). Fixed host `api.fdic.gov`,
28
+ * `https` only. The path is a FIXED endpoint constant the TOOL chooses
29
+ * (`institutions` | `financials`) — NO caller value on the path. Every param
30
+ * (`filters` / `search` / `fields` / `sort_by` / `sort_order` / `limit` /
31
+ * `offset` / `format`) goes through URLSearchParams, built server-side from
32
+ * allowlisted structured inputs with escaped values. Belt-and-suspenders builder
33
+ * assertion (mirrors ckan/socrata): `new URL(url).hostname === "api.fdic.gov"`
34
+ * and `protocol === "https:"` → else invalid_input. `limit`/`offset` are bounded
35
+ * ints (no unbounded paging). `redirect:"error"` on every getJson (a future 3xx
36
+ * off `api.fdic.gov` throws rather than being silently followed off-host).
37
+ *
38
+ * ★ HONESTY DESIGN (P1–P4; ADR §4 + the v2 review) — closing the landmines:
39
+ * (A/P4) filter-FIELD names are compile-time constants behind NAMED structured
40
+ * inputs; a caller can never supply a field name, so a typo'd field can never
41
+ * reach the wire as a false genuine-empty. Belt-and-suspenders: the builder
42
+ * asserts every emitted filter field ∈ its allowlist (institutions
43
+ * {STALP,ACTIVE,CERT}; financials {CERT}); every emitted SEARCH field ∈
44
+ * {NAME,CITY}; every sortBy ∈ the per-endpoint sort allowlist (a Set.has
45
+ * recheck behind the server's Zod enum) → an unknown sort field is
46
+ * invalid_input BEFORE fetch (live: `sort_by=NOTAFIELD` + a sort_order → HTTP
47
+ * 400, so the pre-fetch guard is load-bearing).
48
+ * ★M1 (BLOCKER) — `name`/`city` route through FDIC's full-text `search` param,
49
+ * NOT `filters=NAME/CITY:"…"`. The `filters` DSL treats NAME/CITY as
50
+ * case-sensitive EXACT-keyword (live: `filters=NAME:"chase"` → total 0 — a
51
+ * confident false-empty), whereas `search=NAME:chase` is a case-insensitive
52
+ * full-text token match (live: 43) that combines cleanly with `filters` (live:
53
+ * `search=NAME:first` + `filters=STALP:VA AND ACTIVE:1` → 9). We emit BOTH
54
+ * `filters=` (STALP/ACTIVE/CERT) and `search=` (NAME/CITY) when present.
55
+ * Disclosed in `_meta.notes`.
56
+ * ★M2 (v2 fix — the search value is UNQUOTED) — the `search` term is built
57
+ * `NAME:<escaped>` (NO surrounding quotes). Quoting a single token makes FDIC
58
+ * run a `match_phrase` that COLLAPSES recall to zero for real brand-name banks
59
+ * (live: `NAME:"Axos"`→0 but `NAME:Axos`→1 — Axos Bank CERT 35546 exists), the
60
+ * exact M1 false-empty class. The value is instead backslash-escaped for the
61
+ * UNQUOTED Lucene reserved chars the char-class allows (`( ) & / -` + `\`; see
62
+ * escapeSearch) — belt-and-suspenders on the Zod-bypass path. The `search`
63
+ * param is provably non-injectable for WIDENING (default-AND token semantics —
64
+ * `OR`/`&&` never form a union; live: `NAME:zzz OR STALP:VA`→0), so no quotes
65
+ * are needed for security either.
66
+ * ★S1 — a multi-word name/city `search` value is matched PER-TOKEN by FDIC's
67
+ * full-text index (may be BROADER than a literal substring — a record sharing
68
+ * only ONE token can match; live: `search=NAME:First Community`→225 incl.
69
+ * "First State Bank"). We disclose this in `_meta.notes` whenever a name/city
70
+ * value contains a space.
71
+ * (P1) EXACT total → honest pagination: `totalAvailable = num(meta.total)`
72
+ * (stable across offset); `records = data.map(d => d.data)`; `hasMore =
73
+ * offset + returned < totalAvailable`; `nextOffset`. Via withMeta/buildMeta.
74
+ * (P2) 3-envelope drift-guard: require `meta` object AND `data` Array AND
75
+ * `typeof meta.total === "number"` → else driftError (rejects the errors[] /
76
+ * message+statusCode shapes at HTTP 200). A non-JSON 200 body (json parse
77
+ * throw) → reclassified to driftError. The ONLY honest empty = HTTP 200 +
78
+ * `meta.total:0` + `data:[]` → returned:0, totalAvailable:0, complete:true.
79
+ * Everything else THROWS — never a fake-empty.
80
+ * (P3) $thousands→USD ×1000, null-never-0: FDIC publishes ASSET/DEP/NETINC in
81
+ * $thousands; `assetUSD = num(rec.ASSET) === null ? null : num(rec.ASSET) *
82
+ * 1000` (the null-guard PRECEDES the ×1000, so an absent value stays null,
83
+ * never 0). Disclosed in `_meta.notes`. CERT/REPDTE/ACTIVE via `num`;
84
+ * NAME/CITY/STALP/ID/ESTYMD via `str`.
85
+ * (B) Returned-fields disclosure: the union of keys across `data[].data`; any
86
+ * projected field absent from ALL records (when returned > 0) →
87
+ * `_meta.fieldsUnavailable` + a note (a field FDIC silently stops returning
88
+ * surfaces instead of vanishing).
89
+ * Snapshot-freshness: `meta.index.{name,createTimestamp}` → `_meta.notes`
90
+ * ("point-in-time snapshot, not a live-this-second read"; the institutions
91
+ * and financials indexes carry DIFFERENT snapshot times).
92
+ *
93
+ * ★ A1 — provenance `source` is set INLINE in each tool's withMeta partial (like
94
+ * ckan.ts). There is NO `fdic_` branch in server.ts's synthesizeDefaultMeta (both
95
+ * tools return a MetaBundle, so that switch is never consulted — it would be dead
96
+ * code shipping blank provenance).
97
+ */
98
+
99
+ import { ToolErrorCarrier } from "./errors.js";
100
+ import { getJson, driftError, throughGate } from "./datasource.js";
101
+ import { num, str } from "./coerce.js";
102
+ import { withMeta, type MetaBundle, type ResponseMeta } from "./meta.js";
103
+
104
+ // Re-export the shared honesty coercion (single audited copy in ./coerce.js) so
105
+ // the fault suite's num-parity guard resolves fdic.num === coerce.num, exactly
106
+ // like ckan.num / socrata.num.
107
+ export { num };
108
+
109
+ // ─── Constants (SSRF core) ─────────────────────────────────────────
110
+ const FDIC_HOST = "api.fdic.gov";
111
+ // One host = one rate budget. ~5 req/s; conservative — FDIC publishes no hard
112
+ // ceiling, so stay polite. The gate label is host-only (getJson contract).
113
+ const FDIC_MIN_INTERVAL_MS = 200;
114
+ const LABEL = "fdic:" + FDIC_HOST; // → ToolError.upstreamEndpoint (host-only)
115
+ // The throughGate KEY — host-scoped so BOTH endpoints share one rate budget.
116
+ const GATE_KEY = "fdic";
117
+
118
+ // Fixed endpoint constants — the TOOL chooses these; NO caller value on the path.
119
+ const ENDPOINT_INSTITUTIONS = "institutions";
120
+ const ENDPOINT_FINANCIALS = "financials";
121
+
122
+ // Fixed field projections (every field live-verified valid → none silently
123
+ // dropped by a typo). The output maps each to a typed key.
124
+ const INST_FIELDS = "NAME,CITY,STALP,CERT,ASSET,ACTIVE,ESTYMD,ID";
125
+ const FIN_FIELDS = "CERT,REPDTE,ASSET,DEP,NETINC,ID";
126
+ // The raw FDIC field names, for the returned-fields (B) disclosure.
127
+ const INST_PROJECTION = [
128
+ "NAME",
129
+ "CITY",
130
+ "STALP",
131
+ "CERT",
132
+ "ASSET",
133
+ "ACTIVE",
134
+ "ESTYMD",
135
+ "ID",
136
+ ] as const;
137
+ const FIN_PROJECTION = ["CERT", "REPDTE", "ASSET", "DEP", "NETINC", "ID"] as const;
138
+
139
+ // Filter-FIELD allowlists (P4 belt-and-suspenders). NAME/CITY are NOT here — they
140
+ // route through `search` (M1). These fields carry only constrained non-string
141
+ // values (STALP a 2-letter code, ACTIVE 0/1, CERT an int) so they need no quoting.
142
+ const INST_FILTER_FIELDS: ReadonlySet<string> = new Set(["STALP", "ACTIVE", "CERT"]);
143
+ const FIN_FILTER_FIELDS: ReadonlySet<string> = new Set(["CERT"]);
144
+ // SEARCH-FIELD allowlist (M1) — free-text NAME/CITY only (phrase-quoted + escaped).
145
+ const INST_SEARCH_FIELDS: ReadonlySet<string> = new Set(["NAME", "CITY"]);
146
+ // sortBy allowlists (mirror the server's Zod enums; a Set.has recheck in the
147
+ // builder → an unknown sort field is invalid_input BEFORE fetch).
148
+ const INST_SORT_FIELDS: ReadonlySet<string> = new Set([
149
+ "NAME",
150
+ "CERT",
151
+ "ASSET",
152
+ "ESTYMD",
153
+ "STALP",
154
+ "CITY",
155
+ "ACTIVE",
156
+ ]);
157
+ const FIN_SORT_FIELDS: ReadonlySet<string> = new Set([
158
+ "REPDTE",
159
+ "ASSET",
160
+ "DEP",
161
+ "NETINC",
162
+ ]);
163
+
164
+ // ─── Value escaping + builders (SSRF / injection discipline) ───────
165
+
166
+ /**
167
+ * ★M2 (v2 fix) — escape an UNQUOTED `search` value: backslash FIRST (so a
168
+ * pre-existing `\` becomes a literal `\\` and we never double-process the escapes
169
+ * we add), then backslash-escape the Lucene reserved chars the char-class ALLOWS
170
+ * and that are meaningful UNQUOTED — grouping `(` `)`, the boolean-forming `&`
171
+ * (`&&`), the regex delimiter `/`, and the prefix/NOT operator `-`. There are NO
172
+ * surrounding quotes: FDIC's `search` treats a quoted single token as a
173
+ * `match_phrase` that COLLAPSES recall to ZERO for real brand-name banks (live:
174
+ * `NAME:"Axos"`→0 but `NAME:Axos`→1) — the exact M1 false-empty class this design
175
+ * exists to prevent. Quotes are also gratuitous for security: the SSRF review
176
+ * proved the `search` param is non-injectable for WIDENING (default-AND token
177
+ * semantics — `OR`/`&&` never form a union; live: `NAME:zzz OR STALP:VA`→0 vs
178
+ * STALP:VA→5998), and a field-pivot needs `:`, which the Zod char-class rejects.
179
+ * So this escape is belt-and-suspenders for the Zod-bypass path (`:` and `"` are
180
+ * char-class-rejected on the validated path). Live-verified 2026-07-13 that the
181
+ * escape preserves recall (`Farmers & Merchants`→181, `First-Citizens`→85,
182
+ * `Mizuho Bank \(USA\)`→1). Exported for the direct-builder fault fixture.
183
+ */
184
+ export function escapeSearch(v: string): string {
185
+ return v.replace(/\\/g, "\\\\").replace(/[()&/\-]/g, "\\$&");
186
+ }
187
+
188
+ /** The filter-field allowlists, exported so the fault suite can drive the
189
+ * belt-and-suspenders field-guard directly (the tool functions only ever pass
190
+ * hardcoded fields, so this defense-in-depth check is otherwise unreachable). */
191
+ export { INST_FILTER_FIELDS, FIN_FILTER_FIELDS, INST_SEARCH_FIELDS };
192
+
193
+ /** A `filters` term `FIELD:VALUE` — asserts the field ∈ its allowlist (P4). A
194
+ * NON-NUMERIC value is DOUBLE-QUOTED; a numeric value stays bare. This quoting is
195
+ * LOAD-BEARING for correctness: the FDIC `filters` DSL is Lucene-style, so a
196
+ * BAREWORD state code that collides with a boolean operator is mis-parsed. Live-
197
+ * verified 2026-07-13: `PSTALP:OR` (Oregon, unquoted) → HTTP 400 parse_exception (a
198
+ * HARD-FAIL that throws, NOT an honest empty), whereas `PSTALP:"OR"` → 4289 rows.
199
+ * OR is the only 2-letter US state code that collides with a Lucene operator, but
200
+ * quoting is EQUIVALENT for every non-operator value (live: `STALP:CA` === `STALP:"CA"`
201
+ * === 1287; `PSTALP:CA`(failures) === 265; `PSTALP:CA`(history) === 35892) so it is
202
+ * uniformly safe for all state filters and fixes all 4 FDIC tools at once (they all
203
+ * call this). Numeric fields (CERT/CHANGECODE/FAILYR/EFFYEAR/ACTIVE) stay bare — the
204
+ * values are `^\d+$` by construction and a number never collides with an operator.
205
+ * The value is escaped backslash-first then `"` (defensive — `^[A-Z]{2}$`-constrained
206
+ * state values contain neither, and no other field routes a free string through here).
207
+ * This is the `filters`-DSL path ONLY; the `search` param (searchTerm/escapeSearch)
208
+ * is a DIFFERENT surface and stays correctly UNQUOTED (C116). Exported for the
209
+ * allowlist-bypass fault fixture (§7(b)). */
210
+ export function filterTerm(
211
+ field: string,
212
+ value: string,
213
+ allowed: ReadonlySet<string>,
214
+ ): string {
215
+ if (!allowed.has(field)) {
216
+ throw new ToolErrorCarrier({
217
+ kind: "invalid_input",
218
+ message: `FDIC filter field ${JSON.stringify(field)} is not on the allowlist — refusing to build an un-allowlisted filter (P4 / SSRF safety).`,
219
+ retryable: false,
220
+ });
221
+ }
222
+ // ★OR-fix — a non-numeric value is double-quoted so a Lucene-operator-colliding
223
+ // state code (e.g. OR = Oregon) is parsed as a literal term, not a boolean
224
+ // operator (bare `PSTALP:OR` → live HTTP 400). Numeric values stay bare.
225
+ const emitted = /^\d+$/.test(value)
226
+ ? value
227
+ : `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
228
+ return `${field}:${emitted}`;
229
+ }
230
+
231
+ /** A `search` term `FIELD:<escaped>` (M1 route for NAME/CITY) — asserts the field
232
+ * ∈ the search allowlist and M2-escapes the value UNQUOTED (see escapeSearch: NO
233
+ * surrounding quotes — quotes collapse match_phrase recall → false-empties).
234
+ * Exported for the allowlist-bypass fault fixture (§7(b)). */
235
+ export function searchTerm(field: string, value: string): string {
236
+ if (!INST_SEARCH_FIELDS.has(field)) {
237
+ throw new ToolErrorCarrier({
238
+ kind: "invalid_input",
239
+ message: `FDIC search field ${JSON.stringify(field)} is not on the allowlist — refusing to build an un-allowlisted search (P4 / SSRF safety).`,
240
+ retryable: false,
241
+ });
242
+ }
243
+ return `${field}:${escapeSearch(value)}`;
244
+ }
245
+
246
+ /**
247
+ * Build the institutions `filters` string (STALP/ACTIVE/CERT only — NAME/CITY go
248
+ * through `search`, M1). Terms joined with ` AND `. Returns "" when there is no
249
+ * structured filter clause. Exported for the fault fixtures.
250
+ */
251
+ export function buildInstFilters(inp: {
252
+ state?: string;
253
+ activeOnly?: boolean;
254
+ cert?: number;
255
+ }): string {
256
+ const terms: string[] = [];
257
+ if (inp.state !== undefined) terms.push(filterTerm("STALP", inp.state, INST_FILTER_FIELDS));
258
+ if (inp.activeOnly !== undefined)
259
+ terms.push(filterTerm("ACTIVE", inp.activeOnly ? "1" : "0", INST_FILTER_FIELDS));
260
+ if (inp.cert !== undefined) terms.push(filterTerm("CERT", String(inp.cert), INST_FILTER_FIELDS));
261
+ return terms.join(" AND ");
262
+ }
263
+
264
+ /**
265
+ * ★M1 + ★M2 — build the institutions `search` string (FDIC full-text; NAME/CITY
266
+ * only). Each value is char-class-validated at the server boundary, then here
267
+ * M2-escaped UNQUOTED (backslash-first; NO surrounding quotes — see escapeSearch).
268
+ * Terms joined with ` AND ` (live-verified: `search=NAME:first AND CITY:richmond`
269
+ * → both must match). Returns "" when neither is present. Exported for the M1/M2
270
+ * fault fixtures.
271
+ */
272
+ export function buildInstSearch(inp: { name?: string; city?: string }): string {
273
+ const terms: string[] = [];
274
+ if (inp.name !== undefined) terms.push(searchTerm("NAME", inp.name));
275
+ if (inp.city !== undefined) terms.push(searchTerm("CITY", inp.city));
276
+ return terms.join(" AND ");
277
+ }
278
+
279
+ /** Build the financials `filters` string — the sole filter is the numeric
280
+ * `CERT:<int>` (zero string inputs → zero injection surface). */
281
+ export function buildFinFilters(inp: { cert: number }): string {
282
+ return filterTerm("CERT", String(inp.cert), FIN_FILTER_FIELDS);
283
+ }
284
+
285
+ /**
286
+ * Resolve the sort params against the per-endpoint allowlist (belt-and-suspenders
287
+ * behind the server's Zod enum). An unknown sortBy is invalid_input BEFORE any
288
+ * fetch (never `sort_by=NOTAFIELD` on the wire — FDIC 400s it when a sort_order
289
+ * accompanies it). Returns {} when no sortBy (institutions may omit sorting).
290
+ */
291
+ function sortParams(
292
+ sortBy: string | undefined,
293
+ sortOrder: string | undefined,
294
+ allowed: ReadonlySet<string>,
295
+ endpoint: string,
296
+ ): { sort_by?: string; sort_order?: string } {
297
+ if (sortBy === undefined) return {};
298
+ if (!allowed.has(sortBy)) {
299
+ throw new ToolErrorCarrier({
300
+ kind: "invalid_input",
301
+ message: `FDIC ${endpoint} sortBy ${JSON.stringify(sortBy)} is not an allowed sort field. Allowed: ${[...allowed].join(", ")}.`,
302
+ retryable: false,
303
+ });
304
+ }
305
+ return { sort_by: sortBy, sort_order: sortOrder === "DESC" ? "DESC" : "ASC" };
306
+ }
307
+
308
+ // ─── fetch layer ──────────────────────────────────────────────────
309
+
310
+ /**
311
+ * GET one FDIC BankFind endpoint through the shared port. SSRF guard: the path is
312
+ * a FIXED endpoint constant (`institutions` | `financials`) — no caller value —
313
+ * and the CONSTRUCTED URL's hostname === api.fdic.gov over https. One shared gate
314
+ * `throughGate("fdic", 200, …)` around EVERY fetch (one host = one rate budget) +
315
+ * `redirect:"error"` (B1) + NO headers (keyless — byte-clean init). A JSON-parse
316
+ * throw from a non-JSON 200 body (an HTML/error page) is reclassified to
317
+ * driftError; a ToolErrorCarrier from the 429/5xx/404/400 taxonomy propagates
318
+ * unchanged. Returns the parsed JSON (unknown; the caller validates the envelope).
319
+ */
320
+ async function getFdic(endpoint: string, params: URLSearchParams): Promise<unknown> {
321
+ const url = `https://${FDIC_HOST}/banks/${endpoint}?${params.toString()}`;
322
+ const built = new URL(url);
323
+ if (built.hostname !== FDIC_HOST || built.protocol !== "https:") {
324
+ throw new ToolErrorCarrier({
325
+ kind: "invalid_input",
326
+ message: `Constructed FDIC URL host ${JSON.stringify(built.hostname)} (${built.protocol}) is not ${FDIC_HOST} over https — refusing to fetch (SSRF safety).`,
327
+ retryable: false,
328
+ });
329
+ }
330
+ try {
331
+ return await throughGate(GATE_KEY, FDIC_MIN_INTERVAL_MS, () =>
332
+ getJson(url, { label: LABEL, redirect: "error" }),
333
+ );
334
+ } catch (e) {
335
+ // Preserve the structured taxonomy (404/429/5xx/400/timeout) unchanged.
336
+ if (e instanceof ToolErrorCarrier) throw e;
337
+ // A 200 non-JSON body makes getJson's r.json() throw a SyntaxError → classify
338
+ // as schema_drift (an honest THROW), never a fake-empty. getJson stays
339
+ // byte-identical (the reclassification is at THIS call site — the fema pattern).
340
+ if (e instanceof SyntaxError) {
341
+ throw driftError(
342
+ LABEL,
343
+ "FDIC returned a non-JSON body at HTTP 200 — treating as schema drift.",
344
+ );
345
+ }
346
+ throw e;
347
+ }
348
+ }
349
+
350
+ // ─── envelope parse (P2 3-envelope drift-guard) + map helpers ──────
351
+ type FdicEnvelope = {
352
+ records: Record<string, unknown>[];
353
+ totalAvailable: number;
354
+ indexName: string | null;
355
+ indexCreated: string | null;
356
+ };
357
+
358
+ /**
359
+ * ★P2 — the 3-envelope drift-guard on the parsed 200 body. Require the SUCCESS
360
+ * shape: `meta` is an object AND `data` is an Array (rejects the errors[] QUERY-
361
+ * ERROR shape and the message+statusCode ROUTING shape at HTTP 200), AND `typeof
362
+ * meta.total === "number"` (before num; a non-number/absent total is drift, never
363
+ * a fake empty). Records are nested under `data[].data`. Reads the snapshot-
364
+ * freshness `meta.index.{name,createTimestamp}`.
365
+ */
366
+ function parseEnvelope(body: unknown): FdicEnvelope {
367
+ const b = (body ?? {}) as { meta?: unknown; data?: unknown };
368
+ if (b.meta === null || typeof b.meta !== "object" || !Array.isArray(b.data)) {
369
+ throw driftError(
370
+ LABEL,
371
+ "FDIC BankFind returned an unexpected shape (expected { meta:{…}, data:[…] }) — treating as schema drift (rejects the errors[] / message+statusCode envelopes served at HTTP 200).",
372
+ );
373
+ }
374
+ const meta = b.meta as { total?: unknown; index?: unknown };
375
+ if (typeof meta.total !== "number") {
376
+ throw driftError(
377
+ LABEL,
378
+ "FDIC BankFind returned a non-number meta.total — treating as schema drift (num() alone cannot distinguish a non-number total from an absent one).",
379
+ );
380
+ }
381
+ const totalAvailable = num(meta.total);
382
+ if (totalAvailable === null) {
383
+ throw driftError(LABEL, "FDIC BankFind meta.total is not a finite number — treating as schema drift.");
384
+ }
385
+ const records = (b.data as Array<{ data?: unknown }>).map((d) =>
386
+ d && typeof d === "object" && d.data && typeof d.data === "object"
387
+ ? (d.data as Record<string, unknown>)
388
+ : {},
389
+ );
390
+ const idx = (meta.index ?? {}) as { name?: unknown; createTimestamp?: unknown };
391
+ return {
392
+ records,
393
+ totalAvailable,
394
+ indexName: str(idx.name),
395
+ indexCreated: str(idx.createTimestamp),
396
+ };
397
+ }
398
+
399
+ /** ★B — the union of keys across the raw records; a projected field absent from
400
+ * ALL records (when returned > 0) is surfaced (never silently vanished). */
401
+ function fieldsUnavailable(
402
+ records: Record<string, unknown>[],
403
+ projection: readonly string[],
404
+ ): string[] {
405
+ if (records.length === 0) return [];
406
+ const present = new Set<string>();
407
+ for (const r of records) for (const k of Object.keys(r)) present.add(k);
408
+ return projection.filter((f) => !present.has(f));
409
+ }
410
+
411
+ /** ★P3 — $thousands → whole USD with the null-guard BEFORE the ×1000 (an absent
412
+ * value stays null, never 0). */
413
+ function thousandsToUsd(v: unknown): number | null {
414
+ const n = num(v);
415
+ return n === null ? null : n * 1000;
416
+ }
417
+
418
+ export type FdicInstitution = {
419
+ name: string | null;
420
+ city: string | null;
421
+ state: string | null;
422
+ cert: number | null;
423
+ assetUSD: number | null;
424
+ active: number | null;
425
+ establishedDate: string | null;
426
+ id: string | null;
427
+ };
428
+
429
+ function mapInstitution(rec: Record<string, unknown>): FdicInstitution {
430
+ return {
431
+ name: str(rec.NAME),
432
+ city: str(rec.CITY),
433
+ state: str(rec.STALP),
434
+ cert: num(rec.CERT),
435
+ assetUSD: thousandsToUsd(rec.ASSET),
436
+ active: num(rec.ACTIVE),
437
+ establishedDate: str(rec.ESTYMD),
438
+ id: str(rec.ID),
439
+ };
440
+ }
441
+
442
+ export type FdicFinancials = {
443
+ cert: number | null;
444
+ reportDate: number | null;
445
+ assetUSD: number | null;
446
+ depositsUSD: number | null;
447
+ netIncomeUSD: number | null;
448
+ id: string | null;
449
+ };
450
+
451
+ function mapFinancials(rec: Record<string, unknown>): FdicFinancials {
452
+ return {
453
+ cert: num(rec.CERT),
454
+ reportDate: num(rec.REPDTE),
455
+ assetUSD: thousandsToUsd(rec.ASSET),
456
+ depositsUSD: thousandsToUsd(rec.DEP),
457
+ netIncomeUSD: thousandsToUsd(rec.NETINC),
458
+ id: str(rec.ID),
459
+ };
460
+ }
461
+
462
+ // ─── shared disclosure notes ───────────────────────────────────────
463
+ const ASSET_NOTE =
464
+ "FDIC publishes ASSET/DEP/NETINC in $thousands; normalized here to whole USD (×1,000). A real 0 stays 0; an absent value is null (never 0).";
465
+ const NAME_CITY_SEARCH_NOTE =
466
+ "name/city use FDIC's full-text `search` (case-insensitive token match); a multi-word value is matched per-token and may be BROADER than a literal substring (it can match records sharing only some tokens) — verify counts.";
467
+
468
+ function freshnessNote(name: string | null, created: string | null): string {
469
+ return `Served from FDIC search-index snapshot ${name ?? "(unnamed)"}${created ? ` built ${created}` : ""} — a point-in-time snapshot, not a live-this-second read; the institutions and financials indexes carry DIFFERENT snapshot times.`;
470
+ }
471
+
472
+ // ─── Tool 1: fdic_search_institutions ──────────────────────────────
473
+ /**
474
+ * Search the FDIC-insured-institution directory (`/banks/institutions`).
475
+ * Structured inputs: `state` (STALP filter), `activeOnly` (ACTIVE filter), `cert`
476
+ * (CERT filter) → the `filters` param; `name`/`city` → the full-text `search`
477
+ * param (M1; case-insensitive token match, UNQUOTED + M2-escaped). Plus
478
+ * `limit`/`offset`/`sortBy`/`sortOrder`. Fixed field projection.
479
+ *
480
+ * HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1); the 3-
481
+ * envelope drift-guard makes the ONLY honest empty `200 + total:0 + data:[]`,
482
+ * everything else THROWS (P2); ASSET is $thousands → whole USD ×1000 null-never-0
483
+ * (P3); a projected field absent from all records → fieldsUnavailable (B); the
484
+ * snapshot build time is disclosed. name/city search + multi-word per-token are
485
+ * disclosed (M1/S1).
486
+ */
487
+ export async function searchInstitutions(args: {
488
+ state?: string;
489
+ activeOnly?: boolean;
490
+ cert?: number;
491
+ name?: string;
492
+ city?: string;
493
+ limit?: number;
494
+ offset?: number;
495
+ sortBy?: string;
496
+ sortOrder?: string;
497
+ }): Promise<MetaBundle> {
498
+ const limit = args.limit ?? 100;
499
+ const offset = args.offset ?? 0;
500
+
501
+ const filters = buildInstFilters({
502
+ state: args.state,
503
+ activeOnly: args.activeOnly,
504
+ cert: args.cert,
505
+ });
506
+ const search = buildInstSearch({ name: args.name, city: args.city });
507
+ const sort = sortParams(args.sortBy, args.sortOrder, INST_SORT_FIELDS, "institutions");
508
+
509
+ const params = new URLSearchParams();
510
+ if (filters) params.set("filters", filters);
511
+ if (search) params.set("search", search);
512
+ params.set("fields", INST_FIELDS);
513
+ params.set("limit", String(limit));
514
+ params.set("offset", String(offset));
515
+ if (sort.sort_by) {
516
+ params.set("sort_by", sort.sort_by);
517
+ params.set("sort_order", sort.sort_order as string);
518
+ }
519
+ params.set("format", "json");
520
+
521
+ const body = await getFdic(ENDPOINT_INSTITUTIONS, params);
522
+ const env = parseEnvelope(body);
523
+ const records = env.records.map(mapInstitution);
524
+ const returned = records.length;
525
+ const totalAvailable = env.totalAvailable;
526
+ const hasMore = offset + returned < totalAvailable;
527
+ const nextOffset = hasMore ? offset + returned : null;
528
+
529
+ const filtersApplied: string[] = [];
530
+ if (args.state !== undefined) filtersApplied.push("state");
531
+ if (args.activeOnly !== undefined) filtersApplied.push("activeOnly");
532
+ if (args.cert !== undefined) filtersApplied.push("cert");
533
+ if (args.name !== undefined) filtersApplied.push("name");
534
+ if (args.city !== undefined) filtersApplied.push("city");
535
+ if (sort.sort_by) filtersApplied.push("sort");
536
+
537
+ const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
538
+ if (args.name !== undefined || args.city !== undefined) {
539
+ notes.push(NAME_CITY_SEARCH_NOTE);
540
+ // ★S1 — a multi-word name/city value is matched PER-TOKEN (phrase-quoting does
541
+ // NOT phrase-scope the `search` param). Disclose whenever a value has a space.
542
+ const multi: string[] = [];
543
+ if (args.name !== undefined && /\s/.test(args.name.trim())) multi.push("name");
544
+ if (args.city !== undefined && /\s/.test(args.city.trim())) multi.push("city");
545
+ if (multi.length > 0) {
546
+ notes.push(
547
+ `Multi-word ${multi.join(" and ")} is matched PER-TOKEN by FDIC's full-text index (the tokens are AND-combined but each is fuzzy — a record sharing only ONE token can match, e.g. \`search=NAME:First Community\` returns "First State Bank"); results may be BROADER than a literal substring — verify counts.`,
548
+ );
549
+ }
550
+ }
551
+ const fu = fieldsUnavailable(env.records, INST_PROJECTION);
552
+ if (fu.length > 0) {
553
+ notes.push(
554
+ `Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
555
+ );
556
+ }
557
+
558
+ return withMeta(
559
+ { institutions: records },
560
+ {
561
+ source: "api.fdic.gov/banks/institutions (BankFind, keyless)",
562
+ keylessMode: true,
563
+ returned,
564
+ totalAvailable,
565
+ filtersApplied,
566
+ filtersDropped: [],
567
+ fieldsUnavailable: fu,
568
+ pagination: { offset, limit, hasMore, nextOffset },
569
+ notes,
570
+ } satisfies Partial<ResponseMeta>,
571
+ );
572
+ }
573
+
574
+ // ─── Tool 2: fdic_institution_financials ───────────────────────────
575
+ /**
576
+ * Quarterly financial time-series for ONE FDIC-insured institution by `cert`
577
+ * (`/banks/financials`). The sole filter is the numeric `CERT:<int>` (zero string
578
+ * inputs → zero injection surface). `sortBy` defaults to REPDTE + `sortOrder`
579
+ * DESC → newest quarter first. Fixed field projection. Consumes the IDENTICAL
580
+ * fetch → 3-envelope guard → pagination machinery as tool 1.
581
+ *
582
+ * HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live: CERT
583
+ * 10363 → total 169, stable across offset); the 3-envelope drift-guard (P2);
584
+ * ASSET/DEP/NETINC $thousands → whole USD ×1000 null-never-0 (P3); the
585
+ * fieldsUnavailable disclosure (B); the snapshot build time is disclosed.
586
+ */
587
+ export async function institutionFinancials(args: {
588
+ cert: number;
589
+ limit?: number;
590
+ offset?: number;
591
+ sortBy?: string;
592
+ sortOrder?: string;
593
+ }): Promise<MetaBundle> {
594
+ const limit = args.limit ?? 100;
595
+ const offset = args.offset ?? 0;
596
+ // Financials default to REPDTE DESC (newest first) when the server's Zod default
597
+ // did not supply one (defensive — the server always defaults sortBy=REPDTE).
598
+ const sort = sortParams(
599
+ args.sortBy ?? "REPDTE",
600
+ args.sortOrder ?? "DESC",
601
+ FIN_SORT_FIELDS,
602
+ "financials",
603
+ );
604
+
605
+ const params = new URLSearchParams();
606
+ params.set("filters", buildFinFilters({ cert: args.cert }));
607
+ params.set("fields", FIN_FIELDS);
608
+ params.set("limit", String(limit));
609
+ params.set("offset", String(offset));
610
+ if (sort.sort_by) {
611
+ params.set("sort_by", sort.sort_by);
612
+ params.set("sort_order", sort.sort_order as string);
613
+ }
614
+ params.set("format", "json");
615
+
616
+ const body = await getFdic(ENDPOINT_FINANCIALS, params);
617
+ const env = parseEnvelope(body);
618
+ const records = env.records.map(mapFinancials);
619
+ const returned = records.length;
620
+ const totalAvailable = env.totalAvailable;
621
+ const hasMore = offset + returned < totalAvailable;
622
+ const nextOffset = hasMore ? offset + returned : null;
623
+
624
+ const notes: string[] = [freshnessNote(env.indexName, env.indexCreated), ASSET_NOTE];
625
+ const fu = fieldsUnavailable(env.records, FIN_PROJECTION);
626
+ if (fu.length > 0) {
627
+ notes.push(
628
+ `Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
629
+ );
630
+ }
631
+
632
+ return withMeta(
633
+ { cert: args.cert, financials: records },
634
+ {
635
+ source: "api.fdic.gov/banks/financials (BankFind, keyless)",
636
+ keylessMode: true,
637
+ returned,
638
+ totalAvailable,
639
+ filtersApplied: ["cert", "sort"],
640
+ filtersDropped: [],
641
+ fieldsUnavailable: fu,
642
+ pagination: { offset, limit, hasMore, nextOffset },
643
+ notes,
644
+ } satisfies Partial<ResponseMeta>,
645
+ );
646
+ }
647
+
648
+ // ═══════════════════════════════════════════════════════════════════
649
+ // Tool 3: fdic_bank_failures (ADR-0029) — the historical failed / FDIC-assisted
650
+ // institution list (`/banks/failures`). B2G counterparty-risk: a failed or
651
+ // FDIC-assisted institution is a due-diligence red flag; `CERT` links a failure
652
+ // back to fdic_search_institutions / fdic_institution_financials. Reuses the
653
+ // C116-hardened adapter VERBATIM (getFdic / parseEnvelope 3-envelope-guard /
654
+ // filter-field allowlist-by-construction / sortBy enum+Set.has / EXACT meta.total
655
+ // pagination / snapshot-freshness / $thousands→USD ×1000 null-never-0). The NEW
656
+ // surface is exactly three things, all forced by the v2 (cycle-33) live review:
657
+ // ★F1 — the state field on /failures is PSTALP, NOT STALP (live: `STALP:CA`→
658
+ // total 0 = the unknown-field false-empty landmine; `PSTALP:CA`→265). We map
659
+ // state→PSTALP in the filter, the allowlist, the projection, and the output.
660
+ // ★F2 — the `search` param is IGNORED on /failures (live: `search=NAME:…` AND
661
+ // no-params BOTH return total 4115 = the whole dataset = a false-FLOOD). So
662
+ // this tool has NO name/city filter and NEVER emits `search=` — it does NOT
663
+ // reuse searchTerm/escapeSearch. NAME/CITY stay in the PROJECTION (surfaced
664
+ // per row) but are not filterable; name-based lookup is the honest 2-step CERT
665
+ // linkage (resolve CERT in fdic_search_institutions → filter here by cert).
666
+ // ★F3 — FAILDATE is `M/D/YYYY` (not financials' YYYYMMDD): normFailDate does an
667
+ // EXACT Date.UTC round-trip → ISO YYYY-MM-DD (an unrecognized value is
668
+ // surfaced RAW + disclosed, never nulled/fabricated); COST can be a genuine 0
669
+ // (fully-assisted, no DIF loss) or NEGATIVE (a net DIF recovery/gain) —
670
+ // thousandsToUsd keeps both faithfully (null-guard BEFORE the ×1000).
671
+ // ═══════════════════════════════════════════════════════════════════
672
+
673
+ // Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
674
+ const ENDPOINT_FAILURES = "failures";
675
+
676
+ // Fixed field projection (every field live-verified 2026-07-13). NAME/CITY are
677
+ // PROJECTED (surfaced per output row) but NOT filterable (F2).
678
+ const FAIL_FIELDS = "NAME,CERT,FAILDATE,FAILYR,CITY,PSTALP,COST,RESTYPE,SAVR,QBFDEP,QBFASSET,ID";
679
+ const FAIL_PROJECTION = [
680
+ "NAME",
681
+ "CERT",
682
+ "FAILDATE",
683
+ "FAILYR",
684
+ "CITY",
685
+ "PSTALP",
686
+ "COST",
687
+ "RESTYPE",
688
+ "SAVR",
689
+ "QBFDEP",
690
+ "QBFASSET",
691
+ "ID",
692
+ ] as const;
693
+
694
+ // ★F1 — the failures filter-FIELD allowlist (P4 belt-and-suspenders). The state
695
+ // field is PSTALP (NOT STALP — that is a false-empty landmine). NAME/CITY are NOT
696
+ // here (F2 — /failures has no working name/city filter). These fields carry only
697
+ // constrained non-string values (PSTALP a 2-letter code, FAILYR a year, CERT an
698
+ // int) → no quoting needed.
699
+ const FDIC_FAILURES_FILTER_FIELDS: ReadonlySet<string> = new Set(["PSTALP", "FAILYR", "CERT"]);
700
+ // sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams
701
+ // → an unknown sort field is invalid_input BEFORE fetch).
702
+ const FAIL_SORT_FIELDS: ReadonlySet<string> = new Set([
703
+ "FAILDATE",
704
+ "COST",
705
+ "QBFASSET",
706
+ "QBFDEP",
707
+ "NAME",
708
+ "FAILYR",
709
+ ]);
710
+
711
+ // Exported so the fault suite can drive the belt-and-suspenders field-guard directly.
712
+ export { FDIC_FAILURES_FILTER_FIELDS };
713
+
714
+ /**
715
+ * Build the failures `filters` string — EXACT-KEY terms only: `state`→`PSTALP:<s>`
716
+ * (★F1), `failYear`→`FAILYR:<year>` (emit the integer's digits — FAILYR is a
717
+ * string field but the digits filter cleanly, live-verified), `cert`→`CERT:<int>`.
718
+ * There is NO name/city term (★F2 — /failures ignores `search` and NAME/CITY only
719
+ * filter as brittle exact-uppercase foot-guns). Terms joined with ` AND `. Returns
720
+ * "" when there is no structured filter clause. Exported for the fault fixtures.
721
+ */
722
+ export function buildFailFilters(inp: {
723
+ state?: string;
724
+ failYear?: number;
725
+ cert?: number;
726
+ }): string {
727
+ const terms: string[] = [];
728
+ if (inp.state !== undefined)
729
+ terms.push(filterTerm("PSTALP", inp.state, FDIC_FAILURES_FILTER_FIELDS));
730
+ if (inp.failYear !== undefined)
731
+ terms.push(filterTerm("FAILYR", String(inp.failYear), FDIC_FAILURES_FILTER_FIELDS));
732
+ if (inp.cert !== undefined)
733
+ terms.push(filterTerm("CERT", String(inp.cert), FDIC_FAILURES_FILTER_FIELDS));
734
+ return terms.join(" AND ");
735
+ }
736
+
737
+ /**
738
+ * ★F3 — normalize FDIC's `FAILDATE` (`M/D/YYYY`, e.g. `3/10/2023`) to ISO
739
+ * `YYYY-MM-DD`. Parse `^(\d{1,2})/(\d{1,2})/(\d{4})$`, then validate the calendar
740
+ * day with the EXACT 3-component Date.UTC round-trip (rejects JS's silent
741
+ * roll-overs like `2/30/2023`→Mar-02). On success → the padded ISO string
742
+ * (`normalized:true`). If the value does NOT match the pattern or fails the
743
+ * round-trip → surface the RAW upstream value UNCHANGED (`normalized:false`; NEVER
744
+ * null or fabricate a present date — the handler discloses the raw passthrough); a
745
+ * genuinely-absent value (null/undefined) → null. Live-confirmed 4115/4115 rows
746
+ * normalize (1934–2026), so the raw-passthrough branch is defensive-only. Exported
747
+ * for the fault fixtures.
748
+ */
749
+ export function normFailDate(raw: unknown): { value: string | null; normalized: boolean } {
750
+ // A non-string (absent/null/numeric) → honest str coercion (null for absent);
751
+ // never String()-fabricate "null"/"undefined"/"[object Object]".
752
+ if (typeof raw !== "string") return { value: str(raw), normalized: false };
753
+ const m = /^(\d{1,2})\/(\d{1,2})\/(\d{4})$/.exec(raw);
754
+ if (m) {
755
+ const mo = Number(m[1]);
756
+ const d = Number(m[2]);
757
+ const y = Number(m[3]);
758
+ const dt = new Date(Date.UTC(y, mo - 1, d));
759
+ if (dt.getUTCFullYear() === y && dt.getUTCMonth() + 1 === mo && dt.getUTCDate() === d) {
760
+ return {
761
+ value: `${m[3]}-${String(mo).padStart(2, "0")}-${String(d).padStart(2, "0")}`,
762
+ normalized: true,
763
+ };
764
+ }
765
+ }
766
+ // Present but unrecognized → surface the raw value verbatim (defensive; never taken live).
767
+ return { value: raw, normalized: false };
768
+ }
769
+
770
+ export type FdicFailure = {
771
+ name: string | null;
772
+ cert: number | null;
773
+ failDate: string | null;
774
+ failYear: string | null;
775
+ city: string | null;
776
+ state: string | null;
777
+ resolutionType: string | null;
778
+ resolutionFund: string | null;
779
+ estimatedLossUSD: number | null;
780
+ depositsUSD: number | null;
781
+ assetsUSD: number | null;
782
+ id: string | null;
783
+ };
784
+
785
+ function mapFailure(rec: Record<string, unknown>): FdicFailure {
786
+ return {
787
+ name: str(rec.NAME),
788
+ cert: num(rec.CERT),
789
+ failDate: normFailDate(rec.FAILDATE).value,
790
+ failYear: str(rec.FAILYR),
791
+ city: str(rec.CITY),
792
+ state: str(rec.PSTALP), // ★F1 — PSTALP, NOT STALP
793
+ resolutionType: str(rec.RESTYPE),
794
+ resolutionFund: str(rec.SAVR),
795
+ estimatedLossUSD: thousandsToUsd(rec.COST),
796
+ depositsUSD: thousandsToUsd(rec.QBFDEP),
797
+ assetsUSD: thousandsToUsd(rec.QBFASSET),
798
+ id: str(rec.ID),
799
+ };
800
+ }
801
+
802
+ // ─── failures disclosure notes ─────────────────────────────────────
803
+ const FAIL_DATE_NOTE = "failDate is normalized from FDIC's M/D/YYYY to ISO YYYY-MM-DD.";
804
+ const FAIL_COST_NOTE =
805
+ "COST is FDIC's estimated loss to the Deposit Insurance Fund; COST/QBFDEP/QBFASSET are $thousands, normalized here to whole USD (×1,000). A genuine 0 = a fully-assisted resolution with NO DIF loss (a real 0, not absence); a NEGATIVE value = a net DIF recovery/gain (NOT a loss); an absent value is null (never 0).";
806
+ const FAIL_SCOPE_NOTE =
807
+ "Historical FDIC-insured institution failures / assistance transactions; a bank ABSENT here has no recorded FDIC failure (it may be active, acquired non-failed, or never FDIC-insured) — cross-check with fdic_search_institutions.";
808
+ const FAIL_CERT_NOTE =
809
+ "To find a specific institution's failure, resolve its CERT via fdic_search_institutions, then filter here by `cert`; name/city are shown but not searchable on this endpoint (FDIC's /failures `search` param is ignored and would return the whole dataset).";
810
+
811
+ /**
812
+ * Historical FDIC bank failures / assistance transactions (`/banks/failures`).
813
+ * Exact-key structured inputs: `state` (→ PSTALP filter, ★F1), `failYear` (→
814
+ * FAILYR), `cert` (→ CERT) → the `filters` param; plus `limit`/`offset`/`sortBy`/
815
+ * `sortOrder` (default FAILDATE DESC → most-recent failures first). Fixed field
816
+ * projection. NO name/city filter and NEVER a `search=` param (★F2). Consumes the
817
+ * IDENTICAL fetch → 3-envelope guard → pagination machinery as tools 1 & 2.
818
+ *
819
+ * HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1); the 3-
820
+ * envelope drift-guard makes the ONLY honest empty `200 + total:0 + data:[]`,
821
+ * everything else THROWS (P2); COST/QBFDEP/QBFASSET are $thousands → whole USD
822
+ * ×1000 null-never-0 (P3; genuine 0 stays 0, negative = a net recovery, absent →
823
+ * null); failDate normalized M/D/YYYY→ISO (unrecognized → raw + disclosed, never
824
+ * nulled/fabricated); a projected field absent from all records → fieldsUnavailable
825
+ * (B); the snapshot build time is disclosed.
826
+ */
827
+ export async function bankFailures(args: {
828
+ state?: string;
829
+ failYear?: number;
830
+ cert?: number;
831
+ limit?: number;
832
+ offset?: number;
833
+ sortBy?: string;
834
+ sortOrder?: string;
835
+ }): Promise<MetaBundle> {
836
+ const limit = args.limit ?? 100;
837
+ const offset = args.offset ?? 0;
838
+ // Default FAILDATE DESC (most-recent first) when the server's Zod default did not
839
+ // supply one (defensive — the server always defaults sortBy=FAILDATE/DESC).
840
+ const sort = sortParams(
841
+ args.sortBy ?? "FAILDATE",
842
+ args.sortOrder ?? "DESC",
843
+ FAIL_SORT_FIELDS,
844
+ "failures",
845
+ );
846
+
847
+ const filters = buildFailFilters({ state: args.state, failYear: args.failYear, cert: args.cert });
848
+
849
+ const params = new URLSearchParams();
850
+ if (filters) params.set("filters", filters);
851
+ params.set("fields", FAIL_FIELDS);
852
+ params.set("limit", String(limit));
853
+ params.set("offset", String(offset));
854
+ if (sort.sort_by) {
855
+ params.set("sort_by", sort.sort_by);
856
+ params.set("sort_order", sort.sort_order as string);
857
+ }
858
+ params.set("format", "json");
859
+
860
+ const body = await getFdic(ENDPOINT_FAILURES, params);
861
+ const env = parseEnvelope(body);
862
+ const records = env.records.map(mapFailure);
863
+ const returned = records.length;
864
+ const totalAvailable = env.totalAvailable;
865
+ const hasMore = offset + returned < totalAvailable;
866
+ const nextOffset = hasMore ? offset + returned : null;
867
+
868
+ const filtersApplied: string[] = [];
869
+ if (args.state !== undefined) filtersApplied.push("state");
870
+ if (args.failYear !== undefined) filtersApplied.push("failYear");
871
+ if (args.cert !== undefined) filtersApplied.push("cert");
872
+ if (sort.sort_by) filtersApplied.push("sort");
873
+
874
+ const notes: string[] = [
875
+ freshnessNote(env.indexName, env.indexCreated),
876
+ FAIL_DATE_NOTE,
877
+ FAIL_COST_NOTE,
878
+ FAIL_SCOPE_NOTE,
879
+ FAIL_CERT_NOTE,
880
+ ];
881
+ // ★F3 — disclose any present-but-unrecognized FAILDATE surfaced raw (defensive;
882
+ // live 4115/4115 normalize, so this branch essentially never fires).
883
+ const anyRawFailDate = env.records.some((rec) => {
884
+ const fd = normFailDate(rec.FAILDATE);
885
+ return !fd.normalized && fd.value !== null;
886
+ });
887
+ if (anyRawFailDate) {
888
+ notes.push(
889
+ "One or more FAILDATE values did not match FDIC's M/D/YYYY format (or failed the calendar round-trip) and were surfaced RAW (not normalized to ISO) — never nulled or fabricated.",
890
+ );
891
+ }
892
+ const fu = fieldsUnavailable(env.records, FAIL_PROJECTION);
893
+ if (fu.length > 0) {
894
+ notes.push(
895
+ `Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
896
+ );
897
+ }
898
+
899
+ return withMeta(
900
+ { failures: records },
901
+ {
902
+ source: "api.fdic.gov/banks/failures (BankFind, keyless)",
903
+ keylessMode: true,
904
+ returned,
905
+ totalAvailable,
906
+ filtersApplied,
907
+ filtersDropped: [],
908
+ fieldsUnavailable: fu,
909
+ pagination: { offset, limit, hasMore, nextOffset },
910
+ notes,
911
+ } satisfies Partial<ResponseMeta>,
912
+ );
913
+ }
914
+
915
+ // ═══════════════════════════════════════════════════════════════════
916
+ // Tool 4: fdic_institution_history (ADR-0030) — the institution-level STRUCTURAL-
917
+ // CHANGE event log (`/banks/history`): mergers, absorptions, consolidations,
918
+ // failures, name/location/charter/regulator changes, branch open/close, trust-power
919
+ // grants, FRS membership changes. It COMPLETES the FDIC entity cluster (institutions
920
+ // directory + financials time-series + failures resolution events + history full
921
+ // structural lineage). Reuses the C116-hardened adapter VERBATIM (getFdic /
922
+ // parseEnvelope 3-envelope-guard / filter-field allowlist-by-construction / sortBy
923
+ // enum+Set.has / EXACT meta.total pagination / snapshot-freshness). It does NOT use
924
+ // searchTerm/escapeSearch (no name search) or thousandsToUsd (no money field). The
925
+ // NEW surface is exactly four things, all live-verified 2026-07-13:
926
+ // ★F1-analog — the state field on /history is PSTALP, NOT STALP (live: `STALP:CA`
927
+ // → total 0 = the unknown-field false-empty landmine; `PSTALP:CA`→35892). We map
928
+ // state→PSTALP in the filter, the allowlist, the projection, and the output.
929
+ // ★F2-analog — the `search` param does NOT work for name on /history (live:
930
+ // `search=INSTNAME:chase` AND `search=INSTNAME:zzzznomatch` BOTH return total 0
931
+ // = a false-EMPTY). So this tool has NO name/city filter and NEVER emits
932
+ // `search=`. INSTNAME + the counterparty names are PROJECTED (surfaced per row)
933
+ // but not filterable; name-based lookup is the honest 2-step CERT linkage
934
+ // (resolve CERT in fdic_search_institutions → filter here by cert).
935
+ // ★Q1 — CHANGECODE is NOT opaque: FDIC co-serves an authoritative CHANGECODE_DESC
936
+ // INLINE in every record (27 distinct codes sampled, 0 nulls). We surface BOTH
937
+ // the numeric changeCode (authoritative) AND changeDescription = the co-served
938
+ // CHANGECODE_DESC PROJECTED verbatim (NOT a static embedded hand-map — strictly
939
+ // more honest, zero-drift, auto-covers every code). A null DESC (never observed)
940
+ // stays null via str, surfaced by fieldsUnavailable — never invented.
941
+ // ★Q2 — EFFDATE/PROCDATE are `YYYY-MM-DDT00:00:00` (a THIRD date format in this
942
+ // source): normHistDate strips the time + does an EXACT Date.UTC round-trip → ISO
943
+ // YYYY-MM-DD (an unrecognized value is surfaced RAW + disclosed, never nulled/
944
+ // fabricated); a 9999-* value is FDIC's "not-applicable/open" sentinel (surfaced
945
+ // verbatim + disclosed).
946
+ // ★Q3 — the ACQ_/OUT_/SUR_ counterparty CERT-triad is the headline value: on a
947
+ // merger/failure row it carries the acquiring/outgoing/surviving institution's
948
+ // CERT + INSTNAME, and each CERT links straight back to the CERT-keyed tools. On
949
+ // a NON-merger row (e.g. a 520 location change) the *_CERT/*_INSTNAME fields are
950
+ // live-verified ABSENT → num/str pass them through as null (an honest "no
951
+ // counterparty"), NEVER a fabricated 0. We use *_CERT (the clean null-when-N/A
952
+ // linkage key), NOT *_UNINUM (which carries a 0 sentinel for "none" = a
953
+ // misleading fake identifier; live: ACQ_UNINUM:0 on a 520 row).
954
+ // ═══════════════════════════════════════════════════════════════════
955
+
956
+ // Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
957
+ const ENDPOINT_HISTORY = "history";
958
+
959
+ // Fixed field projection on the wire (all 16 fields; every one live-verified valid).
960
+ // The counterparty triad ACQ_/OUT_/SUR_ is REQUESTED here but is event-conditional
961
+ // (absent on non-merger rows) — it is deliberately EXCLUDED from HIST_PROJECTION
962
+ // (the fieldsUnavailable check) below.
963
+ const HIST_FIELDS =
964
+ "CERT,INSTNAME,PSTALP,CHANGECODE,CHANGECODE_DESC,EFFDATE,PROCDATE,EFFYEAR,TRANSNUM,ACQ_CERT,ACQ_INSTNAME,OUT_CERT,OUT_INSTNAME,SUR_CERT,SUR_INSTNAME,ID";
965
+ // ★Q3-NUANCE — the fieldsUnavailable "projected-field-absent-from-ALL-records"
966
+ // check is scoped to the ALWAYS-PRESENT fields ONLY. The ACQ_/OUT_/SUR_ counterparty
967
+ // triad is LEGITIMATELY event-conditional (a page of all-non-merger rows — e.g.
968
+ // branch closings — would correctly omit them), so including them here would fire a
969
+ // spurious "schema drift" on the expected shape. Their nullness is disclosed via
970
+ // COUNTERPARTY_NOTE (Q3), NOT fieldsUnavailable.
971
+ const HIST_PROJECTION = [
972
+ "CERT",
973
+ "INSTNAME",
974
+ "PSTALP",
975
+ "CHANGECODE",
976
+ "CHANGECODE_DESC",
977
+ "EFFDATE",
978
+ "PROCDATE",
979
+ "EFFYEAR",
980
+ "TRANSNUM",
981
+ "ID",
982
+ ] as const;
983
+
984
+ // ★F1-analog — the history filter-FIELD allowlist (P4 belt-and-suspenders). The
985
+ // state field is PSTALP (NOT STALP — a false-empty landmine). NAME/CITY are NOT
986
+ // here (F2-analog — /history has no working name/city filter). These fields carry
987
+ // only constrained non-string values (PSTALP a 2-letter code, EFFYEAR a year, CERT
988
+ // / CHANGECODE ints) → no quoting needed.
989
+ const FDIC_HISTORY_FILTER_FIELDS: ReadonlySet<string> = new Set([
990
+ "CERT",
991
+ "CHANGECODE",
992
+ "EFFYEAR",
993
+ "PSTALP",
994
+ ]);
995
+ // sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams
996
+ // → an unknown sort field is invalid_input BEFORE fetch; live `sort_by=NOTAFIELD`
997
+ // → 400, so the pre-fetch guard is load-bearing).
998
+ const HIST_SORT_FIELDS: ReadonlySet<string> = new Set([
999
+ "EFFDATE",
1000
+ "PROCDATE",
1001
+ "CHANGECODE",
1002
+ "TRANSNUM",
1003
+ ]);
1004
+
1005
+ // Exported so the fault suite can drive the belt-and-suspenders field-guard directly.
1006
+ export { FDIC_HISTORY_FILTER_FIELDS };
1007
+
1008
+ /**
1009
+ * Build the history `filters` string — EXACT-KEY terms only: `cert`→`CERT:<int>`
1010
+ * (the PRIMARY lookup), `changeCode`→`CHANGECODE:<int>`, `effYear`→`EFFYEAR:<year>`
1011
+ * (emit the integer's digits — EFFYEAR is a string field but the digits filter
1012
+ * cleanly, live-verified), `state`→`PSTALP:<state>` (★F1-analog — PSTALP, NEVER
1013
+ * STALP). There is NO name/city term (★F2-analog — /history's `search` param
1014
+ * returns 0 for INSTNAME; this tool never emits `search=`). Terms joined with
1015
+ * ` AND `. Returns "" when there is no structured filter clause. Exported for the
1016
+ * fault fixtures.
1017
+ */
1018
+ export function buildHistFilters(inp: {
1019
+ cert?: number;
1020
+ changeCode?: number;
1021
+ effYear?: number;
1022
+ state?: string;
1023
+ }): string {
1024
+ const terms: string[] = [];
1025
+ if (inp.cert !== undefined)
1026
+ terms.push(filterTerm("CERT", String(inp.cert), FDIC_HISTORY_FILTER_FIELDS));
1027
+ if (inp.changeCode !== undefined)
1028
+ terms.push(filterTerm("CHANGECODE", String(inp.changeCode), FDIC_HISTORY_FILTER_FIELDS));
1029
+ if (inp.effYear !== undefined)
1030
+ terms.push(filterTerm("EFFYEAR", String(inp.effYear), FDIC_HISTORY_FILTER_FIELDS));
1031
+ if (inp.state !== undefined)
1032
+ terms.push(filterTerm("PSTALP", inp.state, FDIC_HISTORY_FILTER_FIELDS));
1033
+ return terms.join(" AND ");
1034
+ }
1035
+
1036
+ /**
1037
+ * ★Q2 — normalize FDIC's `EFFDATE`/`PROCDATE` (`YYYY-MM-DDT00:00:00`, e.g.
1038
+ * `2002-07-01T00:00:00`) to ISO `YYYY-MM-DD`. Match
1039
+ * `^(\d{4})-(\d{2})-(\d{2})T00:00:00$`, extract y/m/d, then validate the calendar
1040
+ * day with the EXACT 3-component Date.UTC round-trip (`getUTCFullYear/Month+1/Date`
1041
+ * all match — rejects JS's silent roll-overs). On success → the ISO date
1042
+ * (`normalized:true`; the month/day are already zero-padded by the `\d{2}` capture).
1043
+ * If the value does NOT match the pattern or fails the round-trip → surface the RAW
1044
+ * upstream value UNCHANGED (`normalized:false`; NEVER null or fabricate a present
1045
+ * date — the handler discloses the raw passthrough); a genuinely-absent value
1046
+ * (null/undefined/"") → str-coerced (null for absent, "" preserved). Live-confirmed
1047
+ * 0/2000 nulls and 0 non-`T00:00:00` and old rows (1782 → `1782-01-01T00:00:00`)
1048
+ * conform, so the raw-passthrough branch is defensive-only. The `9999-12-31T00:00:00`
1049
+ * sentinel round-trips fine to `9999-12-31` (disclosed by the handler's sentinel
1050
+ * note). Exported for the fault fixtures.
1051
+ */
1052
+ export function normHistDate(raw: unknown): { value: string | null; normalized: boolean } {
1053
+ // A non-string (absent/null/numeric) → honest str coercion (null for absent);
1054
+ // never String()-fabricate "null"/"undefined"/"[object Object]".
1055
+ if (typeof raw !== "string") return { value: str(raw), normalized: false };
1056
+ const m = /^(\d{4})-(\d{2})-(\d{2})T00:00:00$/.exec(raw);
1057
+ if (m) {
1058
+ const y = Number(m[1]);
1059
+ const mo = Number(m[2]);
1060
+ const d = Number(m[3]);
1061
+ const dt = new Date(Date.UTC(y, mo - 1, d));
1062
+ if (dt.getUTCFullYear() === y && dt.getUTCMonth() + 1 === mo && dt.getUTCDate() === d) {
1063
+ return { value: `${m[1]}-${m[2]}-${m[3]}`, normalized: true };
1064
+ }
1065
+ }
1066
+ // Present but unrecognized → surface the raw value verbatim (defensive; never taken live).
1067
+ return { value: raw, normalized: false };
1068
+ }
1069
+
1070
+ export type FdicHistory = {
1071
+ cert: number | null;
1072
+ instName: string | null;
1073
+ state: string | null;
1074
+ changeCode: number | null;
1075
+ changeDescription: string | null;
1076
+ effectiveDate: string | null;
1077
+ processDate: string | null;
1078
+ effYear: string | null;
1079
+ transNum: number | null;
1080
+ acquirerCert: number | null;
1081
+ acquirerName: string | null;
1082
+ outgoingCert: number | null;
1083
+ outgoingName: string | null;
1084
+ survivingCert: number | null;
1085
+ survivingName: string | null;
1086
+ id: string | null;
1087
+ };
1088
+
1089
+ function mapHistory(rec: Record<string, unknown>): FdicHistory {
1090
+ return {
1091
+ cert: num(rec.CERT),
1092
+ instName: str(rec.INSTNAME),
1093
+ state: str(rec.PSTALP), // ★F1-analog — PSTALP, NOT STALP
1094
+ changeCode: num(rec.CHANGECODE),
1095
+ // ★Q1 — changeDescription is FDIC's OWN co-served CHANGECODE_DESC, passed
1096
+ // through verbatim (NOT a hand-map); the numeric changeCode is authoritative.
1097
+ changeDescription: str(rec.CHANGECODE_DESC),
1098
+ effectiveDate: normHistDate(rec.EFFDATE).value,
1099
+ processDate: normHistDate(rec.PROCDATE).value,
1100
+ effYear: str(rec.EFFYEAR),
1101
+ transNum: num(rec.TRANSNUM),
1102
+ // ★Q3 — counterparty triad from *_CERT (NOT *_UNINUM's 0 sentinel). On a
1103
+ // non-merger row these are ABSENT → num/str → null (never a fabricated 0/"").
1104
+ acquirerCert: num(rec.ACQ_CERT),
1105
+ acquirerName: str(rec.ACQ_INSTNAME),
1106
+ outgoingCert: num(rec.OUT_CERT),
1107
+ outgoingName: str(rec.OUT_INSTNAME),
1108
+ survivingCert: num(rec.SUR_CERT),
1109
+ survivingName: str(rec.SUR_INSTNAME),
1110
+ id: str(rec.ID),
1111
+ };
1112
+ }
1113
+
1114
+ // ─── history disclosure notes ──────────────────────────────────────
1115
+ const HIST_DATE_NOTE =
1116
+ "effectiveDate/processDate are normalized from FDIC's YYYY-MM-DDT00:00:00 to ISO YYYY-MM-DD.";
1117
+ const HIST_CHANGEDESC_NOTE =
1118
+ "changeDescription is FDIC's own CHANGECODE_DESC, co-served with the numeric code in each record; the numeric changeCode is authoritative.";
1119
+ const HIST_COUNTERPARTY_NOTE =
1120
+ "acquirer/outgoing/surviving identify the merger counterparties; each Cert links back to fdic_search_institutions / fdic_institution_financials / fdic_bank_failures; null = no counterparty for this event type.";
1121
+ const HIST_SCOPE_NOTE =
1122
+ "Structural-change events for FDIC-insured institutions; a bank ABSENT here (for a given CERT) has no recorded structural change — cross-check with fdic_search_institutions. Name/city are surfaced (instName + counterparty names) but NOT searchable on this endpoint (FDIC's /history `search` param returns 0 for INSTNAME); to find an institution's history, resolve its CERT via fdic_search_institutions, then filter here by `cert`.";
1123
+
1124
+ /**
1125
+ * Institution-level structural-change event log (`/banks/history`). Exact-key
1126
+ * structured inputs: `cert` (→ CERT filter, the PRIMARY lookup), `changeCode` (→
1127
+ * CHANGECODE), `effYear` (→ EFFYEAR), `state` (→ PSTALP filter, ★F1-analog) → the
1128
+ * `filters` param; plus `limit`/`offset`/`sortBy`/`sortOrder` (default EFFDATE DESC
1129
+ * → newest structural change first). Fixed field projection. NO name/city filter and
1130
+ * NEVER a `search=` param (★F2-analog). Consumes the IDENTICAL fetch → 3-envelope
1131
+ * guard → pagination machinery as tools 1–3.
1132
+ *
1133
+ * HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live: CERT 3510
1134
+ * → total 13794, stable across offset); the 3-envelope drift-guard makes the ONLY
1135
+ * honest empty `200 + total:0 + data:[]`, everything else THROWS (P2); changeDescription
1136
+ * is FDIC's co-served CHANGECODE_DESC passed through verbatim (Q1, never hand-mapped);
1137
+ * effectiveDate/processDate normalized YYYY-MM-DDT00:00:00→ISO (unrecognized → raw +
1138
+ * disclosed, never nulled/fabricated; 9999-* sentinel disclosed) (Q2); the ACQ_/OUT_/
1139
+ * SUR_ counterparty CERT-triad is null-never-0 and uses *_CERT not *_UNINUM's 0
1140
+ * sentinel (Q3); a projected always-present field absent from all records →
1141
+ * fieldsUnavailable (B); the snapshot build time is disclosed.
1142
+ */
1143
+ export async function institutionHistory(args: {
1144
+ cert?: number;
1145
+ changeCode?: number;
1146
+ effYear?: number;
1147
+ state?: string;
1148
+ limit?: number;
1149
+ offset?: number;
1150
+ sortBy?: string;
1151
+ sortOrder?: string;
1152
+ }): Promise<MetaBundle> {
1153
+ const limit = args.limit ?? 100;
1154
+ const offset = args.offset ?? 0;
1155
+ // Default EFFDATE DESC (newest structural change first) when the server's Zod
1156
+ // default did not supply one (defensive — the server always defaults sortBy=EFFDATE/DESC).
1157
+ const sort = sortParams(
1158
+ args.sortBy ?? "EFFDATE",
1159
+ args.sortOrder ?? "DESC",
1160
+ HIST_SORT_FIELDS,
1161
+ "history",
1162
+ );
1163
+
1164
+ const filters = buildHistFilters({
1165
+ cert: args.cert,
1166
+ changeCode: args.changeCode,
1167
+ effYear: args.effYear,
1168
+ state: args.state,
1169
+ });
1170
+
1171
+ const params = new URLSearchParams();
1172
+ if (filters) params.set("filters", filters);
1173
+ params.set("fields", HIST_FIELDS);
1174
+ params.set("limit", String(limit));
1175
+ params.set("offset", String(offset));
1176
+ if (sort.sort_by) {
1177
+ params.set("sort_by", sort.sort_by);
1178
+ params.set("sort_order", sort.sort_order as string);
1179
+ }
1180
+ params.set("format", "json");
1181
+
1182
+ const body = await getFdic(ENDPOINT_HISTORY, params);
1183
+ const env = parseEnvelope(body);
1184
+ const records = env.records.map(mapHistory);
1185
+ const returned = records.length;
1186
+ const totalAvailable = env.totalAvailable;
1187
+ const hasMore = offset + returned < totalAvailable;
1188
+ const nextOffset = hasMore ? offset + returned : null;
1189
+
1190
+ const filtersApplied: string[] = [];
1191
+ if (args.cert !== undefined) filtersApplied.push("cert");
1192
+ if (args.changeCode !== undefined) filtersApplied.push("changeCode");
1193
+ if (args.effYear !== undefined) filtersApplied.push("effYear");
1194
+ if (args.state !== undefined) filtersApplied.push("state");
1195
+ if (sort.sort_by) filtersApplied.push("sort");
1196
+
1197
+ const notes: string[] = [
1198
+ freshnessNote(env.indexName, env.indexCreated),
1199
+ HIST_DATE_NOTE,
1200
+ HIST_CHANGEDESC_NOTE,
1201
+ HIST_COUNTERPARTY_NOTE,
1202
+ HIST_SCOPE_NOTE,
1203
+ ];
1204
+ // ★Q2 — disclose any present-but-unrecognized EFFDATE/PROCDATE surfaced raw
1205
+ // (defensive; live all conform, so this branch essentially never fires).
1206
+ const anyRawDate = env.records.some((rec) =>
1207
+ (["EFFDATE", "PROCDATE"] as const).some((f) => {
1208
+ const nd = normHistDate(rec[f]);
1209
+ return !nd.normalized && nd.value !== null && nd.value !== "";
1210
+ }),
1211
+ );
1212
+ if (anyRawDate) {
1213
+ notes.push(
1214
+ "One or more effectiveDate/processDate values did not match FDIC's YYYY-MM-DDT00:00:00 format (or failed the calendar round-trip) and were surfaced RAW (not normalized to ISO) — never nulled or fabricated.",
1215
+ );
1216
+ }
1217
+ // ★Q2 sentinel — a 9999-* effectiveDate/processDate is FDIC's "not-applicable /
1218
+ // open" sentinel (normally on the un-projected ACQDATE/ENDDATE, but flagged here
1219
+ // should it ever surface on the projected dates). It round-trips to itself.
1220
+ const anySentinel = env.records.some((rec) =>
1221
+ (["EFFDATE", "PROCDATE"] as const).some(
1222
+ (f) => typeof rec[f] === "string" && (rec[f] as string).startsWith("9999"),
1223
+ ),
1224
+ );
1225
+ if (anySentinel) {
1226
+ notes.push(
1227
+ "One or more effectiveDate/processDate values carry FDIC's 9999-* 'not-applicable / open' sentinel — surfaced verbatim, not treated as a real event date.",
1228
+ );
1229
+ }
1230
+ const fu = fieldsUnavailable(env.records, HIST_PROJECTION);
1231
+ if (fu.length > 0) {
1232
+ notes.push(
1233
+ `Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
1234
+ );
1235
+ }
1236
+
1237
+ return withMeta(
1238
+ { history: records },
1239
+ {
1240
+ source: "api.fdic.gov/banks/history (BankFind, keyless)",
1241
+ keylessMode: true,
1242
+ returned,
1243
+ totalAvailable,
1244
+ filtersApplied,
1245
+ filtersDropped: [],
1246
+ fieldsUnavailable: fu,
1247
+ pagination: { offset, limit, hasMore, nextOffset },
1248
+ notes,
1249
+ } satisfies Partial<ResponseMeta>,
1250
+ );
1251
+ }
1252
+
1253
+ // ═══════════════════════════════════════════════════════════════════
1254
+ // Tool 5: fdic_industry_summary (ADR-0031) — the FDIC's OWN aggregate/statistical
1255
+ // roll-ups (`/banks/summary`): industry-wide and per-state ANNUAL aggregate financials
1256
+ // (total assets, deposits, net income, equity, net interest income) + structural
1257
+ // counts (institutions, offices, branches, employees), grouped by CHARTER CLASS
1258
+ // (commercial banks vs savings institutions). ★NEW CAPABILITY TYPE for the source —
1259
+ // the FIRST AGGREGATE / statistical tool (the 4 existing tools are all per-ENTITY,
1260
+ // keyed on CERT). Answers "how big is the US (or a state's) banking industry this
1261
+ // year, and how many institutions?" — a question NONE of the 4 entity tools can
1262
+ // express without manually summing thousands of institution rows. Reuses the
1263
+ // C116/C118-hardened adapter VERBATIM (getFdic / parseEnvelope 3-envelope guard /
1264
+ // filterTerm allowlist-by-construction + Set.has / sortParams enum+Set.has / EXACT
1265
+ // meta.total pagination / snapshot-freshness / $thousands→USD ×1000 null-never-0 /
1266
+ // the C118-quoted filterTerm). It does NOT use searchTerm/escapeSearch (no name
1267
+ // search — /summary's `search` param is a no-op that returns the whole year). The NEW
1268
+ // surface is exactly four things, all live-verified 2026-07-13:
1269
+ // ★S1 — TWO cross-cut dimensions (NOT one): (1) charter class CB_SI (CB=commercial
1270
+ // banks, SI=savings institutions — every row is scoped to EXACTLY ONE; there is NO
1271
+ // pre-combined "all institutions" row), and (2) geography STALP (a MIX of
1272
+ // per-jurisdiction leaf rows AND geographic ROLL-UP rows).
1273
+ // ★S2 (the honesty crux) — the roll-up-vs-jurisdiction split. STALP ∈ {USA,US,OT,PI}
1274
+ // are GEOGRAPHIC AGGREGATES (scope national_total / national_states_dc /
1275
+ // territories_total / pacific_islands, isRollup:true); every other STALP is a
1276
+ // single jurisdiction (isRollup:false). Surfaced as derived scope+isRollup on
1277
+ // EVERY row + a MANDATORY disclosure: NEVER sum a roll-up row with jurisdiction
1278
+ // rows or across scopes (live-proven: Σ jurisdictions = USA; USA − US = OT). A
1279
+ // roll-up row must NEVER masquerade as a state.
1280
+ // ★S3 — the aggregate FIELD NAMES: number-of-institutions is BANKS (NOT NUMINST);
1281
+ // /summary serves NO ratio fields at all (ROA/ROE/NIMY/ERNAST absent) — only
1282
+ // $-aggregates + integer COUNTS.
1283
+ // ★S4 (the NIM foot-gun) — on /summary NIM is net interest INCOME in $thousands
1284
+ // (Alabama CB 2023 → 7,457,074 = $7.5B; USA CB → 660,219,591 = $660B), NOT the
1285
+ // net-interest-margin percentage. It MUST be ×1000-scaled like every money field
1286
+ // AND relabeled net interest income (netInterestIncomeUSD). Money
1287
+ // (ASSET/DEP/NETINC/EQ/NIM) ×1000 with the null-guard BEFORE the multiply; counts
1288
+ // (BANKS/OFFICES/BRANCHES/NUMEMP) pass through un-scaled (a count ×1000 is a
1289
+ // fabrication). A genuine 0 (American Samoa CB BANKS:0/ASSET:0/NIM:0) stays 0; an
1290
+ // absent value (American Samoa SI has NO BANKS key) stays null (never 0).
1291
+ // The state filter field is STALP (NOT PSTALP — a per-endpoint difference from
1292
+ // /failures & /history), C118-quoted so Oregon `STALP:"OR"` is operator-safe. NO
1293
+ // name/city filter, NEVER a `search=` param. The unknown-filter-field false-empty
1294
+ // (200/total:0) is neutralized by the allowlist-by-construction; the malformed-year
1295
+ // false-empty (YEAR:notanum → total:0) is guarded by Zod .int() at the boundary.
1296
+ // ═══════════════════════════════════════════════════════════════════
1297
+
1298
+ // Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
1299
+ const ENDPOINT_SUMMARY = "summary";
1300
+
1301
+ // Fixed field projection on the wire (every field live-verified present + non-null on
1302
+ // both a leaf row (CA CB) and a roll-up row (USA CB); genuine 0/absent handled by the
1303
+ // map). NO ratio fields (they do not exist on /summary).
1304
+ const SUMMARY_FIELDS =
1305
+ "YEAR,CB_SI,STNAME,STALP,STNUM,BANKS,OFFICES,BRANCHES,NUMEMP,ASSET,DEP,NETINC,EQ,NIM,ID";
1306
+ const SUMMARY_PROJECTION = [
1307
+ "YEAR",
1308
+ "CB_SI",
1309
+ "STNAME",
1310
+ "STALP",
1311
+ "STNUM",
1312
+ "BANKS",
1313
+ "OFFICES",
1314
+ "BRANCHES",
1315
+ "NUMEMP",
1316
+ "ASSET",
1317
+ "DEP",
1318
+ "NETINC",
1319
+ "EQ",
1320
+ "NIM",
1321
+ "ID",
1322
+ ] as const;
1323
+
1324
+ // ★S3 — the summary filter-FIELD allowlist (P4 belt-and-suspenders). ONLY these three
1325
+ // (a caller never supplies a field name — they are compile-time constants behind named
1326
+ // inputs). YEAR is numeric (emitted bare by filterTerm); STALP/CB_SI are non-numeric
1327
+ // (C118-double-quoted). An un-allowlisted field → invalid_input pre-fetch (guards the
1328
+ // live HTTP-200 total:0 unknown-field false-empty).
1329
+ const FDIC_SUMMARY_FILTER_FIELDS: ReadonlySet<string> = new Set(["YEAR", "STALP", "CB_SI"]);
1330
+ // sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams →
1331
+ // an unknown sort field is invalid_input BEFORE fetch; live `sort_by=NOTAFIELD` → 400,
1332
+ // so the pre-fetch guard is load-bearing).
1333
+ const SUMMARY_SORT_FIELDS: ReadonlySet<string> = new Set(["YEAR", "ASSET", "DEP", "NETINC", "BANKS"]);
1334
+
1335
+ // Exported so the fault suite can drive the belt-and-suspenders field-guard directly.
1336
+ export { FDIC_SUMMARY_FILTER_FIELDS };
1337
+
1338
+ // ★S2 — the explicit roll-up STALP set (the honesty discriminator; live-verified
1339
+ // exhaustive over all 121 rows of 2023, and structurally by FIPS STNUM ∈ {0,99,98,97}).
1340
+ const ROLLUP_STALP: ReadonlySet<string> = new Set(["USA", "US", "OT", "PI"]);
1341
+
1342
+ /**
1343
+ * ★CRUX-1 (S2) — derive the geographic SCOPE from the raw STALP, using the explicit
1344
+ * roll-up set (a fixed 4-element discriminator, NOT a fragile numeric threshold).
1345
+ * A non-roll-up value (incl. null / any future/unknown STALP) falls through to
1346
+ * "jurisdiction" and is surfaced by the ALWAYS-projected raw STNAME/STALP/STNUM — a
1347
+ * caller can always see the literal label, never a silently-mislabeled aggregate.
1348
+ * Exported for the fault fixtures.
1349
+ */
1350
+ export function scopeOf(stalp: string | null): string {
1351
+ switch (stalp) {
1352
+ case "USA":
1353
+ return "national_total"; // STNUM 0 — 50 states + DC + all territories (grand total)
1354
+ case "US":
1355
+ return "national_states_dc"; // STNUM 99 — 50 states + DC, EXCL territories
1356
+ case "OT":
1357
+ return "territories_total"; // STNUM 98 — all US territories
1358
+ case "PI":
1359
+ return "pacific_islands"; // STNUM 97 — Pacific-island territories (⊂ OT)
1360
+ default:
1361
+ return "jurisdiction"; // a single state / DC / individual territory (or an absent/unknown STALP)
1362
+ }
1363
+ }
1364
+
1365
+ /**
1366
+ * ★CRUX-1b (S1) — map the raw CB_SI charter code to a readable class. An UNMAPPED
1367
+ * value returns the raw code (never fabricated); a genuinely-absent (null) value stays
1368
+ * null. Exported for the fault fixtures.
1369
+ */
1370
+ export function charterClassOf(code: string | null): string | null {
1371
+ if (code === "CB") return "commercial_banks";
1372
+ if (code === "SI") return "savings_institutions";
1373
+ return code; // unmapped → the raw code (never invented); null → null
1374
+ }
1375
+
1376
+ /**
1377
+ * Build the summary `filters` string — EXACT-KEY terms only: `year`→`YEAR:<int>`
1378
+ * (numeric → emitted BARE by filterTerm; a non-int is guarded pre-fetch by Zod .int()),
1379
+ * `state`→`STALP:"<code>"` (★S2 — STALP is the /summary state field, NOT PSTALP;
1380
+ * C118-double-quoted so Oregon `STALP:"OR"` is Lucene-operator-safe), `charterClass`
1381
+ * →`CB_SI:"<v>"` (quoted). There is NO name/city term (★/summary's `search` is a no-op
1382
+ * that returns the whole year — this tool never emits `search=`). Terms joined with
1383
+ * ` AND `. Returns "" when there is no structured filter clause. Exported for the fault
1384
+ * fixtures.
1385
+ */
1386
+ export function buildSummaryFilters(inp: {
1387
+ year?: number;
1388
+ state?: string;
1389
+ charterClass?: string;
1390
+ }): string {
1391
+ const terms: string[] = [];
1392
+ if (inp.year !== undefined)
1393
+ terms.push(filterTerm("YEAR", String(inp.year), FDIC_SUMMARY_FILTER_FIELDS));
1394
+ if (inp.state !== undefined)
1395
+ terms.push(filterTerm("STALP", inp.state, FDIC_SUMMARY_FILTER_FIELDS));
1396
+ if (inp.charterClass !== undefined)
1397
+ terms.push(filterTerm("CB_SI", inp.charterClass, FDIC_SUMMARY_FILTER_FIELDS));
1398
+ return terms.join(" AND ");
1399
+ }
1400
+
1401
+ export type FdicIndustrySummary = {
1402
+ year: number | null;
1403
+ charterClass: string | null;
1404
+ charterClassCode: string | null;
1405
+ geography: string | null;
1406
+ stateCode: string | null;
1407
+ stateFips: string | null;
1408
+ scope: string;
1409
+ isRollup: boolean;
1410
+ institutionCount: number | null;
1411
+ officeCount: number | null;
1412
+ branchCount: number | null;
1413
+ employeeCount: number | null;
1414
+ totalAssetsUSD: number | null;
1415
+ totalDepositsUSD: number | null;
1416
+ netIncomeUSD: number | null;
1417
+ totalEquityUSD: number | null;
1418
+ netInterestIncomeUSD: number | null;
1419
+ id: string | null;
1420
+ };
1421
+
1422
+ function mapSummary(rec: Record<string, unknown>): FdicIndustrySummary {
1423
+ const stateCode = str(rec.STALP);
1424
+ const charterClassCode = str(rec.CB_SI);
1425
+ const scope = scopeOf(stateCode); // ★S2 — derived from the explicit roll-up set
1426
+ return {
1427
+ year: num(rec.YEAR), // YEAR is a string field ("2023") → num parses the digits
1428
+ charterClass: charterClassOf(charterClassCode), // ★S1 — CB→commercial_banks, SI→savings_institutions (unmapped→raw)
1429
+ charterClassCode,
1430
+ geography: str(rec.STNAME),
1431
+ stateCode,
1432
+ stateFips: str(rec.STNUM),
1433
+ scope,
1434
+ isRollup: scope !== "jurisdiction", // ★S2 — a roll-up must NEVER masquerade as a state
1435
+ // ★S4 / P3 — COUNT fields pass through via num (NEVER ×1000; a count ×1000 is a
1436
+ // fabrication). A genuine 0 (American Samoa CB BANKS:0) stays 0; an absent value
1437
+ // (American Samoa SI has no BANKS key) stays null (never 0).
1438
+ institutionCount: num(rec.BANKS),
1439
+ officeCount: num(rec.OFFICES),
1440
+ branchCount: num(rec.BRANCHES),
1441
+ employeeCount: num(rec.NUMEMP),
1442
+ // ★S4 / P3 — MONEY fields ($thousands → whole USD ×1000, null-guard BEFORE the
1443
+ // multiply; a genuine 0 stays 0, absent → null).
1444
+ totalAssetsUSD: thousandsToUsd(rec.ASSET),
1445
+ totalDepositsUSD: thousandsToUsd(rec.DEP),
1446
+ netIncomeUSD: thousandsToUsd(rec.NETINC),
1447
+ totalEquityUSD: thousandsToUsd(rec.EQ),
1448
+ // ★S4 — NIM is net interest INCOME ($thousands), NOT the margin ratio: scaled ×1000
1449
+ // and relabeled income (never surfaced as a "margin").
1450
+ netInterestIncomeUSD: thousandsToUsd(rec.NIM),
1451
+ id: str(rec.ID),
1452
+ };
1453
+ }
1454
+
1455
+ // ─── summary disclosure notes ──────────────────────────────────────
1456
+ // ★S2 — the load-bearing roll-up double-count-prevention disclosure (P4/P1 frontier).
1457
+ const SUMMARY_ROLLUP_NOTE =
1458
+ "Rows cross charter class (CB_SI) × geography (STALP); STALP ∈ {USA,US,OT,PI} are ROLL-UP totals (isRollup:true, scope national_total/national_states_dc/territories_total/pacific_islands) — NEVER sum a roll-up row with jurisdiction rows, and NEVER sum across scopes: national_total (USA) = national_states_dc (US) + territories_total (OT), and pacific_islands (PI) is a SUBSET of territories (live-proven: Σ jurisdictions = USA; USA − US = OT). A geography's total = its CB row + its SI row (there is NO pre-combined charter row). Filter by state/charterClass or by isRollup to avoid double-counting; to get one national figure read the national_total (USA) row directly rather than summing states — a roll-up row is NOT a state.";
1459
+ // ★S1 — the charter-class split (no combined row).
1460
+ const SUMMARY_CHARTER_NOTE =
1461
+ "Each row covers ONE charter class: CB = commercial_banks, SI = savings_institutions. There is NO pre-combined 'all institutions' row — a geography's all-FDIC-insured total for a year = its CB row + its SI row (omit charterClass to fetch both).";
1462
+ // ★S4 / P3 — money vs count units + the NIM foot-gun + the no-ratios fact.
1463
+ const SUMMARY_UNITS_NOTE =
1464
+ "ASSET/DEP/NETINC/EQ/NIM are $thousands, normalized here to whole USD (×1,000); NIM is net interest INCOME (a dollar sum, surfaced as netInterestIncomeUSD), NOT the net-interest-margin ratio. BANKS/OFFICES/BRANCHES/EMPLOYEES are COUNTS (not scaled). This endpoint provides NO ratio fields (ROA/ROE); derive them from netIncomeUSD / totalAssetsUSD / totalEquityUSD if needed. A real 0 stays 0; an absent value is null (never 0).";
1465
+ // ★ name-search scope (no institution-name search on /summary).
1466
+ const SUMMARY_SCOPE_NOTE =
1467
+ "This is FDIC's aggregate roll-up endpoint; it has no institution-name search (FDIC's /summary `search` param is ignored and returns the whole year). To drill from an industry aggregate to individual institutions, use fdic_search_institutions (filter by state) or fdic_institution_financials (by CERT).";
1468
+
1469
+ /**
1470
+ * FDIC industry & state banking-sector ANNUAL aggregates (`/banks/summary`) — the
1471
+ * FIRST aggregate/statistical FDIC tool. Exact-key structured inputs (all optional,
1472
+ * AND-combined): `year` (→ YEAR filter), `state` (→ STALP filter, ★S2 — STALP NOT
1473
+ * PSTALP; accepts a jurisdiction code OR a roll-up code USA/US/OT/PI), `charterClass`
1474
+ * (→ CB_SI filter; CB/SI) → the `filters` param; plus `limit`/`offset`/`sortBy`/
1475
+ * `sortOrder` (default YEAR DESC → newest aggregate year first). Fixed field
1476
+ * projection. NO name/city filter and NEVER a `search=` param. Consumes the IDENTICAL
1477
+ * fetch → 3-envelope guard → pagination machinery as tools 1–4.
1478
+ *
1479
+ * HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live YEAR:2023 →
1480
+ * 121, stable across offset); the 3-envelope drift-guard makes the ONLY honest empty
1481
+ * `200 + total:0 + data:[]`, everything else THROWS (P2); money (ASSET/DEP/NETINC/EQ/
1482
+ * NIM) is $thousands → whole USD ×1000 null-never-0, counts (BANKS/OFFICES/BRANCHES/
1483
+ * NUMEMP) pass through un-scaled (P3; a genuine 0 stays 0, absent → null); ★NIM is net
1484
+ * interest INCOME (scaled + relabeled netInterestIncomeUSD), NOT the margin ratio (S4);
1485
+ * ★scope/isRollup are derived per row from the explicit roll-up STALP set so a roll-up
1486
+ * never masquerades as a state (S2); charterClass is derived from CB_SI (S1); a
1487
+ * projected field absent from all records → fieldsUnavailable (B); the snapshot build
1488
+ * time is disclosed.
1489
+ */
1490
+ export async function industrySummary(args: {
1491
+ year?: number;
1492
+ state?: string;
1493
+ charterClass?: string;
1494
+ limit?: number;
1495
+ offset?: number;
1496
+ sortBy?: string;
1497
+ sortOrder?: string;
1498
+ }): Promise<MetaBundle> {
1499
+ const limit = args.limit ?? 100;
1500
+ const offset = args.offset ?? 0;
1501
+ // Default YEAR DESC (newest aggregate year first) when the server's Zod default did
1502
+ // not supply one (defensive — the server always defaults sortBy=YEAR/DESC).
1503
+ const sort = sortParams(args.sortBy ?? "YEAR", args.sortOrder ?? "DESC", SUMMARY_SORT_FIELDS, "summary");
1504
+
1505
+ const filters = buildSummaryFilters({
1506
+ year: args.year,
1507
+ state: args.state,
1508
+ charterClass: args.charterClass,
1509
+ });
1510
+
1511
+ const params = new URLSearchParams();
1512
+ if (filters) params.set("filters", filters);
1513
+ params.set("fields", SUMMARY_FIELDS);
1514
+ params.set("limit", String(limit));
1515
+ params.set("offset", String(offset));
1516
+ if (sort.sort_by) {
1517
+ params.set("sort_by", sort.sort_by);
1518
+ params.set("sort_order", sort.sort_order as string);
1519
+ }
1520
+ params.set("format", "json");
1521
+
1522
+ const body = await getFdic(ENDPOINT_SUMMARY, params);
1523
+ const env = parseEnvelope(body);
1524
+ const records = env.records.map(mapSummary);
1525
+ const returned = records.length;
1526
+ const totalAvailable = env.totalAvailable;
1527
+ const hasMore = offset + returned < totalAvailable;
1528
+ const nextOffset = hasMore ? offset + returned : null;
1529
+
1530
+ const filtersApplied: string[] = [];
1531
+ if (args.year !== undefined) filtersApplied.push("year");
1532
+ if (args.state !== undefined) filtersApplied.push("state");
1533
+ if (args.charterClass !== undefined) filtersApplied.push("charterClass");
1534
+ if (sort.sort_by) filtersApplied.push("sort");
1535
+
1536
+ const notes: string[] = [
1537
+ freshnessNote(env.indexName, env.indexCreated),
1538
+ SUMMARY_ROLLUP_NOTE,
1539
+ SUMMARY_CHARTER_NOTE,
1540
+ SUMMARY_UNITS_NOTE,
1541
+ SUMMARY_SCOPE_NOTE,
1542
+ ];
1543
+ const fu = fieldsUnavailable(env.records, SUMMARY_PROJECTION);
1544
+ if (fu.length > 0) {
1545
+ // /banks/summary is a 2-D roll-up: some count fields (e.g. BANKS/OFFICES/
1546
+ // BRANCHES) are STRUCTURALLY absent for certain roll-up/territory rows
1547
+ // (e.g. the Pacific-Islands roll-up carries no bank/branch count) — that is
1548
+ // a legitimate structural absence, NOT necessarily a schema change. Disclose
1549
+ // the absence honestly without falsely diagnosing "schema drift".
1550
+ notes.push(
1551
+ `Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record in this result set — for /banks/summary this is usually a structural absence (some count fields are not reported for certain roll-up or territory rows), though it can also indicate a schema change; the affected values are surfaced as null, never fabricated.`,
1552
+ );
1553
+ }
1554
+
1555
+ return withMeta(
1556
+ { summary: records },
1557
+ {
1558
+ source: "api.fdic.gov/banks/summary (BankFind, keyless)",
1559
+ keylessMode: true,
1560
+ returned,
1561
+ totalAvailable,
1562
+ filtersApplied,
1563
+ filtersDropped: [],
1564
+ fieldsUnavailable: fu,
1565
+ pagination: { offset, limit, hasMore, nextOffset },
1566
+ notes,
1567
+ } satisfies Partial<ResponseMeta>,
1568
+ );
1569
+ }
1570
+
1571
+ // ═══════════════════════════════════════════════════════════════════
1572
+ // Tool 6: fdic_risk_ratios (ADR-0040) — the FDIC counterparty-SOUNDNESS lane, a
1573
+ // WITHIN-SOURCE DEPTH tool on the ALREADY-WIRED `/banks/financials` endpoint (NO new
1574
+ // endpoint constant). The 5 existing FDIC tools surface only $-aggregates (assets /
1575
+ // deposits / net income); this tool projects the CURATED RISK-RATIO catalog
1576
+ // (profitability ROA/ROE, margin NIM, cost efficiency, capital adequacy leverage /
1577
+ // tier-1 / total risk-based, asset quality net charge-offs) + the tier-1 capital
1578
+ // LEVEL, keyed on CERT. Reuses the C116/C118-hardened adapter VERBATIM (getFdic /
1579
+ // parseEnvelope 3-envelope guard / filterTerm allowlist + Set.has / sortParams
1580
+ // enum+Set.has / EXACT meta.total pagination / freshness / thousandsToUsd / num). The
1581
+ // NEW surface is the PER-FIELD-UNITS ratio catalog + honesty, all live-verified
1582
+ // 2026-07-13:
1583
+ // ★P3 UNITS-IN-THE-KEY — a MODULE-CONSTANT catalog fieldCode→{outputKey,label,unit,
1584
+ // route}. The 9 PERCENT ratios (ROA/ROAPTX/ROE/NIMY/EEFFR/NTLNLSR/RBC1AAJ/RBC1RWAJ/
1585
+ // RBCRWAJ) route through `num` and are surfaced VERBATIM (NO scale, NO recompute);
1586
+ // the ONE $-amount (RBCT1J, tier-1 capital in $thousands) routes through
1587
+ // thousandsToUsd → tier1CapitalUSD (×1000). The unit rides the output KEY (…Pct /
1588
+ // …USD) AND the mandatory RATIO_UNITS_NOTE — a consumer never reads a 77.98%
1589
+ // efficiency ratio as a dollar amount or ×1000-scales a percent.
1590
+ // ★P3 NULL-NEVER-0 — a not-reported ratio → null (via `num`, which maps BOTH JSON
1591
+ // null and undefined → null), NEVER 0 (a false "no return / no capital"). FDIC
1592
+ // returns a not-reported ratio as an EXPLICIT null (key present, value null), NOT
1593
+ // an absent key — so we read every ratio via `num(rec.CODE)`, NEVER via
1594
+ // `'CODE' in rec` / `=== undefined` / hasOwnProperty (S2).
1595
+ // ★M1 (the BLOCKER) — the CBLR `RBCRWAJ=0` sentinel. LIVE: 37% of banks are
1596
+ // Community-Bank-Leverage-Ratio filers (CBLRIND:1) that return RBCRWAJ as a
1597
+ // LITERAL 0 (not null) while RBC1RWAJ is null and the leverage ratio RBC1AAJ is
1598
+ // populated + solvent. Routing RBCRWAJ verbatim through num yields num(0)=0 = a
1599
+ // false "0% total capital / insolvent" on healthy banks. FIX (CBLR-scoped, no
1600
+ // recompute, NO blanket 0→null): CBLRIND is added to the projection; when
1601
+ // CBLRIND===1 we map BOTH totalRiskBasedCapitalRatioPct AND
1602
+ // tier1RiskBasedCapitalRatioPct to null ("not applicable — CBLR framework") and
1603
+ // surface a per-row cblrFramework:boolean. Detection is via FDIC's OWN CBLRIND
1604
+ // flag — never a derived/recomputed ratio. Genuine zeros on OTHER fields
1605
+ // (NTLNLSR:0 = zero net charge-offs; /sod DEPSUMBR:0) are UNTOUCHED — this is NOT
1606
+ // a blanket 0→null.
1607
+ // ★P3 NO-RECOMPUTE — every ratio is FDIC's published value surfaced verbatim; the
1608
+ // tool never computes ROE=NETINC/EQ (or any ratio) itself (a computed ratio would
1609
+ // diverge from FDIC's official figure = a fabrication).
1610
+ // ★S2 per-code not-reported marker — each catalog entry carries an explicit
1611
+ // `notReported` marker ('null' | 'zero-sentinel-when-CBLR'): FDIC's not-reported
1612
+ // encoding is FIELD-SPECIFIC (RBC1RWAJ uses JSON null; RBCRWAJ uses a literal-0
1613
+ // sentinel in the CBLR cohort; NTLNLSR/EEFFR/NIMY 0 are GENUINE zeros). Any newly
1614
+ // added code MUST be live-validated for its 0/null encoding before shipping —
1615
+ // never assume the ROA/ROE null path generalizes.
1616
+ // ═══════════════════════════════════════════════════════════════════
1617
+
1618
+ // ★P3 — the per-field UNIT of a catalog ratio: a PERCENT (surfaced verbatim via num,
1619
+ // no scale) or a $-amount FDIC publishes in $thousands (×1000 via thousandsToUsd).
1620
+ type RatioUnit = "percent" | "usd-thousands";
1621
+ // ★S2 — the FIELD-SPECIFIC not-reported encoding marker. 'null' = FDIC returns an
1622
+ // explicit JSON null when not reported (the ROA/ROE/… path). 'zero-sentinel-when-CBLR'
1623
+ // = FDIC returns a LITERAL 0 sentinel for the risk-based capital ratios when the bank
1624
+ // files under the CBLR framework (the M1 blocker) — a 0 there is NOT a real 0%.
1625
+ type RatioNotReported = "null" | "zero-sentinel-when-CBLR";
1626
+ type RatioEntry = {
1627
+ code: string; // the FDIC field code (live-verified valid — a typo would silently drop off the wire)
1628
+ outputKey: string; // the output key WITH its unit suffix (…Pct / …USD)
1629
+ label: string; // human label (from the FDIC RIS / Call-Report data dictionary)
1630
+ unit: RatioUnit;
1631
+ notReported: RatioNotReported;
1632
+ };
1633
+
1634
+ // ★P3 — the CURATED ratio catalog (a MODULE CONSTANT). Every code live-verified valid
1635
+ // + its unit pinned from the FDIC data dictionary AND the live values. Do NOT include
1636
+ // ROAA/ROEA (invalid codes — silently dropped from the record). Exported for the
1637
+ // catalog-shape fault fixture.
1638
+ const RATIO_CATALOG: readonly RatioEntry[] = [
1639
+ { code: "ROA", outputKey: "returnOnAssetsPct", label: "Return on assets", unit: "percent", notReported: "null" },
1640
+ { code: "ROAPTX", outputKey: "preTaxReturnOnAssetsPct", label: "Pretax return on assets", unit: "percent", notReported: "null" },
1641
+ { code: "ROE", outputKey: "returnOnEquityPct", label: "Return on equity", unit: "percent", notReported: "null" },
1642
+ { code: "NIMY", outputKey: "netInterestMarginPct", label: "Net interest margin", unit: "percent", notReported: "null" },
1643
+ { code: "EEFFR", outputKey: "efficiencyRatioPct", label: "Efficiency ratio (noninterest expense / revenue)", unit: "percent", notReported: "null" },
1644
+ { code: "NTLNLSR", outputKey: "netChargeOffsToLoansPct", label: "Net charge-offs to loans & leases", unit: "percent", notReported: "null" },
1645
+ { code: "RBC1AAJ", outputKey: "leverageRatioPct", label: "Leverage (core capital) ratio", unit: "percent", notReported: "null" },
1646
+ // ★M1 — the two risk-based capital ratios carry the CBLR literal-0 / null sentinel.
1647
+ { code: "RBC1RWAJ", outputKey: "tier1RiskBasedCapitalRatioPct", label: "Tier-1 risk-based capital ratio", unit: "percent", notReported: "zero-sentinel-when-CBLR" },
1648
+ { code: "RBCRWAJ", outputKey: "totalRiskBasedCapitalRatioPct", label: "Total risk-based capital ratio", unit: "percent", notReported: "zero-sentinel-when-CBLR" },
1649
+ { code: "RBCT1J", outputKey: "tier1CapitalUSD", label: "Tier-1 (core) capital", unit: "usd-thousands", notReported: "null" },
1650
+ ] as const;
1651
+
1652
+ export { RATIO_CATALOG };
1653
+
1654
+ // Fixed field projection on the wire (CERT/REPDTE/ID + CBLRIND [★M1 sentinel detection]
1655
+ // + every catalog code). Built FROM the catalog so a catalog edit can never drift from
1656
+ // the wire projection.
1657
+ const RATIO_CODES: readonly string[] = RATIO_CATALOG.map((e) => e.code);
1658
+ const RATIO_FIELDS = ["CERT", "REPDTE", "CBLRIND", ...RATIO_CODES, "ID"].join(",");
1659
+ // The returned-fields (B) disclosure projection. FDIC returns a not-reported ratio as
1660
+ // an EXPLICIT null (key PRESENT) — so a code shows in Object.keys even when null, and
1661
+ // fieldsUnavailable fires ONLY on a genuinely-absent key (real schema drift / a code
1662
+ // FDIC does not publish for these rows), never on a normal not-reported null.
1663
+ const RATIO_PROJECTION = ["CERT", "REPDTE", "CBLRIND", ...RATIO_CODES, "ID"] as const;
1664
+
1665
+ // ★S1 — the ratio filter-FIELD allowlist (P4 belt-and-suspenders). CERT + optional
1666
+ // REPDTE, both NUMERIC (emitted bare by filterTerm). Do NOT reuse FIN_FILTER_FIELDS
1667
+ // ({CERT}) — it would THROW on REPDTE. Exported for the fault fixture.
1668
+ const RATIO_FILTER_FIELDS: ReadonlySet<string> = new Set(["CERT", "REPDTE"]);
1669
+ // sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams →
1670
+ // an unknown sort field is invalid_input BEFORE fetch).
1671
+ const RATIO_SORT_FIELDS: ReadonlySet<string> = new Set(["REPDTE", "ROA", "ROE", "RBCRWAJ", "EEFFR"]);
1672
+
1673
+ export { RATIO_FILTER_FIELDS };
1674
+
1675
+ /**
1676
+ * Build the risk-ratio `filters` string — `cert`→`CERT:<int>` (REQUIRED, numeric →
1677
+ * bare) + optional `reportDate`→`REPDTE:<int>` (numeric → bare, a YYYYMMDD quarter-end).
1678
+ * Both fields are on RATIO_FILTER_FIELDS (NOT FIN_FILTER_FIELDS, which is {CERT} and
1679
+ * would throw on REPDTE — S1). Terms joined with ` AND `. Exported for the fault fixtures.
1680
+ */
1681
+ export function buildRatioFilters(inp: { cert: number; reportDate?: number }): string {
1682
+ const terms: string[] = [];
1683
+ terms.push(filterTerm("CERT", String(inp.cert), RATIO_FILTER_FIELDS));
1684
+ if (inp.reportDate !== undefined)
1685
+ terms.push(filterTerm("REPDTE", String(inp.reportDate), RATIO_FILTER_FIELDS));
1686
+ return terms.join(" AND ");
1687
+ }
1688
+
1689
+ /**
1690
+ * ★M1 — detect the Community Bank Leverage Ratio framework via FDIC's OWN `CBLRIND`
1691
+ * flag (CBLRIND===1). A CBLR filer does NOT report risk-based capital ratios: FDIC
1692
+ * returns RBCRWAJ as a LITERAL 0 sentinel (not null) and RBC1RWAJ as null. Detection is
1693
+ * strictly the flag — NO derived/recomputed ratio, NO blanket 0→null. Exported for the
1694
+ * fault fixture. (num maps a string/number/null CBLRIND consistently; an absent CBLRIND
1695
+ * → null → not CBLR → the ratios pass through verbatim.)
1696
+ */
1697
+ export function isCblrFramework(rec: Record<string, unknown>): boolean {
1698
+ return num(rec.CBLRIND) === 1;
1699
+ }
1700
+
1701
+ export type FdicRiskRatios = {
1702
+ cert: number | null;
1703
+ reportDate: number | null;
1704
+ cblrFramework: boolean;
1705
+ returnOnAssetsPct: number | null;
1706
+ preTaxReturnOnAssetsPct: number | null;
1707
+ returnOnEquityPct: number | null;
1708
+ netInterestMarginPct: number | null;
1709
+ efficiencyRatioPct: number | null;
1710
+ netChargeOffsToLoansPct: number | null;
1711
+ leverageRatioPct: number | null;
1712
+ tier1RiskBasedCapitalRatioPct: number | null;
1713
+ totalRiskBasedCapitalRatioPct: number | null;
1714
+ tier1CapitalUSD: number | null;
1715
+ id: string | null;
1716
+ };
1717
+
1718
+ /**
1719
+ * ★P3 + ★M1 — map ONE `/financials` record to the ratio row. The catalog drives the
1720
+ * per-field route: a PERCENT code → `num` VERBATIM (no scale, null-never-0); the
1721
+ * $-amount RBCT1J → `thousandsToUsd` (×1000, null-guard BEFORE the multiply). ★M1: when
1722
+ * the bank files under CBLR (CBLRIND===1) the two `zero-sentinel-when-CBLR` codes
1723
+ * (RBCRWAJ / RBC1RWAJ) map to NULL (never the 0 sentinel / never a false 0% capital);
1724
+ * a per-row `cblrFramework` explains the null. Genuine zeros on the other codes
1725
+ * (NTLNLSR:0 …) are surfaced verbatim by `num` — this is NOT a blanket 0→null.
1726
+ */
1727
+ function mapRiskRatios(rec: Record<string, unknown>): FdicRiskRatios {
1728
+ const cblr = isCblrFramework(rec);
1729
+ const r: Record<string, number | null> = {};
1730
+ for (const entry of RATIO_CATALOG) {
1731
+ // ★M1 — CBLR-scoped sentinel → null (BOTH risk-based capital ratios), never 0.
1732
+ if (cblr && entry.notReported === "zero-sentinel-when-CBLR") {
1733
+ r[entry.outputKey] = null;
1734
+ continue;
1735
+ }
1736
+ // ★P3 — $-amount ×1000 (thousandsToUsd); percent verbatim (num, no scale). Both
1737
+ // are null-never-0 (num / thousandsToUsd map null/undefined/""/"null" → null).
1738
+ r[entry.outputKey] =
1739
+ entry.unit === "usd-thousands" ? thousandsToUsd(rec[entry.code]) : num(rec[entry.code]);
1740
+ }
1741
+ // Each key is guaranteed populated by the catalog loop above; `?? null` only
1742
+ // satisfies noUncheckedIndexedAccess (an unexpected catalog-key drift → null,
1743
+ // never undefined — still null-never-0).
1744
+ return {
1745
+ cert: num(rec.CERT),
1746
+ reportDate: num(rec.REPDTE),
1747
+ cblrFramework: cblr,
1748
+ returnOnAssetsPct: r.returnOnAssetsPct ?? null,
1749
+ preTaxReturnOnAssetsPct: r.preTaxReturnOnAssetsPct ?? null,
1750
+ returnOnEquityPct: r.returnOnEquityPct ?? null,
1751
+ netInterestMarginPct: r.netInterestMarginPct ?? null,
1752
+ efficiencyRatioPct: r.efficiencyRatioPct ?? null,
1753
+ netChargeOffsToLoansPct: r.netChargeOffsToLoansPct ?? null,
1754
+ leverageRatioPct: r.leverageRatioPct ?? null,
1755
+ tier1RiskBasedCapitalRatioPct: r.tier1RiskBasedCapitalRatioPct ?? null,
1756
+ totalRiskBasedCapitalRatioPct: r.totalRiskBasedCapitalRatioPct ?? null,
1757
+ tier1CapitalUSD: r.tier1CapitalUSD ?? null,
1758
+ id: str(rec.ID),
1759
+ };
1760
+ }
1761
+
1762
+ // ─── risk-ratio disclosure notes ───────────────────────────────────
1763
+ // ★P3 + ★M1 — units in the key + the corrected CBLR honesty (does NOT promise every
1764
+ // shown 0 is real; a null capital ratio on a CBLR bank is a normal framework artifact).
1765
+ const RATIO_UNITS_NOTE =
1766
+ "Each *Pct field is an FDIC-published PERCENTAGE surfaced verbatim (ROA/ROE/margin/efficiency/capital ratios) — do NOT read it as a dollar amount and do NOT ×1000-scale it. tier1CapitalUSD is a DOLLAR amount (FDIC publishes it in $thousands; normalized here ×1,000). A null ratio means FDIC did not report that ratio for this bank/period — it is NOT 0% (never read a null ratio as 'no return / no capital'). Banks reporting under the Community Bank Leverage Ratio (CBLR) framework (cblrFramework:true) do NOT report the risk-based capital ratios: FDIC returns a literal 0 for the total risk-based ratio, which this tool maps to null for BOTH tier1RiskBasedCapitalRatioPct and totalRiskBasedCapitalRatioPct — a null risk-based capital ratio here is frequently a NORMAL framework artifact (read it alongside the populated leverageRatioPct), not a red flag or a real 0% capital reading. Ratios are surfaced exactly as FDIC computes them; none is recomputed.";
1767
+ const RATIO_NOT_DETERMINATION_NOTE =
1768
+ "FDIC risk ratios are reported regulatory metrics from the bank's Call Report, NOT a soundness rating, safety-and-soundness examination result, or failure prediction. A single-period ratio is a snapshot; read the time-series and cross-check the institution's condition (fdic_search_institutions for status, fdic_bank_failures for resolution history, fdic_institution_history for structural changes). FDIC keys on CERT, not SAM UEI/DUNS.";
1769
+ const RATIO_EMPTY_NOTE =
1770
+ "No financial report is on record for this CERT/period — this does NOT mean the bank is unsound or unrated; the CERT may be wrong, or the bank may not have filed for this period. Confirm the CERT via fdic_search_institutions.";
1771
+
1772
+ /**
1773
+ * FDIC counterparty RISK RATIOS for ONE institution by `cert` (`/banks/financials` —
1774
+ * the ALREADY-wired endpoint; NO new endpoint constant). Structured inputs: `cert`
1775
+ * (REQUIRED → CERT), optional `reportDate` (→ REPDTE, a YYYYMMDD quarter-end), plus
1776
+ * `limit`/`offset`/`sortBy` (allowlist {REPDTE,ROA,ROE,RBCRWAJ,EEFFR}, default REPDTE)/
1777
+ * `sortOrder` (default DESC → newest quarter first). Consumes the IDENTICAL fetch →
1778
+ * 3-envelope guard → pagination machinery as tools 1–5.
1779
+ *
1780
+ * HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1); the 3-envelope
1781
+ * drift-guard makes the ONLY honest empty `200 + total:0 + data:[]`, everything else
1782
+ * THROWS (P2); ★P3 percent ratios surfaced VERBATIM via num (null-never-0, NO scale, NO
1783
+ * recompute), the ONE $-amount (RBCT1J) via thousandsToUsd → tier1CapitalUSD; ★M1 the
1784
+ * CBLR risk-based capital ratios map to null (never the 0 sentinel) with a per-row
1785
+ * cblrFramework flag; a projected field absent from all records → fieldsUnavailable (B);
1786
+ * the snapshot build time is disclosed.
1787
+ */
1788
+ export async function riskRatios(args: {
1789
+ cert: number;
1790
+ reportDate?: number;
1791
+ limit?: number;
1792
+ offset?: number;
1793
+ sortBy?: string;
1794
+ sortOrder?: string;
1795
+ }): Promise<MetaBundle> {
1796
+ const limit = args.limit ?? 100;
1797
+ const offset = args.offset ?? 0;
1798
+ // Default REPDTE DESC (newest quarter first) when the server's Zod default did not
1799
+ // supply one (defensive — the server always defaults sortBy=REPDTE/DESC).
1800
+ const sort = sortParams(
1801
+ args.sortBy ?? "REPDTE",
1802
+ args.sortOrder ?? "DESC",
1803
+ RATIO_SORT_FIELDS,
1804
+ "financials",
1805
+ );
1806
+
1807
+ const params = new URLSearchParams();
1808
+ params.set("filters", buildRatioFilters({ cert: args.cert, reportDate: args.reportDate }));
1809
+ params.set("fields", RATIO_FIELDS);
1810
+ params.set("limit", String(limit));
1811
+ params.set("offset", String(offset));
1812
+ if (sort.sort_by) {
1813
+ params.set("sort_by", sort.sort_by);
1814
+ params.set("sort_order", sort.sort_order as string);
1815
+ }
1816
+ params.set("format", "json");
1817
+
1818
+ const body = await getFdic(ENDPOINT_FINANCIALS, params);
1819
+ const env = parseEnvelope(body);
1820
+ const records = env.records.map(mapRiskRatios);
1821
+ const returned = records.length;
1822
+ const totalAvailable = env.totalAvailable;
1823
+ const hasMore = offset + returned < totalAvailable;
1824
+ const nextOffset = hasMore ? offset + returned : null;
1825
+
1826
+ const filtersApplied: string[] = ["cert"];
1827
+ if (args.reportDate !== undefined) filtersApplied.push("reportDate");
1828
+ if (sort.sort_by) filtersApplied.push("sort");
1829
+
1830
+ const notes: string[] = [
1831
+ freshnessNote(env.indexName, env.indexCreated),
1832
+ RATIO_UNITS_NOTE,
1833
+ RATIO_NOT_DETERMINATION_NOTE,
1834
+ ];
1835
+ if (totalAvailable === 0) notes.push(RATIO_EMPTY_NOTE);
1836
+ const fu = fieldsUnavailable(env.records, RATIO_PROJECTION);
1837
+ if (fu.length > 0) {
1838
+ notes.push(
1839
+ `Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record in this result set — the affected values are surfaced as null (never fabricated); this can be a ratio FDIC does not publish for these institution(s)/period(s), or a schema change.`,
1840
+ );
1841
+ }
1842
+
1843
+ return withMeta(
1844
+ { cert: args.cert, ratios: records },
1845
+ {
1846
+ source: "api.fdic.gov/banks/financials (BankFind, keyless)",
1847
+ keylessMode: true,
1848
+ returned,
1849
+ totalAvailable,
1850
+ filtersApplied,
1851
+ filtersDropped: [],
1852
+ fieldsUnavailable: fu,
1853
+ pagination: { offset, limit, hasMore, nextOffset },
1854
+ notes,
1855
+ } satisfies Partial<ResponseMeta>,
1856
+ );
1857
+ }
1858
+
1859
+ // ═══════════════════════════════════════════════════════════════════
1860
+ // Tool 7: fdic_branch_deposits (ADR-0040) — the FDIC branch-deposit footprint
1861
+ // (`/banks/sod`, Summary of Deposits): the annual June-30 branch-office deposit
1862
+ // distribution ("where does this bank hold deposits, and how concentrated?"). ONE new
1863
+ // fixed endpoint constant `ENDPOINT_SOD = "sod"` on getFdic's `/banks/${endpoint}`
1864
+ // template (NO caller value on the path). Reuses the C116/C118-hardened adapter VERBATIM
1865
+ // (getFdic / parseEnvelope 3-envelope guard / filterTerm allowlist + Set.has + the C118
1866
+ // non-numeric quote / sortParams enum+Set.has / EXACT meta.total pagination / freshness /
1867
+ // thousandsToUsd / num / str), live-verified 2026-07-13:
1868
+ // ★S1 — the SOD filter-FIELD allowlist is {CERT, STALPBR, YEAR} (its OWN Set — NOT
1869
+ // FIN_FILTER_FIELDS). LOAD-BEARING: a bad /sod filter field silently returns
1870
+ // total:0 (a FALSE-empty, NOT a 400), so an un-allowlisted/mistyped/injected field
1871
+ // must be rejected BY CONSTRUCTION before the wire.
1872
+ // ★C118 — the state field STALPBR is non-numeric → filterTerm DOUBLE-QUOTES it
1873
+ // (Oregon `STALPBR:"OR"` is Lucene-operator-safe; bare `STALPBR:OR` → live HTTP 400).
1874
+ // ★P3 — DEPSUMBR (branch deposits, $thousands) → thousandsToUsd → depositsUSD
1875
+ // (null-guard BEFORE the ×1000); a GENUINE 0 stays 0, an absent value → null. YEAR
1876
+ // is a JSON integer via num; names/city/address/zip via str.
1877
+ // ★freshness — the /sod index is a DISTINCT ANNUAL snapshot (sod_*), far less fresh
1878
+ // than the quarterly /financials index — disclosed via freshnessNote + a snapshot note.
1879
+ // No PII — bank-BRANCH facility data (branch name/address/city/state/zip/deposits),
1880
+ // public commercial-bank infrastructure; no officer/personal-contact fields.
1881
+ // ═══════════════════════════════════════════════════════════════════
1882
+
1883
+ // Fixed endpoint constant — the TOOL chooses it; NO caller value on the path (SSRF core).
1884
+ const ENDPOINT_SOD = "sod";
1885
+
1886
+ // Fixed field projection on the wire (every field live-verified valid).
1887
+ const SOD_FIELDS = "CERT,NAMEFULL,BRNUM,NAMEBR,CITYBR,STALPBR,ZIPBR,ADDRESBR,DEPSUMBR,YEAR,ID";
1888
+ const SOD_PROJECTION = [
1889
+ "CERT",
1890
+ "NAMEFULL",
1891
+ "BRNUM",
1892
+ "NAMEBR",
1893
+ "CITYBR",
1894
+ "STALPBR",
1895
+ "ZIPBR",
1896
+ "ADDRESBR",
1897
+ "DEPSUMBR",
1898
+ "YEAR",
1899
+ "ID",
1900
+ ] as const;
1901
+
1902
+ // ★S1 — the SOD filter-FIELD allowlist (P4 belt-and-suspenders; LOAD-BEARING — a bad
1903
+ // /sod field is a silent total:0 false-empty, NOT a 400). CERT/YEAR numeric (bare),
1904
+ // STALPBR non-numeric (C118-quoted). Do NOT reuse FIN_FILTER_FIELDS ({CERT} — would
1905
+ // throw on STALPBR/YEAR). Exported for the fault fixture.
1906
+ const SOD_FILTER_FIELDS: ReadonlySet<string> = new Set(["CERT", "STALPBR", "YEAR"]);
1907
+ // sortBy allowlist (mirrors the server's Zod enum; a Set.has recheck in sortParams →
1908
+ // an unknown sort field is invalid_input BEFORE fetch; live `sort_by=NOTAFIELD` → 400).
1909
+ const SOD_SORT_FIELDS: ReadonlySet<string> = new Set(["YEAR", "DEPSUMBR"]);
1910
+
1911
+ export { SOD_FILTER_FIELDS };
1912
+
1913
+ /**
1914
+ * Build the /sod `filters` string — `cert`→`CERT:<int>` (numeric → bare), `state`→
1915
+ * `STALPBR:"<code>"` (★C118 non-numeric → DOUBLE-QUOTED; Oregon `STALPBR:"OR"` is
1916
+ * operator-safe), `year`→`YEAR:<int>` (numeric → bare). All fields on SOD_FILTER_FIELDS
1917
+ * (★S1). Terms joined with ` AND `. Returns "" when there is no structured filter clause.
1918
+ * Exported for the fault fixtures.
1919
+ */
1920
+ export function buildSodFilters(inp: { cert?: number; state?: string; year?: number }): string {
1921
+ const terms: string[] = [];
1922
+ if (inp.cert !== undefined) terms.push(filterTerm("CERT", String(inp.cert), SOD_FILTER_FIELDS));
1923
+ if (inp.state !== undefined) terms.push(filterTerm("STALPBR", inp.state, SOD_FILTER_FIELDS));
1924
+ if (inp.year !== undefined) terms.push(filterTerm("YEAR", String(inp.year), SOD_FILTER_FIELDS));
1925
+ return terms.join(" AND ");
1926
+ }
1927
+
1928
+ export type FdicBranchDeposit = {
1929
+ cert: number | null;
1930
+ institutionName: string | null;
1931
+ branchNumber: number | null;
1932
+ branchName: string | null;
1933
+ city: string | null;
1934
+ state: string | null;
1935
+ zip: string | null;
1936
+ address: string | null;
1937
+ depositsUSD: number | null;
1938
+ year: number | null;
1939
+ id: string | null;
1940
+ };
1941
+
1942
+ function mapBranchDeposit(rec: Record<string, unknown>): FdicBranchDeposit {
1943
+ return {
1944
+ cert: num(rec.CERT),
1945
+ institutionName: str(rec.NAMEFULL),
1946
+ branchNumber: num(rec.BRNUM),
1947
+ branchName: str(rec.NAMEBR),
1948
+ city: str(rec.CITYBR),
1949
+ state: str(rec.STALPBR),
1950
+ zip: str(rec.ZIPBR),
1951
+ address: str(rec.ADDRESBR),
1952
+ // ★P3 — DEPSUMBR is $thousands → whole USD ×1000 (null-guard BEFORE the multiply);
1953
+ // a GENUINE 0 stays 0, an absent value → null (never 0).
1954
+ depositsUSD: thousandsToUsd(rec.DEPSUMBR),
1955
+ year: num(rec.YEAR),
1956
+ id: str(rec.ID),
1957
+ };
1958
+ }
1959
+
1960
+ // ─── /sod disclosure notes ─────────────────────────────────────────
1961
+ const SOD_UNITS_NOTE =
1962
+ "depositsUSD is FDIC's DEPSUMBR (branch-office deposits), published in $thousands and normalized here to whole USD (×1,000). A real 0 stays 0; an absent value is null (never 0).";
1963
+ const SOD_SNAPSHOT_NOTE =
1964
+ "Summary of Deposits is an ANNUAL June-30 branch-office snapshot (a DISTINCT index, far less fresh than the quarterly /financials data). Branch name/city/address are shown per row but the endpoint filters only by cert/state/year — resolve a bank's CERT via fdic_search_institutions.";
1965
+ const SOD_EMPTY_NOTE =
1966
+ "No Summary-of-Deposits branch records match — the bank may report no branches for this year, or the CERT/state/year filter may not match; SOD is an annual June-30 snapshot. Confirm the CERT via fdic_search_institutions.";
1967
+
1968
+ /**
1969
+ * FDIC branch-deposit footprint (`/banks/sod`, Summary of Deposits). Structured inputs
1970
+ * (all optional, AND-combined; ≥1 recommended): `cert` (→ CERT), `state` (→ STALPBR,
1971
+ * ★C118-quoted), `year` (→ YEAR), plus `limit`/`offset`/`sortBy` (allowlist
1972
+ * {YEAR,DEPSUMBR}, default YEAR)/`sortOrder` (default DESC → newest snapshot first).
1973
+ * Consumes the IDENTICAL fetch → 3-envelope guard → pagination machinery as tools 1–6.
1974
+ *
1975
+ * HONESTY: EXACT `meta.total` → exact totalAvailable + hasMore (P1; live: CERT 10004 →
1976
+ * 74, STALPBR:"OR" → 31093); the 3-envelope drift-guard makes the ONLY honest empty
1977
+ * `200 + total:0 + data:[]`, everything else THROWS (P2); ★the /sod false-empty landmine
1978
+ * (a bad filter field → silent total:0) is neutralized by the S1 allowlist-by-
1979
+ * construction; DEPSUMBR is $thousands → whole USD ×1000 null-never-0 (P3; a genuine 0
1980
+ * stays 0, absent → null); a projected field absent from all records → fieldsUnavailable
1981
+ * (B); the DISTINCT annual snapshot build time is disclosed.
1982
+ */
1983
+ export async function branchDeposits(args: {
1984
+ cert?: number;
1985
+ state?: string;
1986
+ year?: number;
1987
+ limit?: number;
1988
+ offset?: number;
1989
+ sortBy?: string;
1990
+ sortOrder?: string;
1991
+ }): Promise<MetaBundle> {
1992
+ const limit = args.limit ?? 100;
1993
+ const offset = args.offset ?? 0;
1994
+ // Default YEAR DESC (newest snapshot first) when the server's Zod default did not
1995
+ // supply one (defensive — the server always defaults sortBy=YEAR/DESC).
1996
+ const sort = sortParams(args.sortBy ?? "YEAR", args.sortOrder ?? "DESC", SOD_SORT_FIELDS, "sod");
1997
+
1998
+ const filters = buildSodFilters({ cert: args.cert, state: args.state, year: args.year });
1999
+
2000
+ const params = new URLSearchParams();
2001
+ if (filters) params.set("filters", filters);
2002
+ params.set("fields", SOD_FIELDS);
2003
+ params.set("limit", String(limit));
2004
+ params.set("offset", String(offset));
2005
+ if (sort.sort_by) {
2006
+ params.set("sort_by", sort.sort_by);
2007
+ params.set("sort_order", sort.sort_order as string);
2008
+ }
2009
+ params.set("format", "json");
2010
+
2011
+ const body = await getFdic(ENDPOINT_SOD, params);
2012
+ const env = parseEnvelope(body);
2013
+ const records = env.records.map(mapBranchDeposit);
2014
+ const returned = records.length;
2015
+ const totalAvailable = env.totalAvailable;
2016
+ const hasMore = offset + returned < totalAvailable;
2017
+ const nextOffset = hasMore ? offset + returned : null;
2018
+
2019
+ const filtersApplied: string[] = [];
2020
+ if (args.cert !== undefined) filtersApplied.push("cert");
2021
+ if (args.state !== undefined) filtersApplied.push("state");
2022
+ if (args.year !== undefined) filtersApplied.push("year");
2023
+ if (sort.sort_by) filtersApplied.push("sort");
2024
+
2025
+ const notes: string[] = [
2026
+ freshnessNote(env.indexName, env.indexCreated),
2027
+ SOD_UNITS_NOTE,
2028
+ SOD_SNAPSHOT_NOTE,
2029
+ ];
2030
+ if (totalAvailable === 0) notes.push(SOD_EMPTY_NOTE);
2031
+ const fu = fieldsUnavailable(env.records, SOD_PROJECTION);
2032
+ if (fu.length > 0) {
2033
+ notes.push(
2034
+ `Requested field(s) ${fu.join(", ")} were not returned by FDIC for any record — possible schema drift / rename.`,
2035
+ );
2036
+ }
2037
+
2038
+ return withMeta(
2039
+ { branches: records },
2040
+ {
2041
+ source: "api.fdic.gov/banks/sod (BankFind Summary of Deposits, keyless)",
2042
+ keylessMode: true,
2043
+ returned,
2044
+ totalAvailable,
2045
+ filtersApplied,
2046
+ filtersDropped: [],
2047
+ fieldsUnavailable: fu,
2048
+ pagination: { offset, limit, hasMore, nextOffset },
2049
+ notes,
2050
+ } satisfies Partial<ResponseMeta>,
2051
+ );
2052
+ }