@cliwant/mcp-sam-gov 0.2.1 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (228) hide show
  1. package/LICENSE +21 -21
  2. package/README.ja.md +48 -16
  3. package/README.ko.md +48 -16
  4. package/README.md +279 -67
  5. package/dist/attachments.d.ts +63 -0
  6. package/dist/attachments.d.ts.map +1 -0
  7. package/dist/attachments.js +576 -0
  8. package/dist/attachments.js.map +1 -0
  9. package/dist/bls.d.ts +267 -0
  10. package/dist/bls.d.ts.map +1 -0
  11. package/dist/bls.js +1549 -0
  12. package/dist/bls.js.map +1 -0
  13. package/dist/cache.d.ts +42 -0
  14. package/dist/cache.d.ts.map +1 -0
  15. package/dist/cache.js +64 -0
  16. package/dist/cache.js.map +1 -0
  17. package/dist/census.d.ts +182 -0
  18. package/dist/census.d.ts.map +1 -0
  19. package/dist/census.js +576 -0
  20. package/dist/census.js.map +1 -0
  21. package/dist/ckan.d.ts +141 -0
  22. package/dist/ckan.d.ts.map +1 -0
  23. package/dist/ckan.js +393 -0
  24. package/dist/ckan.js.map +1 -0
  25. package/dist/clinicaltrials.d.ts +180 -0
  26. package/dist/clinicaltrials.d.ts.map +1 -0
  27. package/dist/clinicaltrials.js +730 -0
  28. package/dist/clinicaltrials.js.map +1 -0
  29. package/dist/cms.d.ts +140 -0
  30. package/dist/cms.d.ts.map +1 -0
  31. package/dist/cms.js +482 -0
  32. package/dist/cms.js.map +1 -0
  33. package/dist/coerce.d.ts +32 -0
  34. package/dist/coerce.d.ts.map +1 -0
  35. package/dist/coerce.js +50 -0
  36. package/dist/coerce.js.map +1 -0
  37. package/dist/datagov-catalog.d.ts +84 -0
  38. package/dist/datagov-catalog.d.ts.map +1 -0
  39. package/dist/datagov-catalog.js +233 -0
  40. package/dist/datagov-catalog.js.map +1 -0
  41. package/dist/datagov.d.ts +146 -0
  42. package/dist/datagov.d.ts.map +1 -0
  43. package/dist/datagov.js +689 -0
  44. package/dist/datagov.js.map +1 -0
  45. package/dist/datagovKey.d.ts +36 -0
  46. package/dist/datagovKey.d.ts.map +1 -0
  47. package/dist/datagovKey.js +60 -0
  48. package/dist/datagovKey.js.map +1 -0
  49. package/dist/datasource.d.ts +313 -0
  50. package/dist/datasource.d.ts.map +1 -0
  51. package/dist/datasource.js +551 -0
  52. package/dist/datasource.js.map +1 -0
  53. package/dist/disclosure.d.ts +55 -0
  54. package/dist/disclosure.d.ts.map +1 -0
  55. package/dist/disclosure.js +57 -0
  56. package/dist/disclosure.js.map +1 -0
  57. package/dist/ecfr.d.ts +4 -2
  58. package/dist/ecfr.d.ts.map +1 -1
  59. package/dist/ecfr.js +92 -17
  60. package/dist/ecfr.js.map +1 -1
  61. package/dist/echo.d.ts +143 -0
  62. package/dist/echo.d.ts.map +1 -0
  63. package/dist/echo.js +424 -0
  64. package/dist/echo.js.map +1 -0
  65. package/dist/edgar.d.ts +377 -0
  66. package/dist/edgar.d.ts.map +1 -0
  67. package/dist/edgar.js +2418 -0
  68. package/dist/edgar.js.map +1 -0
  69. package/dist/errors.d.ts +102 -0
  70. package/dist/errors.d.ts.map +1 -0
  71. package/dist/errors.js +247 -0
  72. package/dist/errors.js.map +1 -0
  73. package/dist/fac.d.ts +180 -0
  74. package/dist/fac.d.ts.map +1 -0
  75. package/dist/fac.js +416 -0
  76. package/dist/fac.js.map +1 -0
  77. package/dist/far.d.ts +170 -0
  78. package/dist/far.d.ts.map +1 -0
  79. package/dist/far.js +804 -0
  80. package/dist/far.js.map +1 -0
  81. package/dist/fdic.d.ts +599 -0
  82. package/dist/fdic.d.ts.map +1 -0
  83. package/dist/fdic.js +1624 -0
  84. package/dist/fdic.js.map +1 -0
  85. package/dist/federal-register.d.ts +139 -2
  86. package/dist/federal-register.d.ts.map +1 -1
  87. package/dist/federal-register.js +432 -15
  88. package/dist/federal-register.js.map +1 -1
  89. package/dist/fema.d.ts +181 -0
  90. package/dist/fema.d.ts.map +1 -0
  91. package/dist/fema.js +436 -0
  92. package/dist/fema.js.map +1 -0
  93. package/dist/fpds.d.ts +108 -0
  94. package/dist/fpds.d.ts.map +1 -0
  95. package/dist/fpds.js +519 -0
  96. package/dist/fpds.js.map +1 -0
  97. package/dist/gao.d.ts +64 -0
  98. package/dist/gao.d.ts.map +1 -0
  99. package/dist/gao.js +640 -0
  100. package/dist/gao.js.map +1 -0
  101. package/dist/govinfo.d.ts +111 -0
  102. package/dist/govinfo.d.ts.map +1 -0
  103. package/dist/govinfo.js +422 -0
  104. package/dist/govinfo.js.map +1 -0
  105. package/dist/grants.d.ts +27 -4
  106. package/dist/grants.d.ts.map +1 -1
  107. package/dist/grants.js +114 -11
  108. package/dist/grants.js.map +1 -1
  109. package/dist/gsa-csv.d.ts +249 -0
  110. package/dist/gsa-csv.d.ts.map +1 -0
  111. package/dist/gsa-csv.js +784 -0
  112. package/dist/gsa-csv.js.map +1 -0
  113. package/dist/integrity.d.ts +212 -0
  114. package/dist/integrity.d.ts.map +1 -0
  115. package/dist/integrity.js +707 -0
  116. package/dist/integrity.js.map +1 -0
  117. package/dist/meta.d.ts +165 -0
  118. package/dist/meta.d.ts.map +1 -0
  119. package/dist/meta.js +162 -0
  120. package/dist/meta.js.map +1 -0
  121. package/dist/nih.d.ts +117 -0
  122. package/dist/nih.d.ts.map +1 -0
  123. package/dist/nih.js +291 -0
  124. package/dist/nih.js.map +1 -0
  125. package/dist/nppes.d.ts +157 -0
  126. package/dist/nppes.d.ts.map +1 -0
  127. package/dist/nppes.js +648 -0
  128. package/dist/nppes.js.map +1 -0
  129. package/dist/nsf.d.ts +176 -0
  130. package/dist/nsf.d.ts.map +1 -0
  131. package/dist/nsf.js +554 -0
  132. package/dist/nsf.js.map +1 -0
  133. package/dist/nvd.d.ts +176 -0
  134. package/dist/nvd.d.ts.map +1 -0
  135. package/dist/nvd.js +912 -0
  136. package/dist/nvd.js.map +1 -0
  137. package/dist/ofac.d.ts +205 -0
  138. package/dist/ofac.d.ts.map +1 -0
  139. package/dist/ofac.js +919 -0
  140. package/dist/ofac.js.map +1 -0
  141. package/dist/pricing.d.ts +110 -0
  142. package/dist/pricing.d.ts.map +1 -0
  143. package/dist/pricing.js +843 -0
  144. package/dist/pricing.js.map +1 -0
  145. package/dist/sam-gov/client.d.ts +60 -2
  146. package/dist/sam-gov/client.d.ts.map +1 -1
  147. package/dist/sam-gov/client.js +320 -54
  148. package/dist/sam-gov/client.js.map +1 -1
  149. package/dist/sam-gov/index.d.ts +1 -1
  150. package/dist/sam-gov/index.d.ts.map +1 -1
  151. package/dist/sam-gov/index.js +1 -1
  152. package/dist/sam-gov/index.js.map +1 -1
  153. package/dist/sam-gov/types.d.ts +24 -0
  154. package/dist/sam-gov/types.d.ts.map +1 -1
  155. package/dist/sba.d.ts +72 -0
  156. package/dist/sba.d.ts.map +1 -0
  157. package/dist/sba.js +281 -0
  158. package/dist/sba.js.map +1 -0
  159. package/dist/server.d.ts +14 -2
  160. package/dist/server.d.ts.map +1 -1
  161. package/dist/server.js +3897 -295
  162. package/dist/server.js.map +1 -1
  163. package/dist/snapshot.d.ts +98 -0
  164. package/dist/snapshot.d.ts.map +1 -0
  165. package/dist/snapshot.js +146 -0
  166. package/dist/snapshot.js.map +1 -0
  167. package/dist/socrata.d.ts +157 -0
  168. package/dist/socrata.d.ts.map +1 -0
  169. package/dist/socrata.js +448 -0
  170. package/dist/socrata.js.map +1 -0
  171. package/dist/treasury.d.ts +143 -0
  172. package/dist/treasury.d.ts.map +1 -0
  173. package/dist/treasury.js +436 -0
  174. package/dist/treasury.js.map +1 -0
  175. package/dist/usaspending.d.ts +260 -65
  176. package/dist/usaspending.d.ts.map +1 -1
  177. package/dist/usaspending.js +1664 -228
  178. package/dist/usaspending.js.map +1 -1
  179. package/dist/usitc.d.ts +142 -0
  180. package/dist/usitc.d.ts.map +1 -0
  181. package/dist/usitc.js +339 -0
  182. package/dist/usitc.js.map +1 -0
  183. package/package.json +24 -2
  184. package/src/attachments.ts +652 -0
  185. package/src/bls.ts +1943 -0
  186. package/src/cache.ts +73 -0
  187. package/src/census.ts +735 -0
  188. package/src/ckan.ts +495 -0
  189. package/src/clinicaltrials.ts +923 -0
  190. package/src/cms.ts +634 -0
  191. package/src/coerce.ts +47 -0
  192. package/src/datagov-catalog.ts +296 -0
  193. package/src/datagov.ts +907 -0
  194. package/src/datagovKey.ts +68 -0
  195. package/src/datasource.ts +721 -0
  196. package/src/disclosure.ts +61 -0
  197. package/src/ecfr.ts +231 -127
  198. package/src/echo.ts +496 -0
  199. package/src/edgar.ts +3014 -0
  200. package/src/errors.ts +303 -0
  201. package/src/fac.ts +529 -0
  202. package/src/far.ts +1007 -0
  203. package/src/fdic.ts +2052 -0
  204. package/src/federal-register.ts +706 -191
  205. package/src/fema.ts +541 -0
  206. package/src/fpds.ts +620 -0
  207. package/src/gao.ts +744 -0
  208. package/src/govinfo.ts +497 -0
  209. package/src/grants.ts +290 -155
  210. package/src/gsa-csv.ts +992 -0
  211. package/src/integrity.ts +928 -0
  212. package/src/meta.ts +292 -0
  213. package/src/nih.ts +375 -0
  214. package/src/nppes.ts +834 -0
  215. package/src/nsf.ts +706 -0
  216. package/src/nvd.ts +1124 -0
  217. package/src/ofac.ts +1166 -0
  218. package/src/pricing.ts +1075 -0
  219. package/src/sam-gov/client.ts +345 -63
  220. package/src/sam-gov/index.ts +5 -1
  221. package/src/sam-gov/types.ts +22 -0
  222. package/src/sba.ts +357 -0
  223. package/src/server.ts +4559 -327
  224. package/src/snapshot.ts +192 -0
  225. package/src/socrata.ts +532 -0
  226. package/src/treasury.ts +575 -0
  227. package/src/usaspending.ts +2680 -925
  228. package/src/usitc.ts +420 -0
