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 shape | What the implicit context binds | Symptom |
|---|---|---|
Already registers a bare NpgsqlDataSource for something else | that pool | release-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 NpgsqlDataSource | null | signer (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):
- the
ServiceDoctorContextbinds YOUR data source and YOUR configuration — never another service’s (that is the disease being removed); - a throwing check becomes an unhealthy result, never a crash;
- envelopes are deterministic (no timestamps in the payload);
- an unbudgeted size check reports the measured size at Info — visible, never silent; budgets come from
Doctor:State:{StateId}:BudgetBytes/Doctor:Db:BudgetBytes.
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:
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 DBallowed_scopesgrant survives the standard plugin’s client ensure, and the descriptor registry honors it after an Authority restart.Wiring. Copy the reference block (hub:
Vulnerabilities.WebService/ Program.cs; platform:Platform.WebService/Program.cs):AddServiceDoctorRegistration(configuration)+ hang client-credentials auth on thedoctor-registrationnamed 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.Config (all under
Doctor:Registration:; everything fail-closed):Key Example Note 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 client never a shared account Tenantdefaultdrives stellaops:tenanton 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)
ID2083 — “This server only accepts HTTPS requests”. Authority rejects BOTH token and discovery requests over plain HTTP. Point
Authorityat HTTPS and set the explicitTokenEndpoint; mount the lab CA bundle so outbound HTTPS validates (devops/compose/gateway-ca-bundle.crt→/etc/ssl/certs/ca-certificates.crtin the compose service).Token cache.
AddStellaOpsAuthClientthrows at first use in Production without an explicit cache. Single-replica services register the in-memory cache viaReplace(the AdvisoryAI precedent, copied by both reference adopters); multi-replica services use the messaging cache.Image prune eats the SDK.
StellaOps.Doctor.Plugin.Abstractions.dllmatches the*.Plugin.*prune pattern by name; it is allowlisted inbuild-service-publish.sh— if your service image crashes at boot withFileNotFoundExceptionfor it, the allowlist regressed.GET /doctor/my-service/checks401 through the gateway means your own policy — not the SDK.403on registration means the scope grant is missing.A blank credential is not the same as a missing one — reject both.
Doctor:Registration:{Authority,ClientId,ClientSecret}are typically wired as${VAR:-}, so an operator who flipsEnabledwithout setting the client gets"". Guard withstring.IsNullOrWhiteSpace, never??(null-coalescing does not fire on an empty string): otherwise the host starts and 403-loops against Platform with an empty client id, which is the failure the default-off posture exists to prevent, arriving by a different door. Test it with" "rather than""— whitespace is the shape a half-filled compose value actually takes, and an empty-string fixture also passes against!IsNullOrEmpty, which would leave the whitespace door open.§2’s reference block now carries the corrected form, so copying it is safe again, and
DoctorRegistrationCredentialGuardConformanceTests(src/__Tests/architecture/StellaOps.Architecture.Contracts.Tests/) fails any adopter that reintroduces??on those three keys. This is not an SDK options validator, and cannot become one: a host that rides its own existing auth client rather than minting a doctor one (AdvisoryAI’s five live adapters, Authority’s Console workspace token) reads a blankClientIdas the legitimate “ride the host identity” signal — their compose files say to leaveDoctor__Registration__ClientIdunset, and${VAR:-}renders exactly the blank such a validator would reject. Requiring the values is per-host auth posture; rejecting the unsafe shape is estate-wide.Correction history Found independently, within an hour SPRINT_20260722_010 FND-7 ( f0757c42c9), SPRINT_20260722_012 JOB-7 (eb6565fb5d) — threeAssert.Throwscases verified red firstGuide corrected, adopters pointed away from §2 c42cb0f9d7Reference hosts fixed at source + conformance guard added SPRINT_20260722_009 DOC-3 — Vulnerabilities.WebService,Platform.WebService; red-proofed 3/3 per hostA check scoped to “a schema called X” is wider than the plane it names. The null-context trap above is about the context binding the WRONG pool (or none). This is its sibling and it survives a correct context: the pool is right, the SQL is right, and the check still answers about the wrong data, because a schema NAME is not an identity. Live instance, measured 2026-08-23: the hub’s folded binary plane lives in schemas
binaries/symbolsinstellaops_vuln, and the retiringbinaryindex-web/symbolsdeployables keep schemas with exactly those two names instellaops_platformon the same PostgreSQL server — the two planes even share six table names. Pointed at the neighbour,doctor.vulnerabilities.binaries.corpus-footprintreportedHealthy = true, “Binary plane occupies 1,171,456 bytes, within the 25 GiB budget” — a fluent, plausible, entirely wrong green. Nothing about the output looks like a bug.The fix is identity before measurement: resolve a marker that only YOUR plane has (
BinaryPlaneTableCensusCheck.PlaneMarkerTablesis the reference shape —to_regclassover a fixed list), and when it is absent report not healthy without publishing the number. Publishing the measurement is what makes the wrong answer authoritative.The test that catches this is not a malformed input — a garbage database fails every check for the wrong reason. Plant the neighbouring domain: build the sibling plane faithfully, shared table names included, and assert the check refuses.
BinaryPlaneDoctorCheckTests.The_footprint_check_refuses_a_verdict_about_the_NEIGHBOURING_plane(SPRINT_20260722_014 BIN-7) is the reference; it was red-proofed by disabling the gate and watching it return the confident green above.
4. What NOT to do
- Do not register another service’s checks, probe another service’s database, or proxy someone else’s
/doctor/my-service/checks— the registry + per-service endpoints replace central probing entirely (X10; the DoctorPlugins.Databasecross-schema checks are deleted by DOC-4). - Do not push check RESULTS to the registry — it stores claims; results are pulled live from
SelfEndpoint. - Do not enable registration before the scope grant lands; the default-off posture exists so a misconfigured service degrades to local-only checks instead of a 403 loop.
