Sentinel Signal

Sentinel Policy M10: Commercial Delivery Surfaces (v1.0.697)

Source: docs/sentinel-policy-m10-commercial-delivery-surfaces-v1.0.697.md

Document Content

Sentinel Policy M10: Commercial Delivery Surfaces (v1.0.697)

What this milestone adds

Per the roadmap's M10 bullet: "watches, digests, CSV, webhooks (HMAC/retry/replay), read-only MCP." Built entirely on M9's customer tenancy/auth and M0's rights-aware projection layer -- no new bypass of either.

  • Saved watches (SavedWatch): a named filter (same shape as
  • GET /v1/changes's query params) plus a DAILY/WEEKLY cadence and a recipient list. Self-service via POST/GET/PATCH/DELETE /v1/watches (API or Production tier). A worker-loop poll (delivery/digests.py::poll_due_digests_once) computes matching events since each watch's last send and emails a digest.

  • Email: stdlib smtplib + EmailMessage
  • (delivery/email.py), ported from verify's own NotificationDispatcher._deliver_email shape -- no third-party provider, no template engine (matches this repo's established convention). SENTINEL_POLICY_SMTP_* env vars; empty smtp_host skips sends cleanly rather than crashing.

  • CSV: GET /v1/changes.csv, same filters and auth as
  • GET /v1/changes, same io.StringIO + csv.DictWriter + text/csv pattern used elsewhere in this repo (app_public_web/main.py). Capped at 40 pages (4,000 rows) per request -- API-002's "no unbounded pagination" spirit applies here too.

  • Webhooks: WebhookEndpoint (Production-tier only, its own
  • filter) + WebhookDelivery (HMAC-signed via X-Sentinel-Signature-256, six attempts over a 1m/5m/30m/2h/12h backoff schedule, then EXHAUSTED). Fan-out is polled from the worker loop against ChangeEvent.webhooks_dispatched_at, not hooked into the publish call sites directly. Replay (POST /v1/webhooks/{id}/deliveries/{id}/replay) creates a new row referencing the original rather than mutating it. See docs/adr/sentinel-policy-adr-003-webhook-delivery-and-replay.md for the full design and the tier/CSV scope judgment call.

  • Read-only MCP tools (mcp/server.py): 9 tools mirroring M9's REST
  • surface (list_changes, get_change, get_change_evidence, list_policies, get_policy, list_policy_versions, get_policy_diff, list_sources, get_source_status), mounted at /mcp via streamable-HTTP. Tool discovery is unauthenticated; each tool call authenticates individually from its own Authorization header, reusing REST's own auth function (authenticate_bearer_token, factored out of require_customer_credential this milestone). Uses the official mcp SDK directly, not the third-party fastmcp package -- see the ADR for why every version of that package is incompatible with this project's pinned fastapi/pydantic versions, verified directly.

  • Shared query logic (api/customer_service.py, new): M9's REST
  • route bodies were inlined query/filter/projection logic; this milestone extracts it into plain functions so REST, the new CSV route, and the new MCP tools all call the exact same filtering code -- including matches_filter(), the single-event predicate both webhook fan-out and digest computation use.

