Estate-doctor Scheduler/worker plugin staging
CURRENCY — 2026-09-12 (SPRINT_20260722_012 JOB-9). The producer host in this runbook is now
jobengine-web, notscheduler-web: the ADR-039 D14 cutover merged scheduler-web and packsregistry-web into the consolidated JobEngine family and froze both predecessors undersrc/__Obsoleted/JobEngine/. Two consequences for the steps below. (a) Every service name, pin file and build key was repointed in place (docker-compose.local-jobengine-web.ymlsucceedsdocker-compose.local-scheduler-web.yml). (b) The former step 9 (“Seedstellaops_platform”) was deleted on 2026-09-14 (SPRINT_20260914_003 JEF-1). It existed only because the live scheduler fired fromstellaops_platformwhile the producer read constraints and pack content fromstellaops_jobengine, so the same schedule had to be seeded into BOTH. After the cutover there is ONE database — seedstellaops_jobengine(step 7) and stop; PLT-4 dropped the platform-sidescheduler/packsschemas on 2026-09-14. The simple-identifier rule it carried now lives in step 7: it is a model-level read validation that applies to whatever you write. Paragraphs that record what was MEASURED on 2026-09-10 keep their original service names on purpose; they are history, not instructions.
Status and boundary
The first part of this runbook is repository-side staging: it prepares the signed Scheduler producer and exact five-family Doctor runtime-plugin set, and proves that their held-back Compose overlay renders. It does not by itself authorize a service start, recreate, deployment, image or registry publish, release, or live-estate forcing call. The estate activation record at the end is the procedure that WAS executed on the lab estate on 2026-09-10 under an owner-directed window (SPRINT_20260722_009 DOC-4b); it is the reference for the next estate.
The overlay devops/compose/docker-compose.plugins.scheduler-estate-doctor.yml is absent from the default Compose chain. It takes effect only when an owner-attended window explicitly adds the file and selects --profile estate-doctor.
Verified against implementation commits c8efe1397e80161f5abb625b159aceac894e6b62, 1fa385c0b53b70dceefd232f72ff4df245270e76, and ab32c14569547aaca53de9c69366d0756fb07657. Re-verify the contract and admission behavior with the targeted commands in Repository verification.
Artifact map
| Plane | Staged artifact | Admission contract |
|---|---|---|
| Scheduler producer | devops/plugins/scheduler/estate-doctor/stellaops.scheduler.plugin.estate-doctor/ | id stellaops.scheduler.plugin.estate-doctor; capability scheduler.job.estate-doctor; job kind/profile estate-doctor |
| Estate worker checks | devops/plugins/doctor/estate-doctor/ | exactly five child bundles: stellaops.doctor.docker, stellaops.doctor.observability, stellaops.doctor.servicegraph, stellaops.doctor.security, stellaops.doctor.core.worker; capability doctor:checks; every managed DLL has its own detached signature |
| Scheduler trust root | devops/etc/certificates/trust-roots/plugins/scheduler/cosign.pub | mounted read-only at /app/trust-roots/plugins/scheduler/cosign.pub |
| Doctor trust root | devops/etc/certificates/trust-roots/plugins/doctor/cosign.pub | mounted read-only at /app/trust-roots/plugins/doctor/cosign.pub |
The Scheduler bundle is only the producer that evaluates the pack gate and queues approved work. It deliberately contains no Doctor implementation DLL. The separate Doctor root contains exactly the five ruled estate families and no other executable family. The loader binds each id to its exact primary assembly and refuses a missing or unexpected directory before loading any assembly. The overlay mounts both bundle roots and both trust roots read-only; it does not alter the base worker’s Docker endpoint or socket mapping.
Required operator inputs
Supply secrets through the operator’s environment or mounted files. Never put their values in this repository, command history, chat, or evidence output.
STELLAOPS_POSTGRES_JOBENGINE_CONNECTIONSCHEDULER_COSIGN_KEY_PATHSCHEDULER_COSIGN_PUBLIC_KEY_PATHDOCTOR_COSIGN_KEY_PATHDOCTOR_COSIGN_PUBLIC_KEY_PATH(production signing; the lab estate instead uses the packager’s-UseOfflineDevSignertogether with the module’s-Sign<Module>Bundlesswitch — the offline signer alone signs nothing (measured 2026-09-14:signed: false, zero.sig); its RSA key pairs live git-ignored underdevops/plugins/_signing/runtime-dev/<module>/and the packager writes the public halves to the trust-root directories)KESTREL_CERT_PASSWORDwhen required by the selected base Compose stackESTATE_DOCTOR_WORKER_ROLEandESTATE_DOCTOR_TENANTfor the attended windowESTATE_DOCTOR_DOCKER_ENDPOINTandESTATE_DOCTOR_DOCKER_SOCKETonly when the estate’s Docker endpoint differs from the base defaults
Stage the signed bundles
Run these from the repository root only after the signing key paths and public key paths are present in the local operator environment:
pwsh ./devops/build/package-runtime-plugins.ps1 `
-Module scheduler `
-Profile estate-doctor `
-SignSchedulerBundles `
-SchedulerCosignKeyPath $env:SCHEDULER_COSIGN_KEY_PATH `
-SchedulerCosignPublicKeyPath $env:SCHEDULER_COSIGN_PUBLIC_KEY_PATH
pwsh ./devops/build/package-runtime-plugins.ps1 `
-Module doctor `
-Profile estate-doctor `
-SignDoctorBundles `
-DoctorCosignKeyPath $env:DOCTOR_COSIGN_KEY_PATH `
-DoctorCosignPublicKeyPath $env:DOCTOR_COSIGN_PUBLIC_KEY_PATH
The 2026-08-26 repository rehearsal used only an ephemeral scratch RSA key under git-ignored tmp/scratch/; no production signing key was used and no artifact was published or activated. Generated bundles, signatures, reports, and copied trust roots are operational material: inspect them locally and never commit them.
Inspect the staged artifacts
The canonical descriptor is manifest.json (not runtime-plugin.json). The following checks must all pass before a window can be considered:
$schedulerBundle = 'devops/plugins/scheduler/estate-doctor/stellaops.scheduler.plugin.estate-doctor'
$doctorRoot = 'devops/plugins/doctor/estate-doctor'
$doctorFamilies = [ordered]@{
'stellaops.doctor.docker' = 'StellaOps.Doctor.Plugins.Docker.dll'
'stellaops.doctor.observability' = 'StellaOps.Doctor.Plugins.Observability.dll'
'stellaops.doctor.servicegraph' = 'StellaOps.Doctor.Plugins.ServiceGraph.dll'
'stellaops.doctor.security' = 'StellaOps.Doctor.Plugins.Security.dll'
'stellaops.doctor.core.worker' = 'StellaOps.Doctor.Plugins.Core.Worker.dll'
}
$requiredDoctorIds = @($doctorFamilies.Keys | Sort-Object)
$actualDoctorIds = @(Get-ChildItem -LiteralPath $doctorRoot -Directory |
Select-Object -ExpandProperty Name | Sort-Object)
if (@(Compare-Object $requiredDoctorIds $actualDoctorIds).Count -ne 0) {
throw "The estate-doctor Doctor profile must contain exactly: $($requiredDoctorIds -join ', ')"
}
$schedulerManifest = Get-Content -LiteralPath (Join-Path $schedulerBundle 'manifest.json') -Raw | ConvertFrom-Json
if ($schedulerManifest.id -ne 'stellaops.scheduler.plugin.estate-doctor' -or
$schedulerManifest.scheduler.jobKind -ne 'estate-doctor' -or
$schedulerManifest.scheduler.enabledProfile -ne 'estate-doctor') {
throw 'The Scheduler estate-doctor manifest is not the expected producer.'
}
if (Get-ChildItem -LiteralPath $schedulerBundle -Filter 'StellaOps.Doctor*.dll' -File -Recurse) {
throw 'The Scheduler producer bundle must not carry Doctor implementation DLLs.'
}
foreach ($family in $doctorFamilies.GetEnumerator()) {
$doctorBundle = Join-Path $doctorRoot $family.Key
$manifest = Get-Content -LiteralPath (Join-Path $doctorBundle 'manifest.json') -Raw | ConvertFrom-Json
$capabilities = @($manifest.capabilities)
if ($manifest.id -ne $family.Key -or $manifest.module -ne 'doctor' -or
$manifest.profile -ne 'estate-doctor' -or
$manifest.contractVersion -ne 'runtime-bundle.v1' -or
$manifest.assembly.path -ne $family.Value -or
$capabilities.Count -ne 1 -or $capabilities[0] -ne 'doctor:checks') {
throw "Doctor manifest binding is invalid for $($family.Key)."
}
$primary = Join-Path $doctorBundle $family.Value
$actualPrimaryHash = (Get-FileHash -LiteralPath $primary -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actualPrimaryHash -ne ([string]$manifest.assembly.sha256).ToLowerInvariant()) {
throw "Primary digest mismatch for $($family.Key)."
}
$unsignedDoctorDlls = @(Get-ChildItem -LiteralPath $doctorBundle -Filter '*.dll' -File -Recurse |
Where-Object { -not (Test-Path -LiteralPath ($_.FullName + '.sig') -PathType Leaf) })
if ($unsignedDoctorDlls.Count -ne 0) {
throw "Every managed Doctor DLL must have a detached signature; missing: $($unsignedDoctorDlls.FullName -join ', ')"
}
foreach ($line in Get-Content -LiteralPath (Join-Path $doctorBundle 'checksums.sha256')) {
if ($line -notmatch '^([0-9a-f]{64}) (.+)$') {
throw "Malformed checksum row in $($family.Key): $line"
}
$payload = Join-Path $doctorBundle ($Matches[2] -replace '/', [IO.Path]::DirectorySeparatorChar)
$actual = (Get-FileHash -LiteralPath $payload -Algorithm SHA256).Hash.ToLowerInvariant()
if ($actual -ne $Matches[1]) {
throw "Checksum mismatch in $($family.Key): $($Matches[2])"
}
}
}
Render the held-back Compose profile
Rendering proves merge/configuration coherence only. It does not start or recreate any container.
Windows PowerShell uses NUL as the empty env file:
docker compose --env-file NUL `
-f devops/compose/docker-compose.stella-services.yml `
-f devops/compose/docker-compose.jobengine.yml `
-f devops/compose/docker-compose.plugins.scheduler-estate-doctor.yml `
--profile estate-doctor config --no-interpolate --quiet
if ($LASTEXITCODE -ne 0) { throw "Compose render failed with rc=$LASTEXITCODE" }
On POSIX, replace NUL with /dev/null. Do not use /dev/null on Windows: Docker Compose resolves it as a Windows path and may read an unrelated C:\dev\null file instead of an empty env file.
The rendered configuration must show:
- only the explicit
estate-doctorprofile enables these services; - Scheduler requires
STELLAOPS_POSTGRES_JOBENGINE_CONNECTION, allows only theestate-doctoroptional profile, and refuses unsigned bundles; - the worker’s runtime-plugin root directory is
/app/plugins/doctor/estate-doctorwith profileestate-doctorand trust root/app/trust-roots/plugins/doctor/cosign.pub; - both executable bundle mounts and both trust-root mounts are read-only;
- the base
EstateDoctor__DockerEndpointsetting and Docker socket mapping are still present and unchanged.
Repository verification
These commands use the MTP-aware targeted wrapper; confirm the reported ran-versus-suite count rather than relying only on the process exit code:
pwsh ./tools/scripts/test-targeted-xunit.ps1 `
-Project src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/StellaOps.Scheduler.WebService.Tests.csproj `
-Method '*EstateDoctorPluginStagingContractTests*'
pwsh ./tools/scripts/test-targeted-xunit.ps1 `
-Project src/JobEngine/StellaOps.JobEngine.__Tests/StellaOps.JobEngine.EstateWorker.Tests/StellaOps.JobEngine.EstateWorker.Tests.csproj `
-Method '*EstateDoctorRuntimePluginLoaderTests*'
pwsh ./tools/scripts/test-targeted-xunit.ps1 `
-Project src/JobEngine/StellaOps.Scheduler.__Tests/StellaOps.Scheduler.WebService.Tests/StellaOps.Scheduler.WebService.Tests.csproj `
-Method '*EstateDoctorBundle*'
The first check pins the one-Scheduler/five-Doctor packaging selections, recursive per-DLL signing, opt-in read-only mounts, fail-closed root setting, and absence from the default chain. The second pins exact-family membership, real family/check activation, digest/tamper rejection, per-DLL signatures, and real staged RSA admission. The third performs real RSA admission and one-byte tamper rejection for the Scheduler producer.
At the staged implementation rehearsal, the reported counts were 2/2 for the packager/overlay contract, 9/9 with zero skipped for the worker loader, and 2/2 for Scheduler signed admission/tamper. Without staged artifacts the loader’s artifact-conditioned method explicitly skips; that result is not staging evidence. Any other ran-versus-suite count is a stop, even when the command exits successfully.
Window handoff and stop
Stop after staging and repository verification. Before any up, recreate, or live forcing call, the owner-attended window must separately confirm the signed bundle/trust-root inputs, JobEngine connection, worker role and tenant, candidate images, service-scoped rollback, and the intended positive and negative forcing calls. Starting the profile is a deployment and is outside this runbook’s repository-side authorization.
Estate activation record (2026-09-10)
Executed on the lab estate under the owner-directed SPRINT_20260722_009 DOC-4b window. Everything below is the additive subset of the JobEngine cutover (SPRINT_20260722_012 JOB-9): a database, a role, environment keys, the opt-in overlay, and two service recreates. The data move, grant revocation, soak and gateway route swap are NOT part of it and remain JOB-9’s.
Preconditions that were measured, not assumed
- The scheduler host must KNOW the
estate-doctorjob kind and carry the signed-bundle admission path. The livescheduler-webimage was built 2026-08-11, before the job kind (2026-08-17) and the producer plugin (2026-08-25) existed, so the host was rebuilt from current source first. Check withdocker image inspect --format '{{.Created}}'againstgit logofSchedulerJobKindCatalog.cs. - Disk floor (
check-disk-floor.sh) and live chains (check-live-compose-chains.sh) before the window; the label chain of the runningscheduler-webwas a tmp-derived kestrel-rotation chain and was migrated to the tracked per-service chain by this recreate, as composeAGENTS.md§1 asks.
Steps, in the order that worked
Database + role (idempotent):
PGPASSWORD_SERVICE="$JOBENGINE_POSTGRES_PASSWORD" bash tools/scripts/deploy/postgres/provision-service-database.sh stellaops_jobengine jobengine—jobengineisNOSUPERUSER NOBYPASSRLS, PUBLIC connect revoked.devops/compose/.env:JOBENGINE_POSTGRES_USER,JOBENGINE_POSTGRES_PASSWORD,STELLAOPS_POSTGRES_JOBENGINE_CONNECTION(ENV-001: thePassword=literal must match),JOBENGINE_IMAGE_TAG,ESTATE_DOCTOR_WORKER_ROLE=docker-readonly,ESTATE_DOCTOR_TENANT=default. All six are documented indevops/compose/env/stellaops.env.example.Bundles: the two packager commands from Stage the signed bundles (lab:
-UseOfflineDevSigner), then the inspection block. Expect one producer bundle with noStellaOps.Doctor*.dll, five Doctor family bundles, every managed DLL signed (63 of 63 on 2026-09-10),cosign.pubpresent under both trust-root directories.Images:
IMAGE_TAG=dev bash devops/docker/build-service-publish.sh jobengine-web jobengine-estate-workerfrom a clean committed checkout; read/app/buildinfo.jsoninside each image (MSYS_NO_PATHCONV=1 docker run --rm --entrypoint cat <image> /app/buildinfo.json) and expectworktreeState=clean. Pin both by image ID indocker-compose.local-jobengine-web.ymlanddocker-compose.local-jobengine-estate-worker.yml(deploy by digest, never by tag).Render and parity-diff before recreating: render the chain below with
config, extract thejobengine-webservice, and diff itsenvironmentand mount targets againstdocker inspectof the running container. On 2026-09-10 the only deltas were the estate keys, the two new read-only mounts, andRouter__AuthorizationTrustMode=GatewayEnforced(folded into the per-service pin because the tracked estate-wide setter is not in this chain).Recreate the estate worker first (it converges the schema into the empty database as the owner role), then seed, then recreate
jobengine-web:cd devops/compose docker compose -p stellaops --project-directory "$PWD" \ -f docker-compose.stella-ops.yml \ -f docker-compose.jobengine.yml \ -f docker-compose.plugins.scheduler-estate-doctor.yml \ -f docker-compose.local-jobengine-web.yml \ -f docker-compose.local-jobengine-estate-worker.yml \ -f docker-compose.existing-networks.override.yml \ --profile estate-doctor \ up -d --no-deps --force-recreate jobengine-estate-workerExpect in its log:
Applied 7 migration(s) for JobEngine.Consolidated,Applied 4 migration(s) for Eventing.Reliability, andpolling the estate-doctor queue for 1 tenant(s) as role 'docker-readonly' granting [docker.socket.read]. Never--remove-orphans.Seed
stellaops_jobengine(as the superuser; the tables force RLS and the superuser bypasses it): onepacks.packsrow —pack_id = '<manifest.packId>@<manifest.version>'(the canonical registry identity, D-JEF-3;name= the manifest’spackId,version= itsversion, soname || '@' || version = pack_id),status='published',content= the exact bytes of the committed manifest,digest = 'sha256:<hex of content>'(verify in SQL:encode(sha256(content),'hex') = substr(digest,8)) — and onescheduler.schedulesrow withjob_kind='estate-doctor',plugin_config = {"packId": "<name>@<version>", "actionIds": [...]}(the plugin refuses a barepackIdat validation) and a non-NULLestate_constraintsdocument (NULL means CLOSED). Note: on jobengine-web’s first start the JOB-11 storage reconciler moves the inline bytes to the upload store (compose_packsregistry-upload-data) and clearscontent; both estate readers follow that move (F-B, SPRINT_20260914_003 JEF-3), and the estate worker mounts the same volume read-only. The benign pack used isdocs/modules/doctor/estate-pack-scheduled-diagnostics-manifest.json(sha25681f4ee2c9e24003d207c8f60d3cea206da084f366c0ba1ea91824239c4c21480): five read-only Docker checks,requiresApproval=false— cron carries no approval token, so an approval-requiring action is refusedApprovalRequiredby construction and can never be the scheduled proof. Everycreated_by/updated_by/deleted_byvalue must be a simple identifier (letters, digits,-,_,.,/): the model validates them on read, and one non-conforming ENABLED row makes the whole tenant’s cron pass throw until it is repaired (measured: 16 failed passes from one hand-writtenupdated_bywith a colon and spaces).Recreate
jobengine-webon the same chain (same command, servicejobengine-web). Expect the Router registration andCron triggering enabledin its log;/app/plugins/scheduler/estate-doctorand/app/trust-roots/plugins/scheduler/cosign.pubmounted inside the container.Observe. Producer (
jobengine-web):Cron trigger created run run-cron-<schedule>-<epoch> … kind=estate-doctorthenestate-doctor dispatched N of N action(s) from pack '<packId>'; platformscheduler.runsrowcompleted. Worker:Estate run <id> (tenant default) finished in …: Action '<checkId>' completed 1 check(s). Records instellaops_jobengine:scheduler.estate_run_dispatches(claimed),estate_run_queue(outcome='Executed',attempts=1),estate_approval_audit(decision='Allowed',approval_token_present=false),estate_dispatch_ledger. Prove the tenant scoping with the real role:PGOPTIONS="-c app.tenant_id=default -c app.current_tenant=default" psql -U jobengine …sees the rows, another tenant sees none, no tenant context is refused (P0001). Thenbash tools/scripts/deploy/postgres/probe-database-isolation.sh(append the vulnerability hub’s live connection first, per the script’s own trap note) — expect PASS with thejobenginepair in the matrix.Settle the cadence. The proof ran on
*/2 * * * *; steady state is0 * * * *in the schedule row withmaxExecutionsPerWindow=5per action per hour.
What the first live run found
The first two occurrences failed with P0001: app.tenant_id session variable not set, and the worker’s second poll onward failed the same way while its first poll had succeeded. TenantScopedDataSources set the RLS GUC once per physical connection; Npgsql’s DISCARD ALL on pool return runs RESET ALL, which clears a set_config value but RESTORES a startup-packet value. Every prior suite and the 2026-08-17 rehearsal had run as a superuser, which bypasses FORCE ROW LEVEL SECURITY, so nothing could observe it. Fix 75211c3711: the tenant rides in the connection’s startup Options (-c app.tenant_id=… -c app.current_tenant=…) after validation as a plain token; TenantScopedDataSourcesTests pins it under a NOSUPERUSER NOBYPASSRLS owner role. Rebuild the worker image and RE-PACKAGE the producer bundle after any change to StellaOps.JobEngine.Persistence, and jobengine-web needs a recreate to reload it. Since 2026-09-14 (SPRINT_20260914_003 JEF-3, D-JEF-8) the packager prunes every host-shared assembly from the producer bundle before it is signed, so the bundle ships only StellaOps.Scheduler.Plugin.EstateDoctor.dll: the consolidated jobengine-web host carries all 17 former dependencies in /app, and a bundle copy of any of them loads into the plugin’s own load context, where the IEstateScheduleConstraintsSource the plugin’s stores implement is not the host’s type — every cron fire then fails Entry point was not found, whatever the bundle’s build stamp says. A bundle that lists more than its own assembly is wrong even if freshly packaged.
Rollback (each switch independent)
- Stop the plane: disable the platform schedule row (
enabled=false, simple-identifierupdated_by), or set the jobengine row’sestate_constraintsto a closed window, ordocker compose … --profile estate-doctor stop jobengine-estate-worker. jobengine-web: recreate on the pin’s recorded rollback image (header ofdocker-compose.local-jobengine-web.yml); the previous chain files are all tracked.- The database and role stay: dropping
stellaops_jobengineis a destructive, owner-gated action and now also JOB-9’s concern (the cutover merges into it).
