JobEngine Architecture

Verified against commit 942a18727c (2026-09-26). Re-verify with: rg -n "Map(Get|Post|Patch|Delete|Group)\(" src/JobEngine/StellaOps.JobEngine.WebService src/JobEngine/StellaOps.JobEngine.__Libraries/StellaOps.JobEngine.Scheduling && ls src/JobEngine/StellaOps.JobEngine.__Libraries/StellaOps.JobEngine.Persistence/Migrations && rg -n "^ jobengine-(web|worker|estate-worker):" devops/compose devops/release/bundle/docker-compose.yml

JobEngine is the consolidated family for scheduled scan orchestration (the Scheduler subdomain) and Task Pack registry management (the PacksRegistry subdomain). One web host serves both surfaces, and the family owns one database.

The generic release control plane (sources, quotas, throttles, incidents, dead-letter views, deployment runs, SLO read models) is not part of this module. It belongs to src/ReleaseOrchestrator/ (see Release Orchestrator).

1) Topology

RoleProjectCompose keyServes
Websrc/JobEngine/StellaOps.JobEngine.WebService/jobengine-webEvery JobEngine HTTP route (Scheduler and Task Pack registry), Doctor checks, and the embedded Scheduler worker services
Workersrc/JobEngine/StellaOps.JobEngine.Worker/jobengine-workerHealth endpoints only (/health/liveness, /health/readiness). It converges the family database on startup; its hosted service does no dispatch work yet
Estate workersrc/JobEngine/StellaOps.JobEngine.EstateWorker/jobengine-estate-workerHealth endpoints only. Executes signed, approval-gated estate-doctor packs; profile-gated

Libraries:

Retired and frozen under src/__Obsoleted/JobEngine/ (CoC §15.4; do not edit or reference): StellaOps.Scheduler.WebService, StellaOps.Scheduler.Worker.Host, StellaOps.PacksRegistry.WebService, StellaOps.PacksRegistry.Worker, and the predecessor StellaOps.Scheduler.Persistence / StellaOps.PacksRegistry.Persistence projects (kept only for their applied migration history). The scheduler-web, packsregistry-web and packsregistry-worker deployables were removed at JOB-9 (2026-09-12); the scheduler and packsregistry hostname aliases were removed at JOB-10 (2026-09-13). The live gateway host is jobengine.stella-ops.local.

2) Persistence

Tenant run-usage measurement

GET /api/v1/jobengine/quotas/usage returns the authenticated tenant’s count of persisted scheduler.runs records created in the current UTC day. The interval includes its start and excludes the next day’s start. Every state and retry record counts; list pagination does not cap this aggregate. The owner supplies the tenant, unit, period bounds and measurement time under the jobengine.run-quota.v1 contract. It supplies usage rather than licence entitlements, operator ceilings or promotion decisions.

The read requires quota.read or orch:quota and tenant binding. Quota permission does not grant the Scheduler run-list read. Platform consumes this owner API through STELLAOPS_JOBENGINE_URL; it never connects to the JobEngine database.

Tenant-boundary review (PCD-425: serving source 4d2839563c, acceptance implementation e7b27c4e2b, 2026-10-06 UTC). The JWT host registers ClaimsTenantContextAccessor; authentication and authorization run before tenant middleware. The quota handler reads the authenticated stellaops:tenant claim through that accessor and passes its trimmed tenant to DailyRunQuotaUsageService, which forwards it to the owner usage reader. For ordinary callers, a tenant header cannot replace the signed claim. A missing or blank signed tenant returns HTTP 400 tenant_missing before the usage reader runs, including when a caller supplies a spoofed canonical or harness tenant header. Scope failures and anonymous requests return HTTP 403 and 401 without usage reads.

The HTTP acceptance tests boot TestingLocalHarness, use the host’s own tenant/scope registrations and observe the actual harness reader without replacing its counts. Separate PostgreSQL reader tests verify persisted tenant/day isolation, reader recreation and unattributed-input refusal. These are source and isolated runtime-fixture checks; they do not attest the deployed estate.

Re-verify with RunQuotaUsageReaderTests in the Scheduler persistence project and RunQuotaUsageEndpointTests in its web-service test project, using tools/scripts/test-targeted-xunit.ps1 -Class "*RunQuotaUsage*" -BuildProjectReferences.

3) Job and run model