What live testing (well, direct local investigation) caught before deploy

  • A real, hard dependency conflict, found by installing each
  • candidate combination directly rather than trusting changelogs: no version of the third-party fastmcp PyPI package is compatible with both fastapi==0.129.0's starlette<1.0.0 pin and this project's pinned pydantic==2.12.5 at the same time. Resolved by dropping the third-party package entirely in favor of the official mcp SDK's own mcp.server.fastmcp.FastMCP.

  • DNS-rebinding protection rejected every MCP request by default
  • (421 Misdirected Request on initialize itself) -- the SDK's TransportSecuritySettings defaults to an empty allow-list when protection is enabled, which it is by default via FastMCP's own settings layer. Fixed by building a real allow-list from public_base_url plus the test/dev aliases this service is actually ever addressed by.

  • **A route-ordering bug that 404'd /healthz, /readyz, and
  • /v1/build**: app.mount("/", mcp_asgi_app) was originally registered before those three routes were defined further down main.py -- since Starlette matches routes in registration order and a root Mount matches almost any path, it silently shadowed them. The same order-dependent-shadowing bug class as M9's own /v1/policies//v1/sources collision. Fixed by moving the mount to the very end of the file, after every other route. Caught directly by this milestone's own test suite (test_readyz_reports_database_ connectivity failing with 404), not found by hand.

  • **The MCP session manager can only be .run() once per process**:
  • entering with TestClient(app) per test function (the natural pattern every other test file doesn't need, since none of them trigger app lifespan) crashed on the second test with "Task group is not initialized." Fixed with a module-scoped fixture for test_mcp_server.py specifically.

  • A naive/aware datetime comparison bug in the digest due-check
  • (SavedWatch.last_sent_at read back from SQLite loses its tzinfo, same class of bug M2 hit and fixed for Source.last_attempt_at) -- fixed with the same tzinfo is None normalization pattern already established in this codebase.

  • **test_no_commercial_endpoint_bypasses_admin_auth's route walker
  • doesn't recurse into nested dependencies**: require_production_tier is a distinct callable from require_customer_credential even though it composes it internally, and FastAPI's Dependant tree exposes only the direct dependency at each level -- every /v1/webhooks* route showed up as "unprotected" until require_production_tier was added to the test's known-auth-dependencies set alongside it.

  • Cross-test contamination in a shared test database: several new
  • tests initially asserted on aggregate counts (sent_count == 1, delivered == 1) returned by functions that poll the entire saved_watches/webhook_deliveries tables -- correct in isolation, wrong once other tests in the same shared SQLite DB had already created their own due watches/pending deliveries. Fixed by scoping every assertion to the specific row/recipient/URL each test itself created, not the aggregate return value.

Files changed

  • policy/src/sentinel_policy/db/models.py -- SavedWatch,
  • WebhookEndpoint, WebhookDelivery, ChangeEvent.webhooks_dispatched_at.

  • policy/alembic/versions/0010_delivery_surfaces.py -- new migration.
  • policy/src/sentinel_policy/api/customer_service.py -- new, the
  • extracted shared query/filter/projection logic.

  • policy/src/sentinel_policy/api/customer.py -- routes now call
  • customer_service; new GET /v1/changes.csv.

  • policy/src/sentinel_policy/api/customer_auth.py --
  • authenticate_bearer_token factored out for MCP reuse.

  • policy/src/sentinel_policy/api/watches.py,
  • webhooks.py -- new, self-service CRUD.

  • policy/src/sentinel_policy/delivery/email.py, digests.py,
  • webhooks.py -- new.

  • policy/src/sentinel_policy/mcp/server.py -- new.
  • policy/src/sentinel_policy/workers/loop.py -- three new poll phases.
  • policy/src/sentinel_policy/config.py -- SMTP + webhook settings.
  • policy/src/sentinel_policy/main.py -- new routers, MCP mount
  • (registered last), merged lifespan.

  • policy/pyproject.toml -- mcp>=1.9,<2 (no third-party fastmcp).
  • policy/tests/test_webhooks.py, test_watches_digests.py,
  • test_csv_export.py, test_mcp_server.py -- new.

  • policy/tests/test_api.py, test_migrations.py,
  • test_rights.py -- updated for the new milestone string, tables, and auth dependency.

Test results

PYTHONPATH=policy/src:policy/tests python -m pytest policy/tests -q -- 196 passing (up from 170). Also re-ran verify's full 829-test suite against the shared dev virtualenv after the mcp/starlette dependency changes -- all still passing.

Honest status against the exit criterion

The mechanism -- signing, retry, replay, digest computation, CSV export, and MCP tool auth/tier-gating -- is fully live and tested against real seeded fixtures, including the positive path (a COMMERCIAL_APPROVED source's events really do fan out to a matching webhook and a matching digest). What it cannot yet demonstrate live is real delivery volume against production data, because M0's fail-closed state (no source is COMMERCIAL_APPROVED today) is unchanged by this milestone -- exactly the same disclosed limitation M9 shipped with.