sourcelock 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,1569 @@
1
+ """CMS Medicare coverage-policy route: Coverage API + MCD bulk exports.
2
+
3
+ Upstream is the keyless CMS Coverage API (``api.coverage.cms.gov``) with the
4
+ Medicare Coverage Database bulk export ZIPs (``downloads.cms.gov``) as the
5
+ documented fallback. Document families: NCDs (national, binding in all
6
+ states), LCDs (per-MAC-jurisdiction), and Articles (billing/coding guidance
7
+ attached to LCDs -- since the 2019 MCD restructuring, CPT/HCPCS/ICD-10 codes
8
+ live in Articles, not LCDs, except some DME LCDs).
9
+
10
+ License posture (deliberate, load-bearing):
11
+
12
+ * Part of the API sits behind an AMA/ADA/AHA license-agreement Bearer token.
13
+ Fetching that token from ``/v1/metadata/license-agreement/`` legally
14
+ constitutes accepting the AMA CPT personal-use license. **This adapter never
15
+ calls that endpoint.** Automated paths use only keyless surfaces.
16
+ * If the operator has minted a token themselves (their own acceptance act),
17
+ they may export it as ``HC_SOURCE_COVERAGE_LICENSE_TOKEN``; the adapter will
18
+ then use the token-gated ``related-documents`` endpoint (whose payload is
19
+ bare ID tuples, free of licensed descriptor text) instead of downloading the
20
+ bulk export. Endpoints whose responses contain CPT/CDT/NUBC descriptor
21
+ content (``hcpc-code``, ``bill-codes``, ``revenue-code``, LCD/Article
22
+ narrative) are not exposed as tools at all.
23
+ * NCD narrative is redacted whenever the record flags AMA content
24
+ (``ama_statement`` non-empty -- or missing, which is the same thing minus the
25
+ ability to check).
26
+ * Everything that reaches ``data`` passes a structural ALLOWLIST of field names
27
+ per endpoint (``NCD_DETAIL_FIELDS`` and friends). A field CMS adds or renames
28
+ is quarantined: dropped, with only its NAME in a receipt warning. A denylist
29
+ of the known narrative keys cannot see the next licensed field coming.
30
+
31
+ The LCD <-> companion-Article linkage (the route's core query) was verified
32
+ live 2026-08-01 on both surfaces: API ``/v1/data/lcd/related-documents``
33
+ (token-gated; L33818 -> A57660) and the keyless bulk crosswalk
34
+ ``lcd_related_documents.csv`` inside ``current_lcd.zip`` (identical column
35
+ set; 967 of 969 current final LCDs carry at least one companion link). See
36
+ the route dossier's VERIFIED ADDENDUM.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import csv
42
+ import dataclasses
43
+ import hashlib
44
+ import io
45
+ import json
46
+ import os
47
+ import re
48
+ import zipfile
49
+ from datetime import date, datetime, timedelta
50
+ from typing import Any, Literal
51
+
52
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
53
+
54
+ from ..http import FetchResult, SourceUnreachable, as_cache_hit, fetch, fetch_json
55
+ from ..interfaces import Canary, CanaryObservation, SourceAdapter, ToolResult, ToolSpec
56
+ from ..receipts import build_receipt
57
+ from ..schemas import CanaryStatus, SourceContract
58
+
59
+ SOURCE_ID = "coverage"
60
+ TRANSFORM_VERSION = "1"
61
+
62
+ API = "https://api.coverage.cms.gov"
63
+ SCHEDULE_URL = f"{API}/v1/metadata/local-data-schedule/"
64
+ STATES_URL = f"{API}/v1/metadata/states/"
65
+ CONTRACT_TYPES_URL = f"{API}/v1/metadata/contract-type/"
66
+ NCD_REPORT_URL = f"{API}/v1/reports/national-coverage-ncd/"
67
+ NCD_DETAIL_URL = f"{API}/v1/data/ncd/"
68
+ FINAL_LCDS_URL = f"{API}/v1/reports/local-coverage-final-lcds/"
69
+ ARTICLES_REPORT_URL = f"{API}/v1/reports/local-coverage-articles/"
70
+ WHATS_NEW_LOCAL_URL = f"{API}/v1/reports/whats-new/local/"
71
+ WHATS_NEW_NATIONAL_URL = f"{API}/v1/reports/whats-new/national/"
72
+ LCD_RELATED_URL = f"{API}/v1/data/lcd/related-documents"
73
+ LCD_DETAIL_URL = f"{API}/v1/data/lcd/"
74
+ SPEC_URL = f"{API}/docs/v1/coverage-api.json"
75
+
76
+ BULK_BASE = "https://downloads.cms.gov/medicare-coverage-database/downloads/exports"
77
+ BULK_LCD_ZIP_URL = f"{BULK_BASE}/current_lcd.zip"
78
+ NCD_ZIP_URL = f"{BULK_BASE}/ncd.zip"
79
+
80
+ #: Operator-minted AMA/ADA/AHA license token. The adapter NEVER mints one.
81
+ LICENSE_TOKEN_ENV = "HC_SOURCE_COVERAGE_LICENSE_TOKEN"
82
+
83
+ #: Spec paths this adapter (or its canaries) actually calls. The api_contract
84
+ #: canary alarms only when one of these disappears; additions upstream are
85
+ #: informational (additive-path-growth policy).
86
+ REQUIRED_PATHS = (
87
+ "/v1/data/lcd/",
88
+ "/v1/data/lcd/related-documents",
89
+ "/v1/data/ncd/",
90
+ "/v1/metadata/contract-type/",
91
+ "/v1/metadata/local-data-schedule/",
92
+ "/v1/metadata/states/",
93
+ "/v1/reports/local-coverage-articles/",
94
+ "/v1/reports/local-coverage-final-lcds/",
95
+ "/v1/reports/national-coverage-ncd/",
96
+ "/v1/reports/whats-new/local/",
97
+ "/v1/reports/whats-new/national/",
98
+ )
99
+
100
+ #: NCD narrative fields withheld when the record flags licensed AMA content.
101
+ NCD_NARRATIVE_FIELDS = (
102
+ "item_service_description",
103
+ "indications_limitations",
104
+ "cross_reference",
105
+ "revision_history",
106
+ "other_text",
107
+ "reasons_for_denial",
108
+ )
109
+
110
+ #: Fields each Coverage API surface is allowed to emit, endpoint by endpoint.
111
+ #: These are ALLOWLISTS on purpose. The six narrative keys above are a denylist,
112
+ #: and a denylist over an upstream that can add or rename a column passes the
113
+ #: seventh licensed field straight through into `data` -- AMA CPT, ADA CDT, or
114
+ #: AHA/NUBC content SourceLock is not licensed to redistribute, published under
115
+ #: a receipt saying it is redistribution-clean. Anything not named here is
116
+ #: quarantined: dropped from `data`, with only its NAME recorded in a receipt
117
+ #: warning so the omission is visible and auditable. Extending a list is a
118
+ #: deliberate act that belongs with a contamination review, not a shrug.
119
+ #: Recorded live 2026-08-01 from each endpoint's ``meta.fields``, in upstream
120
+ #: column order so the two can be diffed by eye.
121
+ NCD_DETAIL_FIELDS = (
122
+ "document_id",
123
+ "document_version",
124
+ "document_display_id",
125
+ "title",
126
+ "publication_number",
127
+ "effective_date",
128
+ "effective_end_date",
129
+ "implementation_date",
130
+ "qr_modifier_date",
131
+ "benefit_category",
132
+ "item_service_description",
133
+ "indications_limitations",
134
+ "cross_reference",
135
+ "transmittal_number",
136
+ "transmittal_url",
137
+ "revision_history",
138
+ "other_text",
139
+ "ama_statement",
140
+ "reasons_for_denial",
141
+ )
142
+
143
+ #: The three local-document report surfaces (final LCDs, Articles, whats-new
144
+ #: local) share one column set upstream and therefore one allowlist here.
145
+ LOCAL_REPORT_FIELDS = (
146
+ "document_id",
147
+ "document_version",
148
+ "document_display_id",
149
+ "document_type",
150
+ "note",
151
+ "title",
152
+ "contractor_name_type",
153
+ "updated_on",
154
+ "updated_on_sort",
155
+ "effective_date",
156
+ "retirement_date",
157
+ "url",
158
+ )
159
+
160
+ NCD_REPORT_FIELDS = (
161
+ "document_id",
162
+ "document_version",
163
+ "document_display_id",
164
+ "last_updated",
165
+ "last_updated_sort",
166
+ "document_type",
167
+ "title",
168
+ "chapter",
169
+ "is_lab",
170
+ "url",
171
+ )
172
+
173
+ WHATS_NEW_NATIONAL_FIELDS = (
174
+ "document_id",
175
+ "document_version",
176
+ "document_display_id",
177
+ "document_status",
178
+ "last_updated",
179
+ "last_updated_sort",
180
+ "document_type",
181
+ "title",
182
+ "whats_new_description",
183
+ "url",
184
+ )
185
+
186
+ #: Upstream statuses that mean "CMS had a bad minute", not "CMS changed
187
+ #: something". Every route here that passes raise_for_status=False and reads the
188
+ #: status itself must convert these into SourceUnreachable: doctor grades an
189
+ #: unreadable source UNREACHABLE and a changed value DRIFT, and the two get
190
+ #: different fixes. A 503 on the license-gate probe used to read as drift, and
191
+ #: that canary's drift remediation tells a human to STOP and open a CPT
192
+ #: contamination review -- over one bad minute at CMS. A 200 carrying changed
193
+ #: content is still drift; only these statuses (and the connect/read timeouts
194
+ #: `fetch` already converts, whatever raise_for_status says) are transient.
195
+ TRANSIENT_STATUSES = frozenset({429, 500, 502, 503, 504})
196
+
197
+ #: Bulk ZIP size band for ncd.zip (was 1,276,829 B on 2026-08-01).
198
+ NCD_ZIP_SIZE_BAND = (900_000, 2_500_000)
199
+
200
+ #: MCD-internal state ids are alphabetical-order integers, NOT FIPS; resolve
201
+ #: through /v1/metadata/states/ descriptions. This table only maps postal
202
+ #: abbreviations to the names those descriptions start with (public-domain).
203
+ STATE_NAMES = {
204
+ "AL": "Alabama", "AK": "Alaska", "AS": "American Samoa", "AZ": "Arizona",
205
+ "AR": "Arkansas", "CA": "California", "CO": "Colorado", "CT": "Connecticut",
206
+ "DE": "Delaware", "DC": "District of Columbia", "FL": "Florida",
207
+ "GA": "Georgia", "GU": "Guam", "HI": "Hawaii", "ID": "Idaho",
208
+ "IL": "Illinois", "IN": "Indiana", "IA": "Iowa", "KS": "Kansas",
209
+ "KY": "Kentucky", "LA": "Louisiana", "ME": "Maine", "MD": "Maryland",
210
+ "MA": "Massachusetts", "MI": "Michigan", "MN": "Minnesota",
211
+ "MS": "Mississippi", "MO": "Missouri", "MT": "Montana", "NE": "Nebraska",
212
+ "NV": "Nevada", "NH": "New Hampshire", "NJ": "New Jersey",
213
+ "NM": "New Mexico", "NY": "New York", "NC": "North Carolina",
214
+ "ND": "North Dakota", "MP": "Northern Mariana Islands", "OH": "Ohio",
215
+ "OK": "Oklahoma", "OR": "Oregon", "PA": "Pennsylvania",
216
+ "PR": "Puerto Rico", "RI": "Rhode Island", "SC": "South Carolina",
217
+ "SD": "South Dakota", "TN": "Tennessee", "TX": "Texas", "UT": "Utah",
218
+ "VT": "Vermont", "VA": "Virginia", "VI": "Virgin Islands",
219
+ "WA": "Washington", "WV": "West Virginia", "WI": "Wisconsin",
220
+ "WY": "Wyoming",
221
+ }
222
+
223
+ CONTRACT = SourceContract(
224
+ source_id=SOURCE_ID,
225
+ authority_url=API,
226
+ fallback_url=f"{BULK_BASE}/",
227
+ license_notes=(
228
+ "The coverage documents themselves are US Government works. However, parts of the "
229
+ "Coverage API and the bulk exports embed licensed third-party code sets: CPT "
230
+ "(AMA, personal-use-only license), CDT (ADA), and UB-04 bill/revenue codes "
231
+ "(AHA/NUBC). This adapter never accepts those licenses programmatically, never "
232
+ "calls the license-agreement token endpoint, and never returns licensed "
233
+ "descriptor content; ID tuples, titles, dates, and jurisdiction data returned "
234
+ "here are redistribution-clean. Operators who need descriptor surfaces must "
235
+ "accept the license and mint a token themselves (see "
236
+ "HC_SOURCE_COVERAGE_LICENSE_TOKEN)."
237
+ ),
238
+ cadence=(
239
+ "local coverage (LCD/Article): weekly snapshot, captured Sundays 11:59pm ET, "
240
+ "published Thursdays ~02:00 GMT; national documents (NCD/NCA/CAL): event-driven"
241
+ ),
242
+ effective_date_semantics=(
243
+ "For NCDs, effective_from/effective_to are the NCD's own effective and "
244
+ "end dates (implementation_date, when claims processing starts, may differ). "
245
+ "For LCDs/Articles, effective_from is the document's effective date within the "
246
+ "weekly MCD snapshot named in source_version; the latest version of a document "
247
+ "may be future-effective or retired. Documents retired over a year leave the "
248
+ "API and bulk exports entirely for the MCD Archive."
249
+ ),
250
+ invariants=[
251
+ "the weekly local-data-schedule reports exactly one row, with "
252
+ "data_captured_through (a Sunday) strictly before refreshed_on_date",
253
+ "the OpenAPI spec keeps serving every path this adapter calls",
254
+ "internal document ids are stable: ncdid=11 is NCD 30.3 'Acupuncture' (pub 100-3)",
255
+ "the bulk export ZIPs stay reachable at their documented URLs and ncd.zip stays "
256
+ "within its size band",
257
+ "LCD/Article data endpoints stay behind the AMA/ADA/AHA license gate (HTTP 401 "
258
+ "without a token)",
259
+ "the MCD-internal state and contract-type keyspaces are stable (state 6 is "
260
+ "'California - Entire State'; contract types are exactly ids 8-13)",
261
+ ],
262
+ )
263
+
264
+ NON_CLAIMS = [
265
+ "DOES_NOT_PROVE_COVERAGE: an NCD/LCD/Article match does not prove Medicare will "
266
+ "cover or pay any particular claim; coverage turns on medical necessity, "
267
+ "jurisdiction, claim context, and edits outside these documents.",
268
+ "CODING_LIVES_IN_ARTICLES: LCDs generally carry no CPT/HCPCS/ICD-10 codes; billing "
269
+ "and coding specifics live in the companion Billing-and-Coding Article (some DME "
270
+ "LCDs excepted).",
271
+ "VERSIONS_ARE_NOT_ALWAYS_IN_EFFECT: the latest version of a document may be "
272
+ "future-effective or retired, and documents retired over a year move to the MCD "
273
+ "Archive; check status and effective dates before relying on a match.",
274
+ "NO_LICENSED_CODE_CONTENT: CPT (AMA), CDT (ADA), and UB-04/NUBC (AHA) descriptor "
275
+ "content is licensed and never returned by this adapter; consult those surfaces "
276
+ "under your own license.",
277
+ "DOES_NOT_PROVE_CURRENCY: local coverage answers come from the weekly MCD snapshot "
278
+ "(captured Sundays, published Thursdays) and may lag the MACs by up to a week.",
279
+ ]
280
+
281
+ #: In-process cache of the parsed bulk crosswalk, bound to the weekly MCD
282
+ #: snapshot the rows were downloaded under:
283
+ #: {"snapshot_version": str, "fetch": FetchResult, "rows": list[dict]}.
284
+ #: The binding is the load-bearing part -- see _load_bulk_crosswalk. Tests
285
+ #: clear it.
286
+ _BULK_CACHE: dict[str, Any] = {}
287
+
288
+ _DISPLAY_DATE_RE = re.compile(r"^(\d{2})/(\d{2})/(\d{4})$")
289
+ _YYYYMMDD_RE = re.compile(r"^\d{8}$")
290
+
291
+
292
+ # --------------------------------------------------------------------------
293
+ # small helpers
294
+ # --------------------------------------------------------------------------
295
+
296
+
297
+ def _rows(payload: Any) -> list[dict[str, Any]]:
298
+ if isinstance(payload, dict) and isinstance(payload.get("data"), list):
299
+ return payload["data"]
300
+ return []
301
+
302
+
303
+ def _fields(payload: Any) -> list[str]:
304
+ if isinstance(payload, dict):
305
+ meta = payload.get("meta") or {}
306
+ fields = meta.get("fields")
307
+ if isinstance(fields, list):
308
+ return [str(f) for f in fields]
309
+ return []
310
+
311
+
312
+ def _canonical_hash(obj: Any) -> str:
313
+ return hashlib.sha256(
314
+ json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8")
315
+ ).hexdigest()
316
+
317
+
318
+ def _parse_display_date(value: Any) -> date | None:
319
+ """MM/DD/YYYY display strings; 'N/A' and empty are None."""
320
+ if not isinstance(value, str):
321
+ return None
322
+ m = _DISPLAY_DATE_RE.match(value.strip())
323
+ if not m:
324
+ return None
325
+ return date(int(m.group(3)), int(m.group(1)), int(m.group(2)))
326
+
327
+
328
+ def _parse_yyyymmdd(value: Any) -> date | None:
329
+ if isinstance(value, str) and _YYYYMMDD_RE.match(value):
330
+ return date(int(value[:4]), int(value[4:6]), int(value[6:8]))
331
+ return None
332
+
333
+
334
+ def _clean_crlf(value: Any) -> Any:
335
+ return value.replace("\r\n", " ").replace("\r", " ") if isinstance(value, str) else value
336
+
337
+
338
+ def _quarantine(
339
+ row: dict[str, Any], allowed: tuple[str, ...]
340
+ ) -> tuple[dict[str, Any], list[str]]:
341
+ """Project one upstream row onto its endpoint's allowlist.
342
+
343
+ Returns the kept fields (CRLF-normalized) and the NAMES of the fields that
344
+ were dropped. The VALUE of a dropped field is never returned, never logged,
345
+ and never put in a warning -- the reason it was dropped is precisely that
346
+ nobody has established what it contains.
347
+ """
348
+ kept = {k: _clean_crlf(v) for k, v in row.items() if k in allowed}
349
+ return kept, [k for k in row if k not in allowed]
350
+
351
+
352
+ def _quarantine_warning(endpoint: str, names: list[str]) -> str:
353
+ return (
354
+ f"{len(names)} field(s) returned by {endpoint} are not on this adapter's "
355
+ f"allowlist and were dropped from the answer: {', '.join(names)}. Names "
356
+ "only: an unrecognised field may carry AMA CPT, ADA CDT, or AHA/NUBC "
357
+ "licensed content, so its value is never emitted. Read it on the MCD "
358
+ "website under your own license, or extend the allowlist in "
359
+ "hc_source/adapters/coverage.py after a contamination review."
360
+ )
361
+
362
+
363
+ def _quarantine_rows(
364
+ rows: list[dict[str, Any]],
365
+ allowed: tuple[str, ...],
366
+ endpoint: str,
367
+ warnings: list[str],
368
+ ) -> list[dict[str, Any]]:
369
+ """Project a list of rows and append one warning naming every dropped field."""
370
+ projected: list[dict[str, Any]] = []
371
+ dropped: set[str] = set()
372
+ for row in rows:
373
+ kept, names = _quarantine(row, allowed)
374
+ projected.append(kept)
375
+ dropped.update(names)
376
+ if dropped:
377
+ warnings.append(_quarantine_warning(endpoint, sorted(dropped)))
378
+ return projected
379
+
380
+
381
+ def _raise_if_transient(result: FetchResult, url: str) -> None:
382
+ """Turn a transient upstream status into an unreachable source.
383
+
384
+ Called by every route that inspects a status itself. Without it a 429 or a
385
+ 5xx becomes an observation value, which doctor then reports as drift -- a
386
+ false claim that CMS changed something, carrying a remediation that sends a
387
+ human to re-pin or to open a contamination review over an outage.
388
+ """
389
+ if result.status in TRANSIENT_STATUSES:
390
+ raise SourceUnreachable(
391
+ url,
392
+ "transient upstream failure; the source was not read this run",
393
+ status=result.status,
394
+ )
395
+
396
+
397
+ def _snapshot_version() -> tuple[FetchResult, str, str]:
398
+ """The canonical weekly-dataset version string for local coverage data."""
399
+ result, payload = fetch_json(SCHEDULE_URL)
400
+ rows = _rows(payload)
401
+ if len(rows) != 1:
402
+ raise SourceUnreachable(SCHEDULE_URL, "local-data-schedule returned unexpected shape")
403
+ refreshed = str(rows[0].get("refreshed_on_date", ""))
404
+ captured = str(rows[0].get("data_captured_through", ""))
405
+ if not (_YYYYMMDD_RE.match(refreshed) and _YYYYMMDD_RE.match(captured)):
406
+ raise SourceUnreachable(SCHEDULE_URL, "local-data-schedule dates were not YYYYMMDD")
407
+ return result, refreshed, captured
408
+
409
+
410
+ def _resolve_state_id(state: str) -> tuple[int | None, list[str]]:
411
+ """Resolve a postal abbreviation to the MCD-internal state_id (NOT FIPS)."""
412
+ warnings: list[str] = []
413
+ name = STATE_NAMES.get(state.upper())
414
+ if name is None:
415
+ return None, [f"Unknown state abbreviation {state!r}."]
416
+ _, payload = fetch_json(STATES_URL)
417
+ matches = [
418
+ row
419
+ for row in _rows(payload)
420
+ if isinstance(row.get("description"), str)
421
+ and (row["description"] == name or row["description"].startswith(name + " "))
422
+ ]
423
+ if not matches:
424
+ return None, [f"State {name!r} not present in /v1/metadata/states/."]
425
+ matches.sort(key=lambda r: int(r["state_id"]))
426
+ if len(matches) > 1:
427
+ warnings.append(
428
+ f"State {name!r} matched {len(matches)} MCD state entries; using "
429
+ f"state_id={matches[0]['state_id']} ({matches[0]['description']!r})."
430
+ )
431
+ return int(matches[0]["state_id"]), warnings
432
+
433
+
434
+ def _license_token() -> str | None:
435
+ token = os.environ.get(LICENSE_TOKEN_ENV, "").strip()
436
+ return token or None
437
+
438
+
439
+ # --------------------------------------------------------------------------
440
+ # typed public parameters
441
+ # --------------------------------------------------------------------------
442
+
443
+
444
+ class NoParams(BaseModel):
445
+ model_config = ConfigDict(extra="forbid")
446
+
447
+
448
+ class LookupNcdParams(BaseModel):
449
+ model_config = ConfigDict(extra="forbid")
450
+
451
+ section: str = Field(
452
+ pattern=r"^\d{1,3}(\.\d{1,3}){0,2}$",
453
+ description="NCD manual section number, e.g. '30.3' (Publication 100-3 numbering).",
454
+ )
455
+ version: int | None = Field(
456
+ default=None, ge=1, description="NCD version; omit for the latest."
457
+ )
458
+
459
+
460
+ class LookupLcdParams(BaseModel):
461
+ model_config = ConfigDict(extra="forbid")
462
+
463
+ lcd_id: int = Field(ge=1, description="LCD number without the 'L' prefix, e.g. 33818.")
464
+ state: str | None = Field(
465
+ default=None,
466
+ pattern=r"^[A-Za-z]{2}$",
467
+ description="Two-letter state/territory abbreviation to scope the jurisdiction.",
468
+ )
469
+
470
+
471
+ class CompanionArticlesParams(BaseModel):
472
+ model_config = ConfigDict(extra="forbid")
473
+
474
+ lcd_id: int = Field(ge=1, description="LCD number without the 'L' prefix, e.g. 33818.")
475
+ version: int | None = Field(
476
+ default=None, ge=1, description="LCD version; omit for the latest in the crosswalk."
477
+ )
478
+
479
+
480
+ class SearchDocumentsParams(BaseModel):
481
+ model_config = ConfigDict(extra="forbid")
482
+
483
+ keyword: str = Field(
484
+ min_length=2,
485
+ max_length=80,
486
+ pattern=r"^[A-Za-z0-9][A-Za-z0-9 &,()\-./']{1,79}$",
487
+ description="Case-insensitive title keyword, e.g. 'malignant'.",
488
+ )
489
+ document_type: Literal["lcd", "article", "ncd"] = Field(
490
+ description="Which document family to search."
491
+ )
492
+ state: str | None = Field(
493
+ default=None,
494
+ pattern=r"^[A-Za-z]{2}$",
495
+ description="Two-letter state abbreviation (local documents only).",
496
+ )
497
+ contractor_id: int | None = Field(
498
+ default=None, ge=1, description="MAC contractor id (local documents only)."
499
+ )
500
+ status: Literal["A", "R", "F"] | None = Field(
501
+ default=None,
502
+ description="A=Active, R=Retired, F=Future Effective (local documents only).",
503
+ )
504
+
505
+ @model_validator(mode="after")
506
+ def _local_filters_only_for_local(self) -> SearchDocumentsParams:
507
+ if self.document_type == "ncd" and (self.state or self.contractor_id or self.status):
508
+ raise ValueError(
509
+ "state, contractor_id, and status apply to local documents only; "
510
+ "NCDs are national"
511
+ )
512
+ return self
513
+
514
+
515
+ class WhatsChangedParams(BaseModel):
516
+ model_config = ConfigDict(extra="forbid")
517
+
518
+ scope: Literal["local", "national"] = Field(
519
+ description="local = LCD/Article whats-new feed; national = NCD/NCA/CAL feed."
520
+ )
521
+ days_back: int = Field(
522
+ default=7, ge=1, le=120, description="Look-back window in days (max 120)."
523
+ )
524
+ document_type: Literal["NCD", "NCA", "CAL", "MEDCAC", "TA", "MCD"] | None = Field(
525
+ default=None, description="National feed only: restrict to one document type."
526
+ )
527
+ contractor_id: int | None = Field(
528
+ default=None, ge=1, description="Local feed only: restrict to one MAC contractor."
529
+ )
530
+
531
+ @model_validator(mode="after")
532
+ def _filters_match_scope(self) -> WhatsChangedParams:
533
+ if self.scope == "local" and self.document_type:
534
+ raise ValueError("document_type applies to scope='national' only")
535
+ if self.scope == "national" and self.contractor_id:
536
+ raise ValueError("contractor_id applies to scope='local' only")
537
+ return self
538
+
539
+
540
+ # --------------------------------------------------------------------------
541
+ # handlers
542
+ # --------------------------------------------------------------------------
543
+
544
+
545
+ def _snapshot(_: NoParams) -> ToolResult:
546
+ result, refreshed, captured = _snapshot_version()
547
+ refreshed_date = _parse_yyyymmdd(refreshed)
548
+ captured_date = _parse_yyyymmdd(captured)
549
+ data = {
550
+ "data_captured_through": captured,
551
+ "refreshed_on_date": refreshed,
552
+ "captured_through_date": captured_date.isoformat() if captured_date else None,
553
+ "refreshed_on": refreshed_date.isoformat() if refreshed_date else None,
554
+ }
555
+ return ToolResult(
556
+ data=data,
557
+ receipt=build_receipt(
558
+ contract=CONTRACT,
559
+ route="coverage.snapshot",
560
+ fetch=result,
561
+ source_version=f"mcd-weekly-{refreshed}",
562
+ transform_version=TRANSFORM_VERSION,
563
+ effective_from=refreshed_date,
564
+ non_claims=NON_CLAIMS,
565
+ ),
566
+ )
567
+
568
+
569
+ def _lookup_ncd(params: LookupNcdParams) -> ToolResult:
570
+ warnings: list[str] = []
571
+
572
+ report_fetch, report = fetch_json(NCD_REPORT_URL)
573
+ anchor = next(
574
+ (r for r in _rows(report) if str(r.get("document_display_id")) == params.section),
575
+ None,
576
+ )
577
+ if anchor is None:
578
+ warnings.append(
579
+ "The requested NCD section was not found in the national-coverage-ncd report; "
580
+ "it may never have existed or may have been removed."
581
+ )
582
+ return ToolResult(
583
+ data=None,
584
+ receipt=build_receipt(
585
+ contract=CONTRACT,
586
+ route="coverage.lookup_ncd",
587
+ fetch=report_fetch,
588
+ source_version="national-coverage-ncd report",
589
+ transform_version=TRANSFORM_VERSION,
590
+ warnings=warnings,
591
+ non_claims=NON_CLAIMS,
592
+ ),
593
+ )
594
+
595
+ query: dict[str, Any] = {"ncdid": int(anchor["document_id"])}
596
+ if params.version is not None:
597
+ query["ncdver"] = params.version
598
+ detail_fetch = fetch(NCD_DETAIL_URL, params=query, raise_for_status=False)
599
+ # A 5xx is not "no such NCD". Reporting it as a miss would tell a caller the
600
+ # section does not exist on the strength of an outage.
601
+ _raise_if_transient(detail_fetch, NCD_DETAIL_URL)
602
+ record: dict[str, Any] | None = None
603
+ if detail_fetch.status == 200:
604
+ detail_rows = _rows(detail_fetch.json())
605
+ record = dict(detail_rows[0]) if detail_rows else None
606
+ if record is None:
607
+ warnings.append(
608
+ f"The requested NCD (ncdid={query['ncdid']}"
609
+ + (f", version {params.version}" if params.version is not None else "")
610
+ + f") returned no record (HTTP {detail_fetch.status})."
611
+ )
612
+ return ToolResult(
613
+ data=None,
614
+ receipt=build_receipt(
615
+ contract=CONTRACT,
616
+ route="coverage.lookup_ncd",
617
+ fetch=detail_fetch,
618
+ source_version=f"ncd {params.section} (not found)",
619
+ transform_version=TRANSFORM_VERSION,
620
+ warnings=warnings,
621
+ non_claims=NON_CLAIMS,
622
+ ),
623
+ )
624
+
625
+ record, dropped = _quarantine(record, NCD_DETAIL_FIELDS)
626
+ if dropped:
627
+ warnings.append(_quarantine_warning("/v1/data/ncd/", dropped))
628
+
629
+ # Withheld unless the record positively asserts an EMPTY ama_statement. A
630
+ # missing key is treated as flagged, not as clean: if CMS renames the flag,
631
+ # the failure mode of the old test (`record.get("ama_statement")` -> None ->
632
+ # falsy -> publish) was to redistribute licensed narrative. Failing towards
633
+ # withholding costs a caller some public-domain prose; failing the other way
634
+ # costs an AMA license SourceLock does not hold.
635
+ ama_statement = record.get("ama_statement")
636
+ if "ama_statement" not in record or str(ama_statement or "").strip():
637
+ for field_name in NCD_NARRATIVE_FIELDS:
638
+ if field_name in record:
639
+ record[field_name] = None
640
+ warnings.append(
641
+ "Narrative fields withheld: this NCD flags licensed AMA CPT content "
642
+ "(ama_statement present, or absent from a record whose narrative "
643
+ "cannot then be cleared). Read the narrative on the MCD website under "
644
+ "your own CPT license."
645
+ )
646
+
647
+ return ToolResult(
648
+ data=record,
649
+ receipt=build_receipt(
650
+ contract=CONTRACT,
651
+ route="coverage.lookup_ncd",
652
+ fetch=detail_fetch,
653
+ source_version=f"ncd {params.section} v{record.get('document_version')}",
654
+ transform_version=TRANSFORM_VERSION,
655
+ effective_from=_parse_display_date(record.get("effective_date")),
656
+ effective_to=_parse_display_date(record.get("effective_end_date")),
657
+ warnings=warnings,
658
+ non_claims=NON_CLAIMS,
659
+ ),
660
+ )
661
+
662
+
663
+ def _parse_contractors(name_type: Any) -> list[dict[str, str]]:
664
+ """Split 'Name\r\n(Types)\r\nName2\r\n(Types2)' into structured entries."""
665
+ contractors: list[dict[str, str]] = []
666
+ if not isinstance(name_type, str):
667
+ return contractors
668
+ for part in (p.strip() for p in name_type.split("\r\n") if p.strip()):
669
+ if part.startswith("(") and contractors:
670
+ contractors[-1]["contract_types"] = part.strip("()")
671
+ else:
672
+ contractors.append({"name": part, "contract_types": ""})
673
+ return contractors
674
+
675
+
676
+ def _lookup_lcd(params: LookupLcdParams) -> ToolResult:
677
+ warnings: list[str] = []
678
+ _, refreshed, _captured = _snapshot_version()
679
+
680
+ query: dict[str, Any] = {}
681
+ scope_note = ""
682
+ if params.state:
683
+ state_id, state_warnings = _resolve_state_id(params.state)
684
+ warnings.extend(state_warnings)
685
+ if state_id is None:
686
+ return ToolResult(
687
+ data=None,
688
+ receipt=build_receipt(
689
+ contract=CONTRACT,
690
+ route="coverage.lookup_lcd",
691
+ fetch=fetch(SCHEDULE_URL),
692
+ source_version=f"mcd-weekly-{refreshed}",
693
+ transform_version=TRANSFORM_VERSION,
694
+ warnings=warnings,
695
+ non_claims=NON_CLAIMS,
696
+ ),
697
+ )
698
+ query["state_id"] = state_id
699
+ scope_note = f" for state {params.state.upper()}"
700
+
701
+ report_fetch, report = fetch_json(FINAL_LCDS_URL, params=query or None)
702
+ matches = [r for r in _rows(report) if r.get("document_id") == params.lcd_id]
703
+ if not matches:
704
+ warnings.append(
705
+ f"L{params.lcd_id} not found in the current final-LCDs report{scope_note}. "
706
+ "It may be proposed-only, outside the selected jurisdiction, or retired "
707
+ "over a year (in which case it lives in the MCD Archive, which has no API)."
708
+ )
709
+ return ToolResult(
710
+ data=None,
711
+ receipt=build_receipt(
712
+ contract=CONTRACT,
713
+ route="coverage.lookup_lcd",
714
+ fetch=report_fetch,
715
+ source_version=f"mcd-weekly-{refreshed}",
716
+ transform_version=TRANSFORM_VERSION,
717
+ warnings=warnings,
718
+ non_claims=NON_CLAIMS,
719
+ ),
720
+ )
721
+
722
+ # No _quarantine call here: this route names every field it emits, which is
723
+ # an allowlist by construction. A new upstream column cannot reach `data`.
724
+ first = matches[0]
725
+ contractors: list[dict[str, str]] = []
726
+ for row in matches:
727
+ contractors.extend(_parse_contractors(row.get("contractor_name_type")))
728
+ data = {
729
+ "lcd_id": params.lcd_id,
730
+ "display_id": first.get("document_display_id"),
731
+ "version": first.get("document_version"),
732
+ "title": first.get("title"),
733
+ "status_note": first.get("note") or "",
734
+ "effective_date": first.get("effective_date"),
735
+ "retirement_date": (
736
+ None if first.get("retirement_date") in (None, "N/A") else first.get("retirement_date")
737
+ ),
738
+ "contractors": contractors,
739
+ "url": first.get("url"),
740
+ }
741
+ if data["status_note"] == "Future":
742
+ warnings.append(
743
+ f"L{params.lcd_id} is future-effective (effective {data['effective_date']}); "
744
+ "it is not in force yet."
745
+ )
746
+
747
+ return ToolResult(
748
+ data=data,
749
+ receipt=build_receipt(
750
+ contract=CONTRACT,
751
+ route="coverage.lookup_lcd",
752
+ fetch=report_fetch,
753
+ source_version=f"mcd-weekly-{refreshed}",
754
+ transform_version=TRANSFORM_VERSION,
755
+ effective_from=_parse_display_date(first.get("effective_date")),
756
+ effective_to=_parse_display_date(first.get("retirement_date")),
757
+ warnings=warnings,
758
+ non_claims=NON_CLAIMS,
759
+ ),
760
+ )
761
+
762
+
763
+ #: current_lcd.zip measured 31.8 MB on 2026-08-01 -- the largest real full-body
764
+ #: fetch in the product. 128 MiB is 4x that, and the timeout of 300s below is
765
+ #: the other half of the same acknowledgement that this one is big.
766
+ BULK_ZIP_MAX_BYTES = 128 * 1024 * 1024
767
+
768
+
769
+ def _load_bulk_crosswalk(
770
+ snapshot_version: str,
771
+ ) -> tuple[FetchResult, list[dict[str, str]], bool]:
772
+ """Fetch and parse lcd_related_documents.csv from the keyless bulk export.
773
+
774
+ Returns ``(fetch_result, rows, served_from_cache)``. The parsed crosswalk is
775
+ cached in-process -- the export only changes on the weekly Thursday refresh
776
+ -- but the entry is bound to ``snapshot_version``, the weekly MCD release
777
+ observed when those rows were downloaded, and a different release refetches.
778
+
779
+ The cache used to be keyed by nothing at all (one entry, one constant URL)
780
+ while every answer was stamped with a freshly-fetched schedule version. A
781
+ process that stayed up across a Thursday refresh then served week-N rows
782
+ under a receipt claiming week N+1: rows from one release paired with the
783
+ version identifier of another, which is the one thing a receipt must never
784
+ do. Refetching beats serving the old rows under the old version plus a
785
+ staleness warning: the release moves once a week, so a long-lived process
786
+ pays one extra download per refresh, and what it then returns is the
787
+ crosswalk the caller actually asked for rather than a correctly-labelled
788
+ stale one.
789
+ """
790
+ if _BULK_CACHE.get("snapshot_version") == snapshot_version:
791
+ return _BULK_CACHE["fetch"], _BULK_CACHE["rows"], True
792
+ result = fetch(BULK_LCD_ZIP_URL, timeout=300.0, max_bytes=BULK_ZIP_MAX_BYTES)
793
+ try:
794
+ with zipfile.ZipFile(io.BytesIO(result.content)) as outer:
795
+ inner_name = next(n for n in outer.namelist() if n.endswith("_csv.zip"))
796
+ with zipfile.ZipFile(io.BytesIO(outer.read(inner_name))) as inner:
797
+ text = inner.read("lcd_related_documents.csv").decode("utf-8-sig")
798
+ except (zipfile.BadZipFile, KeyError, StopIteration) as exc:
799
+ raise SourceUnreachable(
800
+ BULK_LCD_ZIP_URL,
801
+ f"bulk export did not contain lcd_related_documents.csv ({type(exc).__name__})",
802
+ ) from exc
803
+ rows = list(csv.DictReader(io.StringIO(text)))
804
+ # The FetchResult object itself is cached, never rebuilt: it carries the
805
+ # retrieved_at stamped when these bytes were read, and a rebuilt one would
806
+ # make the receipt claim a read that did not happen.
807
+ _BULK_CACHE["snapshot_version"] = snapshot_version
808
+ _BULK_CACHE["fetch"] = result
809
+ _BULK_CACHE["rows"] = rows
810
+ return result, rows, False
811
+
812
+
813
+ def _relation_entries(rows: list[dict[str, Any]]) -> tuple[list[dict], list[dict]]:
814
+ """Normalize crosswalk rows (API or bulk CSV) into articles + related LCDs."""
815
+ articles: list[dict[str, Any]] = []
816
+ related_lcds: list[dict[str, Any]] = []
817
+ for row in rows:
818
+ article_id = row.get("r_article_id")
819
+ lcd_id = row.get("r_lcd_id")
820
+ if article_id not in (None, ""):
821
+ articles.append(
822
+ {
823
+ "article_id": int(article_id),
824
+ "display_id": f"A{int(article_id)}",
825
+ "article_version": (
826
+ int(row["r_article_version"])
827
+ if row.get("r_article_version") not in (None, "")
828
+ else None
829
+ ),
830
+ "contractor_id": (
831
+ int(row["r_contractor_id"])
832
+ if row.get("r_contractor_id") not in (None, "")
833
+ else None
834
+ ),
835
+ }
836
+ )
837
+ elif lcd_id not in (None, ""):
838
+ related_lcds.append(
839
+ {
840
+ "lcd_id": int(lcd_id),
841
+ "display_id": f"L{int(lcd_id)}",
842
+ "lcd_version": (
843
+ int(row["r_lcd_version"])
844
+ if row.get("r_lcd_version") not in (None, "")
845
+ else None
846
+ ),
847
+ }
848
+ )
849
+ articles.sort(key=lambda a: a["article_id"])
850
+ related_lcds.sort(key=lambda entry: entry["lcd_id"])
851
+ return articles, related_lcds
852
+
853
+
854
+ def _companion_articles(params: CompanionArticlesParams) -> ToolResult:
855
+ warnings: list[str] = []
856
+ _, refreshed, _captured = _snapshot_version()
857
+
858
+ relation_rows: list[dict[str, Any]] | None = None
859
+ result: FetchResult | None = None
860
+ resolved_version: int | None = params.version
861
+
862
+ token = _license_token()
863
+ if token is not None:
864
+ query: dict[str, Any] = {"lcdid": params.lcd_id}
865
+ if params.version is not None:
866
+ query["ver"] = params.version
867
+ try:
868
+ api_fetch = fetch(
869
+ LCD_RELATED_URL,
870
+ params=query,
871
+ headers={"Authorization": f"Bearer {token}"},
872
+ raise_for_status=False,
873
+ )
874
+ except SourceUnreachable as exc:
875
+ warnings.append(
876
+ f"Token-gated related-documents endpoint unreachable ({exc.reason}); "
877
+ "falling back to the keyless bulk export."
878
+ )
879
+ else:
880
+ if api_fetch.status == 200:
881
+ relation_rows = _rows(api_fetch.json())
882
+ result = api_fetch
883
+ warnings.append(
884
+ "Fetched via the operator-supplied license token "
885
+ f"({LICENSE_TOKEN_ENV}). Minting that token was the operator's own "
886
+ "AMA/ADA/AHA license acceptance; SourceLock never accepts it for you."
887
+ )
888
+ if relation_rows and resolved_version is None:
889
+ resolved_version = relation_rows[0].get("lcd_version")
890
+ elif api_fetch.status in TRANSIENT_STATUSES:
891
+ # Not a token problem. Telling an operator their token expired
892
+ # over a 503 sends them to re-mint a token that was fine.
893
+ warnings.append(
894
+ f"Token-gated related-documents endpoint returned a transient "
895
+ f"upstream failure (HTTP {api_fetch.status}); that is a CMS-side "
896
+ "outage, and it says nothing about the token. Falling back to the "
897
+ "keyless bulk export."
898
+ )
899
+ else:
900
+ warnings.append(
901
+ f"Operator-supplied license token was rejected (HTTP {api_fetch.status}); "
902
+ "it expires one hour after minting. Falling back to the keyless bulk "
903
+ "export."
904
+ )
905
+
906
+ if relation_rows is None:
907
+ bulk_fetch, all_rows, cached = _load_bulk_crosswalk(refreshed)
908
+ lcd_rows = [r for r in all_rows if r.get("lcd_id") == str(params.lcd_id)]
909
+ if params.version is None:
910
+ versions = [int(r["lcd_version"]) for r in lcd_rows if r.get("lcd_version")]
911
+ resolved_version = max(versions) if versions else None
912
+ if resolved_version is not None:
913
+ lcd_rows = [
914
+ r for r in lcd_rows if r.get("lcd_version") == str(resolved_version)
915
+ ]
916
+ relation_rows = lcd_rows
917
+ result = dataclasses.replace(
918
+ bulk_fetch, fallback_used=True, fallback_name="mcd-bulk-current-lcd-zip"
919
+ )
920
+ if cached:
921
+ # The prose warning below said "served from the in-process cache"
922
+ # while the structured field said cache_hit: false. A machine
923
+ # reading the receipt believes the field. retrieved_at and
924
+ # revalidated_at stay where the download put them -- reuse inside
925
+ # one weekly snapshot contacted nobody.
926
+ result = as_cache_hit(result)
927
+ # Reason-neutral wording: this branch is also reached when a token WAS
928
+ # supplied and the endpoint failed, and the warning above says why.
929
+ warnings.append(
930
+ "Used the keyless MCD bulk export crosswalk (current_lcd.zip / "
931
+ "lcd_related_documents.csv) rather than the token-gated related-documents "
932
+ f"endpoint. Set {LICENSE_TOKEN_ENV} to use the live endpoint under your own "
933
+ "license acceptance."
934
+ )
935
+ if cached:
936
+ warnings.append(
937
+ "Crosswalk served from the in-process cache of a bulk export downloaded "
938
+ f"earlier in this run under this same weekly snapshot (mcd-weekly-"
939
+ f"{refreshed}); receipt provenance and retrieved_at cover that download, "
940
+ "and cache_hit says so."
941
+ )
942
+
943
+ articles, related_lcds = _relation_entries(relation_rows)
944
+ if not articles:
945
+ warnings.append(
946
+ f"No companion Article found for L{params.lcd_id}"
947
+ + (f" v{resolved_version}" if resolved_version is not None else "")
948
+ + ". Genuine for a few LCDs (notably DME), otherwise check the LCD id."
949
+ )
950
+
951
+ assert result is not None # both branches set it or raised
952
+ return ToolResult(
953
+ data={
954
+ "lcd_id": params.lcd_id,
955
+ "display_id": f"L{params.lcd_id}",
956
+ "lcd_version": resolved_version,
957
+ "companion_articles": articles,
958
+ "related_lcds": related_lcds,
959
+ },
960
+ receipt=build_receipt(
961
+ contract=CONTRACT,
962
+ route="coverage.companion_articles",
963
+ fetch=result,
964
+ source_version=f"mcd-weekly-{refreshed}",
965
+ transform_version=TRANSFORM_VERSION,
966
+ warnings=warnings,
967
+ non_claims=NON_CLAIMS,
968
+ ),
969
+ )
970
+
971
+
972
+ _SEARCH_MATCH_CAP = 50
973
+
974
+
975
+ def _search_documents(params: SearchDocumentsParams) -> ToolResult:
976
+ warnings: list[str] = []
977
+
978
+ if params.document_type == "ncd":
979
+ url = NCD_REPORT_URL
980
+ allowed = NCD_REPORT_FIELDS
981
+ endpoint = "/v1/reports/national-coverage-ncd/"
982
+ query: dict[str, Any] = {}
983
+ source_version = "national-coverage-ncd report"
984
+ else:
985
+ url = FINAL_LCDS_URL if params.document_type == "lcd" else ARTICLES_REPORT_URL
986
+ allowed = LOCAL_REPORT_FIELDS
987
+ endpoint = (
988
+ "/v1/reports/local-coverage-final-lcds/"
989
+ if params.document_type == "lcd"
990
+ else "/v1/reports/local-coverage-articles/"
991
+ )
992
+ _, refreshed, _captured = _snapshot_version()
993
+ source_version = f"mcd-weekly-{refreshed}"
994
+ query = {}
995
+ if params.state:
996
+ state_id, state_warnings = _resolve_state_id(params.state)
997
+ warnings.extend(state_warnings)
998
+ if state_id is None:
999
+ return ToolResult(
1000
+ data=None,
1001
+ receipt=build_receipt(
1002
+ contract=CONTRACT,
1003
+ route="coverage.search_documents",
1004
+ fetch=fetch(SCHEDULE_URL),
1005
+ source_version=source_version,
1006
+ transform_version=TRANSFORM_VERSION,
1007
+ warnings=warnings,
1008
+ non_claims=NON_CLAIMS,
1009
+ ),
1010
+ )
1011
+ query["state_id"] = state_id
1012
+ if params.contractor_id:
1013
+ query["contractor_id"] = params.contractor_id
1014
+ if params.status:
1015
+ query["status"] = params.status
1016
+
1017
+ report_fetch, report = fetch_json(url, params=query or None)
1018
+ rows = _rows(report)
1019
+ needle = params.keyword.lower()
1020
+ matches = [r for r in rows if needle in str(r.get("title", "")).lower()]
1021
+ if len(matches) > _SEARCH_MATCH_CAP:
1022
+ warnings.append(
1023
+ f"{len(matches)} titles matched; returning the first {_SEARCH_MATCH_CAP}. "
1024
+ "Narrow the keyword or add filters."
1025
+ )
1026
+ matches = matches[:_SEARCH_MATCH_CAP]
1027
+ # After the cap: only fields that would actually have been emitted are
1028
+ # reported as quarantined.
1029
+ matches = _quarantine_rows(matches, allowed, endpoint, warnings)
1030
+ if not matches:
1031
+ # The keyword is NOT echoed. This warning is persisted into the evidence
1032
+ # receipt, which is the one artifact the product tells users to keep --
1033
+ # round-1 finding C2 made it a PHI sink. Length is enough for an
1034
+ # operator to recognise which call this was without quoting anything.
1035
+ warnings.append(
1036
+ f"No {params.document_type} titles matched the supplied keyword "
1037
+ f"({len(params.keyword)} characters). Title search only: the MCD full-text "
1038
+ "search surface is not part of this adapter."
1039
+ )
1040
+
1041
+ return ToolResult(
1042
+ data={
1043
+ "matches": matches,
1044
+ "match_count": len(matches),
1045
+ "documents_searched": len(rows),
1046
+ },
1047
+ receipt=build_receipt(
1048
+ contract=CONTRACT,
1049
+ route="coverage.search_documents",
1050
+ fetch=report_fetch,
1051
+ source_version=source_version,
1052
+ transform_version=TRANSFORM_VERSION,
1053
+ warnings=warnings,
1054
+ non_claims=NON_CLAIMS,
1055
+ ),
1056
+ )
1057
+
1058
+
1059
+ def _whats_changed(params: WhatsChangedParams) -> ToolResult:
1060
+ warnings: list[str] = []
1061
+ today = date.today()
1062
+ window_start = today - timedelta(days=params.days_back - 1)
1063
+ window_end = today + timedelta(days=1) # [start, end) semantics upstream
1064
+
1065
+ if params.scope == "national":
1066
+ query: dict[str, Any] = {"timeframe": params.days_back}
1067
+ if params.document_type:
1068
+ query["document_type"] = params.document_type
1069
+ result, payload = fetch_json(WHATS_NEW_NATIONAL_URL, params=query)
1070
+ raw_changes = _rows(payload)
1071
+ allowed = WHATS_NEW_NATIONAL_FIELDS
1072
+ endpoint = "/v1/reports/whats-new/national/"
1073
+ source_version = "whats-new national feed (event-driven)"
1074
+ else:
1075
+ allowed = LOCAL_REPORT_FIELDS
1076
+ endpoint = "/v1/reports/whats-new/local/"
1077
+ _, refreshed, _captured = _snapshot_version()
1078
+ source_version = f"mcd-weekly-{refreshed}"
1079
+ raw_changes = []
1080
+ seen: set[tuple] = set()
1081
+ result = None
1082
+ cursor = window_start
1083
+ window_count = 0
1084
+ while cursor < window_end:
1085
+ chunk_end = min(cursor + timedelta(days=7), window_end)
1086
+ query = {
1087
+ "start_date": cursor.strftime("%Y%m%d"),
1088
+ "end_date": chunk_end.strftime("%Y%m%d"),
1089
+ }
1090
+ if params.contractor_id:
1091
+ query["contractor_id"] = params.contractor_id
1092
+ result, payload = fetch_json(WHATS_NEW_LOCAL_URL, params=query)
1093
+ window_count += 1
1094
+ for row in _rows(payload):
1095
+ key = (
1096
+ row.get("document_id"),
1097
+ row.get("document_version"),
1098
+ row.get("document_type"),
1099
+ row.get("updated_on_sort"),
1100
+ )
1101
+ if key not in seen:
1102
+ seen.add(key)
1103
+ raw_changes.append(row)
1104
+ cursor = chunk_end
1105
+ if window_count > 1:
1106
+ warnings.append(
1107
+ f"Aggregated {window_count} weekly windows (the upstream feed caps each "
1108
+ "request at one week); receipt provenance covers the final window's fetch."
1109
+ )
1110
+
1111
+ assert result is not None
1112
+ changes = _quarantine_rows(raw_changes, allowed, endpoint, warnings)
1113
+ return ToolResult(
1114
+ data={
1115
+ "scope": params.scope,
1116
+ "window_start": window_start.isoformat(),
1117
+ "window_end": today.isoformat(),
1118
+ "changes": changes,
1119
+ "change_count": len(changes),
1120
+ },
1121
+ receipt=build_receipt(
1122
+ contract=CONTRACT,
1123
+ route="coverage.whats_changed",
1124
+ fetch=result,
1125
+ source_version=source_version,
1126
+ transform_version=TRANSFORM_VERSION,
1127
+ effective_from=window_start,
1128
+ effective_to=today,
1129
+ warnings=warnings,
1130
+ non_claims=NON_CLAIMS,
1131
+ ),
1132
+ )
1133
+
1134
+
1135
+ # --------------------------------------------------------------------------
1136
+ # canaries
1137
+ # --------------------------------------------------------------------------
1138
+
1139
+
1140
+ def _observe_weekly_snapshot() -> CanaryObservation:
1141
+ result, payload = fetch_json(SCHEDULE_URL)
1142
+ rows = _rows(payload)
1143
+ if len(rows) != 1:
1144
+ raise SourceUnreachable(SCHEDULE_URL, "local-data-schedule returned unexpected shape")
1145
+ refreshed = str(rows[0].get("refreshed_on_date", ""))
1146
+ captured = str(rows[0].get("data_captured_through", ""))
1147
+ refreshed_date = _parse_yyyymmdd(refreshed)
1148
+ captured_date = _parse_yyyymmdd(captured)
1149
+ if refreshed_date is None or captured_date is None or not captured_date < refreshed_date:
1150
+ raise SourceUnreachable(
1151
+ SCHEDULE_URL, "local-data-schedule dates malformed or out of order"
1152
+ )
1153
+ note = f"captured through {captured}"
1154
+ age = (date.today() - refreshed_date).days
1155
+ # Nine days is one weekly cycle plus two days of slack for a skipped
1156
+ # Thursday. Past that, "the refresh id still matches the pin" stops meaning
1157
+ # "nothing changed upstream" and starts meaning "the thing we watch has
1158
+ # stopped moving" -- which is not evidence of anything, so doctor grades it
1159
+ # STALE rather than OK.
1160
+ stale = age > 9
1161
+ if stale:
1162
+ note += f" (STALE: refresh is {age} days old; CMS occasionally skips a Thursday)"
1163
+ return CanaryObservation(
1164
+ value=refreshed,
1165
+ schema_hash=_canonical_hash(_fields(payload)),
1166
+ upstream_status=result.status,
1167
+ note=note,
1168
+ stale=stale,
1169
+ )
1170
+
1171
+
1172
+ def _weekly_snapshot_remediation(status: CanaryStatus, observed, expected) -> str:
1173
+ if status is CanaryStatus.UNREACHABLE:
1174
+ return (
1175
+ "The MCD local-data-schedule endpoint could not be read or returned a "
1176
+ "malformed body. Check https://www.cms.gov/medicare-coverage-database/ for "
1177
+ "outage banners, then re-run `hc-source doctor`."
1178
+ )
1179
+ return (
1180
+ f"CMS published a new weekly MCD snapshot (refreshed {observed}; source-lock.json "
1181
+ f"pins {expected}). Local coverage data moved with it: review whats-new "
1182
+ "(`hc-source call coverage.whats_changed --param scope=local`), then re-pin with "
1183
+ "`hc-source lock init`."
1184
+ )
1185
+
1186
+
1187
+ def _observe_api_contract() -> CanaryObservation:
1188
+ result, payload = fetch_json(SPEC_URL)
1189
+ paths = payload.get("paths")
1190
+ if not isinstance(paths, dict):
1191
+ raise SourceUnreachable(SPEC_URL, "OpenAPI spec had no paths object")
1192
+ openapi = str(payload.get("openapi", "?"))
1193
+ title = str((payload.get("info") or {}).get("title", "?"))
1194
+ info_version = str((payload.get("info") or {}).get("version", "?"))
1195
+ missing = sorted(p for p in REQUIRED_PATHS if p not in paths)
1196
+ if missing:
1197
+ value = f"openapi={openapi};title={title};missing={','.join(missing)}"
1198
+ else:
1199
+ value = f"openapi={openapi};title={title};required-paths=ok"
1200
+ return CanaryObservation(
1201
+ value=value,
1202
+ schema_hash=_canonical_hash(sorted(p for p in REQUIRED_PATHS if p in paths)),
1203
+ upstream_status=result.status,
1204
+ note=(
1205
+ f"spec v{info_version}, {len(paths)} total paths (policy: additions upstream "
1206
+ "are informational; only removal of a load-bearing path alarms)"
1207
+ ),
1208
+ )
1209
+
1210
+
1211
+ def _api_contract_remediation(status: CanaryStatus, observed, expected) -> str:
1212
+ if status is CanaryStatus.UNREACHABLE:
1213
+ return (
1214
+ "The Coverage API OpenAPI spec could not be read. No path was observed to "
1215
+ "have moved -- the spec document itself was unavailable. Check "
1216
+ "https://api.coverage.cms.gov/docs/v1/coverage-api.json by hand, then "
1217
+ "re-run `hc-source doctor`."
1218
+ )
1219
+ return (
1220
+ "The CMS Coverage API OpenAPI spec changed in a way that touches a path this "
1221
+ f"adapter calls (observed {observed!r}, pinned {expected!r}). Read "
1222
+ "https://api.coverage.cms.gov/docs/release_notes, re-verify the adapter's "
1223
+ "endpoints against https://api.coverage.cms.gov/docs/v1/coverage-api.json, then "
1224
+ "re-pin with `hc-source lock init`."
1225
+ )
1226
+
1227
+
1228
+ def _observe_ncd_anchor() -> CanaryObservation:
1229
+ result = fetch(NCD_DETAIL_URL, params={"ncdid": 11}, raise_for_status=False)
1230
+ # A 5xx must not be pinned or graded as an id-scheme break: the remediation
1231
+ # for that sends a human to rebuild the section->ncdid mapping.
1232
+ _raise_if_transient(result, NCD_DETAIL_URL)
1233
+ if result.status != 200:
1234
+ return CanaryObservation(
1235
+ value=f"invalid-anchor-http-{result.status}",
1236
+ upstream_status=result.status,
1237
+ note="ncdid=11 no longer resolves; the internal document-id scheme may have moved",
1238
+ )
1239
+ payload = result.json()
1240
+ rows = _rows(payload)
1241
+ if not rows:
1242
+ raise SourceUnreachable(NCD_DETAIL_URL, "ncd detail returned no rows for ncdid=11")
1243
+ row = rows[0]
1244
+ value = (
1245
+ f"{row.get('document_display_id')}|{row.get('title')}|{row.get('publication_number')}"
1246
+ )
1247
+ return CanaryObservation(
1248
+ value=value,
1249
+ schema_hash=_canonical_hash(_fields(payload)),
1250
+ upstream_status=result.status,
1251
+ note=(
1252
+ f"document_version {row.get('document_version')} (version bumps are routine "
1253
+ "policy updates and deliberately excluded from the pinned value)"
1254
+ ),
1255
+ )
1256
+
1257
+
1258
+ def _ncd_anchor_remediation(status: CanaryStatus, observed, expected) -> str:
1259
+ if status is CanaryStatus.UNREACHABLE:
1260
+ return (
1261
+ "The NCD detail endpoint could not be read. The anchor record was not "
1262
+ "observed this run, so nothing is known about the internal document-id "
1263
+ "scheme: do not rebuild the mapping on the strength of this. Check "
1264
+ "https://api.coverage.cms.gov/docs/release_notes for an outage or a "
1265
+ "planned change, then re-run `hc-source doctor`."
1266
+ )
1267
+ if status is CanaryStatus.SCHEMA_CHANGED:
1268
+ return (
1269
+ "The NCD detail column set changed while the anchor record stayed put. Review "
1270
+ "the new fields, bump TRANSFORM_VERSION in hc_source/adapters/coverage.py if "
1271
+ "the mapping changed, then re-pin with `hc-source lock init`."
1272
+ )
1273
+ return (
1274
+ f"NCD anchor check failed for ncdid=11 (expected NCD 30.3 'Acupuncture', pub "
1275
+ f"100-3; observed {observed!r}). If the id no longer resolves, CMS changed its "
1276
+ "internal document ids: rebuild the section->ncdid mapping from "
1277
+ "/v1/reports/national-coverage-ncd/, re-record the coverage fixtures, and re-pin "
1278
+ "with `hc-source lock init`."
1279
+ )
1280
+
1281
+
1282
+ _CONTENT_RANGE_RE = re.compile(r"^bytes \d+-\d+/(\d+)$")
1283
+
1284
+
1285
+ def _observe_bulk_export() -> CanaryObservation:
1286
+ result = fetch(NCD_ZIP_URL, headers={"Range": "bytes=0-0"})
1287
+ content_type = result.headers.get("content-type", "?").split(";")[0].strip()
1288
+ size: int | None = None
1289
+ match = _CONTENT_RANGE_RE.match(result.headers.get("content-range", ""))
1290
+ if match:
1291
+ size = int(match.group(1))
1292
+ elif result.headers.get("content-length", "").isdigit():
1293
+ size = int(result.headers["content-length"])
1294
+ low, high = NCD_ZIP_SIZE_BAND
1295
+ if size is not None and low <= size <= high:
1296
+ value = f"{content_type};size-band=ok"
1297
+ else:
1298
+ value = f"{content_type};size={size}"
1299
+ return CanaryObservation(
1300
+ value=value,
1301
+ upstream_status=result.status,
1302
+ note=(
1303
+ f"{size} bytes, last-modified {result.headers.get('last-modified', '?')} "
1304
+ f"(1-byte ranged probe; band {low}-{high})"
1305
+ ),
1306
+ )
1307
+
1308
+
1309
+ def _bulk_export_remediation(status: CanaryStatus, observed, expected) -> str:
1310
+ if status is CanaryStatus.UNREACHABLE:
1311
+ return (
1312
+ "The MCD bulk export ncd.zip could not be probed. Re-derive the current "
1313
+ "export links from "
1314
+ "https://www.cms.gov/medicare-coverage-database/downloads/downloads.aspx and "
1315
+ "update the URL table in hc_source/adapters/coverage.py if they moved."
1316
+ )
1317
+ return (
1318
+ f"The MCD bulk export probe changed (observed {observed!r}, pinned {expected!r}). "
1319
+ "If the size left its band, the dataset shape changed: re-download, re-verify "
1320
+ "the CSV inventory, widen NCD_ZIP_SIZE_BAND deliberately, and re-pin with "
1321
+ "`hc-source lock init`."
1322
+ )
1323
+
1324
+
1325
+ def _observe_license_gate() -> CanaryObservation:
1326
+ # Deliberately unauthenticated: this canary asserts the AMA/ADA/AHA gate is
1327
+ # still up. It must NEVER fetch a token (fetching one = accepting the license).
1328
+ result = fetch(LCD_DETAIL_URL, params={"lcdid": 33818}, raise_for_status=False)
1329
+ # Transient statuses are unreachability, not a change of posture. Grading a
1330
+ # 503 as drift here fires the remediation below, which tells a human to STOP
1331
+ # and open a CPT-contamination review; the gate never moved.
1332
+ _raise_if_transient(result, LCD_DETAIL_URL)
1333
+ return CanaryObservation(
1334
+ value=str(result.status),
1335
+ upstream_status=result.status,
1336
+ note="unauthenticated LCD detail probe; 401 means the license gate is intact",
1337
+ )
1338
+
1339
+
1340
+ def _license_gate_remediation(status: CanaryStatus, observed, expected) -> str:
1341
+ if status is CanaryStatus.UNREACHABLE:
1342
+ # The drift text below tells a human to STOP and open a CPT-contamination
1343
+ # review. It must never be printed for an outage: nothing was observed
1344
+ # about the gate's posture this run.
1345
+ return (
1346
+ "The unauthenticated LCD detail probe could not be read (transient upstream "
1347
+ "failure or transport error). This says nothing about the AMA/ADA/AHA "
1348
+ "license gate and is NOT a reason to open a contamination review. Check "
1349
+ "https://www.cms.gov/medicare-coverage-database/ for outage banners, then "
1350
+ "re-run `hc-source doctor`."
1351
+ )
1352
+ return (
1353
+ f"The Coverage API license gate changed posture (unauthenticated LCD call "
1354
+ f"returned HTTP {observed}, pinned {expected}). If it now returns 200, CMS "
1355
+ "dropped the AMA/ADA/AHA token gate: STOP and re-run the CPT-contamination "
1356
+ "review in the coverage dossier (section 6) before exposing or redistributing "
1357
+ "any newly reachable field, then re-pin with `hc-source lock init`."
1358
+ )
1359
+
1360
+
1361
+ def _observe_reference_tables() -> CanaryObservation:
1362
+ result_states, states_payload = fetch_json(STATES_URL, params={"state_id": 6})
1363
+ state_rows = _rows(states_payload)
1364
+ state6 = next((r for r in state_rows if r.get("state_id") == 6), None)
1365
+ state_desc = state6.get("description") if state6 else "absent"
1366
+ _result_types, types_payload = fetch_json(CONTRACT_TYPES_URL)
1367
+ type_ids = sorted(
1368
+ int(r["contract_type_id"])
1369
+ for r in _rows(types_payload)
1370
+ if r.get("contract_type_id") is not None
1371
+ )
1372
+ return CanaryObservation(
1373
+ value=(
1374
+ f"state6={state_desc};contract_types={','.join(str(i) for i in type_ids)}"
1375
+ ),
1376
+ schema_hash=_canonical_hash([_fields(states_payload), _fields(types_payload)]),
1377
+ upstream_status=result_states.status,
1378
+ note="MCD-internal keyspaces backing the jurisdiction joins (state_id is not FIPS)",
1379
+ )
1380
+
1381
+
1382
+ def _reference_tables_remediation(status: CanaryStatus, observed, expected) -> str:
1383
+ if status is CanaryStatus.UNREACHABLE:
1384
+ return (
1385
+ "The MCD reference-table endpoints could not be read, so the keyspaces were "
1386
+ "not observed this run. Nothing moved as far as anyone knows: do not "
1387
+ "re-record fixtures on the strength of an outage. Check "
1388
+ "https://www.cms.gov/medicare-coverage-database/ for outage banners, then "
1389
+ "re-run `hc-source doctor`."
1390
+ )
1391
+ return _REFERENCE_TABLES_REMEDIATION
1392
+
1393
+
1394
+ _REFERENCE_TABLES_REMEDIATION = (
1395
+ "The MCD reference tables moved (states/contract-types). The jurisdiction-mapping "
1396
+ "keyspace changed and stored contractor->state joins may be silently wrong: "
1397
+ "re-record tests/fixtures/coverage/states.json and contract_types.json from "
1398
+ "/v1/metadata/states/ and /v1/metadata/contract-type/, re-validate STATE_NAMES "
1399
+ "resolution in hc_source/adapters/coverage.py, then re-pin with `hc-source lock init`."
1400
+ )
1401
+
1402
+
1403
+ class CoverageAdapter(SourceAdapter):
1404
+ source_id = SOURCE_ID
1405
+ contract = CONTRACT
1406
+
1407
+ # These canaries read a live CMS service, so a single 502 is not news --
1408
+ # it is Tuesday. Two retries with exponential backoff, applied only to
1409
+ # transient failures (see hc_source.doctor._observe). A gate that goes red
1410
+ # on somebody else's bad afternoon gets uninstalled.
1411
+ canary_retries = 2
1412
+
1413
+ def canaries(self) -> list[Canary]:
1414
+ return [
1415
+ Canary(
1416
+ canary_id="coverage.weekly_snapshot",
1417
+ source_id=SOURCE_ID,
1418
+ description=(
1419
+ "Weekly MCD local-coverage snapshot identity "
1420
+ "(refreshed_on_date from /v1/metadata/local-data-schedule/)."
1421
+ ),
1422
+ observe=_observe_weekly_snapshot,
1423
+ remediation=(
1424
+ "CMS published a new weekly MCD snapshot. Review the diff, then "
1425
+ "re-pin with `hc-source lock init`."
1426
+ ),
1427
+ remediation_for=_weekly_snapshot_remediation,
1428
+ ),
1429
+ Canary(
1430
+ canary_id="coverage.api_contract",
1431
+ source_id=SOURCE_ID,
1432
+ description=(
1433
+ "Coverage API OpenAPI contract: every load-bearing path stays "
1434
+ "served (additive growth upstream is informational, never an alarm)."
1435
+ ),
1436
+ observe=_observe_api_contract,
1437
+ remediation=(
1438
+ "The Coverage API spec dropped a path this adapter calls. Read "
1439
+ "https://api.coverage.cms.gov/docs/release_notes and re-verify the "
1440
+ "adapter, then re-pin with `hc-source lock init`."
1441
+ ),
1442
+ remediation_for=_api_contract_remediation,
1443
+ ),
1444
+ Canary(
1445
+ canary_id="coverage.ncd_anchor",
1446
+ source_id=SOURCE_ID,
1447
+ description=(
1448
+ "NCD anchor-record integrity: ncdid=11 must still be NCD 30.3 "
1449
+ "'Acupuncture' (internal-id scheme stability)."
1450
+ ),
1451
+ observe=_observe_ncd_anchor,
1452
+ remediation=(
1453
+ "The NCD anchor moved. Rebuild the section->ncdid mapping from "
1454
+ "/v1/reports/national-coverage-ncd/ and re-pin with "
1455
+ "`hc-source lock init`."
1456
+ ),
1457
+ remediation_for=_ncd_anchor_remediation,
1458
+ ),
1459
+ Canary(
1460
+ canary_id="coverage.bulk_export",
1461
+ source_id=SOURCE_ID,
1462
+ description=(
1463
+ "MCD bulk export availability: 1-byte ranged probe of ncd.zip "
1464
+ "(content type + size band; no full download)."
1465
+ ),
1466
+ observe=_observe_bulk_export,
1467
+ remediation=(
1468
+ "The MCD bulk export probe failed. Re-derive export URLs from the "
1469
+ "MCD downloads page and re-pin with `hc-source lock init`."
1470
+ ),
1471
+ remediation_for=_bulk_export_remediation,
1472
+ ),
1473
+ Canary(
1474
+ canary_id="coverage.license_gate",
1475
+ source_id=SOURCE_ID,
1476
+ description=(
1477
+ "AMA/ADA/AHA license-gate posture: an unauthenticated LCD detail "
1478
+ "call must return HTTP 401. Never mints a token."
1479
+ ),
1480
+ observe=_observe_license_gate,
1481
+ remediation=(
1482
+ "The Coverage API license gate changed posture. Re-run the "
1483
+ "CPT-contamination review before shipping any behavior change, then "
1484
+ "re-pin with `hc-source lock init`."
1485
+ ),
1486
+ remediation_for=_license_gate_remediation,
1487
+ ),
1488
+ Canary(
1489
+ canary_id="coverage.reference_tables",
1490
+ source_id=SOURCE_ID,
1491
+ description=(
1492
+ "MCD-internal reference keyspaces: state 6 stays 'California - "
1493
+ "Entire State' and contract types stay exactly ids 8-13."
1494
+ ),
1495
+ observe=_observe_reference_tables,
1496
+ remediation=_REFERENCE_TABLES_REMEDIATION,
1497
+ remediation_for=_reference_tables_remediation,
1498
+ ),
1499
+ ]
1500
+
1501
+ def tools(self) -> list[ToolSpec]:
1502
+ return [
1503
+ ToolSpec(
1504
+ name="coverage.snapshot",
1505
+ description=(
1506
+ "Return the current weekly MCD local-coverage snapshot identity "
1507
+ "(data_captured_through / refreshed_on_date)."
1508
+ ),
1509
+ params_model=NoParams,
1510
+ handler=_snapshot,
1511
+ tags=("metadata",),
1512
+ ),
1513
+ ToolSpec(
1514
+ name="coverage.lookup_ncd",
1515
+ description=(
1516
+ "Look up a National Coverage Determination by manual section "
1517
+ "number (e.g. 30.3); narrative is withheld when the NCD flags "
1518
+ "licensed AMA CPT content."
1519
+ ),
1520
+ params_model=LookupNcdParams,
1521
+ handler=_lookup_ncd,
1522
+ tags=("lookup",),
1523
+ ),
1524
+ ToolSpec(
1525
+ name="coverage.lookup_lcd",
1526
+ description=(
1527
+ "Look up a Local Coverage Determination by L-number in the current "
1528
+ "weekly snapshot, jurisdiction-aware via an optional state filter."
1529
+ ),
1530
+ params_model=LookupLcdParams,
1531
+ handler=_lookup_lcd,
1532
+ tags=("lookup",),
1533
+ ),
1534
+ ToolSpec(
1535
+ name="coverage.companion_articles",
1536
+ description=(
1537
+ "Resolve an LCD's companion Billing-and-Coding Article(s) via the "
1538
+ "LCD<->Article crosswalk (keyless bulk export, or the live endpoint "
1539
+ "under an operator-supplied license token)."
1540
+ ),
1541
+ params_model=CompanionArticlesParams,
1542
+ handler=_companion_articles,
1543
+ tags=("lookup",),
1544
+ ),
1545
+ ToolSpec(
1546
+ name="coverage.search_documents",
1547
+ description=(
1548
+ "Search NCD/LCD/Article titles by keyword in the current release, "
1549
+ "with state/contractor/status filters for local documents."
1550
+ ),
1551
+ params_model=SearchDocumentsParams,
1552
+ handler=_search_documents,
1553
+ tags=("search",),
1554
+ ),
1555
+ ToolSpec(
1556
+ name="coverage.whats_changed",
1557
+ description=(
1558
+ "What changed in Medicare coverage policy over the last N days, "
1559
+ "from the MCD whats-new feeds (local weekly snapshot or national "
1560
+ "event stream)."
1561
+ ),
1562
+ params_model=WhatsChangedParams,
1563
+ handler=_whats_changed,
1564
+ tags=("changes",),
1565
+ ),
1566
+ ]
1567
+
1568
+
1569
+ ADAPTER = CoverageAdapter()