doctor-check/v1 adoption guide (per-service)

Audience: the implementer adopting the doctor SDK in a service, with their wave/program (SPRINT_20260722_009 DOC-3 deliverable). Reference adopters: vulnerabilities-web and platform-web — copy their wiring, both were proven live on an isolated scratch assembly (ledger round 55).

The model in one paragraph: every service hosts its OWN checks against its OWN database/config (doctor-check/v1), serves them on GET /doctor/my-service/checks under its own auth policy, and — once its program flips registration on — announces its check catalog to the Platform registry (claims, never results; the aggregation layer pulls results from each service’s endpoint). The old central prober’s cross-schema reach is exactly what this replaces (X10).

1. Host the checks

// Program.cs — SDK: src/__Libraries/StellaOps.Doctor.Plugin.Abstractions
builder.Services.AddServiceDoctorChecks("my-service");           // standard checks: db.connection, db.migration-status, db.size-budget
builder.Services.AddOwnedStateSizeCheck(new MyValkeyMeasurer()); // optional: one per owned non-Postgres state (ownership matrix §6.1–6.3)
...
app.MapServiceDoctorEndpoints().RequireAuthorization(MyPolicies.SomeReadPolicy);

The path carries your service name, and you do not spell it twice. The SDK maps GET /doctor/<service>/checks, taking <service> from the name you passed to AddServiceDoctorChecks — the same string the response body stamps in its service field, so the path and the payload can never disagree about who answered. Until 2026-09-08 every adopter mapped the flat /doctor/checks and published it to the gateway, which keeps one owner per template and ranks equal templates by last heartbeat: four rounds of that path through the gateway were answered by four different services in turn. There is no alias for the old path (SPRINT_20260722_023 GRA-11).

One case needs the name at the call site. If your host gates AddServiceDoctorChecks behind configuration but maps the endpoint unconditionally — so an unprovisioned host still answers an honest 503 — the route object is absent exactly when the gate is closed. Pass the name instead: app.MapServiceDoctorEndpoints("my-service"). platform-web, scanner-web, signals, signer and timeline are the hosts in that shape. Do neither and the host fails at composition rather than serving an unowned path.

Check what the implicit context actually binds on YOUR host, in BOTH directions. AddServiceDoctorChecks registers the context through TryAdd and binds provider.GetService<NpgsqlDataSource>(), and two adopters have now been bitten from opposite sides:

Host shapeWhat the implicit context bindsSymptom
Already registers a bare NpgsqlDataSource for something elsethat poolrelease-orchestrator (RO-3) would have run the three standard checks on the pool that unseals deployment bundles — and it points at the right database today, which makes the accident correct and therefore harder to notice
Registers a DbContext and/or a store that builds its own pool internally, and no bare NpgsqlDataSourcenullsigner (SGN-4) — all three standard checks answer a null OwnDatabase with Healthy = true, "Service registers no database; nothing to check", a clean green about eight owned tables

The second is the worse failure: a wrong pool eventually surfaces, whereas a healthy “no database” answers the question. Neither host can rely on the implicit context, so both register ServiceDoctorContext explicitly and BEFORE AddServiceDoctorChecks (it TryAdds, so an explicit registration placed after it loses silently), with a named data source of their own:

builder.Services.AddSingleton(provider => new ServiceDoctorContext
{
    ServiceName = "my-service",
    OwnDatabase = new NpgsqlDataSourceBuilder(myConnectionString) { Name = "MyService.DoctorStandard" }.Build(),
    Configuration = provider.GetRequiredService<IConfiguration>(),
});
builder.Services.AddServiceDoctorChecks("my-service");   // TryAdds — must come second

A naming aside worth copying: give the doctor’s pool its own Name so doctor traffic is attributable in pg_stat_activity and can never be confused with a serving pool, and keep it OUT of DI so it cannot shadow one in the other direction. Pin the ordering with a test — SignerConsolidationConformanceTests.DoctorContextRegistration_PrecedesAddServiceDoctorChecks is the reference shape.

Rules the SDK enforces (do not fight them):

Guard the endpoint with the ops-health capability (ops.health — the reference adopters both do): one operational-read identity then covers the aggregation fan-out instead of the aggregator collecting every domain read scope. The SDK never invents an auth posture — the policy registration is still yours.

2. Flip registration (with your program, not before)

Prerequisites, in order:

  1. Scope grant. Your service’s Authority client must be allowed platform:doctor:register (scope exists in the catalog — S046; NO grants ship in the seed by design). The grant belongs in the client’s bootstrap config with your program’s rollout. Fact worth knowing: a DB allowed_scopes grant survives the standard plugin’s client ensure, and the descriptor registry honors it after an Authority restart.

  2. Wiring. Copy the reference block (hub: Vulnerabilities.WebService/ Program.cs; platform: Platform.WebService/Program.cs): AddServiceDoctorRegistration(configuration) + hang client-credentials auth on the doctor-registration named HttpClient + an explicit token cache (the auth client refuses the implicit in-memory cache outside Dev/Testing). Both blocks reject a blank credential, not only a missing one — keep that guard when you copy them (§3), it is conformance-enforced.

  3. Config (all under Doctor:Registration:; everything fail-closed):

    KeyExampleNote
    Enabledtruedefault false — an ungrated client must not hammer Platform with 403s
    PlatformBaseAddresshttp://platform.stella-ops.localin-network
    SelfEndpointhttp://my-service.stella-ops.local/doctor/my-service/checkswhat the aggregation layer calls
    Authorityhttps://authority.stella-ops.localHTTPS — see trap below
    TokenEndpointhttps://authority.stella-ops.local/tokenexplicit override, see trap below
    ClientId / ClientSecretyour service clientnever a shared account
    Tenantdefaultdrives stellaops:tenant on the token

Behavior after the flip: register on start (registered: N check(s) in the log), heartbeat on HeartbeatInterval; a heartbeat 404 re-registers immediately — the registry lost us, never a silent OK.

3. Traps (each cost a debugging round on the reference adoption)

4. What NOT to do