Sentinel Signal

v1.0.672 — Sentinel Policy M2: CMS NCD vertical slice (acquisition)

Source: docs/sentinel-policy-m2-cms-ncd-vertical-slice-v1.0.672.md

Document Content

v1.0.672 — Sentinel Policy M2: CMS NCD vertical slice (acquisition)

Context

M1 shipped M1's ingestion spine live with one generic DirectDocumentAdapter and no real source registered. M2's job, per the product spec's own milestone plan: prove the spine works against a real authoritative source — CMS National Coverage Determinations (NCDs) — end to end, idempotently. Acquisition only: no policy identity/versioning (M3), no diffing (M4), no extraction (M5), no customer-facing anything (M9+, still gated behind the M0 legal review that hasn't happened).

Before writing any code, pulled the real, official CMS Coverage API OpenAPI spec directly (https://api.coverage.cms.gov/docs/v1/coverage-api.json, discovered via the Swagger UI's index.js config after the HTML shell alone wasn't enough — 97 endpoints total) and hit three live endpoints to confirm actual response shapes before designing anything against them.

What shipped

**CMSCoverageAdapter** (policy/src/sentinel_policy/adapters/cms_coverage.py) — a real, second implementation of M1's unchanged SourceAdapter Protocol:

  • First-ever run (since is None): GET /v1/reports/national-coverage-ncd/ — confirmed live, 345
  • NCDs, single unpaginated response.

  • Every run after (since set): GET /v1/reports/whats-new/national/?timeframe=N&document_type=NCD
  • — incremental, timeframe computed from since and clamped to the API's own documented 120-day maximum.

  • Per-document fetch: GET /v1/data/ncd/?ncdid=X&ncdver=Y, via M1's unmodified secure_fetch()
  • — no new fetch/retry/SSRF logic needed anywhere.

Adapter registry (policy/src/sentinel_policy/adapters/registry.py, new): build_adapter() moved out of direct_document.py now that a second adapter exists; dispatches on adapter_type. No behavior change for the existing direct_document path.

Deliberate non-goal, not an oversight: no AMA/ADA/AHA license-agreement Bearer-token flow is implemented, even though the real API confirms some NCD content requires one. Accepting a licensing agreement programmatically is exactly the kind of decision the product spec's M0 legal-review gate exists for. Any license-gated fetch 401/403s, which M1's existing fetcher already classifies as a permanent error — the source lands in REVIEW_REQUIRED with zero new code, not a crash.

A real bug found and fixed during testing

_clamp_timeframe_days() assumed its since argument was always timezone-aware. SQLite (used for local dev/tests) doesn't round-trip timezone info even through a DateTime(timezone=True) column — a value written as UTC-aware can come back naive on read. Surfaced immediately by the idempotency test's second run_discovery() call, which passes the real source.last_success_at from the DB (not a hand-constructed test datetime). Fixed by treating a naive value as UTC, matching this codebase's existing convention (ingest.py's _utcnow()).

Verified against the real, live CMS API — not just fixtures

Built the real Docker image, ran it against a real local Postgres, registered the actual CMS source via the deployed API shape, and:

  1. Triggered a full backfill: 345/345 real NCD documents discovered and archived successfully
  2. in ~2m15s, zero errors.

  3. Triggered a second run: correctly took the incremental what's-new path (~3s, 8 records — real
  4. CMS "recently updated" NCDs as of today), and **the database still shows exactly 345 unique document_assets** — every one of those 8 was already-archived content, correctly deduped by content hash — while document_observations grew from 345 to 353, proving observation history is preserved per run even when the underlying asset isn't re-created. This is the actual M2 exit criterion ("idempotent... can run repeatedly without duplicates"), proven against production data from the real external source, not recorded fixtures.

Verification

  • PYTHONPATH=policy/src:policy/tests python -m pytest policy/tests -q — 35 passed (was 30; +5 new:
  • full-list parsing against the real captured 345-record fixture, incremental timeframe clamping, idempotent run_discovery() against fixtures, and the license-required-403 → REVIEW_REQUIRED safety test).

  • python -m pytest tests/ -q — 334 passed. verify/tests — 824 passed. Neither touched by this
  • milestone (only files under policy/ changed).

  • Real fixtures (not invented) captured from the three live endpoints during planning, stored under
  • policy/tests/fixtures/cms_coverage/.

Not done / explicitly out of scope

License-token acquisition (see above). LCD/Article/MAC adapters. Policy identity/version modeling, sectionization (M3). Deterministic diffing (M4). LLM extraction (M5). Evidence/confidence scoring (M6). Any customer-facing surface (M9+). No changes to M1's fetcher, storage, ingest orchestration, or DB schema — this milestone's whole point was proving that machinery is genuinely reusable for a real source, not a reason to modify it.