Lifecycle: the planner (PlannerBackgroundService, PlannerQueueDispatcherBackgroundService) evaluates schedules against the ImpactIndex and materializes runs; segments are enqueued on the configured queue transport with idempotency keys and dead-letter handling; RunnerBackgroundService leases scan segments and requests Scanner reports; graph build/overlay and policy-run dispatch run as further hosted services. Operators cancel with POST /api/v1/scheduler/runs/{runId}/cancel and retry with .../retry (a clone with retryOf).

Retry contract (CCP-032). POST /api/v1/scheduler/runs/{runId}/retry requires the Operate policy and the scheduler.runs.manage scope. It accepts a terminal run (completed, error or cancelled) that belongs to an existing schedule; otherwise it answers 404 (unknown run), 400 (no schedule, or the schedule no longer exists) or 409 (not terminal) and stores nothing. A source run is retried at most once. The retry run’s id is derived from the tenant and the source run id (run_ plus the first 32 hex characters of SHA-256 over stellaops.scheduler.run-retry.v1, the tenant and the source run id, newline-separated), and the insert stores nothing when that id exists. The first request returns 201 with the new run, writes one scheduler.run retry audit event naming the actor, and projects the run summary. A repeated request, or a concurrent one that loses the insert, returns 200 with the recorded retry and adds no run, projection or audit event; a retry recorded before this contract (with a generated id) is found through retry_of and returned the same way. A further attempt is a retry of the retry run. The outcome is attributable on the owner record: the retry run carries retryOf, and GET /api/v1/jobengine/dag/job/{runId}/children lists it as a retry edge. There is no dead-letter API and no dead-letter retry: the queue transports keep their own dead-letter handling. A run’s dead-letter proof (Reason.DeadLetterProof) is read by the job detail evidence and by SchedulerOperationsHealthProbe.GetDeadLetterStatusAsync, the source of the Doctor check check.operations.dead-letter; no current host registers that probe.

The ImpactIndex (IImpactIndex, implemented by RoaringImpactIndex) holds one record per tenant and image digest: registry, repository, namespaces, tags, labels, and the image’s components as package URLs, each with a usage of Entrypoint, NotUsed or Unknown. IngestAsync adds or replaces one tenant’s record and RemoveAsync(tenantId, digest) deletes it; the same digest in two tenants is two records. Selector planning reads it with ResolveAllAsync, ResolveByPurlsAsync and ResolveByVulnerabilitiesAsync. Each component purl is also filed under its Vulnerabilities hub product key (a versionless purl, ProductKeyV1; apk and deb packages under their source package), so a by-purl query for pkg:npm/lodash matches every image carrying any lodash version. SelectIndexedAsync returns which of a set of purls or product keys match an image a selector admits. With usageOnly (analysis-only schedules), a by-purl match is dropped only when that component is known not to be used, and an image is dropped from ResolveAllAsync only when every one of its components is known not to be used.

The index is filled from Scanner’s retained scanner.scan.completed v2 feed by the Scanner image projection (Scheduler:Worker:ScannerProjection): the application remains opt-in, while the release bundle and dev stack default JOBENGINE_SCANNER_PROJECTION_ENABLED to true after the PCD-354 cadence ruling. An explicit false override remains supported, and missing required credentials fail startup closed.

The advisory trigger (Scheduler:Worker:VulnerabilityHub:Trigger, default off; it requires the Scanner image projection) replaced the retired Concelier and Excititor export webhooks:

Schedule limits. The planner applies a schedule’s MaxJobs before it records the run’s queued count, choosing a window over the digest-ordered images that starts at an offset derived from the run id, so successive capped runs cover different images. The runner spaces its Scanner calls so the whole run stays at RatePerSecond (each segment gets the rate divided by the shard count); Parallelism is the shard count. The seeded system scan schedules carry conservative limits: content refresh maxJobs 50, ratePerSecond 1, parallelism 1, analysis only maxJobs 200, ratePerSecond 2, parallelism 2; a system scan schedule seeded earlier with no limits at all is given them at startup, and limits an operator set are kept.

System cadence (PCD-354, owner ruling 2026-10-06). Verified against c8c4c128b5; the re-verify tests are listed below. New installations seed five schedules in UTC:

ScheduleCadenceMode
Nightly vulnerability scan02:00 dailyAnalysis only
Advisory refreshEvery four hoursAnalysis only
EPSS score update06:00 dailyAnalysis only
Registry sync04:00 dailyContent refresh
Database maintenance01:00 dailyAnalysis only; no image scan planning

