@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.
- package/LICENSE +21 -21
- package/README.ja.md +48 -16
- package/README.ko.md +48 -16
- package/README.md +279 -67
- package/dist/attachments.d.ts +63 -0
- package/dist/attachments.d.ts.map +1 -0
- package/dist/attachments.js +576 -0
- package/dist/attachments.js.map +1 -0
- package/dist/bls.d.ts +267 -0
- package/dist/bls.d.ts.map +1 -0
- package/dist/bls.js +1549 -0
- package/dist/bls.js.map +1 -0
- package/dist/cache.d.ts +42 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +64 -0
- package/dist/cache.js.map +1 -0
- package/dist/census.d.ts +182 -0
- package/dist/census.d.ts.map +1 -0
- package/dist/census.js +576 -0
- package/dist/census.js.map +1 -0
- package/dist/ckan.d.ts +141 -0
- package/dist/ckan.d.ts.map +1 -0
- package/dist/ckan.js +393 -0
- package/dist/ckan.js.map +1 -0
- package/dist/clinicaltrials.d.ts +180 -0
- package/dist/clinicaltrials.d.ts.map +1 -0
- package/dist/clinicaltrials.js +730 -0
- package/dist/clinicaltrials.js.map +1 -0
- package/dist/cms.d.ts +140 -0
- package/dist/cms.d.ts.map +1 -0
- package/dist/cms.js +482 -0
- package/dist/cms.js.map +1 -0
- package/dist/coerce.d.ts +32 -0
- package/dist/coerce.d.ts.map +1 -0
- package/dist/coerce.js +50 -0
- package/dist/coerce.js.map +1 -0
- package/dist/datagov-catalog.d.ts +84 -0
- package/dist/datagov-catalog.d.ts.map +1 -0
- package/dist/datagov-catalog.js +233 -0
- package/dist/datagov-catalog.js.map +1 -0
- package/dist/datagov.d.ts +146 -0
- package/dist/datagov.d.ts.map +1 -0
- package/dist/datagov.js +689 -0
- package/dist/datagov.js.map +1 -0
- package/dist/datagovKey.d.ts +36 -0
- package/dist/datagovKey.d.ts.map +1 -0
- package/dist/datagovKey.js +60 -0
- package/dist/datagovKey.js.map +1 -0
- package/dist/datasource.d.ts +313 -0
- package/dist/datasource.d.ts.map +1 -0
- package/dist/datasource.js +551 -0
- package/dist/datasource.js.map +1 -0
- package/dist/disclosure.d.ts +55 -0
- package/dist/disclosure.d.ts.map +1 -0
- package/dist/disclosure.js +57 -0
- package/dist/disclosure.js.map +1 -0
- package/dist/ecfr.d.ts +4 -2
- package/dist/ecfr.d.ts.map +1 -1
- package/dist/ecfr.js +92 -17
- package/dist/ecfr.js.map +1 -1
- package/dist/echo.d.ts +143 -0
- package/dist/echo.d.ts.map +1 -0
- package/dist/echo.js +424 -0
- package/dist/echo.js.map +1 -0
- package/dist/edgar.d.ts +377 -0
- package/dist/edgar.d.ts.map +1 -0
- package/dist/edgar.js +2418 -0
- package/dist/edgar.js.map +1 -0
- package/dist/errors.d.ts +102 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +247 -0
- package/dist/errors.js.map +1 -0
- package/dist/fac.d.ts +180 -0
- package/dist/fac.d.ts.map +1 -0
- package/dist/fac.js +416 -0
- package/dist/fac.js.map +1 -0
- package/dist/far.d.ts +170 -0
- package/dist/far.d.ts.map +1 -0
- package/dist/far.js +804 -0
- package/dist/far.js.map +1 -0
- package/dist/fdic.d.ts +599 -0
- package/dist/fdic.d.ts.map +1 -0
- package/dist/fdic.js +1624 -0
- package/dist/fdic.js.map +1 -0
- package/dist/federal-register.d.ts +139 -2
- package/dist/federal-register.d.ts.map +1 -1
- package/dist/federal-register.js +432 -15
- package/dist/federal-register.js.map +1 -1
- package/dist/fema.d.ts +181 -0
- package/dist/fema.d.ts.map +1 -0
- package/dist/fema.js +436 -0
- package/dist/fema.js.map +1 -0
- package/dist/fpds.d.ts +108 -0
- package/dist/fpds.d.ts.map +1 -0
- package/dist/fpds.js +519 -0
- package/dist/fpds.js.map +1 -0
- package/dist/gao.d.ts +64 -0
- package/dist/gao.d.ts.map +1 -0
- package/dist/gao.js +640 -0
- package/dist/gao.js.map +1 -0
- package/dist/govinfo.d.ts +111 -0
- package/dist/govinfo.d.ts.map +1 -0
- package/dist/govinfo.js +422 -0
- package/dist/govinfo.js.map +1 -0
- package/dist/grants.d.ts +27 -4
- package/dist/grants.d.ts.map +1 -1
- package/dist/grants.js +114 -11
- package/dist/grants.js.map +1 -1
- package/dist/gsa-csv.d.ts +249 -0
- package/dist/gsa-csv.d.ts.map +1 -0
- package/dist/gsa-csv.js +784 -0
- package/dist/gsa-csv.js.map +1 -0
- package/dist/integrity.d.ts +212 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/integrity.js +707 -0
- package/dist/integrity.js.map +1 -0
- package/dist/meta.d.ts +165 -0
- package/dist/meta.d.ts.map +1 -0
- package/dist/meta.js +162 -0
- package/dist/meta.js.map +1 -0
- package/dist/nih.d.ts +117 -0
- package/dist/nih.d.ts.map +1 -0
- package/dist/nih.js +291 -0
- package/dist/nih.js.map +1 -0
- package/dist/nppes.d.ts +157 -0
- package/dist/nppes.d.ts.map +1 -0
- package/dist/nppes.js +648 -0
- package/dist/nppes.js.map +1 -0
- package/dist/nsf.d.ts +176 -0
- package/dist/nsf.d.ts.map +1 -0
- package/dist/nsf.js +554 -0
- package/dist/nsf.js.map +1 -0
- package/dist/nvd.d.ts +176 -0
- package/dist/nvd.d.ts.map +1 -0
- package/dist/nvd.js +912 -0
- package/dist/nvd.js.map +1 -0
- package/dist/ofac.d.ts +205 -0
- package/dist/ofac.d.ts.map +1 -0
- package/dist/ofac.js +919 -0
- package/dist/ofac.js.map +1 -0
- package/dist/pricing.d.ts +110 -0
- package/dist/pricing.d.ts.map +1 -0
- package/dist/pricing.js +843 -0
- package/dist/pricing.js.map +1 -0
- package/dist/sam-gov/client.d.ts +60 -2
- package/dist/sam-gov/client.d.ts.map +1 -1
- package/dist/sam-gov/client.js +320 -54
- package/dist/sam-gov/client.js.map +1 -1
- package/dist/sam-gov/index.d.ts +1 -1
- package/dist/sam-gov/index.d.ts.map +1 -1
- package/dist/sam-gov/index.js +1 -1
- package/dist/sam-gov/index.js.map +1 -1
- package/dist/sam-gov/types.d.ts +24 -0
- package/dist/sam-gov/types.d.ts.map +1 -1
- package/dist/sba.d.ts +72 -0
- package/dist/sba.d.ts.map +1 -0
- package/dist/sba.js +281 -0
- package/dist/sba.js.map +1 -0
- package/dist/server.d.ts +14 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +3897 -295
- package/dist/server.js.map +1 -1
- package/dist/snapshot.d.ts +98 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/snapshot.js +146 -0
- package/dist/snapshot.js.map +1 -0
- package/dist/socrata.d.ts +157 -0
- package/dist/socrata.d.ts.map +1 -0
- package/dist/socrata.js +448 -0
- package/dist/socrata.js.map +1 -0
- package/dist/treasury.d.ts +143 -0
- package/dist/treasury.d.ts.map +1 -0
- package/dist/treasury.js +436 -0
- package/dist/treasury.js.map +1 -0
- package/dist/usaspending.d.ts +260 -65
- package/dist/usaspending.d.ts.map +1 -1
- package/dist/usaspending.js +1664 -228
- package/dist/usaspending.js.map +1 -1
- package/dist/usitc.d.ts +142 -0
- package/dist/usitc.d.ts.map +1 -0
- package/dist/usitc.js +339 -0
- package/dist/usitc.js.map +1 -0
- package/package.json +24 -2
- package/src/attachments.ts +652 -0
- package/src/bls.ts +1943 -0
- package/src/cache.ts +73 -0
- package/src/census.ts +735 -0
- package/src/ckan.ts +495 -0
- package/src/clinicaltrials.ts +923 -0
- package/src/cms.ts +634 -0
- package/src/coerce.ts +47 -0
- package/src/datagov-catalog.ts +296 -0
- package/src/datagov.ts +907 -0
- package/src/datagovKey.ts +68 -0
- package/src/datasource.ts +721 -0
- package/src/disclosure.ts +61 -0
- package/src/ecfr.ts +231 -127
- package/src/echo.ts +496 -0
- package/src/edgar.ts +3014 -0
- package/src/errors.ts +303 -0
- package/src/fac.ts +529 -0
- package/src/far.ts +1007 -0
- package/src/fdic.ts +2052 -0
- package/src/federal-register.ts +706 -191
- package/src/fema.ts +541 -0
- package/src/fpds.ts +620 -0
- package/src/gao.ts +744 -0
- package/src/govinfo.ts +497 -0
- package/src/grants.ts +290 -155
- package/src/gsa-csv.ts +992 -0
- package/src/integrity.ts +928 -0
- package/src/meta.ts +292 -0
- package/src/nih.ts +375 -0
- package/src/nppes.ts +834 -0
- package/src/nsf.ts +706 -0
- package/src/nvd.ts +1124 -0
- package/src/ofac.ts +1166 -0
- package/src/pricing.ts +1075 -0
- package/src/sam-gov/client.ts +345 -63
- package/src/sam-gov/index.ts +5 -1
- package/src/sam-gov/types.ts +22 -0
- package/src/sba.ts +357 -0
- package/src/server.ts +4559 -327
- package/src/snapshot.ts +192 -0
- package/src/socrata.ts +532 -0
- package/src/treasury.ts +575 -0
- package/src/usaspending.ts +2680 -925
- 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(/—|—/gi, "—")
|
|
79
|
+
.replace(/–|–/gi, "–")
|
|
80
|
+
.replace(/’|’/gi, "’")
|
|
81
|
+
.replace(/“|“/gi, "“")
|
|
82
|
+
.replace(/”|”/gi, "”")
|
|
83
|
+
.replace(/"/gi, '"')
|
|
84
|
+
.replace(/'/gi, "'")
|
|
85
|
+
.replace(/&/gi, "&")
|
|
86
|
+
.replace(/</gi, "<")
|
|
87
|
+
.replace(/>/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
|
+
}
|