ADR-026: Doctor integration-coverage clause, structural probe hardening, and gated self-healing
Status: Accepted (design-only) Date: 2026-06-08 Sprint: SPRINT_20260608_005_Doctor_integration_clause_contract.md (with 003, 004, 006, 007, 008) Related ADRs: ADR-004 (forward-only migrations — remediation-audit + manifest persistence), ADR-006 (EPF — heal actions dispatch capabilities like restart/redeploy), ADR-024 (silent-green anti-pattern — swallowed failure + conflated readiness)
Read this before adding a Doctor check, touching the Doctor engine/plugin loader, or wiring any remediation/execution path.
Context
Doctor is the operator’s “is the platform healthy” surface. A whole-repo audit on 2026-06-08 (21-agent sweep, cached at workflow wbivcw3to) inventoried 250 external/internal dependency points against 121 existing checks and found the engine bones are sound but coverage is broad-but-shallow, substantially dead, and “healing” is almost entirely guidance rather than execution. The failure shape mirrors ADR-024: a green light that proves nothing.
Verified facts (file:line):
- Engine isolation already exists — but with one hole.
CheckExecutor.ExecuteCheckAsyncwraps every check intry/catchand converts a throw into aFailresult, so one bad check never aborts the run (src/__Libraries/StellaOps.Doctor/Engine/CheckExecutor.cs:91-111). Howevercheck.CanRun(context)is invoked outside that try (CheckExecutor.cs:85) — a throwingCanRuncrashes the parallel body. The per-check timeout is cooperative and a single global 30s (DoctorRunOptions.Timeout;EstimatedDurationis collected but never used to size it), so a check doing blocking I/O that ignores its token stalls a worker for the full hang. Plugin discovery (CheckRegistry.GetChecks/IsAvailable) runs untimed. - Coverage looks greener than it is — config-key drift. Several real checks read the wrong configuration key and therefore silently
Skipon the actual deployment: Valkey readsValkey:ConnectionStringwhile the platform usesRouter__Messaging__valkey__ConnectionString/ConnectionStrings__Redis(ValkeyConnectivityCheck.cs:37-44); the gateway entry inServiceEndpointsCheckgates onStellaOps:GatewayUrlvs the realRouter__Gateways__0__Host(ServiceEndpointsCheck.cs:64); the Database check readsConnectionStrings:DefaultConnectionvsConnectionStrings__StellaOps(DatabaseCheckBase.cs:109-120). A skipped check reports nothing and looks healthy. - Dead and forked plugins inflate the count. Registered in no host:
Plugins.Authority(AddAuthorityDoctorPluginuncalled),Plugins.Cryptography(AddDoctorCryptographyPluginuncalled), in-processPlugins.Notify(no DI extension), mountedPlugin.Notify(absent fromDoctorRuntimePluginTargets),Plugin.Policy(no csproj), bothAgentvariants,AI,Sources. Three capabilities exist as duplicate forks (in-processStellaOps.Doctor.Plugins.*vs mountedStellaOps.Doctor.Plugin.*) — Crypto, Notify, Postgres/Database — with divergent code and the same bugs. - Fake-green stubs. Checks that return hardcoded data while looking like probes: the entire Operations plugin; 3 of 4 Auth checks (
SigningKeyHealthCheckreturns the literal"key-2024-01-15"); the 6 Crypto checks; all 3 Vex checks; the Attestor cosign Fulcio/KMS/Vault paths;PostgresMigrationStatusCheck.GetPendingMigrationsAsync(always empty); EvidenceLocker Merkle verification (field-presence only).check.postgres.migrationsprobes the wrong table (public.__EFMigrationsHistory, EF) vs the real<schema>.schema_migrations(MigrationRunner.cs:419). - Checks can vanish and Doctor still reports all-green. Mounted plugins load via
Assembly.LoadFrominto the default ALC +GetTypes()(DoctorMountedRuntimePluginLoader.cs:476,549-556); a missing trust-root (cosign.pub) or unresolved transitive dep makes the loader silently Reject the whole plugin (:341-343), and its checks disappear from the run./readyzreturns staticOk(Program.cs:219) regardless. Nothing probes loader health. - “Healing” is guidance, not execution. The engine has no executable remediation abstraction:
IDoctorCheck.RunAsynconly yields aDoctorCheckResultcarrying a descriptiveRemediationrecord of command strings (Models/RemediationStep.cs). The only generic executor is the CLIstella doctor fix --apply, hard-gated tostella-prefixed shell commands (DoctorCommandGroup.cs:895-958) — it cannot runsystemctl/psql/docker. Timestamping’sIAutoRemediationServiceis the only executable auto-heal framework, but it is plugin-local, wired to no-opUnavailable*stubs, and never invoked by any host (AutoRemediation.cs;NullDependencyProviders.cs:92-139). The UI “Run Fix” button copies to clipboard. A mature, reusable remediation engine already exists atsrc/ReleaseOrchestrator/__Libraries/StellaOps.ReleaseOrchestrator.Environment/Inventory/Remediation/RemediationEngine.cs(plan/execute split, severity-gating, rate-limit, evidence writer, maintenance-window guard) and is not referenced by Doctor.
The platform is offline / air-gap-first and supply-chain-sensitive. The fix must make coverage and healing structural invariants (impossible to skip, impossible to silently lose) rather than per-author discipline, and any execution must be locally-gated with durable evidence.
Decision
One integration manifest is the source of truth for “all integrations.” Introduce a declared
IntegrationCatalog— every external and internal-3rd-party dependency (Postgres per schema, Valkey, gateway/router, identity-envelope, each sibling service, TSA, Rekor, Vault, OCI/GitLab registries, SmRemote/HSM, NATS, OTLP, SMTP, OCSP/CRL/LOTL, debuginfod, object storage, …) is a registered entry carrying its id, kind, canonical config keys, blast radius, and owning check id. A meta-checkcheck.core.integration-coveragefails when any manifest entry has no live check. Adding an integration without a check is now a red Doctor run, not an oversight.IntegrationCheckbase class makes try/catch structural. Integration checks derive from a base that (a) runs the author’s probe body inside the canonical typed handler (CheckExceptionLogging.IsTransientProbeException/IsParseException→Fail-with-remediation; unexpected types still bubble to the engine), (b) resolves config keys from the manifest (eliminating drift — a check can no longer read a stale ad-hoc key), and © forbids swallow-to-Pass: a probe that cannot reach its dependency must emitFail/Warn/Skip(reason), neverPass. “Each integration tries/catches against failure” becomes a property of the base, not a thing authors remember.Every integration check must offer a heal hook. The check contract gains heal actions (
IReadOnlyList<IRemediationAction> GetHealActions(DoctorCheckResult)/Task<RemediationOutcome> HealAsync(...)). EachIntegrationCheckmust return at least oneIRemediationActionor explicitly declareManualOnly(reason); theBareCatchAnalyzercompanion rule and a registration test enforce it.IRemediationActionis a runnable, idempotent operation (Id/Description/IsDestructive/RequiresApproval/DryRun), not a command string.Executable remediation moves to the engine and reuses the existing engine. Lift the
IRemediationAction/runner pattern out of Timestamping into the shared Doctor engine, backed by theReleaseOrchestrator.EnvironmentRemediationEngine(plan/execute,SeverityScorergating,RemediationRateLimiter,IRemediationEvidenceWriter, maintenance-window guard). Surface it asPOST /api/v1/doctor/remediate(newdoctor:remediatescope, audited), a CLI verb, and real UI wiring. Audit is durable (Postgres, forward-only per ADR-004) — replacing the volatile/no-op stubs.Self-healing posture: full gated auto-heal — this supersedes the
src/Doctor/AGENTS.md“destructive = manual guidance only” rule. Operators chose maximum automation with maximum safety:- Auto-execution is OFF by default; enabled per-action via config, never globally implicitly.
- Non-destructive heal actions (refresh, restart-to-converge, create-dir, failover, cache-warm) may auto-run within rate-limit + maintenance-window once enabled.
- Destructive heal actions are now permitted to auto-execute — but only behind the full gate: explicit per-run approval token, a dry-run preview (
DryRunVariant), an active maintenance-window, durable audit, rate-limit, and thedoctor:remediatescope. Absent any gate element, a destructive action falls back to manual guidance. - Air-gap invariant: all execution is local; no cloud-managed services, no external callbacks, no telemetry egress. Heal actions that would mutate state outside the host (e.g. external registry writes) are non-destructive-only and respect existing opt-in flags. The module
AGENTS.md“Evidence and remediation” section is updated to reference this ADR as the governing policy.
Plugin-load and readiness honesty closes the “checks vanish → all green” hole. Add
check.core.plugins.loaded(expected-vs-actually-admitted plugins, surfacingDoctorPluginProbeReporterrejection reasons) andcheck.core.plugins.trustroot(trust-root presence/validity)./readyzfails when mounted-plugin admission was rejected, the trust-root is missing, the report-store is unreachable, or the identity-envelope key is absent. A hollow-but-“ready” Doctor is no longer possible.Truth-up is a precondition, and fake-green is forbidden. Dead plugins are removed or wired; exactly one implementation tree is chosen per capability; and a check may never return a hardcoded
Pass— a not-yet-implemented probe mustSkip(reason)(visible as “not verified”), never simulate success. Stubbed checks are either implemented as real probes or converted to honestSkip.Engine resilience hardening. Move
CanRuninside the executor try; add a discovery-time timeout forGetChecks/IsAvailable; add a watchdog so a blocking check that ignores its token is reported (not silently slot-hogging); size the per-check timeout fromEstimatedDuration; add a bounded retry/jitter policy distinct from the global timeout so a one-off blip is not a false alarm; and escalateDOCTOR0001to error severity with extended scope (swallow-to-Passand the non-IDoctorCheckhelper classes checks delegate to).
Consequences
- New persistence: a Doctor remediation audit table and the integration manifest store (forward-only migrations per ADR-004, auto-migrated on startup per repo §2.7). The Scheduler’s volatile dev repositories are replaced with the durable Postgres implementations so schedules/trends/alerts survive restart.
- New authorization: a
doctor:remediatescope (catalog inStellaOps.Auth.Abstractions/StellaOpsScopes.cs) plus an auditedPOST /remediateendpoint distinct fromdoctor:admin. - Behavior change (scoped, release-noted): Doctor runs go red for previously-invisible failures — uncovered integrations (
check.core.integration-coverage), skipped-due-to-drift checks (after key fixes), rejected plugins, and hollow/readyz. This is the point. Auto-heal stays off by default so no environment starts executing fixes without explicit opt-in. - Policy supersession: the
src/Doctor/AGENTS.mdrule “Remediation commands must be non-destructive; destructive steps are manual guidance only” is replaced by Decision 5’s gated model. The AGENTS.md text is updated in the same change that lands the gate. - Reuse, not reinvention:
ReleaseOrchestrator.Environment.RemediationEngine+RemediationRateLimiter+ evidence writer; Timestamping’sIRemediationAction/AutoRemediationshape (generalized);CheckExceptionLogging; theRemediationBuilderIsDestructive/DryRunVariantfields (already present). - Determinism / offline: checks remain deterministic and offline-safe in tests; heal execution is local-only; no cloud-managed defaults introduced (repo invariant).
- Scale: the gap is large (98 add-check, 48 harden, 16 add-heal, 34 P0). It is sequenced across
SPRINT_20260608_003..008(truth-up → engine hardening → the clause contract → the gated heal engine → P0 coverage → P1/P2 breadth) so each phase is independently verifiable and the coverage meta-check ratchets monotonically upward.
