Doctor plugin hosting — per-service check contract (DOC-1)

Status: v1 draft, 2026-08-02 (SPRINT_20260722_009 DOC-1). Architecture-role pass done; owner veto window open. Contract version: doctor-check/v1.

1. Problem

Doctor today is a CENTRAL prober: doctor-web holds connections into many services’ schemas (X10 in the ownership register — DatabaseCheckBase opens whatever database a check names) and runs every check family in one host. Under ADR-039 that is exactly backwards: a service’s health, config sanity, data integrity and capacity are facts about the service’s OWN database and config, and only the service may hold that connection (P11/D11, CoC §8.2).

The owner-approved rehoming map (sprint 009 header) splits the estate: per-service check families move INTO each service; estate-level families (Docker, Observability, ServiceGraph) become JobEngine-scheduled packs; gated self-healing maps onto signed Task Packs. This document designs the first piece: the contract and hosting seam a service uses to run its own checks.

2. What is evolved, not invented (the standing meta-lesson)

StellaOps.Doctor already owns the check lineage: IDoctorCheck (id, severity, tags, CanRun/RunAsync, ADR-026 DeclareHeal), DoctorCheckResult, DoctorSeverity, heal declarations. Thirteen plugin families implement it. None of that is re-invented. What changes is WHERE checks execute and WHAT they may reach:

The existing IDoctorCheck is NOT reused as-is, for one deliberate reason: its DoctorPluginContext is the central engine’s context (service registry, remote endpoints, ambient estate view). Carrying that type into every service would drag the engine graph along — the exact P19 failure mode this program removes. The new SDK defines a NARROW context; the old interface remains for the estate-level packs until DOC-4b rehomes them.

3. Contract (doctor-check/v1)

New closed, domain-neutral SDK: src/__Libraries/StellaOps.Doctor.Plugin.Abstractions.

IServiceDoctorCheck
  string CheckId            // "doctor.<service>.<area>.<name>", stable, kebab
  string Category           // health | config | data-integrity | capacity
  DoctorCheckSeverity DefaultSeverity   // info | warning | critical
  Task<ServiceDoctorCheckResult> RunAsync(ServiceDoctorContext ctx, CancellationToken ct)

ServiceDoctorContext
  string ServiceName        // the HOST's registered name (e.g. vulnerabilities-web)
  NpgsqlDataSource? OwnDatabase   // the service's OWN data source, or null for DB-less hosts
  IConfiguration Configuration    // the host's configuration root
  TimeProvider Clock

ServiceDoctorCheckResult    // deterministic envelope
  CheckId, Category, Severity (effective), Healthy (bool)
  Message                   // one sentence, operator-facing
  Evidence                  // ordered key→string map; POINTERS and measured
                            // values only, never dumps (a result must be safe
                            // to ship in a support bundle)
  ContractVersion = "doctor-check/v1"

Rules the contract enforces by construction:

  1. Own-resources only. The context carries the host’s own data source and configuration and nothing else — no service registry, no HTTP factory, no foreign connection strings. A check that needs another service’s state is not a service check; it is an estate check (DOC-4b) or a consumer of the other service’s API.
  2. Deterministic envelope. Results carry measured values and pointers, never wall-clock-derived identity; two runs against identical state produce identical envelopes (the aggregation layer adds observation timestamps, results do not carry them).
  3. Versioned. ContractVersion rides every result; the registry (DOC-2) records it; consumers apply the same n/n-1 window discipline as every other versioned surface.
  4. Heal posture deferred, not dropped. ADR-026 heal declarations map onto JobEngine Task Packs in DOC-4b. v1 deliberately has no heal member — a service-hosted check declares nothing it cannot locally honor, and adding a member later is additive.

4. Hosting seam

services.AddServiceDoctorChecks("service-name") registers:

Execution is on-demand (endpoint) plus an optional hosted background runner with a configurable interval; results are held in-memory (bounded, latest-per-check). Persisting results is the aggregation layer’s business (Platform registry / JobEngine run records per the rehoming map), never the SDK’s — a check library that writes tables would re-create the migration-authority problem the program kills.

5. Standard checks (every adopting service, for free)

CheckId suffixCategoryWhat it measures (own DB only)
db.connectionhealthSELECT 1 round-trip on the own data source; failure = critical
db.migration-statusconfigthe service’s schema_migrations ledger(s): applied count, checksum-verify of the last applied row; a pending/unknown state is warning, a checksum mismatch critical
db.size-budgetcapacitypg_database_size() vs the service’s declared P13 budget (Doctor:Database:BudgetBytes, fail-closed to warning at 80%, critical at 100%; unset budget = info with the measured size — visible, never silently unbudgeted)

6. P19 posture

The SDK is classified domain-neutral-shared in the ownership manifest: its closure is Npgsql + Microsoft.Extensions primitives (+ Infrastructure.Postgres for the ledger read). It may never reference src/Doctor/** or any service implementation; the conformance suite enforces the classification. Adopting services source-reference the SDK only — service-specific checks live in the SERVICE’s tree, not in shared libraries.

7. What this deliberately does not do