Weekly compliance sweep and reachability re-evaluation defaults are retired. Existing rows are reconciled only when their bootstrap ownership, old shipped configuration and default limits still match. Paused schedules and operator changes to cadence, selector, limits, notifications or other configuration remain intact. The tenant-bound database operation compares the observed version and configuration in the same UPDATE, so an operator edit between reading and writing wins. Retirement is a soft delete; no schedule history is removed. Repeated startup makes no further changes once the ruling has converged. Re-verify with SystemScheduleBootstrapLimitsTests and SystemScheduleReconciliationTests using tools/scripts/test-targeted-xunit.ps1.

4) Scheduler job plugins

4.1 Job-kind boundary

SchedulerJobKindCatalog (StellaOps.Scheduler.__Libraries/StellaOps.Scheduler.Plugin.Abstractions/) classifies every job kind before any registry file or mounted bundle can make it executable:

ClassificationJob kindsExecutable through
HostCorescan, database-maintenanceHost code, registered with SchedulerPluginRegistry.RegisterHostCore(...) in Program.cs
DeclarativeConfigurationscan-image, scan-sbom, gate-evaluation, policy-simulation, notification-dispatch, partition-maintenance, bundle-rotation, advisory-sync, vex-sync, graph-build, graph-overlayNothing; data/configuration only
OptionalExecutablePluginadvisory-ai-analysis (profile scheduler-ai), third-party-job (scheduler-external, test harness), feed-processor (scheduler-feed; calls the Concelier jobs API, whose service is retired), estate-doctor (estate-doctor)A signed mounted bundle, disabled until a profile or job kind is enabled
FuturePlaceholderaudit-cleanse (profile scheduler-maintenance)Nothing; disabled until deletion semantics exist

4.2 Admission of optional executable plugins

SchedulerPluginAdmission.LoadAndRegisterExternalPlugins admits optional plugins:

  1. Enabled optional jobs come from Scheduler:Plugins:EnabledProfiles and Scheduler:Plugins:EnabledJobKinds; unknown kinds, unknown profiles and placeholder enablement are rejected.
  2. The plugin directory (Scheduler:Plugins:Directory) is required only when an enabled optional job needs it; a missing directory then fails startup naming the job kinds.
  3. Assemblies load through the shared StellaOps.Plugin.Hosting.PluginHost (src/__Libraries/StellaOps.Plugin/) with signature enforcement on by default (Scheduler:Plugins:Signature:*; EnforceSignatureVerification and AllowUnsigned cannot both be set). A discovered plugin that fails admission fails startup.
  4. Only admitted ISchedulerJobPlugin types are registered, and only enabled plugins get ConfigureServices. A duplicate job kind from a bundle does not replace a HostCore handler.

Bundles sign every DLL they carry. devops/build/package-runtime-plugins.ps1 produces them under .runtime/plugins/scheduler/<profile>/ (generated, git-ignored). Compose overlays docker-compose.plugins.scheduler-ai.yml, docker-compose.plugins.scheduler-feed.yml, docker-compose.plugins.harness.yml, docker-compose.plugins.bad-signature.yml and docker-compose.plugins.scheduler-estate-doctor.yml mount them read-only into jobengine-web under /app/plugins/scheduler/<profile>, with the trust root at /app/trust-roots/plugins/scheduler. The default chain mounts no executable Scheduler bundle; jobengine-web always mounts devops/etc/plugins/scheduler (registry data, read-only) and the scheduler-plugin-scratch volume at /var/lib/stellaops/plugin-scratch/scheduler.

4.3 Estate doctor

The opt-in estate-doctor profile has two parts:

A pack is keyed <manifest.packId>@<manifest.version> (packs.packs.pack_id); a manifest whose identity differs from the registry key is refused. Any change to StellaOps.JobEngine.Persistence requires re-packaging the signed producer bundle; see the estate-doctor staging runbook.

4.4 Plugin diagnostics

