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.
hc_source/receipts.py ADDED
@@ -0,0 +1,74 @@
1
+ """Receipt construction.
2
+
3
+ Adapters build receipts through :func:`build_receipt` so that provenance is
4
+ filled in from the fetch itself rather than retyped, and so that every receipt
5
+ carries the zero-PHI non-claim without the author remembering it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from datetime import date
11
+ from typing import Sequence
12
+
13
+ from .guard import PHI_NON_CLAIM
14
+ from .http import FetchResult
15
+ from .schemas import Receipt, SourceContract
16
+
17
+ __all__ = ["build_receipt"]
18
+
19
+
20
+ def build_receipt(
21
+ *,
22
+ contract: SourceContract,
23
+ route: str,
24
+ fetch: FetchResult,
25
+ source_version: str,
26
+ transform_version: str,
27
+ non_claims: Sequence[str],
28
+ effective_from: date | None = None,
29
+ effective_to: date | None = None,
30
+ warnings: Sequence[str] | None = None,
31
+ ) -> Receipt:
32
+ """Assemble a receipt from a fetch result.
33
+
34
+ ``non_claims`` is required and must be non-empty: state at least one thing
35
+ this result does not prove, in the language of your source. The zero-PHI
36
+ non-claim is prepended for you.
37
+
38
+ ``retrieved_at`` comes from ``fetch``, not from the clock. This function used
39
+ to stamp ``utcnow()`` here, which meant every answer served out of an adapter's
40
+ process cache -- the LEIE snapshot, the coverage bulk crosswalk, the vendored
41
+ code files -- carried a receipt claiming bytes had just been read from CMS.
42
+ """
43
+ domain_claims = [c for c in non_claims if c.strip() and c != PHI_NON_CLAIM]
44
+ if not domain_claims:
45
+ raise ValueError(
46
+ f"{route}: build_receipt requires at least one domain non-claim, e.g. "
47
+ "'DOES_NOT_PROVE_COVERAGE: an active NPI does not mean the payer covers this service.'"
48
+ )
49
+
50
+ return Receipt(
51
+ source_id=contract.source_id,
52
+ route=route,
53
+ upstream_status=fetch.status,
54
+ retrieved_at=fetch.retrieved_at,
55
+ source_version=source_version,
56
+ effective_from=effective_from,
57
+ effective_to=effective_to,
58
+ raw_sha256=fetch.sha256,
59
+ transform_version=transform_version,
60
+ fallback_used=fetch.fallback_used,
61
+ fallback_name=fetch.fallback_name,
62
+ # Both come from the fetch for the same reason ``retrieved_at`` does: a
63
+ # cache that renewed either of these would be attesting to a read that
64
+ # did not happen. Which means an adapter re-answering from its OWN
65
+ # in-process cache has to say so at the fetch object -- pass
66
+ # ``hc_source.http.as_cache_hit(meta)``, not the original result. Two
67
+ # adapters used to pass the original, so their receipts carried a prose
68
+ # warning saying "served from the in-process cache" beside
69
+ # ``cache_hit: false``, and the field is what a machine reads.
70
+ cache_hit=fetch.cache_hit,
71
+ revalidated_at=fetch.revalidated_at,
72
+ warnings=list(warnings or []),
73
+ non_claims=[PHI_NON_CLAIM, *domain_claims],
74
+ )
hc_source/schemas.py ADDED
@@ -0,0 +1,339 @@
1
+ """Core schemas for SourceLock.
2
+
3
+ Three of these types are the public contract that route adapters and CI
4
+ consumers depend on:
5
+
6
+ * ``Receipt`` -- evidence attached to every tool result.
7
+ * ``CanaryResult`` -- one canary observation, as ``hc-source doctor`` reports it.
8
+ * ``SourceContract``-- the promises a source adapter makes about its upstream.
9
+ * ``Lockfile`` -- the pinned state written to ``source-lock.json``.
10
+
11
+ Everything here is strict: unknown fields are rejected so that a typo in an
12
+ adapter fails loudly at construction rather than silently disappearing from a
13
+ receipt.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from datetime import date, datetime, timezone
20
+ from enum import Enum
21
+ from typing import Annotated, Any
22
+
23
+ from pydantic import (
24
+ BaseModel,
25
+ ConfigDict,
26
+ Field,
27
+ field_serializer,
28
+ field_validator,
29
+ model_validator,
30
+ )
31
+
32
+ __all__ = [
33
+ "CanaryResult",
34
+ "CanarySeverity",
35
+ "CanaryStatus",
36
+ "ExpectedCanary",
37
+ "Lockfile",
38
+ "LOCKFILE_VERSION",
39
+ "Receipt",
40
+ "SourceContract",
41
+ "SourceLockEntry",
42
+ "utcnow",
43
+ ]
44
+
45
+ LOCKFILE_VERSION = 1
46
+
47
+ _SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_]*$")
48
+ _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
49
+
50
+ Sha256 = Annotated[str, Field(pattern=r"^[0-9a-f]{64}$")]
51
+
52
+
53
+ def utcnow() -> datetime:
54
+ """Current time as a timezone-aware UTC datetime."""
55
+ return datetime.now(timezone.utc)
56
+
57
+
58
+ def _to_utc(value: datetime) -> datetime:
59
+ if value.tzinfo is None:
60
+ return value.replace(tzinfo=timezone.utc)
61
+ return value.astimezone(timezone.utc)
62
+
63
+
64
+ class _Strict(BaseModel):
65
+ model_config = ConfigDict(extra="forbid", validate_assignment=True)
66
+
67
+
68
+ class Receipt(_Strict):
69
+ """Evidence for one tool call.
70
+
71
+ A receipt answers: which source, which route, what the upstream said, when,
72
+ which version of the source and of our transform, the hash of the raw bytes
73
+ we based the answer on, whether we fell back, and -- explicitly -- what the
74
+ result does NOT prove.
75
+ """
76
+
77
+ source_id: str = Field(description="Adapter source id, e.g. 'nppes'.")
78
+ route: str = Field(description="Fully-qualified tool/route name, e.g. 'nppes.lookup_npi'.")
79
+ upstream_status: int | None = Field(
80
+ default=None, description="HTTP status of the upstream call, or None for local sources."
81
+ )
82
+ retrieved_at: datetime = Field(description="When the upstream bytes were retrieved (UTC).")
83
+ source_version: str = Field(description="Upstream release/version identifier.")
84
+ effective_from: date | None = Field(
85
+ default=None, description="First date this data is in force, per the source contract."
86
+ )
87
+ effective_to: date | None = Field(
88
+ default=None, description="Last date this data is in force; None means open-ended."
89
+ )
90
+ raw_sha256: Sha256 = Field(description="SHA-256 of the raw upstream bytes.")
91
+ transform_version: str = Field(
92
+ description="Version of the adapter transform that produced the result."
93
+ )
94
+ fallback_used: bool = Field(default=False, description="True if the fallback source was used.")
95
+ fallback_name: str | None = Field(
96
+ default=None, description="Name of the fallback used; required when fallback_used is True."
97
+ )
98
+ cache_hit: bool = Field(
99
+ default=False,
100
+ description=(
101
+ "True when the bytes behind this answer came out of a cache rather "
102
+ "than off the wire. `retrieved_at` still reports when they were "
103
+ "RETRIEVED -- a cache that renewed that timestamp would be inventing "
104
+ "a read that did not happen."
105
+ ),
106
+ )
107
+ revalidated_at: datetime | None = Field(
108
+ default=None,
109
+ description=(
110
+ "When upstream last confirmed these bytes are current: the moment of "
111
+ "the 304 on a cache hit, the moment of the download otherwise. Kept "
112
+ "apart from retrieved_at because 'you still have the right bytes' and "
113
+ "'these bytes are new' are different claims."
114
+ ),
115
+ )
116
+ warnings: list[str] = Field(default_factory=list)
117
+ non_claims: list[str] = Field(
118
+ default_factory=list,
119
+ description="Explicit statements of what this result does NOT prove.",
120
+ )
121
+
122
+ @field_validator("revalidated_at")
123
+ @classmethod
124
+ def _utc_optional(cls, v: datetime | None) -> datetime | None:
125
+ return _to_utc(v) if v is not None else None
126
+
127
+ @field_serializer("revalidated_at")
128
+ def _ser_optional_dt(self, v: datetime | None) -> str | None:
129
+ return _to_utc(v).isoformat() if v is not None else None
130
+
131
+ @field_validator("source_id")
132
+ @classmethod
133
+ def _slug(cls, v: str) -> str:
134
+ if not _SLUG_RE.match(v):
135
+ raise ValueError("source_id must be a lowercase slug ([a-z0-9_], not starting with _)")
136
+ return v
137
+
138
+ @field_validator("retrieved_at")
139
+ @classmethod
140
+ def _utc(cls, v: datetime) -> datetime:
141
+ return _to_utc(v)
142
+
143
+ @field_serializer("retrieved_at")
144
+ def _ser_dt(self, v: datetime) -> str:
145
+ return _to_utc(v).isoformat()
146
+
147
+ @model_validator(mode="after")
148
+ def _fallback_consistency(self) -> Receipt:
149
+ if self.fallback_used and not (self.fallback_name or "").strip():
150
+ raise ValueError("fallback_used=True requires fallback_name")
151
+ if not self.fallback_used and self.fallback_name:
152
+ raise ValueError("fallback_name set but fallback_used is False")
153
+ if self.effective_from and self.effective_to and self.effective_to < self.effective_from:
154
+ raise ValueError("effective_to precedes effective_from")
155
+ return self
156
+
157
+
158
+ class CanaryStatus(str, Enum):
159
+ OK = "ok"
160
+ DRIFT = "drift"
161
+ UNREACHABLE = "unreachable"
162
+ SCHEMA_CHANGED = "schema_changed"
163
+ #: Observed successfully but never compared, because nothing pinned it.
164
+ #: Deliberately not ``ok``: a summary that says "22 ok, 0 drift" when
165
+ #: nothing was compared is a green build that verified nothing.
166
+ UNPINNED = "unpinned"
167
+ #: Compared and matched, but the canary itself reported that what it read is
168
+ #: out of date -- a weekly snapshot that has not moved in a month, a
169
+ #: cross-check it had to skip. An `ok` computed from data the canary says is
170
+ #: stale is not assurance, so it does not get to be `ok`.
171
+ STALE = "stale"
172
+ #: The canary raised something that is not a transport failure. Upstream may
173
+ #: be perfectly healthy; SourceLock's own code is what broke. Kept apart from
174
+ #: `unreachable` so "CMS is down" and "our adapter has a bug" are not the
175
+ #: same line in a CI log -- they have completely different fixes.
176
+ ERROR = "error"
177
+
178
+
179
+ class CanarySeverity(str, Enum):
180
+ """Whether a finding from this canary may fail somebody's build.
181
+
182
+ Status and severity are different questions, and conflating them is how a
183
+ check that watches an HTML page CMS restyles quarterly ends up blocking a
184
+ release. `drift` is `drift` either way -- it is reported, annotated, and
185
+ counted. Severity only decides whether it reaches the exit code.
186
+ """
187
+
188
+ BLOCKING = "blocking"
189
+ ADVISORY = "advisory"
190
+
191
+
192
+ class CanaryResult(_Strict):
193
+ """The outcome of one canary, as reported by ``hc-source doctor``."""
194
+
195
+ canary_id: str
196
+ source_id: str
197
+ status: CanaryStatus
198
+ severity: CanarySeverity = Field(
199
+ default=CanarySeverity.BLOCKING,
200
+ description=(
201
+ "Whether this canary's finding counts toward the exit code. Advisory "
202
+ "findings are reported in full and excluded from the verdict."
203
+ ),
204
+ )
205
+ attempts: int = Field(
206
+ default=1,
207
+ ge=1,
208
+ description=(
209
+ "How many times the observation was attempted. Greater than one means "
210
+ "a transient upstream failure was retried -- worth seeing on a canary "
211
+ "that reports ok, because 'ok on the third try' is a different fact "
212
+ "about the source than 'ok'."
213
+ ),
214
+ )
215
+ observed: str | None = Field(default=None, description="What we saw upstream.")
216
+ expected: str | None = Field(default=None, description="What source-lock.json pinned.")
217
+ checked_at: datetime
218
+ remediation: str = Field(
219
+ default="",
220
+ description="Exact human-readable fix. Required for any non-ok status.",
221
+ )
222
+ warnings: list[str] = Field(default_factory=list)
223
+ fallback_used: bool = Field(
224
+ default=False,
225
+ description=(
226
+ "True when a mirror answered because the pinned authority could not be read. "
227
+ "An `ok` from a mirror is not the same claim as an `ok` from the authority."
228
+ ),
229
+ )
230
+ fallback_name: str | None = Field(
231
+ default=None, description="Which mirror answered, when fallback_used is True."
232
+ )
233
+
234
+ @field_validator("checked_at")
235
+ @classmethod
236
+ def _utc(cls, v: datetime) -> datetime:
237
+ return _to_utc(v)
238
+
239
+ @field_serializer("checked_at")
240
+ def _ser_dt(self, v: datetime) -> str:
241
+ return _to_utc(v).isoformat()
242
+
243
+ @model_validator(mode="after")
244
+ def _remediation_required(self) -> CanaryResult:
245
+ if self.status is not CanaryStatus.OK and not self.remediation.strip():
246
+ raise ValueError(f"status={self.status.value} requires non-empty remediation text")
247
+ return self
248
+
249
+ @property
250
+ def ok(self) -> bool:
251
+ return self.status is CanaryStatus.OK
252
+
253
+
254
+ class SourceContract(_Strict):
255
+ """The unit of value: what a source promises and how to read its dates."""
256
+
257
+ source_id: str
258
+ authority_url: str = Field(description="The one canonical, citable upstream URL.")
259
+ fallback_url: str | None = Field(
260
+ default=None, description="Mirror used only when the authority is unreachable."
261
+ )
262
+ license_notes: str = Field(description="Redistribution/attribution terms in plain language.")
263
+ cadence: str = Field(description="How often upstream publishes, e.g. 'quarterly'.")
264
+ effective_date_semantics: str = Field(
265
+ description="What effective_from/effective_to mean for THIS source."
266
+ )
267
+ invariants: list[str] = Field(
268
+ default_factory=list,
269
+ description="Statements that must hold upstream; canaries should test these.",
270
+ )
271
+
272
+ @field_validator("source_id")
273
+ @classmethod
274
+ def _slug(cls, v: str) -> str:
275
+ if not _SLUG_RE.match(v):
276
+ raise ValueError("source_id must be a lowercase slug ([a-z0-9_], not starting with _)")
277
+ return v
278
+
279
+
280
+ class ExpectedCanary(_Strict):
281
+ """The pinned expectation for one canary."""
282
+
283
+ value: str
284
+ schema_hash: str | None = None
285
+
286
+
287
+ class SourceLockEntry(_Strict):
288
+ """Pinned state for one source inside ``source-lock.json``."""
289
+
290
+ source_id: str
291
+ pinned_version: str
292
+ release_id: str | None = None
293
+ schema_hash: str | None = None
294
+ checked_at: datetime
295
+ expected_canaries: dict[str, ExpectedCanary] = Field(default_factory=dict)
296
+
297
+ @field_validator("checked_at")
298
+ @classmethod
299
+ def _utc(cls, v: datetime) -> datetime:
300
+ return _to_utc(v)
301
+
302
+ @field_serializer("checked_at")
303
+ def _ser_dt(self, v: datetime) -> str:
304
+ return _to_utc(v).isoformat()
305
+
306
+
307
+ class Lockfile(_Strict):
308
+ """``source-lock.json``: what CI compares today's upstream against."""
309
+
310
+ lockfile_version: int = LOCKFILE_VERSION
311
+ generated_at: datetime
312
+ sources: dict[str, SourceLockEntry] = Field(default_factory=dict)
313
+
314
+ @field_validator("generated_at")
315
+ @classmethod
316
+ def _utc(cls, v: datetime) -> datetime:
317
+ return _to_utc(v)
318
+
319
+ @field_serializer("generated_at")
320
+ def _ser_dt(self, v: datetime) -> str:
321
+ return _to_utc(v).isoformat()
322
+
323
+ @model_validator(mode="after")
324
+ def _keys_match(self) -> Lockfile:
325
+ for key, entry in self.sources.items():
326
+ if key != entry.source_id:
327
+ raise ValueError(f"lockfile key {key!r} does not match source_id {entry.source_id!r}")
328
+ return self
329
+
330
+ def expected_canary_ids(self) -> set[str]:
331
+ return {cid for entry in self.sources.values() for cid in entry.expected_canaries}
332
+
333
+ def expectation(self, source_id: str, canary_id: str) -> ExpectedCanary | None:
334
+ entry = self.sources.get(source_id)
335
+ return entry.expected_canaries.get(canary_id) if entry else None
336
+
337
+
338
+ def is_sha256(value: Any) -> bool:
339
+ return isinstance(value, str) and bool(_SHA256_RE.match(value))
@@ -0,0 +1,272 @@
1
+ Metadata-Version: 2.4
2
+ Name: sourcelock
3
+ Version: 0.1.0
4
+ Summary: Zero-PHI source-assurance CLI for healthcare's public data inputs
5
+ Author: SourceLock
6
+ License: MIT License
7
+
8
+ Copyright (c) 2026 SourceLock
9
+
10
+ Permission is hereby granted, free of charge, to any person obtaining a copy
11
+ of this software and associated documentation files (the "Software"), to deal
12
+ in the Software without restriction, including without limitation the rights
13
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
14
+ copies of the Software, and to permit persons to whom the Software is
15
+ furnished to do so, subject to the following conditions:
16
+
17
+ The above copyright notice and this permission notice shall be included in all
18
+ copies or substantial portions of the Software.
19
+
20
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
21
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
22
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
23
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
24
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
25
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
26
+ SOFTWARE.
27
+ License-File: LICENSE
28
+ Requires-Python: >=3.11
29
+ Requires-Dist: cryptography>=42
30
+ Requires-Dist: httpx<1.0,>=0.27
31
+ Requires-Dist: mcp==2.0.0
32
+ Requires-Dist: pydantic<3.0,>=2.7
33
+ Requires-Dist: rich>=13.0
34
+ Requires-Dist: typer>=0.12
35
+ Provides-Extra: dev
36
+ Requires-Dist: pytest>=8.0; extra == 'dev'
37
+ Requires-Dist: pyyaml>=6.0; extra == 'dev'
38
+ Requires-Dist: respx>=0.21; extra == 'dev'
39
+ Description-Content-Type: text/markdown
40
+
41
+ # SourceLock
42
+
43
+ **Every answer comes with a receipt, and your build fails when the source moves.**
44
+
45
+ SourceLock reads the public data healthcare software runs on — CMS coverage
46
+ policy, NPPES and PECOS, ICD-10-CM and HCPCS releases, CMS-HCC risk models, the
47
+ OIG LEIE — and returns, with every answer, the release it came from, the SHA-256
48
+ of the raw bytes behind it, when those bytes were retrieved, and what the answer
49
+ does not prove. `hc-source lock init` pins those sources in a `source-lock.json`;
50
+ `hc-source doctor` verifies that pin and fails CI the day one of them changes
51
+ underneath you.
52
+
53
+ ## Install
54
+
55
+ ```
56
+ git clone https://github.com/writtenonwater99/sourcelock
57
+ cd sourcelock
58
+ pipx install .
59
+ ```
60
+
61
+ Python 3.11 or newer. There is no PyPI release yet, so `pip install sourcelock`
62
+ does not work and this file does not pretend it does — see
63
+ [RELEASING.md](RELEASING.md) for what publishing takes.
64
+
65
+ ## See it work, offline, right now
66
+
67
+ ```
68
+ hc-source valid-on E11.9 2026-07-01
69
+ ```
70
+
71
+ No network, no credentials, no lockfile. The release tables are vendored, so
72
+ that command answers in under a second on a plane:
73
+
74
+ ```json
75
+ {
76
+ "code": "E11.9",
77
+ "code_nodot": "E119",
78
+ "code_system": "ICD-10-CM",
79
+ "date": "2026-07-01",
80
+ "valid": true,
81
+ "description": "Type 2 diabetes mellitus without complications",
82
+ "release": "fy2026-april",
83
+ "release_effective_from": "2026-04-01",
84
+ "release_effective_to": "2026-09-30"
85
+ }
86
+ ```
87
+
88
+ followed by the receipt — source, route, release, effective window, retrieval
89
+ time, upstream status, the hash of the bytes the answer was derived from, the
90
+ transform version, and whether a mirror answered — and then the part most tools
91
+ leave out. Four non-claims, printed in full; the first line of each:
92
+
93
+ ```
94
+ does not prove:
95
+ - PHI_NOT_EXPECTED: this tool accepts only public typed parameters.
96
+ - DOES_NOT_INCLUDE_CPT_DESCRIPTORS: no HCPCS Level I (CPT) record and no ADA
97
+ - DOES_NOT_PROVE_COVERAGE: a code being valid for a date of service says
98
+ - DOES_NOT_PROVE_CURRENCY: answers come from the release train vendored at
99
+ ```
100
+
101
+ The real output does not wrap and does not trail off: the CPT one runs to a
102
+ paragraph naming all seventeen CMS descriptions that quote a CPT number, because
103
+ that is what an AMA licence question actually needs. These four are what
104
+ `codes.valid_on` claims it cannot tell you — every one of them, not a
105
+ representative sample, and nothing this product does not actually print.
106
+
107
+ ## What it is for
108
+
109
+ If you are building AI or automation over revenue-cycle data, you have run into
110
+ some version of this:
111
+
112
+ - **A model cited a code set and nobody can say which release.** Every answer
113
+ here carries `source_version`, `effective_from`/`effective_to`, and
114
+ `raw_sha256`. That is an audit trail, not a log line.
115
+ - **A pipeline broke because CMS renamed something.** NPPES retired its V1 bulk
116
+ filenames on 2026-03-03; jobs that still generated them got silent 404s.
117
+ `hc-source lock init` pins 23 cheap checks across six sources and `hc-source
118
+ doctor` fails the build the day one moves — with the fix, not a stack trace.
119
+ - **An agent will confidently answer from a stale cache.** Receipts distinguish
120
+ when bytes were *retrieved* from when upstream last *confirmed* them, a canary
121
+ that read out-of-date data is not allowed to report `ok`, and a source that
122
+ could not be read is never reported as unchanged.
123
+ - **Your reference data is not all public.** Sources are plugins. Your licensed
124
+ AMA CPT tables can be a first-class adapter without forking anything.
125
+ - **PHI must not end up in a tool call.** Parameters are public and typed, no
126
+ receipt or log carries a payload, and a guard refuses input shaped like a
127
+ patient record. It is a structural best-effort refusal, not a HIPAA control:
128
+ `hc_source/guard.py` states exactly what it detects and, just as plainly,
129
+ which evasion classes it does not close.
130
+
131
+ ## The six sources
132
+
133
+ | source | what it answers |
134
+ |------------|-----------------------------------------------------------------------------|
135
+ | `codes` | ICD-10-CM / HCPCS Level II validity on a date of service, release trains |
136
+ | `hcc` | CMS-HCC V24/V28 mapping, hierarchies, community continuing-enrollee scoring |
137
+ | `provider` | NPI Registry lookups, NPPES bulk-file cadence, PECOS enrollment snapshot |
138
+ | `coverage` | CMS coverage policy: NCDs, LCDs and articles, MCD weekly snapshot identity |
139
+ | `leie` | OIG LEIE exclusion checks by NPI, candidate search, monthly refresh status |
140
+ | `demo` | packaged reference adapter used in tests and examples |
141
+
142
+ `codes` and `hcc` answer offline from vendored release data. The rest read live
143
+ CMS and OIG endpoints.
144
+
145
+ ## Everyday commands
146
+
147
+ ```
148
+ hc-source valid-on E11.9 2026-07-01 # offline
149
+ hc-source hcc score --dx E11.9,I50.9 --model v28 --year 2026 --age 72 --sex F
150
+ hc-source lookup-npi 1003000126 # NPPES
151
+ hc-source check-npi 1003000126 # OIG LEIE screen
152
+ hc-source tools # every route, with its parameters
153
+ hc-source call coverage.lookup_ncd --param section=30.3 # the general form
154
+ hc-source mcp # serve it all to an agent over MCP
155
+ ```
156
+
157
+ `call` reaches every route, including ones provided by adapters you installed;
158
+ the named commands are shorthand for the four questions people arrive with.
159
+
160
+ ## Pin your sources and fail the build when they move
161
+
162
+ ```
163
+ hc-source lock init # observe all six sources, write source-lock.json
164
+ hc-source doctor # re-check upstream against the lockfile
165
+ hc-source init --ci # scaffold the GitHub Actions workflow
166
+ ```
167
+
168
+ Commit `source-lock.json` and review its diffs like any other lockfile. A green
169
+ run ends with:
170
+
171
+ ```
172
+ 23 ok, 0 drift, 0 unreachable, 0 schema_changed, 0 unpinned, 0 stale, 0 error
173
+ ```
174
+
175
+ Exit codes are the contract: `0` everything matched, `1` drift or schema change,
176
+ `2` a source was unreachable or an adapter broke, `3` a canary was observed but
177
+ nothing pinned it, `4` everything matched but something reported that what it
178
+ read is out of date. `3` and `4` exist because a missing `source-lock.json` used
179
+ to report `23 ok, 0 drift` at exit 0 — a build that verified nothing, reporting
180
+ green. Full CI setup, per-canary severity, and the `on-unreachable` /
181
+ `on-stale` policies: [docs/ci.md](docs/ci.md).
182
+
183
+ ## Watch it catch drift, in 90 seconds
184
+
185
+ You do not have to wait for CMS to change something. NPPES really did retire its
186
+ V1 bulk-file names on 2026-03-03. The repo carries a recording of what the
187
+ listing page would look like if that class of change happened again:
188
+
189
+ ```
190
+ hc-source lock init
191
+ HC_SOURCE_PROVIDER_NPPES_FILES_URL="file://$PWD/tests/fixtures/provider/npi_files_v1_only.html" \
192
+ hc-source doctor --source provider
193
+ ```
194
+
195
+ Doctor exits `1`, marks `provider.nppes_v2_files` as `drift`, and prints:
196
+
197
+ ```
198
+ provider.nppes_v2_files [drift]: NPPES retired the V1 bulk files on 2026-03-03; only
199
+ *_V2.zip names are published now (V2 extends the First Name and Legal Business Name
200
+ field lengths). The listing this canary just read carries a V1-style name with no _V2
201
+ suffix, which means the page has regressed, a stale mirror is being served, or
202
+ something upstream renamed the grammar again. Fix: open
203
+ https://download.cms.gov/nppes/NPI_Files.html, read the 'Important Information' block
204
+ (where CMS announced the V1 retirement), confirm the current version suffix, make sure
205
+ nothing in your pipeline still generates V1 filenames
206
+ (NPPES_Data_Dissemination_<Month>_<YYYY>.zip silently 404s), then re-pin with
207
+ `hc-source lock init`.
208
+ ```
209
+
210
+ Run it again without the override and it goes back to green. That is the whole
211
+ product: pin what your build depends on, and get told — with an instruction, not
212
+ a stack trace — the moment upstream moves.
213
+
214
+ ## For agents
215
+
216
+ `hc-source mcp` serves every discovered tool over MCP stdio: one MCP tool per
217
+ route, the same typed parameters, the same receipts, the same PHI refusals as
218
+ the CLI. It refuses to start if any adapter failed to import, because an agent
219
+ that sees a short `tools/list` reads it as the whole product.
220
+
221
+ Responses are cached on disk and revalidated conditionally — an entry is stored
222
+ only if the response carried an `ETag` or a `Last-Modified`, and a hit is a
223
+ request that came back `304`, so cached bytes are ones the source confirmed a
224
+ moment ago. A cache hit still reports when its bytes were *retrieved*, with a
225
+ `cache_hit` flag, rather than claiming a fresh read.
226
+
227
+ **How much it helps depends entirely on whether upstream sends validators, and
228
+ that varies a lot.** Measured here against live sources, median of five runs
229
+ each:
230
+
231
+ | command | cold | warm |
232
+ | --- | --- | --- |
233
+ | `hc-source call leie.check_npi --param npi=…` (15.5 MB OIG file) | 2.78s | **0.90s** |
234
+ | `hc-source doctor` (23 canaries) | ~6s | ~6s (no reliable difference) |
235
+
236
+ The LEIE file is served with a `Last-Modified`, so the warm run revalidates it
237
+ in one round trip instead of re-downloading 15.5 MB. `doctor` gets no measurable
238
+ benefit: its canaries are cheap by design, so the round trip already dominates
239
+ what a cache could save. Every path under `api.coverage.cms.gov/v1/` and the
240
+ NPPES NPI Registry API sends **neither** an `ETag` nor a `Last-Modified`, so
241
+ they are re-fetched every time by design — storing a body with no way to
242
+ re-confirm it is exactly the shortcut this product exists not to take.
243
+ `hc-source cache info` lists the endpoints in that state rather than leaving you
244
+ to wonder why the entry count is low.
245
+
246
+ `hc-source cache info|clear`; `HC_SOURCE_CACHE_DIR` moves or disables it.
247
+
248
+ ## Adding your own source
249
+
250
+ Three homes, one contract: ship it in the package, publish it as a distribution
251
+ advertising the `sourcelock.adapters` entry point, or drop a file in
252
+ `./.sourcelock/adapters/` and switch that directory on with
253
+ `HC_SOURCE_LOCAL_ADAPTERS=1` (off by default: loading a file from it means
254
+ executing it, so it is never on where a pull request could add one). A
255
+ third-party adapter gets the same validation, the same PHI guard, and the same
256
+ receipts — and cannot claim a built-in source id.
257
+
258
+ [ADAPTER_GUIDE.md](ADAPTER_GUIDE.md) is the contract;
259
+ [examples/sourcelock-example-adapter/](examples/sourcelock-example-adapter/) is
260
+ a working skeleton with four marked places to change.
261
+
262
+ ## More
263
+
264
+ - [ADAPTER_GUIDE.md](ADAPTER_GUIDE.md) — writing a source adapter
265
+ - [docs/ci.md](docs/ci.md) — the GitHub Action, severity, and CI policy
266
+ - [RELEASING.md](RELEASING.md) — publishing to PyPI
267
+ - `hc_source/guard.py` — what the PHI guard detects, and what it does not
268
+
269
+ ## License
270
+
271
+ MIT. See [LICENSE](LICENSE). Source data carries its own terms; each adapter
272
+ records them in its `SourceContract.license_notes`.