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:
- Today: central engine, cross-DB connections, one deployable probing everything.
- Target: the same conceptual contract, hosted by the owning service in-process, scoped to the service’s own
NpgsqlDataSource/configuration; the central prober dies (DOC-5).
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:
- 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.
- 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).
- Versioned.
ContractVersionrides every result; the registry (DOC-2) records it; consumers apply the same n/n-1 window discipline as every other versioned surface. - Heal posture deferred, not dropped. ADR-026 heal declarations map onto JobEngine Task Packs in DOC-4b.
v1deliberately 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:
- the context (binding the host’s own
NpgsqlDataSourcewhen one is registered); - every
IServiceDoctorCheckin the DI container; - the three STANDARD checks every PostgreSQL-owning service gets for free (§5);
- a minimal endpoint contract:
GET /doctor/<service>/checks(list + last results) served by the host under its normal auth — the aggregation layer (DOC-2) calls this, it never probes the database.
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 suffix | Category | What it measures (own DB only) |
|---|---|---|
db.connection | health | SELECT 1 round-trip on the own data source; failure = critical |
db.migration-status | config | the 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-budget | capacity | pg_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
- No replacement of the 13 existing plugin families in this task — they migrate per-service as each program’s S7/M2 lands (DOC-3 proves the pattern on two services first).
- No capability registry (DOC-2), no estate packs (DOC-4b), no doctor-web retirement (DOC-5).
- No heal execution: ADR-026 semantics arrive with the Task-Pack mapping, gated as today.