GET /internal/plugins/status and POST /internal/plugins/probe (StellaOps.JobEngine.Scheduling/Hosting/SchedulerPluginDiagnosticsEndpoints.cs) report job-kind registration and admission state; the probe calls ValidateConfigAsync with non-executing configuration and creates no schedules or runs. The route group is mapped AllowAnonymous(). It is an /internal/* path; treat its exposure as a network-boundary assumption, not an authorization control.

5) HTTP surface

All routes are served by jobengine-web and published to the Router gateway by service name jobengine. The wire contract is openapi/v1.json, rendered as api-reference.md(generated; do not hand-edit).

Authorization policies are registered once for every authentication branch (AddConsolidatedJobEnginePolicies in Program.cs): SchedulerPolicies.Read / .Operate / .Admin (scheduler:read / scheduler:operate / scheduler:admin), SchedulerPolicies.JobEngineRead (scheduler:read or orch:read), the Task Pack policies (§5.3), and jobengine:health:read (ops.health). Most Scheduler handlers additionally call EnsureScope(...) with a fine-grained scope. Tenant-scoped groups use RequireTenant().

5.1 Scheduler routes

GroupGroup policyRoutes
/api/v1/scheduler/schedulesRead; mutations OperateGET /, GET /{scheduleId}, POST /, PATCH /{scheduleId}, DELETE /{scheduleId} (system schedules return 409), POST /{scheduleId}/pause, POST /{scheduleId}/resume
/api/v1/scheduler/runsRead; mutations OperateGET /, GET /{runId}, GET /{runId}/deltas, GET /{runId}/stream (SSE), GET /queue/lag, POST / (manual runs only), POST /{runId}/cancel, POST /{runId}/retry, POST /preview
/api/v1/scheduler/workersReadGET — execution mode and bounded worker-registration view
/api/v1/scheduler/policy/runsRead; POST OperateGET /, GET /{runId}, POST /
/api/v1/scheduler/policies/simulationsOperateGET /, GET /{simulationId}, GET /{simulationId}/stream, GET /metrics, POST /, POST /preview, POST /{simulationId}/cancel, POST /{simulationId}/retry
/api/v1/scheduler/vuln/resolverOperatePOST /jobs, GET /jobs/{jobId}, GET /metrics
/api/v1/scheduler/failure-signaturesReadGET /best-match
/graphsOperate (handler scopes graph:read / graph:write)POST /build, POST /overlays, GET /jobs, POST /hooks/completed, GET /overlays/lag

Behavior notes:

5.2 JobEngine compatibility read surface

Group policy SchedulerPolicies.JobEngineRead. These routes project durable Scheduler Run rows; they never fabricate placeholder jobs.

No other service maps an /api/v1/jobengine/* prefix. Release Orchestrator’s former aliases (runs, audit, slos, deadletter, dag, pack-runs, stream, sources) are removed; its real capabilities are served at /api/v1/release-orchestrator/runs/{runId}/first-signal, /api/v1/release-orchestrator/audit and /api/v1/release-orchestrator/slos.

5.3 Task Pack registry routes

Composed by src/JobEngine/StellaOps.JobEngine.WebService/PacksRegistryComposition.cs. Every /api/v1 route carries one of two policies (PacksRegistryPolicies):

PolicyScope requirement
packsregistry.readpacks.read or packs.write
packsregistry.writepacks.write
RoutePolicy
POST /api/v1/packs (bounded JSON/base64) · POST /api/v1/packs/stream (length-prefixed descriptor plus streamed bytes)write
GET /api/v1/packs · GET /api/v1/packs/{packId} · /content · /provenance · /manifestread
POST /api/v1/packs/{packId}/signature (signature rotation) · POST /api/v1/packs/re-envelope (bulk re-envelope; accepts an operator private key in the body)write
POST /api/v1/packs/{packId}/attestations · POST .../attestations/streamwrite
GET /api/v1/packs/{packId}/attestations · GET .../attestations/{type}read
GET / POST /api/v1/packs/{packId}/lifecycle · GET / POST /api/v1/packs/{packId}/parityread / write
POST /api/v1/export/offline-seed (full-tenant export) · POST /api/v1/import/offline-seedwrite
POST /api/v1/mirrors · GET /api/v1/mirrors · POST /api/v1/mirrors/{id}/syncwrite / read / write
GET /api/v1/compliance/summaryread

5.4 Health, discovery and Doctor

6) Task Pack storage contract

The contract below is binding for the Task Pack registry. The pre-consolidation PacksRegistry dossier that carried the long-form rationale is archived at docs-archive/modules/packsregistry/architecture.md; where it disagrees with this section or with src/, this section and src/ win. Operator procedure: PacksRegistry dual-mode storage runbook.

6.1 Stores and configuration

jobengine-web runs with PacksRegistry:Storage:Driver=postgres and PacksRegistry:Storage:ObjectStore:Driver=dual-fs:

RoleContainer pathMutability
Operator/offline seed (SeedFs:RootPath)/app/etc/plugins/packsregistry/packs (dev stack: bind of devops/etc/plugins/packsregistry; release bundle: bind of secrets/packsregistry/seed, which the installer signs)Read-only mount; signed seed-manifest.json
API uploads (UploadFs:RootPath)/var/lib/stellaops/packsregistry/uploads (named volume packsregistry-upload-data)Writable, retained across rollback

6.2 Identity, origin and collisions

6.3 Commit order and recovery

Uploads validate tenant, scope, shape, identity, digests and signature first; then, under the shared storage-admission advisory lock, claim a staging row; then reserve capacity and write blobs atomically through .control/upload-temps/v1/<shard> on the upload volume; then, in one transaction, mark the row ready and append the storage event, outbox entry and mutation audit. Stale-claim cleanup uses database time, the configured grace period and a reference proof, and never collects a seed+upload final artifact.

6.4 Signature floor

Each (tenant_id, region_profile) has a durable signature floor (active key id plus observed trust epoch, with every observation kept in packs.pack_signature_floor_epochs). A stale key receives 409 pack_signature_floor_mismatch; an attempt to roll the floor back receives 409 pack_signature_floor_regression; a claim revived under a different key receives 409 pack_upload_claim_verification_mismatch; floor advance during in-flight claims returns 409 re_envelope_claims_active.

6.5 Legacy upgrade gate

An estate that ran the pre-dual-mode binary maps its old mutable payload path to PacksRegistry:Storage:ObjectStore:LegacySeedFs:RootPath. The upgrade is non-rolling: a read-only preflight inventory must pass before any DDL, after which a durable transition journal stages artifacts into the upload store. A crash leaves an explicit transition-in-progress state that resumes from the journal. LegacyPayloadMode and LegacyUpgradeMaintenanceMode under PacksRegistry:Storage:ObjectStore control the posture.

7) Offline-seed export and import

8) Runtime configuration

9) Gateway routing

jobengine-web registers with the Router as microservice jobengine (AddRouterMicroservice(serviceName: "jobengine"), consumer group jobengine) and publishes its endpoints through the HELLO handshake. The gateway carries no static route rows for JobEngine: the former /api/v1/jobengine/jobs, /api/v1/jobengine/dag, /api/v1/jobengine/registry/packs, /api/v1/scheduler/jobs, /api/scheduler, /scheduler host-prefix and /api/v1/scheduler/doctor rows are listed as retired in src/Router/__Tests/StellaOps.Gateway.WebService.Tests/Configuration/retired-routes.json, and re-adding one is a regression. Paths JobEngine does not map (for example /api/v1/jobengine/runs) are published by no service (§5.2).

10) Observability

Metric instruments defined in the Scheduler libraries include scheduler_queue_depth, scheduler_queue_enqueued_total, scheduler_queue_deduplicated_total, scheduler_queue_ack_total, scheduler_queue_retry_total, scheduler_queue_deadletter_total, scheduler_runner_backlog, scheduler_hlc_enqueues_total, scheduler_hlc_enqueues_duplicates_total, scheduler_hlc_enqueue_latency_ms, scheduler_chain_verifications_total, scheduler_chain_verification_failures_total, scheduler_batch_snapshots_total, graph_jobs_inflight, graph_build_seconds, overlay_lag_seconds, policy_simulation_queue_depth and policy_simulation_latency. Re-verify with rg -n "scheduler_|graph_|overlay_|policy_simulation_" src/JobEngine --glob "*.cs".

Audit events are emitted through AddAuditEmission (the release bundle sets AuditEmission__TimelineBaseUrl). The generic job metrics (job_queue_depth, job_latency_seconds, pack_run_*) are not emitted by this module.

11) Deployment wiring

12) Worker SDKs (orphaned)

src/JobEngine/StellaOps.JobEngine.WorkerSdk.Go/ and src/JobEngine/StellaOps.JobEngine.WorkerSdk.Python/ implement a claim/ack/heartbeat/progress client for POST /api/jobs/lease, /api/jobs/{jobId}/ack, /heartbeat and /progress. No service in this repository serves /api/jobs/*; only the SDKs and their self-mocking tests reference it. They are the client half of the removed TaskRunner/legacy orchestrator and are not Release Orchestrator’s agent protocol either. They are not packaged or deployed. Do not build against them. The Go SDK still runs in the report-only .gitea/workflows/go-tests.yml lane against its own mock. Removal is recorded as an open dead-code item (SPRINT_20260712_009 CLO-5, archived).

13) History