package/src/far.ts ADDED
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * FAR / DFARS clause lookup (keyless) — the authoritative clause text + its
3
+ * prescription, from the eCFR **versioner full** endpoint.
4
+ *
5
+ * Why this exists (and why NOT `ecfr_search`)
6
+ * -------------------------------------------
7
+ * The shipped full-text `ecfr_search` mis-ranks EXACT clause numbers: a bare
8
+ * `52.212-4` query returns GSAM **552**.212-4 above the real FAR 52.212-4
9
+ * (doc-09 §1.2). A proposal writer needs the AUTHORITATIVE clause text AND the
10
+ * rule that says WHEN it applies ("As prescribed in …") — the exact pair. The
11
+ * clean path is the versioner full endpoint, which `src/ecfr.ts` does not call:
12
+ *
13
+ * GET /api/versioner/v1/full/{date}/title-48.xml?section={clause}
14
+ *
15
+ * keyless, HTTP 200 (~40 KB XML) for a real clause, clean HTTP 404
16
+ * `{"error":"No matching content found."}` for an absent one. Title 48 = FAR.
17
+ * `{date}` defaults to Title 48 `up_to_date_as_of` (from listTitles, cached).
18
+ *
19
+ * TRUTHFULNESS invariants (a reviewer WILL try to break these):
20
+ * - A DOWN/failing eCFR service must NEVER read as "clause not found": only a
21
+ * genuine HTTP 404 maps to `not_found`; any other fetch error propagates
22
+ * with fetchWithRetry's classification (retryable 5xx/network/etc.).
23
+ * - A genuinely-absent clause is `not_found` (retryable:false), NEVER a fake
24
+ * empty clause. Clause text is never silently dropped or fabricated.
25
+ * - A prescription-section fetch failure is NON-FATAL: `prescription:null` +
26
+ * disclosed in `_meta.notes`; it never crashes the clause result.
27
+ * - `farOverhaulRisk` carries NO fabricated FAR-case numbers/dates — only the
28
+ * fixed structural caveat + the real authoritative-list / deviation URLs.
29
+ *
30
+ * Self-contained: imports `getText` from ./datasource.js (the shared XML/text
31
+ * fetch port, ADR-0013) + ToolErrorCarrier from ./errors.js, memoize from
32
+ * ./cache.js, listTitles from ./ecfr.js, and withMeta from ./meta.js — the
33
+ * versioner XML parse lives here (ecfr.ts's stripHtml is private and stays
34
+ * private).
35
+ */
36
+
37
+ import { ToolErrorCarrier } from "./errors.js";
38
+ import { getText } from "./datasource.js";
39
+ import { memoize } from "./cache.js";
40
+ import { listTitles, search as ecfrSearch } from "./ecfr.js";
41
+ import { withMeta } from "./meta.js";
42
+
43
+ const ECFR = "https://www.ecfr.gov/api";
44
+
45
+ /** The regulation family a clause number belongs to (from its prefix). */
46
+ type Regulation = "FAR" | "DFARS" | "GSAM" | "other";
47
+
48
+ /**
49
+ * Fetch a versioner-full XML document as text via the shared `getText` port
50
+ * (ADR-0013). This thin wrapper owns the far-specific, path-bearing `ecfr:…`
51
+ * label derivation (kept LOCAL — NOT hoisted into the port), then delegates:
52
+ * `getText` retries via fetchWithRetry (retry defaults true) and returns the raw
53
+ * body. The versioner endpoint serves `title-48.xml`. fetchWithRetry throws a
54
+ * classified ToolErrorCarrier on any non-2xx (404 → not_found, 5xx →
55
+ * upstream_unavailable, network → upstream_unavailable), which callers here
56
+ * either map (404 on the CLAUSE) or let propagate.
57
+ */
58
+ async function fetchText(url: string): Promise<string> {
59
+ return getText(url, {
60
+ label: `ecfr:${url.split("/api/")[1] ?? url}`,
61
+ headers: { Accept: "application/xml" },
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Strip XML tags → clean, paragraph-preserving plain text. The versioner body
67
+ * is block XML (`<P>`, `<HD1>`, `<EXTRACT>`, `<I>`…); we drop the tags but keep
68
+ * paragraph boundaries as spaces so the clause reads as continuous prose, then
69
+ * decode the handful of numeric/entity refs the feed uses (—, &, ", ', <, >).
70
+ */
71
+ function stripXml(s: string): string {
72
+ return s
73
+ // Block-level closers become a space so paragraphs don't run together.
74
+ .replace(/<\/(P|HD1|HEAD|EXTRACT|CITA|EDNOTE|PSPACE|HED|DIV8|LI)>/gi, " ")
75
+ // Drop every remaining tag.
76
+ .replace(/<[^>]+>/g, " ")
77
+ // Decode the entities the eCFR XML actually emits.
78
+ .replace(/&#8212;|&mdash;/gi, "—")
79
+ .replace(/&#8211;|&ndash;/gi, "–")
80
+ .replace(/&#8217;|&rsquo;/gi, "’")
81
+ .replace(/&#8220;|&ldquo;/gi, "“")
82
+ .replace(/&#8221;|&rdquo;/gi, "”")
83
+ .replace(/&quot;/gi, '"')
84
+ .replace(/&apos;/gi, "'")
85
+ .replace(/&amp;/gi, "&")
86
+ .replace(/&lt;/gi, "<")
87
+ .replace(/&gt;/gi, ">")
88
+ // Collapse whitespace.
89
+ .replace(/\s+/g, " ")
90
+ .trim();
91
+ }
92
+
93
+ /**
94
+ * Normalize a raw clauseNumber to its bare `NN.NNN-N` / `NNN.NNN-NNNN` core by
95
+ * stripping ONLY a leading `FAR`/`DFARS` prefix (case-insensitive) and
96
+ * surrounding whitespace. It DOES NOT strip embedded characters: doing so would
97
+ * fabricate a plausible-but-wrong clause from garbage (e.g. `52.212-4extra5` →
98
+ * `52.212-45`, a DIFFERENT real clause) that then passes CLAUSE_RE and fetches a
99
+ * silently-wrong answer. By leaving embedded/trailing junk in place, a non-clause
100
+ * input fails CLAUSE_RE below and farClauseLookup throws `invalid_input` — exactly
101
+ * as the server Zod boundary (regex `^\s*(?:d?far[s]?\b[\s.:#-]*)?\d{1,3}\.\d{3,4}-\d{1,4}\s*$`)
102
+ * already rejects it, so far.ts is safe called standalone. The legit shapes
103
+ * (`52.212-4`, `FAR 52.212-4`, `DFARS 252.204-7012`, ` 52.212-4 `) still collapse
104
+ * to the bare clause and succeed.
105
+ */
106
+ function normalizeClauseNumber(raw: string): string {
107
+ return (raw ?? "")
108
+ .trim()
109
+ // Strip ONLY the leading regulation prefix (FAR 52.212-4 / DFARS 252.204-7012)
110
+ // and its separator; NOTHING else is removed, so embedded garbage survives to
111
+ // be rejected by CLAUSE_RE (never mangled into a valid-looking clause).
112
+ .replace(/^\s*(?:d?far[s]?)\b[\s.:#-]*/i, "")
113
+ .trim();
114
+ }
115
+
116
+ const CLAUSE_RE = /^\d{1,3}\.\d{3,4}-\d{1,4}$/;
117
+
118
+ /** Regulation family from the clause-number prefix (deterministic, no fetch). */
119
+ function regulationFor(clauseNumber: string): Regulation {
120
+ if (/^252\./.test(clauseNumber)) return "DFARS";
121
+ if (/^2\d\d\./.test(clauseNumber)) return "DFARS";
122
+ if (/^552\./.test(clauseNumber)) return "GSAM";
123
+ if (/^52\./.test(clauseNumber)) return "FAR";
124
+ return "other";
125
+ }
126
+
127
+ /**
128
+ * The RFO (Revolutionary FAR Overhaul) currency caveat — an ALWAYS-PRESENT
129
+ * structural flag, NOT a per-clause claim. eCFR reflects the CODIFIED FAR only;
130
+ * the RFO is replacing FAR parts via agency class deviations that may not appear
131
+ * in eCFR, so a clause can be current in the CFR yet operationally superseded.
132
+ *
133
+ * This is the HONEST design (doc-09 §3 baked unverified FAR-case numbers/dates
134
+ * as [가설] — those go stale/wrong). We ship the never-stale-wrong version: no
135
+ * fabricated specifics, only the fixed caveat + the real authoritative-list and
136
+ * deviation URLs (all VERIFIED HTTP 200, 2026-07-04). `appliesTo` scopes the
137
+ * caveat to the clause's own regulation family.
138
+ */
139
+ function buildFarOverhaulRisk(regulation: Regulation) {
140
+ return {
141
+ note:
142
+ "eCFR reflects the CODIFIED FAR only. The Revolutionary FAR Overhaul (RFO) is actively replacing FAR parts via agency class deviations that may NOT appear in eCFR — so this clause text can be technically current in the CFR yet operationally superseded. Verify the controlling deviation before relying on it.",
143
+ authoritativeList: "https://www.acquisition.gov/far-overhaul",
144
+ deviationSources: [
145
+ "https://www.acquisition.gov/far-overhaul",
146
+ "https://www.acquisition.gov/dfars",
147
+ "https://www.acq.osd.mil/dpap/dars/",
148
+ ],
149
+ appliesTo: regulation,
150
+ };
151
+ }
152
+
153
+ /** Title 48 currency metadata, cached (titles.json changes infrequently). */
154
+ async function title48Currency(): Promise<{
155
+ upToDateAsOf: string | null;
156
+ latestAmendedOn: string | null;
157
+ }> {
158
+ return memoize("far:title48-currency", async () => {
159
+ const { titles } = await listTitles();
160
+ const t48 = titles.find((t) => t.number === 48);
161
+ return {
162
+ upToDateAsOf: t48?.upToDateAsOf ?? null,
163
+ latestAmendedOn: t48?.latestAmendedOn ?? null,
164
+ };
165
+ });
166
+ }
167
+
168
+ /** Extract the first `<HEAD>…</HEAD>` inner text (tags stripped). */
169
+ function firstHead(xml: string): string | null {
170
+ const m = xml.match(/<HEAD>([\s\S]*?)<\/HEAD>/i);
171
+ if (!m || m[1] === undefined) return null;
172
+ const h = stripXml(m[1]);
173
+ return h.length > 0 ? h : null;
174
+ }
175
+
176
+ /**
177
+ * Does this body look like a REAL eCFR Title-48 section, vs an empty body, a
178
+ * CDN/WAF HTML interstitial, or a truncated proxy response? (Defect-2 guard.)
179
+ * Real versioner section XML carries an uppercase `<HEAD>…</HEAD>` plus
180
+ * substantive text. The `<HEAD>` test is CASE-SENSITIVE on purpose: an HTML
181
+ * challenge page uses lowercase `<head>`, and empty/truncated bodies carry
182
+ * neither — so a hollow 200 fails this check and is refused rather than parsed
183
+ * into a fake `complete:true` clause.
184
+ */
185
+ function looksLikeSectionXml(xml: string): boolean {
186
+ return /<HEAD>/.test(xml) && stripXml(xml).length >= 20;
187
+ }
188
+
189
+ /**
190
+ * Fetch + parse ONE Title-48 section as a prescription reference (its heading +
191
+ * stripped text). NON-FATAL by contract: returns null on ANY failure so a
192
+ * prescription problem never sinks the clause result. Memoized by URL.
193
+ */
194
+ async function fetchPrescription(
195
+ baseSection: string,
196
+ asOfDate: string,
197
+ ): Promise<{ section: string; heading: string | null; text: string } | null> {
198
+ const url = `${ECFR}/versioner/v1/full/${asOfDate}/title-48.xml?section=${baseSection}`;
199
+ try {
200
+ const xml = await memoize(`far:section:${asOfDate}:${baseSection}`, async () => {
201
+ const body = await fetchText(url);
202
+ // A hollow/interstitial 200 is a fetch FAILURE, not an empty prescription
203
+ // (Defect-2, non-fatal path). Throw so it is NOT cached and becomes null.
204
+ if (!looksLikeSectionXml(body))
205
+ throw new Error("non-section prescription body");
206
+ return body;
207
+ });
208
+ const heading = firstHead(xml);
209
+ const text = stripXml(xml);
210
+ return { section: baseSection, heading, text };
211
+ } catch {
212
+ // Any failure (404/5xx/network/parse/hollow-body) → null; caller discloses it.
213
+ return null;
214
+ }
215
+ }
216
+
217
+ export async function farClauseLookup(args: {
218
+ clauseNumber: string;
219
+ includePrescription?: boolean;
220
+ asOfDate?: string;
221
+ }) {
222
+ const clauseNumber = normalizeClauseNumber(args.clauseNumber ?? "");
223
+ const includePrescription = args.includePrescription ?? true;
224
+
225
+ // Defense-in-depth: the server Zod schema already rejects a non-matching
226
+ // clauseNumber, but guard here too so far.ts is safe called directly.
227
+ if (!CLAUSE_RE.test(clauseNumber)) {
228
+ throw new ToolErrorCarrier({
229
+ kind: "invalid_input",
230
+ message: `Invalid FAR/DFARS clause number '${args.clauseNumber}'. Expected a clause like 52.212-4, 252.204-7012, or 52.204-25 (optionally prefixed 'FAR'/'DFARS').`,
231
+ retryable: false,
232
+ upstreamEndpoint: "ecfr:versioner/v1/full/title-48",
233
+ });
234
+ }
235
+
236
+ const regulation = regulationFor(clauseNumber);
237
+
238
+ // asOfDate defaults to Title 48's up_to_date_as_of (cached).
239
+ const currency = await title48Currency();
240
+ const asOfDate = args.asOfDate ?? currency.upToDateAsOf ?? "";
241
+ // Guard (Defect 1): never query a blank/invalid date. If currency could NOT be
242
+ // resolved (titles.json returns 200 but Title 48 — or its up_to_date_as_of —
243
+ // is missing/renamed → upToDateAsOf:null, WITHOUT throwing) AND the caller gave
244
+ // no asOfDate, asOfDate is "". The versioner 404s on a blank-date URL, and that
245
+ // 404 would be mislabeled "clause not found" — a lie about a real, existing
246
+ // clause. This is a currency-RESOLUTION failure, not an absent clause.
247
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(asOfDate)) {
248
+ throw new ToolErrorCarrier({
249
+ kind: "schema_drift",
250
+ message: `Could not resolve Title 48's current codification date from the eCFR titles endpoint (up_to_date_as_of unavailable) and no asOfDate was supplied. Refusing to query a blank date — the versioner would return HTTP 404, which must NOT be reported as a missing clause. Retry shortly, or pass an explicit asOfDate (YYYY-MM-DD).`,
251
+ retryable: true,
252
+ upstreamEndpoint: "ecfr:versioner/v1/titles.json",
253
+ });
254
+ }
255
+ const isCurrent =
256
+ currency.upToDateAsOf !== null && asOfDate === currency.upToDateAsOf;
257
+
258
+ // Guard (Defect 3): a VALID-format asOfDate that is AFTER the latest eCFR
259
+ // codification has no versioner snapshot — the versioner 404s, and the generic
260
+ // not-found path below would mislabel it "clause not found (the clause number
261
+ // may be wrong, reserved, or removed)". That is a LIE about a real, current
262
+ // clause: the problem is the DATE (past the latest edition), not the clause.
263
+ // Common trigger: a caller infers asOfDate = today when today > up_to_date_as_of
264
+ // (observed in dogfood). Fail with an honest, actionable message and
265
+ // `invalid_input` (fix the date) rather than not_found (clause absent).
266
+ if (
267
+ currency.upToDateAsOf !== null &&
268
+ /^\d{4}-\d{2}-\d{2}$/.test(currency.upToDateAsOf) &&
269
+ asOfDate > currency.upToDateAsOf
270
+ ) {
271
+ throw new ToolErrorCarrier({
272
+ kind: "invalid_input",
273
+ message: `eCFR has no Title 48 codification as of ${asOfDate} — the latest available codification is ${currency.upToDateAsOf}. FAR clause ${clauseNumber} is NOT missing or removed; there is simply no eCFR snapshot for a date past the latest edition. Omit asOfDate to use the latest, or pass a date on or before ${currency.upToDateAsOf}.`,
274
+ retryable: false,
275
+ upstreamEndpoint: "ecfr:versioner/v1/full/title-48",
276
+ });
277
+ }
278
+
279
+ // ── Fetch the CLAUSE XML (memoized by URL). ─────────────────────────────
280
+ const clauseUrl = `${ECFR}/versioner/v1/full/${asOfDate}/title-48.xml?section=${clauseNumber}`;
281
+ let xml: string;
282
+ try {
283
+ xml = await memoize(`far:section:${asOfDate}:${clauseNumber}`, async () => {
284
+ const body = await fetchText(clauseUrl);
285
+ // Guard (Defect 2): a 200 with an empty body, a CDN/WAF HTML interstitial,
286
+ // or a truncated proxy response must NOT be parsed into a hollow
287
+ // `complete:true` clause (heading/text empty, yet ok:true). Require real
288
+ // Title-48 section XML. Throwing HERE (inside the memoize producer) keeps
289
+ // the bad body OUT of the cache so a retry re-fetches cleanly.
290
+ if (!looksLikeSectionXml(body)) {
291
+ throw new ToolErrorCarrier({
292
+ kind: "upstream_unavailable",
293
+ message: `The eCFR versioner returned HTTP 200 for ${clauseNumber} (as of ${asOfDate}) but the body was not a parseable Title 48 section (empty, truncated, or a CDN/WAF interstitial). Refusing to emit a hollow clause. Retry shortly.`,
294
+ retryable: true,
295
+ upstreamStatus: 200,
296
+ upstreamEndpoint: "ecfr:versioner/v1/full/title-48",
297
+ });
298
+ }
299
+ return body;
300
+ });
301
+ } catch (e) {
302
+ // A genuine 404 → not_found NAMING the clause (never null/empty). Any OTHER
303
+ // error (5xx/network/timeout) PROPAGATES with its classification so a DOWN
304
+ // service is never misread as "clause not found".
305
+ if (e instanceof ToolErrorCarrier && e.toolError.kind === "not_found") {
306
+ throw new ToolErrorCarrier({
307
+ kind: "not_found",
308
+ message: `FAR/DFARS clause ${clauseNumber} not found in Title 48 as of ${asOfDate}. (The eCFR versioner returned HTTP 404 "No matching content found" — the clause number may be wrong, reserved, or removed in this edition.)`,
309
+ retryable: false,
310
+ upstreamStatus: 404,
311
+ upstreamEndpoint: "ecfr:versioner/v1/full/title-48",
312
+ });
313
+ }
314
+ throw e;
315
+ }
316
+
317
+ // ── Parse the clause body. ──────────────────────────────────────────────
318
+ const rawHead = firstHead(xml);
319
+ // Strip a leading clause number if the HEAD duplicates it
320
+ // ("52.212-4 Contract Terms…" → "Contract Terms…").
321
+ const heading =
322
+ rawHead != null
323
+ ? rawHead.replace(new RegExp(`^${clauseNumber}\\s*[.:\\-—]?\\s*`), "").trim() ||
324
+ rawHead
325
+ : null;
326
+
327
+ const revMatch = xml.match(/\(([A-Z]{3}\.?\s+\d{4})\)/);
328
+ const revision = revMatch?.[1] ?? null;
329
+
330
+ const preMatch = xml.match(
331
+ /As prescribed in (\d{1,3}\.\d+(?:\([a-z0-9]+\))*)/i,
332
+ );
333
+ const prescribedIn = preMatch?.[1] ?? null;
334
+
335
+ // Detect clause vs provision from the prescribing verb. DFARS uses BOTH
336
+ // "insert the following …" and "use the following …" (Defect 3: the narrow
337
+ // /insert/-only regex silently mislabeled DFARS provisions as clauses). When
338
+ // NEITHER verb is present, default to "clause" but DISCLOSE it as inferred
339
+ // (see the note pushed below) rather than assert it.
340
+ const kindMatch = xml.match(/(?:insert|use) the following (clause|provision)/i);
341
+ const kindDetected = kindMatch?.[1]?.toLowerCase() as
342
+ | "clause"
343
+ | "provision"
344
+ | undefined;
345
+ const kind: "clause" | "provision" = kindDetected ?? "clause";
346
+
347
+ const text = stripXml(xml);
348
+
349
+ // ── Optional prescription section (non-fatal). ──────────────────────────
350
+ const notes: string[] = [];
351
+ let prescription:
352
+ | { section: string; heading: string | null; text: string }
353
+ | null = null;
354
+ let prescriptionDegraded = false;
355
+
356
+ if (includePrescription && prescribedIn) {
357
+ // Trim any trailing subparagraph to the base section: 12.301(b)(3) → 12.301.
358
+ const baseSection = prescribedIn.replace(/\(.*$/, "");
359
+ prescription = await fetchPrescription(baseSection, asOfDate);
360
+ if (prescription === null) {
361
+ prescriptionDegraded = true;
362
+ notes.push(
363
+ `The prescribing section ${baseSection} (from "As prescribed in ${prescribedIn}") could NOT be fetched — prescription is null. This is a partial result: the clause text above is complete, but the "when does this clause apply?" rule was not retrieved (fetch it directly at ${ECFR}/versioner/v1/full/${asOfDate}/title-48.xml?section=${baseSection}, or via ecfr on ${`https://www.ecfr.gov/current/title-48/section-${baseSection}`}).`,
364
+ );
365
+ }
366
+ } else if (includePrescription && !prescribedIn) {
367
+ notes.push(
368
+ "No 'As prescribed in …' pointer was found in this clause's text, so no prescription section was fetched (prescription:null). Some provisions/clauses carry the prescription in the parent subpart rather than an inline opener.",
369
+ );
370
+ }
371
+
372
+ // Disclose when `kind` was inferred rather than read from a verb (Defect 3):
373
+ // an undetected verb defaults to "clause", which would silently mislabel a
374
+ // provision — so the consumer is told the field is a default, not an assertion.
375
+ if (kindDetected === undefined) {
376
+ notes.push(
377
+ 'The instrument kind (clause vs provision) could NOT be determined from the text — no "insert/use the following clause/provision" verb was found — so kind defaults to "clause". Verify against the section heading if the clause-vs-provision distinction matters.',
378
+ );
379
+ }
380
+
381
+ const ecfrUrl = `https://www.ecfr.gov/current/title-48/section-${clauseNumber}`;
382
+
383
+ const farOverhaulRisk = buildFarOverhaulRisk(regulation);
384
+
385
+ // Currency disclosures.
386
+ if (!isCurrent) {
387
+ notes.push(
388
+ currency.upToDateAsOf
389
+ ? `asOfDate ${asOfDate} is NOT Title 48's current codification date (${currency.upToDateAsOf}); this is a point-in-time read of the FAR as of ${asOfDate}, which may differ from the clause in force today.`
390
+ : `Title 48's current codification date could not be confirmed from titles.json, so isCurrent is false; treat ${asOfDate} as the requested point-in-time edition.`,
391
+ );
392
+ }
393
+ // The RFO caveat is ALWAYS surfaced (structural, never per-clause-fabricated).
394
+ notes.push(
395
+ `RFO caveat: eCFR carries the CODIFIED ${regulation} only. The Revolutionary FAR Overhaul is replacing FAR parts via agency class deviations that may not appear here — verify the controlling deviation (farOverhaulRisk.authoritativeList) before relying on this clause.`,
396
+ );
397
+
398
+ // Currency + provenance live in `data` (top-level), NOT in the meta partial:
399
+ // the project's buildMeta (meta.ts) finalizes a FIXED-shape ResponseMeta and
400
+ // drops unknown keys, so asOfDate/isCurrent/farOverhaulRisk passed via _meta
401
+ // would be silently discarded. The design note anticipated this — carry them
402
+ // where they actually survive. The honest completeness/degradation signals
403
+ // (complete, fieldsUnavailable, notes) DO belong in _meta and are set there.
404
+ const data = {
405
+ clauseNumber,
406
+ kind,
407
+ regulation,
408
+ heading,
409
+ revision,
410
+ text,
411
+ prescribedIn,
412
+ prescription,
413
+ ecfrUrl,
414
+ // Point-in-time provenance for THIS read (mirrors the design note's _meta
415
+ // fields; placed in data so they are not dropped by buildMeta).
416
+ asOfDate,
417
+ titleUpToDateAsOf: currency.upToDateAsOf,
418
+ titleLatestAmendedOn: currency.latestAmendedOn,
419
+ isCurrent,
420
+ // Always-present structural currency caveat (never fabricated specifics).
421
+ farOverhaulRisk,
422
+ };
423
+
424
+ return withMeta(data, {
425
+ source: "ecfr:versioner/full",
426
+ keylessMode: true,
427
+ // A single authoritative clause record.
428
+ returned: 1,
429
+ totalAvailable: 1,
430
+ // A missing prescription is a genuine partial result → not complete.
431
+ complete: prescriptionDegraded ? false : undefined,
432
+ // fieldsUnavailable ONLY when we tried and failed to get the prescription.
433
+ fieldsUnavailable: prescriptionDegraded ? ["prescription"] : [],
434
+ filtersApplied: [],
435
+ filtersDropped: [],
436
+ notes,
437
+ });
438
+ }
439
+
440
+ // ════════════════════════════════════════════════════════════════════════════
441
+ // far_compliance_matrix — RFP cited-clause list → proposal-ready matrix.
442
+ //
443
+ // COMPOSES farClauseLookup: fan it out (bounded concurrency) over a deduped
444
+ // clause list and assemble a Section-L/M-ready matrix — each clause's text +
445
+ // prescription + whether it is a pass/fail eligibility GATE + the same currency
446
+ // caveat farClauseLookup carries.
447
+ //
448
+ // TRUTHFULNESS — the load-bearing split (the C19 lesson): each clause has THREE
449
+ // possible outcomes and "absent" is NEVER conflated with "couldn't fetch":
450
+ // 1. resolved → a full row in `rows[]`.
451
+ // 2. not_found (404) → `unresolved[]` (the clause genuinely isn't in Title 48).
452
+ // 3. any other error → `errored[]` (a DOWN/failing eCFR — retryable — must NOT
453
+ // read as "clause doesn't exist"; invalid_input too).
454
+ // Every input clause lands in EXACTLY one bucket; summary.total proves it. Gate
455
+ // tags come ONLY from the verified static GATE_MAP — never guessed.
456
+ // ════════════════════════════════════════════════════════════════════════════
457
+
458
+ /**
459
+ * Eligibility-gate map (STATIC, verified live 2026-07-04 — headings confirmed).
460
+ * A resolved row whose clauseNumber is a key here is a pass/fail award-eligibility
461
+ * gate; the value is the disclosed label. Kept deliberately SMALL and defensible:
462
+ * NEVER invent a gate meaning for a clause not in this map.
463
+ */
464
+ const GATE_MAP: Record<string, string> = {
465
+ "52.204-24":
466
+ "Section 889 — covered-telecom/video-surveillance prohibition (award-eligibility gate)",
467
+ "52.204-25":
468
+ "Section 889 — covered-telecom/video-surveillance prohibition (award-eligibility gate)",
469
+ "52.204-26":
470
+ "Section 889 — covered-telecom/video-surveillance prohibition (award-eligibility gate)",
471
+ "52.219-14":
472
+ "Limitations on Subcontracting — set-aside compliance gate",
473
+ "252.204-7012":
474
+ "Safeguarding Covered Defense Information + cyber incident reporting (CUI cyber gate)",
475
+ "252.204-7020": "NIST SP 800-171 DoD Assessment (cyber gate)",
476
+ "252.204-7021": "CMMC compliance (cyber gate)",
477
+ };
478
+
479
+ /** Hard ceiling on clauses processed per call (after dedupe). Mirrors the Zod cap. */
480
+ const MATRIX_MAX_CLAUSES = 25;
481
+ /** Bounded fan-out width — small pool so we never fire 25 eCFR fetches at once. */
482
+ const MATRIX_CONCURRENCY = 5;
483
+
484
+ /** One resolved matrix row: farClauseLookup's honest fields + a gate flag. */
485
+ type MatrixRow = {
486
+ clauseNumber: string;
487
+ kind: "clause" | "provision";
488
+ regulation: Regulation;
489
+ heading: string | null;
490
+ revision: string | null;
491
+ prescribedIn: string | null;
492
+ prescription:
493
+ | { section: string; heading: string | null; text: string }
494
+ | null;
495
+ text: string;
496
+ ecfrUrl: string;
497
+ farOverhaulRisk: ReturnType<typeof buildFarOverhaulRisk>;
498
+ /** The eligibility-gate label (from GATE_MAP), or null when not a mapped gate. */
499
+ gate: string | null;
500
+ };
501
+
502
+ /** A clause that did not resolve, with a disclosed reason. */
503
+ type UnresolvedClause = { clauseNumber: string; reason: string };
504
+
505
+ /**
506
+ * Run an async mapper over `items` with at most `width` in flight at once. A
507
+ * lightweight promise pool (worker-draining a shared cursor): each worker pulls
508
+ * the next index until the list is exhausted, so results are written back by
509
+ * original index. Preserves input order and never fires more than `width`
510
+ * concurrent fetches. Never rejects — the mapper itself must not throw (callers
511
+ * here wrap each unit in try/catch).
512
+ */
513
+ async function mapPool<T, R>(
514
+ items: readonly T[],
515
+ width: number,
516
+ mapper: (item: T, index: number) => Promise<R>,
517
+ ): Promise<R[]> {
518
+ const results = new Array<R>(items.length);
519
+ let cursor = 0;
520
+ const workerCount = Math.max(1, Math.min(width, items.length));
521
+ const worker = async () => {
522
+ for (;;) {
523
+ const i = cursor++;
524
+ if (i >= items.length) return;
525
+ results[i] = await mapper(items[i] as T, i);
526
+ }
527
+ };
528
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
529
+ return results;
530
+ }
531
+
532
+ export async function farComplianceMatrix(args: {
533
+ clauses: string[];
534
+ asOfDate?: string;
535
+ includePrescription?: boolean;
536
+ flagGates?: boolean;
537
+ }) {
538
+ const includePrescription = args.includePrescription ?? true;
539
+ const flagGates = args.flagGates !== false; // default true; only false disables
540
+
541
+ // ── Normalize + dedupe case-insensitively, then cap AFTER dedupe. ─────────
542
+ // normalizeClauseNumber already lowercases nothing (clause numbers are digits),
543
+ // but it strips FAR/DFARS prefixes + stray chars so "52.212-4", "FAR 52.212-4",
544
+ // and " 52.212-4 " collapse to one key. We keep the FIRST spelling's normalized
545
+ // form and preserve input order.
546
+ const seen = new Set<string>();
547
+ const deduped: string[] = [];
548
+ for (const raw of args.clauses ?? []) {
549
+ const norm = normalizeClauseNumber(raw ?? "");
550
+ // Keep even a non-matching normalized token: farClauseLookup will classify it
551
+ // as invalid_input → errored (NOT silently dropped). Dedupe on the normalized
552
+ // key so a malformed value that appears twice is only reported once.
553
+ const key = norm.toLowerCase();
554
+ if (seen.has(key)) continue;
555
+ seen.add(key);
556
+ deduped.push(norm);
557
+ }
558
+ const clauses = deduped.slice(0, MATRIX_MAX_CLAUSES);
559
+ const total = clauses.length;
560
+
561
+ // ── Resolve currency ONCE up front (avoid resolving it 25×). ──────────────
562
+ // farClauseLookup would resolve this per call; we resolve it here and pass an
563
+ // explicit asOfDate into each call. If currency can't be resolved AND no
564
+ // asOfDate was supplied, refuse with a SINGLE schema_drift rather than letting
565
+ // 25 identical ones bubble up (and a blank-date URL must never 404 into a fake
566
+ // "not found"). This mirrors farClauseLookup's Defect-1 guard.
567
+ const currency = await title48Currency();
568
+ const asOfDate = args.asOfDate ?? currency.upToDateAsOf ?? "";
569
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(asOfDate)) {
570
+ throw new ToolErrorCarrier({
571
+ kind: "schema_drift",
572
+ message: `Could not resolve Title 48's current codification date from the eCFR titles endpoint (up_to_date_as_of unavailable) and no asOfDate was supplied. Refusing to build a matrix against a blank date — the versioner would return HTTP 404, which must NOT be reported as missing clauses. Retry shortly, or pass an explicit asOfDate (YYYY-MM-DD).`,
573
+ retryable: true,
574
+ upstreamEndpoint: "ecfr:versioner/v1/titles.json",
575
+ });
576
+ }
577
+
578
+ // ── Fan out farClauseLookup with bounded concurrency, catching EACH clause
579
+ // individually so one failure never sinks the matrix. ─────────────────────
580
+ type Outcome =
581
+ | { status: "resolved"; row: MatrixRow }
582
+ | { status: "unresolved"; entry: UnresolvedClause }
583
+ | { status: "errored"; entry: UnresolvedClause };
584
+
585
+ const outcomes = await mapPool<string, Outcome>(
586
+ clauses,
587
+ MATRIX_CONCURRENCY,
588
+ async (clauseNumber): Promise<Outcome> => {
589
+ try {
590
+ const res = await farClauseLookup({
591
+ clauseNumber,
592
+ asOfDate,
593
+ includePrescription,
594
+ });
595
+ const d = res.data;
596
+ const gate = flagGates ? GATE_MAP[d.clauseNumber] ?? null : null;
597
+ const row: MatrixRow = {
598
+ clauseNumber: d.clauseNumber,
599
+ kind: d.kind,
600
+ regulation: d.regulation,
601
+ heading: d.heading,
602
+ revision: d.revision,
603
+ prescribedIn: d.prescribedIn,
604
+ prescription: d.prescription,
605
+ text: d.text,
606
+ ecfrUrl: d.ecfrUrl,
607
+ farOverhaulRisk: d.farOverhaulRisk,
608
+ gate,
609
+ };
610
+ return { status: "resolved", row };
611
+ } catch (e) {
612
+ const kind =
613
+ e instanceof ToolErrorCarrier ? e.toolError.kind : "unknown";
614
+ const reason =
615
+ e instanceof ToolErrorCarrier
616
+ ? e.toolError.message
617
+ : e instanceof Error
618
+ ? e.message
619
+ : String(e);
620
+ // A genuine 404 (absent clause) → unresolved. ANY OTHER kind (a fetch/
621
+ // service problem: upstream_unavailable / schema_drift / rate_limited /
622
+ // invalid_input / unknown) → errored. A DOWN eCFR must NEVER read as
623
+ // "clause doesn't exist".
624
+ if (kind === "not_found") {
625
+ return {
626
+ status: "unresolved",
627
+ entry: { clauseNumber, reason },
628
+ };
629
+ }
630
+ return { status: "errored", entry: { clauseNumber, reason } };
631
+ }
632
+ },
633
+ );
634
+
635
+ const rows: MatrixRow[] = [];
636
+ const unresolved: UnresolvedClause[] = [];
637
+ const errored: UnresolvedClause[] = [];
638
+ for (const o of outcomes) {
639
+ if (o.status === "resolved") rows.push(o.row);
640
+ else if (o.status === "unresolved") unresolved.push(o.entry);
641
+ else errored.push(o.entry);
642
+ }
643
+
644
+ // ── Summary (must be internally consistent). ──────────────────────────────
645
+ const resolved = rows.length;
646
+ const far = rows.filter((r) => r.regulation === "FAR").length;
647
+ const dfars = rows.filter((r) => r.regulation === "DFARS").length;
648
+ const gsam = rows.filter((r) => r.regulation === "GSAM").length;
649
+ const other = rows.filter((r) => r.regulation === "other").length;
650
+ const gates = rows.filter((r) => r.gate !== null).length;
651
+ const summary = {
652
+ total, // deduped input count === resolved + unresolved.length + errored.length
653
+ resolved,
654
+ unresolved: unresolved.length,
655
+ errored: errored.length,
656
+ far,
657
+ dfars,
658
+ gsam,
659
+ other,
660
+ gates,
661
+ };
662
+
663
+ // ── Disclosing notes — one per non-empty bucket + a single currency caveat. ─
664
+ const notes: string[] = [];
665
+ // Disclose the cap if it dropped clauses (the MCP Zod schema rejects >25, so
666
+ // this only fires for a direct call — but a silent drop is never acceptable).
667
+ if (deduped.length > total) {
668
+ notes.push(
669
+ `Input had ${deduped.length} distinct clauses; capped at ${MATRIX_MAX_CLAUSES} — the ${deduped.length - total} beyond the cap were NOT processed (they appear in NONE of rows/unresolved/errored). Split the list across calls to cover them all.`,
670
+ );
671
+ }
672
+ if (unresolved.length > 0) {
673
+ notes.push(
674
+ `${unresolved.length} clause(s) not found in Title 48 as of ${asOfDate} (listed in unresolved). The clause number(s) may be wrong, reserved, or removed in this edition — this IS a real answer, not a service problem.`,
675
+ );
676
+ }
677
+ if (errored.length > 0) {
678
+ notes.push(
679
+ `${errored.length} clause(s) could not be fetched due to a service issue (listed in errored) — retry. This is NOT a confirmation they don't exist; a DOWN/failing eCFR is distinct from a genuinely-absent clause.`,
680
+ );
681
+ }
682
+ // Surface the RFO currency caveat ONCE if any resolved row is FAR/DFARS (reuse
683
+ // farClauseLookup's wording — eCFR carries only the CODIFIED FAR/DFARS).
684
+ if (rows.some((r) => r.regulation === "FAR" || r.regulation === "DFARS")) {
685
+ notes.push(
686
+ `RFO caveat: eCFR carries the CODIFIED FAR/DFARS only. The Revolutionary FAR Overhaul is replacing FAR parts via agency class deviations that may not appear here — verify the controlling deviation (each row's farOverhaulRisk.authoritativeList) before relying on a clause.`,
687
+ );
688
+ }
689
+
690
+ const data = { asOfDate, rows, unresolved, errored, summary };
691
+
692
+ return withMeta(data, {
693
+ source: "ecfr:versioner/full (matrix over far_clause_lookup)",
694
+ keylessMode: true,
695
+ returned: rows.length,
696
+ // A compliance matrix has NO upstream "match count" — it's a lookup over a
697
+ // caller-supplied clause list, and the requested count is `summary.total`.
698
+ // Use null (not `total`): with returned<total when clauses FAIL, buildMeta
699
+ // would force `truncated:true` (meta.ts:104), falsely signalling a cap when
700
+ // the missing clauses are actually disclosed in unresolved/errored. complete
701
+ // is already explicit-false in that case; truncated must stay false.
702
+ totalAvailable: null,
703
+ // Explicit false whenever ANY clause didn't resolve; undefined lets buildMeta
704
+ // derive true for the all-resolved case.
705
+ complete:
706
+ unresolved.length === 0 && errored.length === 0 ? undefined : false,
707
+ // ONLY the errored/outage bucket counts as degradation — a genuine not_found
708
+ // is a real answer, not a fetch failure.
709
+ degraded: errored.length
710
+ ? { attempted: total, succeeded: resolved, failed: errored.length }
711
+ : undefined,
712
+ filtersApplied: [],
713
+ filtersDropped: [],
714
+ notes,
715
+ });
716
+ }
717
+
718
+ // ════════════════════════════════════════════════════════════════════════════
719
+ // far_search — FAR/DFARS-scoped semantic search (the discovery front-door).
720
+ //
721
+ // COMPOSES ecfr.search. It fixes the two compliance-use-case flaws of the raw
722
+ // full-text ecfr_search: (1) it mixes GSAM/agency-supplement sections into FAR
723
+ // results (the 552-over-52 mis-rank), and (2) it returns eCFR's ~5×-per-section
724
+ // HISTORICAL duplicates. far_search scopes by chapter (FAR=1 / DFARS=2) — which
725
+ // keeps GSAM (chapter 5) and other supplements out at the source — and collapses
726
+ // each section's historical versions to the CURRENT one (ends_on==null). It's
727
+ // the "which clauses touch topic X" front-door that then feeds far_clause_lookup
728
+ // for authoritative text.
729
+ //
730
+ // TRUTHFULNESS invariants (a reviewer WILL attack these):
731
+ // - scope:far returns ONLY FAR (chapter-1) rows — no GSAM/agency-supplement
732
+ // leakage. The chapter filter bites server-side; we also never re-admit a
733
+ // non-FAR section.
734
+ // - dedupeVersions NEVER drops a DISTINCT section — it only collapses the SAME
735
+ // section's historical dups; the raw→distinct collapse is disclosed, and
736
+ // dedupeVersions:false returns every raw row (incl. historical).
737
+ // - isCurrent per row === (endsOn==null), honest. A section with NO current
738
+ // row in the window keeps its LATEST version, marked isCurrent:false + noted.
739
+ // - A search-endpoint FAILURE PROPAGATES (ecfr.search throws) — a DOWN service
740
+ // must NEVER read as "0 results" (the load-bearing project lesson).
741
+ // - No fabricated totalAvailable — a deduped view has no clean upstream count.
742
+ // ════════════════════════════════════════════════════════════════════════════
743
+
744
+ /** Which regulation family a far_search scope targets. */
745
+ type FarSearchScope = "far" | "dfars" | "both";
746
+
747
+ /** eCFR Title-48 chapter for a single-regulation scope (1=FAR, 2=DFARS). */
748
+ const SCOPE_CHAPTER: Record<"far" | "dfars", number> = { far: 1, dfars: 2 };
749
+
750
+ /** The mapped shape of one ecfr.search result row (fields far_search consumes). */
751
+ type EcfrSearchRow = Awaited<ReturnType<typeof ecfrSearch>>["data"]["results"][number];
752
+
753
+ /** One far_search result row. */
754
+ type FarSearchRow = {
755
+ regulation: Regulation;
756
+ type: string;
757
+ /** The FAR/DFARS part as a number (null if unparseable), for partsOnly. */
758
+ part: number | null;
759
+ section: string;
760
+ headingPath: string;
761
+ excerpt: string;
762
+ score: number;
763
+ ecfrUrl: string;
764
+ effectiveOn: string;
765
+ endsOn: string | null;
766
+ /** endsOn==null ⇒ the CURRENT (in-force) version; false ⇒ a kept historical. */
767
+ isCurrent: boolean;
768
+ };
769
+
770
+ /** Regulation family from the eCFR chapter we queried, falling back to the
771
+ * section prefix (252.→DFARS, 52.→FAR) when a row's chapter is ambiguous. The
772
+ * queried chapter is authoritative (the server-side filter guarantees it), so we
773
+ * prefer it and only consult the prefix as a defense-in-depth cross-check. */
774
+ function regulationForRow(queriedChapter: number, section: string): Regulation {
775
+ if (queriedChapter === 1) return "FAR";
776
+ if (queriedChapter === 2) return "DFARS";
777
+ // Defensive fallback (should not hit for scope far/dfars): infer from prefix.
778
+ return regulationFor(section);
779
+ }
780
+
781
+ /** Parse a hierarchy.part string to a number; null when absent/unparseable. */
782
+ function partNumber(part: string | undefined): number | null {
783
+ if (part === undefined || part === "") return null;
784
+ const n = Number(part);
785
+ return Number.isFinite(n) ? n : null;
786
+ }
787
+
788
+ /**
789
+ * Map one raw ecfr.search row → a FarSearchRow, tagging regulation from the
790
+ * chapter we queried with and deriving isCurrent from endsOn.
791
+ */
792
+ function mapFarRow(raw: EcfrSearchRow, queriedChapter: number): FarSearchRow {
793
+ return {
794
+ regulation: regulationForRow(queriedChapter, raw.section ?? ""),
795
+ type: raw.type ?? "",
796
+ part: partNumber(raw.part),
797
+ section: raw.section ?? "",
798
+ headingPath: raw.headingPath ?? "",
799
+ excerpt: raw.excerpt ?? "",
800
+ score: raw.score ?? 0,
801
+ ecfrUrl: raw.ecfrUrl ?? "",
802
+ effectiveOn: raw.effectiveOn ?? "",
803
+ endsOn: raw.endsOn ?? null,
804
+ isCurrent: (raw.endsOn ?? null) === null,
805
+ };
806
+ }
807
+
808
+ /**
809
+ * Collapse same-section historical versions to ONE row per distinct section:
810
+ * keep the CURRENT version (endsOn==null) if present; otherwise keep the LATEST
811
+ * (max effectiveOn) and mark it isCurrent:false. NEVER drops a distinct section —
812
+ * only same-section dups. Input order of first appearance is preserved.
813
+ */
814
+ /**
815
+ * The dedup/identity key for a row. Numbered sections key on `section`. But eCFR
816
+ * returns chapter/part/subpart-level hits — e.g. "Appendix G to Chapter 2" — with
817
+ * NO hierarchy.section; those must NOT all collide on "" (which would silently
818
+ * collapse DISTINCT appendices into one and corrupt distinctSections). Fall back
819
+ * to headingPath, then ecfrUrl, then a per-row anon sentinel — so every DISTINCT
820
+ * entity gets a distinct key, while a single section's own historical versions
821
+ * still group together (their headingPath/ecfrUrl is stable across versions).
822
+ */
823
+ function rowKey(row: FarSearchRow, index: number): string {
824
+ return row.section || row.headingPath || row.ecfrUrl || `__anon_${index}`;
825
+ }
826
+
827
+ function dedupeBySection(rows: FarSearchRow[]): FarSearchRow[] {
828
+ const order: string[] = [];
829
+ const bySection = new Map<string, FarSearchRow>();
830
+ rows.forEach((row, i) => {
831
+ const key = rowKey(row, i);
832
+ const existing = bySection.get(key);
833
+ if (existing === undefined) {
834
+ order.push(key);
835
+ bySection.set(key, row);
836
+ return;
837
+ }
838
+ // Prefer a current row; between two non-current rows keep the later one.
839
+ if (existing.isCurrent) return; // already have the current version
840
+ if (row.isCurrent) {
841
+ bySection.set(key, row);
842
+ return;
843
+ }
844
+ // Both historical → keep the one with the later effectiveOn (string compare
845
+ // is correct for ISO YYYY-MM-DD dates).
846
+ if (row.effectiveOn > existing.effectiveOn) bySection.set(key, row);
847
+ });
848
+ return order.map((k) => bySection.get(k) as FarSearchRow);
849
+ }
850
+
851
+ export async function farSearch(args: {
852
+ query: string;
853
+ scope?: FarSearchScope;
854
+ dedupeVersions?: boolean;
855
+ partsOnly?: number[];
856
+ perPage?: number;
857
+ }) {
858
+ const scope: FarSearchScope = args.scope ?? "far";
859
+ const dedupeVersions = args.dedupeVersions ?? true;
860
+ const perPage = args.perPage ?? 5;
861
+ const partsOnly =
862
+ args.partsOnly && args.partsOnly.length > 0 ? args.partsOnly : null;
863
+
864
+ // Fetch a LARGER raw window than perPage because dedup + partsOnly collapse
865
+ // rows. Cap at 50 (eCFR search allows more, but 50 is plenty for a top-N view).
866
+ const rawWindow = Math.min(perPage * 5, 50);
867
+
868
+ // ── Fetch the raw rows. A search-endpoint failure PROPAGATES (ecfr.search
869
+ // throws via fetchWithRetry) — never caught→empty. `both` = two calls merged.
870
+ const chapters: number[] =
871
+ scope === "both" ? [1, 2] : [SCOPE_CHAPTER[scope]];
872
+ let hitWindowCap = false;
873
+ let offScopeDropped = 0;
874
+ const mapped: FarSearchRow[] = [];
875
+ for (const chapter of chapters) {
876
+ const res = await ecfrSearch({
877
+ query: args.query,
878
+ titleNumber: 48,
879
+ chapter,
880
+ perPage: rawWindow,
881
+ });
882
+ const rows = res.data.results;
883
+ // If a chapter's raw page filled the window, MORE distinct rows may exist
884
+ // beyond it → disclose truncation.
885
+ if (rows.length >= rawWindow) hitWindowCap = true;
886
+ for (const raw of rows) {
887
+ // DEFENSE-IN-DEPTH (the load-bearing "no leakage" invariant): the chapter
888
+ // filter is server-side, but never TRUST it blindly — if a row's OWN
889
+ // hierarchy.chapter doesn't match the chapter we queried (a GSAM/agency
890
+ // section that slipped through), DROP it rather than mislabel it FAR/DFARS.
891
+ // scope:far returns ONLY FAR (chapter 1) rows, full stop. A row with no
892
+ // chapter at all is kept (the server filter is the primary guarantee; we
893
+ // only reject a row that positively contradicts the queried scope).
894
+ const rawChapter =
895
+ raw.chapter !== undefined && raw.chapter !== ""
896
+ ? Number(raw.chapter)
897
+ : null;
898
+ if (rawChapter !== null && rawChapter !== chapter) {
899
+ offScopeDropped++;
900
+ continue;
901
+ }
902
+ mapped.push(mapFarRow(raw, chapter));
903
+ }
904
+ }
905
+
906
+ // ── partsOnly (client-side): restrict to rows whose part is in the list. ──
907
+ const partFiltered = partsOnly
908
+ ? mapped.filter((r) => r.part !== null && partsOnly.includes(r.part))
909
+ : mapped;
910
+
911
+ // ── dedupeVersions (default true): collapse same-section historical dups. ──
912
+ const deduped = dedupeVersions
913
+ ? dedupeBySection(partFiltered)
914
+ : partFiltered;
915
+
916
+ // ── Return the top perPage DISTINCT rows. ────────────────────────────────
917
+ const rows = deduped.slice(0, perPage);
918
+ // Count distinct on the SAME key dedupe uses (section, falling back to
919
+ // headingPath/ecfrUrl for section-less appendix/part-level hits) — counting on
920
+ // `section` alone would report every section-less appendix as one.
921
+ const distinctSections = new Set(rows.map((r, i) => rowKey(r, i))).size;
922
+
923
+ // A raw window that filled up, OR a post-slice cut, both mean more may exist.
924
+ const truncated = hitWindowCap || deduped.length > rows.length;
925
+
926
+ // ── Currency (Title 48) — placed in `data` because buildMeta drops it. ────
927
+ const currency = await title48Currency();
928
+
929
+ // ── Disclosing notes. ─────────────────────────────────────────────────────
930
+ // A human-readable label for the scope (used in several notes below).
931
+ const scopeRegLabel =
932
+ scope === "far" ? "FAR" : scope === "dfars" ? "DFARS" : "FAR/DFARS";
933
+ const notes: string[] = [];
934
+ // The raw→distinct collapse note reports the IN-SCOPE rows (post off-scope
935
+ // drop), so it reflects historical-version collapse only, not the scope guard.
936
+ const inScopeRaw = mapped.length;
937
+ if (dedupeVersions && inScopeRaw > deduped.length) {
938
+ notes.push(
939
+ `${inScopeRaw} raw result(s) → ${deduped.length} distinct current section(s) (historical versions collapsed; set dedupeVersions:false to see all).`,
940
+ );
941
+ }
942
+ // Disclose the defense-in-depth scope guard if it dropped any off-scope row (a
943
+ // GSAM/agency-supplement section the server-side chapter filter let slip).
944
+ if (offScopeDropped > 0) {
945
+ notes.push(
946
+ `${offScopeDropped} result(s) outside the requested scope (${scopeRegLabel}) were dropped by a defense-in-depth chapter check — far_search returns ONLY ${scopeRegLabel} (Title 48 chapter ${chapters.join("/")}) sections, never GSAM/agency-supplement leakage.`,
947
+ );
948
+ }
949
+ // Disclose any kept-historical row (a distinct section with NO current version
950
+ // in the window) so isCurrent:false is never a silent surprise.
951
+ const keptHistorical = rows.filter((r) => !r.isCurrent).map((r) => r.section);
952
+ if (dedupeVersions && keptHistorical.length > 0) {
953
+ notes.push(
954
+ `${keptHistorical.length} section(s) had NO current (in-force) version within the fetched window, so their LATEST historical version was kept and marked isCurrent:false: ${keptHistorical.join(", ")}. Confirm the current text with far_clause_lookup.`,
955
+ );
956
+ }
957
+ if (truncated) {
958
+ notes.push(
959
+ `More distinct sections may exist beyond this view (the raw search window or the perPage limit was reached). Narrow the query or raise perPage to see more.`,
960
+ );
961
+ }
962
+ // The RFO currency caveat is ALWAYS surfaced (structural, never per-row-fabricated).
963
+ notes.push(
964
+ `RFO caveat: eCFR carries the CODIFIED ${scopeRegLabel} only. The Revolutionary FAR Overhaul is replacing FAR parts via agency class deviations that may not appear here — verify the controlling deviation (farOverhaulRisk.authoritativeList) before relying on a result.`,
965
+ );
966
+
967
+ // farOverhaulRisk applies to the whole scope. Pass a representative regulation
968
+ // (FAR for far/both, DFARS for dfars) — the caveat text/URLs are identical; the
969
+ // appliesTo tag reflects the scope's primary family.
970
+ const farOverhaulRisk = buildFarOverhaulRisk(
971
+ scope === "dfars" ? "DFARS" : "FAR",
972
+ );
973
+
974
+ // Currency + farOverhaulRisk live in `data` (top-level), NOT the meta partial:
975
+ // buildMeta finalizes a FIXED-shape ResponseMeta and drops unknown keys, so
976
+ // these would be silently discarded if passed via _meta (mirrors far_clause_lookup).
977
+ const data = {
978
+ query: args.query,
979
+ scope,
980
+ rows,
981
+ returned: rows.length,
982
+ distinctSections,
983
+ titleUpToDateAsOf: currency.upToDateAsOf,
984
+ farOverhaulRisk,
985
+ };
986
+
987
+ const filtersApplied = ["scope"];
988
+ if (partsOnly) filtersApplied.push("partsOnly");
989
+ if (dedupeVersions) filtersApplied.push("dedupeVersions");
990
+
991
+ return withMeta(data, {
992
+ source: "ecfr:search/v1 (FAR-scoped)",
993
+ keylessMode: true,
994
+ returned: rows.length,
995
+ // A deduped/scoped view has NO clean upstream match count (eCFR's total_count
996
+ // counts RAW historical versions across the whole title-chapter, not distinct
997
+ // current sections). Do NOT fabricate one — null is the honest answer.
998
+ totalAvailable: null,
999
+ // With totalAvailable null, buildMeta cannot derive truncation, so we pass it
1000
+ // explicitly when the window/limit was hit (more distinct rows may exist).
1001
+ truncated,
1002
+ filtersApplied,
1003
+ filtersDropped: [],
1004
+ fieldsUnavailable: [],
1005
+ notes,
1006
+ });
1007
+ }