JobEngine consolidation — S0 target design (SPRINT_20260722_012 JOB-1)

Status: APPROVED (owner, 2026-08-03, ledger round 88 ruling C). S2+S3 built 2026-08-04 (JOB-3) — §2.1/§2.2 carry inline JOB-3 corrections where verification against src/ and the ownership matrix contradicted the S0 draft; §2.3 records what the baseline actually contains, §2.4 the staging, §5.2 the applied DC-35 decision, §5.4 the two missing objects found. S4 refactored 2026-08-05 (JOB-4) — §2.5A records the host-resident-domain extraction, §2.6 the final carry/re-author lists, §5.1 the foreign-edge dispositions, §5.5 the DC-13 enforcement; §7 items 6 and 7 are CLOSED and their table rows in §2.1/§2.2 now read DROP.

Verification base: the reconciled dossier architecture.md(2026-05-30/2026-06-06 reconciliation passes), the 001_v1_scheduler_baseline.sql / 001_v1_packsregistry_baseline.sql embedded baselines, the build-boundary report (2026-08-03 regen), and the round-61 X8 dig. Sections marked ⚠ carry an explicit remaining-verification note — this draft does not claim completeness it has not earned.

1. Current → target shape

CurrentTarget
Deployablesscheduler-web (embedded worker by default; separate Scheduler.Worker.Host exists), packsregistry-web, packsregistry-workerjobengine-web + jobengine-worker (web/worker replica roles of ONE family)
Sourcesrc/JobEngine/StellaOps.Scheduler.*, src/JobEngine/StellaOps.PacksRegistry*src/JobEngine/StellaOps.JobEngine.WebService + .Workerbuilt at JOB-3, both born new (neither legacy host carries a target name and JOB-10 deletes both); namespaces StellaOps.JobEngine.* from birth
DALStellaOps.Scheduler.Persistence (schemas scheduler, scheduler_app (RLS helper fn only), and scripts — which it should never have created: that schema is release-orchestrator’s, §2.1), StellaOps.PacksRegistry.Persistence (packs)StellaOps.JobEngine.Persistencebuilt at JOB-3: fresh 001_* baseline over scheduler + scheduler_app + packs, database stellaops_jobengine, env STELLAOPS_POSTGRES_JOBENGINE_CONNECTION fail-closed
Worker SDKsStellaOps.JobEngine.WorkerSdk.Go / .Python (wire clients — untouched by the merge per JOB-4 carry list)unchanged wire contract

2. Table inventory + dispositions (P13)

Schemas from the live baselines. Disposition column is the S0 recommendation; JOB-4 finalizes.

2.1 scheduler (21 enumerated; 19 carry) + scripts (2)

Corrections applied at JOB-3 (2026-08-04). The S0 inventory below was re-enumerated from the collapsed migration FILE; four claims did not survive verification against src/ and the ownership matrix. They are marked inline and summarised in §2.3. The 21-table count for scheduler stands as an enumeration of that file, but the converged legacy schema holds 20 (the file drops audit in its own last statement) and the code needs 21 (it also uses failure_signatures, which no migration creates).

TablePurposeP13 classDisposition
jobsgeneric job rows (idempotency key, lease columns)operationalREDESIGN — the general job/queue substrate is re-authored on the DC-13 idempotency contract; note the only external writer (Policy gate-evaluation, X8) is GONE (round 61) — remaining rows of type policy.gate-evaluation on live estates are orphaned shadow copies, dropped at S9. (JOB-3: authored with the round-18 key composition stamped as columns — idempotency_subject, idempotency_input_hash, corpus_generation, policy_bundle_id, policy_bundle_hash — so a stale-corpus dedup is detectable, not silent. Per-job claim columns stay: DC-35 retires the lock TABLE, not the single-writer claim pattern. Behavioural re-authoring of the queue remains JOB-4.)
triggersschedule triggersoperationalcarry (semantics), re-author storage
workersworker heartbeat registryoperational/ephemeralcarry (wire contract with SDKs)
locksrenewable lease, no fencing token (DC-35)ephemeralREDESIGN → P6 fenced lease (recommendation, §5.2)
job_historyrun historyaudit/windowedcarry with retention header
metricsscheduler metrics rowsderived/windowedDROP — dig-verified 2026-08-03: IMetricsRepository is DI-registered but has ZERO consumers estate-wide (dead substrate; the shift’s third dead-wiring find)
schedulesschedule CRUDoperationalcarry
runsrun state machine (the Console JobEngine-compat read surface projects from it)operationalcarry
impact_snapshots, run_summariesrun projectionsderivedcarry with documented rebuild path
execution_logsper-run logswindowedcarry with retention header
graph_jobs, graph_job_eventsCartographer build/overlay jobs — JobEngine-internal (Graph writes nothing; the worker CALLS Cartographer)operationalcarry
policy_jobsfirst cut of policy-run orchestrationoperationalcarryDROP (JOB-4 ruling, closes §7 item 6). Superseded by policy_run_jobs, which PolicyRunJobRepository actually reads and writes. JOB-3 found no readers; JOB-4 additionally found no writers — no repository, no entity, no SQL outside migration files — so no converged database can hold a row in it. The one thing sustaining it was a Sprint-20 worker note claiming the PolicyRunJob DTO is “stored in policy_jobs”; it is not, and that note is what carried the dead table into the S0 inventory. The note now carries a currency banner.
policy_run_jobsscheduler-owned policy-run orchestration (NOT the X8 gate-eval queue — naming hazard §6)operationalcarry
audit (+ audit_default partition)partitioned auditauditcarryDOES NOT EXIST (drift, corrected JOB-3). The collapsed baseline creates it and then drops it in its own last statement (mig061/009_drop_deprecated_audit_table.sql: DROP TABLE IF EXISTS scheduler.audit CASCADE), so no converged database has it. PostgresSchedulerAuditService confirms it in code — audit now emits only to timeline.unified_audit_events. Same class as the Notify sibling’s notify.audit finding: the re-enumeration counted a CREATE without its later DROP. Not recreated.
scheduler_log, batch_snapshot, chain_headsHLC queue-chain machinery (mig061 lineage)operationalcarry — LIVE (dig-verified 2026-08-03: HlcJobRepositoryDecorator in Scheduler.Queue + the hlc-troubleshooting runbook); the HLC semantics carry with the re-authored queue substrate
scheduler_exceptionsexception lifecycle rowsoperationalcarry
doctor_trendsdoctor trend historyderived/windowedrehome per 009 DOC-4b (estate doctor packs run on JobEngine; the trend store’s successor is the registry/aggregation surface) — do NOT carry silently. (JOB-3: the rehoming decision is still OPEN — §7 item 3 was never closed by S1 — and the table is live: written by SchedulerDoctorJobPlugin/PostgresDoctorTrendRepository, read by the Console category view (the LOWER(category) functional index exists for it). JOB-3 therefore takes the non-destructive branch and carries it loudly, with the pending rehoming named in its P13 header; omitting it would strand live trend rows at the S9 copy. 009 still owns the decision.)
scripts.scripts, scripts.script_versionsrelease-script bundlesoperationalcarryNOT THIS MODULE’S SCHEMA (drift, corrected JOB-3). The ownership matrix assigns scripts to release-orchestrator (row: release, release_orchestrator, release_orchestrator_agent, scriptsstellaops_release_orchestrator), and StellaOps.ReleaseOrchestrator.Scripts owns its own 001_initial.sql, its own ScriptsDataSource, and a startup-migration test asserting scripts.scripts/scripts.script_versions. The legacy scheduler baseline is a second migration authority over another service’s schema — the same defect class the X8 dig found for Policy — and it additionally seeds four demo scripts (scr-001scr-004, is_sample = TRUE, fabricated sha256:a1b2c3d4e5f6-style hashes), breaching §2.11. JobEngine claims neither the schema nor the seed; removing them from the legacy baseline is forward-only work for 019/S10, not JOB-3.
failure_signatures(missing from S0)predictive TTFS failure patterns by scope/toolchain/errorderivedADD (correction, JOB-3). No migration in either lineage ever created it, yet FailureSignatureEndpoints (web), FailureSignatureIndexer (worker), FailureSignatureRepository/IFailureSignatureRepository and the EF compiled model all target scheduler.failure_signatures — so the endpoints fail on any converged database (§2.7 missing-object defect). The consolidated baseline creates it from the entity model + repository SQL; the only prior DDL anywhere is the non-runtime dump in docs/db/schemas/, whose tenant_id UUID is itself wrong (every tenant column here is TEXT, and real ids look like default).

2.2 packs (7 tables)

TablePurposeP13 classDisposition
packstask-pack registry (the runtime’s real home: version/version_count/latest_version, digest, signature, provenance_*, content)operationalcarry
pack_versionsper-version pack catalogoperationalcarryDROP (JOB-4 ruling, closes §7 item 7). See the ⚠ note below — the DC-14 grounding it was carried for is schema-only.
attestationsDSSE attestationsevidencecarry
audit_logpack auditauditcarry
lifecyclesper-pack state machine — DC-14 capability gating composes WITH itoperationalcarry
mirror_sourcesair-gap mirror configoperationalcarry
paritiesparity statederivedcarry with rebuild path

Six of the seven carry unchanged. packs.packs is authored directly in the runtime shape rather than through the legacy 001 + 002 pair: the second legacy migration retypes pack_id UUID→TEXT, adds the content/digest/signature/ provenance columns the repository actually uses, swaps the uniqueness rules, and runs two data-repair UPDATEs. A fresh baseline carries the destination, not the journey.

⚠→RULED (JOB-4). packs.pack_versions is dropped, and the reason matters beyond this table. S0 carried it because it “holds the DC-14 grounding columns” — manifest_json/manifest_digest plus signature_algorithm/signed_by. Those columns existed only in DDL: PacksRegistry has no PackVersion type, no repository and no SQL touching the table anywhere (JOB-3 established no reader; JOB-4 established no writer either), so it has never held a row on any estate, and every pack_versions hit in a repo-wide search is Policy’s policy-as-code table — §5.3 in the flesh. Reproducing dead columns in a fresh baseline would have made the DC register look grounded while nothing read them.

Consequence for DC-14 (capability allowlist × worker role set): its stated mechanical grounding does not exist. DC-14 must be designed against what the runtime actually persists (packs.packs + packs.lifecycles), or author a real per-version catalog when multi-version Task Packs land. Flagged to DC-14’s owner. The enum packs.pack_version_status typed only the dropped column and goes with it.

S9 note: the schema is still copied as a unit; per-schema row-count parity is unaffected because the dropped table is provably empty (no writer has ever existed). The same holds for scheduler.policy_jobs.

2.2A Dual-mode pack content follow-up (owner ruling 2026-08-19)

The approved carry list preserves the Task Pack domain, but it does not preserve the current single-root defect. Source verification after the design approval found that SeedFsPacksRegistryBlobStore is the only IPacksRegistryBlobStore, is registered for both reads and writes, and points at the deliberately read-only seed mount. Upload therefore cannot succeed. The product ruling is additive: immutable seed delivery and durable API upload, explicitly.

JOB-11 through JOB-13 carry the executable follow-up. The detailed contract is in docs/modules/packsregistry/architecture.md: tenant-scoped canonical identity; identical identity+digest dedupe with visible origin; different digest conflict/quarantine with no silent precedence; a signed non-vacuous seed manifest; atomic upload-store writes with a staging/ready metadata protocol; origin-bound reads; capacity/readiness; immutable caller-owned metadata on ready retries; one verified monotonic seed to seed+upload origin union; database-owned stale-claim cleanup under the same pack/attestation admission lock; grace-aged interrupted-writer temp cleanup, including pending expansion claims caught by a seed generation omission; evidence-preserving active-generation replacement; and no mutation or garbage collection against seed bytes.

Both persistence lineages move forward rather than rewriting history. The currently deployed StellaOps.PacksRegistry.Persistence gets an additive migration because its collapsed baseline is already applied. StellaOps.JobEngine.Persistence gets the equivalent migration after 005: its baseline was proven in JOB-8 and remains immutable even though the family is not live. The migrations add catalog/origin/operation state only; §2.11 forbids embedding sample or lab pack content. At JOB-9, database metadata is copied while the existing upload volume and read-only seed mount are carried, not copied into PostgreSQL or recreated.

JOB-11 source checkpoint (2026-08-20; no runtime activation): the two equivalent forward migrations are now PacksRegistry.Persistence/002_dual_mode_pack_storage.sql and JobEngine.Persistence/006_dual_mode_pack_storage.sql. They add the dual-store catalog, seed-generation, storage-event/outbox, signature-history and durable signature-floor structures; the born-new baseline remains the historical JOB-3 artifact before JOB-4’s empty pack_versions drop and this forward addition. The immutable reader, atomic upload writer, tenant-keyed repositories and isolated API proof harness live only in source until JOB-12. The exact applied-001 metadata sentinel is retained quarantined and hidden rather than assigned a fabricated alias. Ready state, storage/outbox and durable mutation audit commit together; a durable operation claim covers both ordinary staging and still-visible seed-origin expansion across a crash. Platform’s bounded ADR-028 active/retiring public-key producer has landed; JOB-11 now remains blocked on the real fresh-process consumer and the executed migration/storage/export proof matrix. The legacy scalar alone still cannot recover retiring PEM after restart.

2.3 JOB-3 baseline decisions (what the S3 artifact actually contains)

Table count as built at JOB-3: 19 (scheduler) + 7 (packs) = 26, plus the five P6 tables the eventing schema brings into the same database. Arithmetic from the S0 enumeration: 21 − locksmetricsaudit + failure_signatures = 19.

After the JOB-4 rulings: 18 + 6 = 24 (− policy_jobs, − pack_versions). Asserted by SchedulerSchema_MatchesTheDesignSection21TableSet, PacksSchema_MatchesTheDesignSection22TableSet and Job4Rulings_TheTwoUnreachableTablesAreAbsent_AndTheirLiveReplacementsAreNot.

DecisionRule applied
Schema names scheduler / scheduler_app / packs reproduced, not renamedS9 copies scheduler and packs as two units with per-schema row-count parity; renaming would make that gate unrunnable. One ledger in scheduler.schema_migrations.
locks retired → eventing.leasesDC-35 §5.2 recommendation, adopted.
workers retainedDC-35 explicitly allows the heartbeat/capacity registry as a separate concern from leasing.
metrics, audit, scripts.* not recreated§2.1 rows above.
failure_signatures, insert_log_with_chain_update added§2.1 row above and §5.4.
policy_jobs carriedDesign says carry, so JOB-3 carries it — but it has zero code readers estate-wide (only policy_run_jobs is live). Recorded as a drop candidate for JOB-4, which the design already assigns the carry/re-author finalization; JOB-3 does not delete a table an approved design says to keep.
RLS posture carried (ENABLE + FORCE + scheduler_app.require_current_tenant())Security control; workers stays exempt as the documented global registry. Risk for S9: FORCE applies to the table owner too, so unless the stellaops_jobengine owner can bypass RLS or sets app.tenant_id per session, every owner-issued query against a tenant-scoped table raises — the same hazard the Notify sibling recorded as R-NTF3-4. Do NOT grant scheduler_admin to the owner: that remedy is broken and this row used to state it (BYPASSRLS is a non-inheritable role attribute, and SET ROLE then fails on privileges because the baseline creates the role with none — see §5’s “Correction owed to JOB-9” and sprint 012 R-JOB3-8 for the working postures). Second trap on the same step: GRANT ... ON ALL TABLES IN SCHEMA is a one-time snapshot and partitions do not inherit privileges, so partitions created after the grant are unreadable until granted explicitly or ALTER DEFAULT PRIVILEGES is in place — this family carries partitioned tables, so the grant cannot be one up-front statement.
Zero seed rows§2.11, including an explicit Seed-category run in the test suite.

2.4 Staging (why the baseline cannot reach the shared platform DB)

The fresh baseline ships in a new assembly, StellaOps.JobEngine.Persistence. Both legacy homes are reachable by three deployed migrators, one more than either sibling faced:

PathReachesPrefix
scheduler-web / Scheduler.Worker.Host → SchedulerPersistenceExtensionsStellaOps.Scheduler.Persistencenone (prefix-free)
packsregistry-web/worker → PacksRegistryPersistenceExtensionsStellaOps.PacksRegistry.Persistencenone (prefix-free)
platform-web central migrator → MigrationModulePlugins.csboth, via SchedulerMigrationModulePlugin (typeof(SchedulerDataSource).Assembly, prefix StellaOps.Scheduler.Persistence.Migrations) and PacksRegistryMigrationModulePlugin (typeof(PacksRegistryDataSource).Assembly, prefix-free)see left

All three bind their migrations assembly by type to a legacy assembly, so none can reach the new one; and the new assembly declares no IMigrationModulePlugin, so platform-web’s AppDomain scan cannot find it either. Both facts are asserted by ConsolidatedJobEngineSchemaTests.ConsolidatedBaseline_CannotBeDiscoveredByAnyDeployedMigrator.

2026-09-14 (SPRINT_20260722_021 PLT-4, DC-26): the platform-web central migrator row above and the IMigrationModulePlugin AppDomain scan are history twice over — the two plugins were deleted at JOB-9 (below), and the whole plugin mechanism plus the stella system migrations-run CLI were deleted on 2026-09-14. Only the two host paths remain.

DC-26 deferral — DISCHARGED at JOB-9 (2026-09-12). The recipe’s S3 says to delete the module’s central-migrator plugin in the same change. JOB-3 did not, following the Notify precedent (D-NTF3-3): the race hazard DC-26 guards against could not occur while the new baseline targeted a different database, and removing the plugins then would have changed platform-web’s live behaviour for schemas scheduler-web and packsregistry-web still depended on.

JOB-9 closed it, and had no choice about the timing. The Q-3 clean cut moved SchedulerDataSource and PacksRegistryDataSource into StellaOps.JobEngine.Persistence, so typeof(SchedulerDataSource).Assembly — the expression both plugins bind — started resolving to the consolidated assembly. Left in place for one rebuild, platform-web would have converged the consolidated baseline into the shared platform database: the exact catastrophe this section was written to prevent, arriving through the door the deferral left open. Both plugins, both StellaOps.Platform.Persistence carrier ProjectReferences and the StellaOps.Cli reference that loaded them into the Default ALC for stella system migrations-run are deleted in the same change.

So the table above is now history, not current state. All three legacy paths are closed: the two legacy DALs are frozen under src/__Obsoleted/JobEngine/ with only their Migrations/ lineages left (readable for the destructive window that drops the old schemas), each legacy host composes the consolidated assembly, and StellaOps.JobEngine.Persistence is the family’s only migration authority — converging stellaops_jobengine alone, fail-closed on STELLAOPS_POSTGRES_JOBENGINE_CONNECTION.

2.5 Dispatch model (OWNER RULING 2026-08-03 — binding)

The scheduler does the mechanics; the services do the work. JobEngine owns what/when (triggers, schedules, retries, quotas, tracking) and starts work by calling the owning service’s API — it never executes foreign domain work in-process. Every service exposes start (idempotent, DC-13 key) / cancel / status for its own work. Task Packs are JobEngine’s own domain and stay on its workers under DC-14 capability gating. Existing precedent: the scheduler worker calling Cartographer’s endpoints; first conforming service surface: Policy’s gate evaluation (start/status/cancel live as of 2026-08-03). JOB-4/JOB-5 are read under this ruling: the re-authored substrate is a trigger engine + API dispatcher + tracker, and per-job-kind execution plugins exist only for JobEngine-domain work.

2.5A The host-resident domain (S4 extraction, 2026-08-05)

The S0 inventory treated the scheduler’s application layer as “carried” without recording where it lived. It lived inside the scheduler-web HOST project: 93 files — schedules, runs, graph jobs, policy runs and simulations, the resolver /jobs family, the JobEngine compatibility read surface, worker visibility, failure signatures, exception lifecycle storage, event webhooks, the doctor job and its trend storage, plugin admission/hosting, the auth and tenancy adapters, telemetry middleware and the options binders.

That shape leaves S5 no honest move: the born-new jobengine-web could serve these routes only by referencing a host project (forbidden by P19/DC-40) or by forking the code and letting the live host drift. JOB-4 therefore moved the layer to StellaOps.JobEngine.Schedulingunder src/JobEngine/StellaOps.JobEngine.__Libraries, leaving only the composition root (Program.cs) and the translation bundle in the legacy host.

The move is byte-identical — 93 renames, 0 insertions, 0 deletions — so the LIVE scheduler-web keeps its exact compiled behaviour until the JOB-9 cutover. Two mechanisms made that possible without editing a single moved file (technique borrowed verbatim from the Notify sibling’s NTF-4, a302bff738): the library’s <Using> items reproduce the implicit usings the Microsoft.NET.Sdk.Web SDK gave these files inside the host, and InternalsVisibleTo grants keep 119 moved types internal rather than widening them to public for a transitional need. The types still declare StellaOps.Scheduler.WebService.* namespaces (the library’s RootNamespace is set to match); renaming them is JOB-10’s job with the deletion of the legacy host, and keeping them is what makes the change auditable as a pure move.

Deliberately NOT done here: the Postgres adapters in the extracted library still bind the LEGACY StellaOps.Scheduler.Persistence. Repointing them onto StellaOps.JobEngine.Persistence changes a live host’s behaviour and belongs to the JOB-9 window. The new hosts are likewise left unwired — attaching them to a library that reaches the legacy DAL would blur the JOB-3 staging invariant for no gain before S5.

PacksRegistry needed no equivalent move. Its domain is already library-resident (PacksRegistry.Core + .Infrastructure); the only host-resident code is Program.cs (1 477 lines of inline endpoint mapping) plus 16 endpoint DTO records and the policy/options constants. Those are S5’s API surface, not S4 domain, and moving endpoints out of top-level statements is a re-author rather than a mechanical move.

2.6 Final carry / re-author lists (S4 evidence)

Carry — verbatim, already library-resident (no JOB-4 change): Scheduler.Models (schedule/trigger/run semantics), Scheduler.Queue (incl. the live HLC queue-chain machinery), Scheduler.Worker (planner/runner/graph/policy execution), Scheduler.ImpactIndex (impact/run models), Scheduler.Plugin.Abstractions and the four plugin projects, Scheduler.DoctorOperations (fate owned by JOB-7 / 009 DOC-5), PacksRegistry.Core + .Infrastructure (pack signing + lifecycle logic), and the Go/Python worker SDKs (untouched, wire contract unchanged — a JOB-5 acceptance).

Carry — moved to a consumable home at JOB-4: the 93-file scheduler application layer, §2.5A.

Re-authored fresh (never ported): the persistence baseline (JOB-3, refined by the two JOB-4 drops and the DC-13 enforcement in §5.5). The scheduler.jobs substrate carries the DC-13 contract structurally.

Re-author deferred, with the owning stage named: the submission API surface → JOB-5; the repoint of the extracted library’s Postgres adapters onto StellaOps.JobEngine.Persistence → JOB-9. Neither is a JOB-4 omission: both change a LIVE host’s behaviour, which a byte-identical S4 must not.

Direct foreign-schema write paths: none remain. Verified by pattern-searching every INSERT/UPDATE/DELETE against a foreign schema qualifier across src/JobEngine — zero hits. The one that reads like a counterexample, PostgresSchedulerAuditService, emits through the shared IAuditEventEmitter rather than writing timeline.* directly. (It still takes a SchedulerDataSource it discards — _ = dataSource; — dead since DEPRECATE-003 dropped scheduler.audit; a free removal at the JOB-9 repoint.)

P13 writers: the re-authored baseline’s only ON CONFLICT … DO UPDATE is scheduler.upsert_chain_head, and it is content-guarded (WHERE chain_heads.last_t_hlc < EXCLUDED.last_t_hlc — monotonic). All four derived tables (impact_snapshots, run_summaries, failure_signatures, packs.parities) carry a documented rebuild path in their P13 header. The legacy DAL’s unguarded upserts are not touched here for the same live-behaviour reason as above; they retire with the JOB-9 repoint.

3. API surface + consumers

Authoritative route inventory: dossier §4 (reconciled). Highlights that shape the merge:

3.1 Gateway route inventory + consumer sweep (JOB-5, 2026-08-05)

Full manifest with per-route fall-through and consumers: tools/scripts/job5-jobengine-route-swap.json. Window runbook: docs/runbooks/jobengine/jobengine-cutover-job5-route-swap.md. Consolidated group: ^/api/jobengine/v1(.*)jobengine.stella-ops.local.

Finding 1 — the family’s live routing is mostly IMPLICIT, and no route list shows it. Ten explicit routes serve scheduler-web + packsregistry-web. But eight of scheduler-web’s ten endpoint groups have no gateway route at all: /api/v1/scheduler/{schedules,runs,workers, failure-signatures,policy/runs,policies/simulations,vuln/resolver,audit} reach the service only through ^/api/v1/([^/]+)(.*)http://$1.stella-ops.local/api/v1/$1$2, which derives the microservice hostname from the first path segment. They work because the deployable happens to be registered as scheduler(StellaOps.Scheduler.WebService/Program.cs:391). Renaming it to jobengine breaks schedule CRUD, run management and the DC-13 submission surface at once, with nothing in the route table to have warned anyone. The manifest carries these as impliedRoutes and JobEngineConsolidatedRouteConfigTests asserts both that they are wildcard-served today and that the consolidated group covers them afterwards.

Finding 2 — the target namespace is already occupied. ^/api/jobengine(.*) (index 175 in both configs) belongs to ReleaseOrchestrator and captures /api/jobengine/v1/*. The gateway is strict first-match over declaration order (StellaOpsRouteResolver.Resolve), so appending the consolidated group would hand this entire plane to release-orchestrator. The swap script inserts at index 85 and re-reads its own output to prove the ordering before writing. Independent corroboration that the two “jobengine” planes collide already existed in the repo: GatewayRouteSearchMappingsTests.RouteTable_MapsJobEngineJobsConsolePathToScheduler_NotReleaseOrchestratorPlaceholder.

More generally, /api/v1/jobengine/* is a shared external namespace: only jobs, dag and registry/packs are this family’s; quotas, deadletter, runs, pack-runs, stream, audit, sources and slos are ReleaseOrchestrator’s (and quotas targets platform in the compose-local config but release-orchestrator in the service default — a pre-existing divergence, not this family’s).

Resolved routing finding — native PacksRegistry surfaces publish through Router. RAR-5 retired the host-capture wildcard, so /api/v1/packs and /api/v1/export/offline-seed no longer depend on a URL segment matching the service name. RAR-6 (2026-08-30) repoints the Console’s pack client from /api/v1/jobengine/registry/packs to the already-published /api/v1/packs root. The alias stays until that Console source is deployed and verified. Unsupported client operations remain a separate API-contract gap; publication does not create version/search/download handlers.

Console consumers (JOB-6 staging, 2026-08-05). Six prefixes now resolve through the one registry (api-path-prefixes.ts). RAR-6 independently repoints current-owner Pack Registry and Scheduler transport paths; the future consolidated-owner flip remains a six-value contract change. The sweep found what a registry reading alone cannot: JOBENGINE_API_BASE_URL fans out to two backendsOrchestratorHttpClient, JobEngineControlHttpClient and FirstSignalHttpClient all resolve to release-orchestrator through it, and only JobEngineJobsClient resolves to scheduler. Flipping that token at the window would drag ReleaseOrchestrator’s whole job surface onto the jobengine host. Guard: tools/scripts/validate/check-console-jobengine-prefix-literals.sh.

Identity. PreserveAuthHeaders is inert on all ten routes — Program.cs:368-370 builds JwtPassthroughPrefixes from Routes.Where(r => r.PreserveAuthHeaders && !r.IsRegex) and every one of them is regex. Authorization is already stripped; identity rides the signed envelope, and the consolidated host must accept it the same way.

Scope catalog: unchanged. No scope added or renamed. The family’s scopes remain scheduler:read/scheduler:operate/scheduler:admin (colon form, StellaOpsScopes.cs:732-742) and packs.read/packs.write/packs.run/packs.approve (dot form, lines 453-468). The two spellings are inconsistent with each other; both are load-bearing today and reconciling them is not this stage’s business.

Still owed before the window opens: the consolidated host must serve /api/jobengine/v1/** natively. The group forwards the tail verbatim, and the S4 move was byte-identical, so every carried MapGroup still hardcodes its old prefix. Host wiring is JOB-7/JOB-9’s; applying the swap first yields a 404 farm that the route tests cannot see, because they assert routing, not answers.

4. Cross-service seams

SeamStateDisposition
X8 Policy gate-eval queueCLOSED 2026-08-03 (round 61)S9 drops orphaned policy.gate-evaluation rows; JOB-10 removes Policy’s inert Postgres__Scheduler compose keys
Policy’s scheduler migration hostGONE (same commit) — scheduler-web is again the SOLE migration authority for schedulernone
graph_jobs / CartographerJobEngine-internal; worker calls Graph’s APIkeep (runtime API seam already correct)
scheduler.doctor_trends + estate doctor packs009 DOC-4b: estate-level packs + ADR-026-gated self-healing run HERE under the DC-14 privileged worker roleinclude in JOB-4 design; trend store rehoming decided with 009
notify.pack_approvalsapproval STATE belongs here, approval NOTIFICATIONS to 015S0 disposition stands; execute with 015
Worker SDKs (Go/Python)wire clients of scheduler-web worker APIscarry unchanged (wire compat is a JOB-5 acceptance)
Queue transport (Valkey Streams / NATS)StellaOps.Scheduler.Queue pluggablecarry; P16 stream ownership rows already in matrix §6.1

5. Build-boundary + decisions

5.1 Foreign edges (2026-08-03 register)

JOB-4 disposition (2026-08-05) — and a correction to the JOB-3 csproj text. StellaOps.JobEngine.WebService.csproj says these pins “are repointed onto contract seams during JOB-4/JOB-5”. That over-promised, in a way worth naming rather than quietly dropping: JobEngine cannot repoint them, because there is nothing wrong on JobEngine’s side to fix. All four producer projects have implementation-free transitive graphs, verified project-by-project:

ProducerTransitive ProjectReferencesVerdict
StellaOps.Notify.Modelsnoneclosed
StellaOps.Notify.QueueNotify.Models onlyclosed
StellaOps.Scanner.Surface.Envnoneclosed
StellaOps.Scanner.Surface.FSStellaOps.Cryptography, StellaOps.Facet — both classified domain-neutral-sharedclosed

None reaches a host, persistence, migrations, worker, startup or domain core, so under the owner’s 2026-07-30 clarification each is eligible for cross-service-client-sdk classification — the same conclusion FND-4 reached for Findings.Disposition.Contracts, whose three pins “were a classification gap, not a build-independence defect”. These are a classification gap too.

What JOB-4 does NOT do: reclassify another family’s projects. The manifest rules for src/Notify and src/Scanner belong to SPRINT_20260722_015 and SPRINT_20260722_017; unilaterally editing them would be a cross-program decision taken by the wrong agent, and Notify.Queue in particular carries a queue client, whose classification 015 may want to shape with its own consolidation. Handed to 015 and 017 with the evidence above. Until then the pins stay recorded, shrink-only, and the byte-identical extraction changed neither: scheduler-web still has exactly 2 violation pairs over the same 4 foreign projects, and the boundary report was byte-identical across two independent regenerations. Only the notify witness path lengthened by one hop (through StellaOps.JobEngine.Scheduling); the register entry was corrected to match.

5.2 DC-35 lease decision — APPLIED at JOB-3

scheduler.locks is a renewable lease with no fencing token — an expired holder can still commit. Recommendation: adopt the P6 fenced lease (StellaOps.Eventing.Reliability, eventing schema homed in stellaops_jobengine) for job/worker coordination in the re-authored substrate, retiring scheduler.locks rather than documenting it as a fourth lock lineage. The estate precedent is uniform (hub projector, Policy queue claim semantics); the worker SDK wire contract is unaffected (leases are server-side).

Applied 2026-08-04 (JOB-3). scheduler.locks is absent from the consolidated baseline; AddConsolidatedJobEnginePersistence registers AddEventingReliability(), which converges eventing.leases (with fencing_token) into the same database. Two boundaries the adoption respects:

5.4 Missing objects the merge must not inherit (found at JOB-3)

Two objects live code calls that no migration in either lineage creates, so they are absent from every converged database:

  1. scheduler.failure_signatures — see §2.1.
  2. scheduler.insert_log_with_chain_update(TEXT, TEXT, TEXT, UUID, BYTEA, BYTEA, BYTEA) → BIGINT — called by PostgresSchedulerLogRepository.InsertWithChainUpdateAsync, which is the ISchedulerLogRepository that HlcSchedulerServiceCollectionExtensions actually registers. The collapsed baseline defines upsert_chain_head but not this one, so the HLC enqueue path fails. Its signature is fixed by the call site and its semantics by the chain_heads contract (“updated atomically with scheduler_log inserts”): append the log row, advance the head, return the storage sequence. The consolidated baseline defines it, and the test suite exercises one enqueue end to end.

Both are carried as corrections, not new design: “carry the HLC machinery” and “carry the failure-signature surface” are only honoured if what the code calls actually exists.

5.5 DC-13 — the round-18 key made structural (JOB-4)

DC-13 has zero external submission adopters after round 61 (§3), so it is the idempotency-key contract on the EXISTING resolver /jobs family. That family is mechanical-class: PostgresResolverJobService composes resolver:{digest} and dedups through GetByIdempotencyKeyAsync, i.e. exactly the tenant/type/input key the ruling allows for non-evaluation work. Nothing in JobEngine is evaluation-class today.

JOB-3 stamped the evaluation columns and stated the rule in a SQL comment. JOB-4 found that insufficient for the same reason pack_versions was: a rule that lives only in prose is satisfiable on paper while the defect survives. A future adopter could declare an evaluation job, leave corpus_generation NULL — or set it but forget to fold it into idempotency_key — and every re-request after a corpus bump would collapse onto the stale verdict, which is precisely the failure round 18 exists to prevent.

Three changes move the contract into the database:

  1. scheduler.job_idempotency_class(mechanical | evaluation), a declared column defaulting to mechanical, rather than a class inferred from job_type strings.
  2. Two CHECKs, binding the class to its inputs in both directions. An evaluation job must carry subject, input hash, corpus generation and policy bundle hash; a mechanical job must carry none of the evaluation inputs — so a job cannot smuggle a policy bundle in while staying mechanical and quietly opting out of the first CHECK.
  3. The dedup key widened to UNIQUE NULLS NOT DISTINCT (tenant_id, idempotency_key, corpus_generation, policy_bundle_hash). NULLS NOT DISTINCT is load-bearing, not style: mechanical rows leave both evaluation columns NULL and must still dedup on (tenant_id, idempotency_key), which default NULL-distinct semantics would break by making every mechanical row unique. For evaluation rows the two columns participate, so a corpus bump or a policy-bundle change yields a NEW row regardless of how idempotency_key was composed.

Available because the estate runs PostgreSQL 18.1 and the test harness PostgreSQL 16; NULLS NOT DISTINCT needs 15+. Proven behaviourally by Dc13_ACorpusBumpCannotDedupOntoAStaleVerdict_WhileMechanicalJobsStillDedup, which drives eight real INSERTs: mechanical replay dedups, an incomplete evaluation job is refused, a corpus bump and a bundle change each create new rows, an exact evaluation replay dedups, and an undeclared evaluation job is refused.

JOB-5 (2026-08-05) discharged its half. The submission surface is the resolver group /api/v1/scheduler/vuln/resolver/api/jobengine/v1/scheduler/vuln/resolverPOST /jobs, GET /jobs/{jobId}, GET /metrics, all behind SchedulerPolicies.Operate with per-endpoint effective.write (create) / findings.read (read). Its class is mechanical, recorded with its rationale in dc13SubmissionSurface of tools/scripts/job5-jobengine-route-swap.json alongside the evaluation-class contract binding on any future adopter. Note the surface reaches the gateway only through the ^/api/v1/([^/]+)(.*) wildcard — it has no route of its own (§3.1 finding 1) — and CreateJobAsync returns a Location header holding an absolute INTERNAL path, which the gateway does not rewrite and which the host wiring must therefore correct.

Still owed by later stages: JOB-9’s repoint should also stamp idempotency_input_hash on mechanical rows (the legacy resolver sets only idempotency_key, which the constraints permit but which leaves the key’s input unauditable).

5.3 Naming hazard (binding)

policy.packs/policy.pack_versions (policy-as-code bundles, Policy’s DB) are a DIFFERENT domain from PacksRegistry Task Packs (packs schema). Three gate_decisions tables exist estate-wide; none of them is this module’s concern after X8.

6. Compose / env delta — STAGED at JOB-8 (2026-08-05)

What JOB-8 actually landed (devops/compose/docker-compose.jobengine.yml, deliberately OUT of the default config_files list until the JOB-9 window; the three legacy keys keep serving):

Deliberately NOT staged, with reasons:

The prerequisite JOB-9 owns, restated because JOB-8 could not discharge it (D-JOB7-1): the consolidated host does not serve /api/jobengine/v1/** natively. StellaOps.JobEngine.Scheduling still binds the LEGACY StellaOps.Scheduler.Persistence, and scheduler-web feeds that DAL the generic shared-database variable — so referencing the library from the born-new host would give a brand-new service a path into the shared platform database (CoC §8.2) and would break JOB-3’s staging proof. The persistence repoint and the endpoint re-mapping are one piece of work, and it is JOB-9’s.

Scope question RULED (Q-3, owner 2026-08-25): extend-scope — EXECUTED 2026-09-12 (JOB-9). The repoint landed as the clean cut. Sprint 012’s cross-module allowance covered src/Platform/ (removing the central-migrator plugins) and src/Cli/ (the legacy DAL consumer). The in-family alternative — StellaOps.JobEngine.Persistence reusing SchedulerDataSource from the legacy assembly — was rejected: the born-new image must not ship a legacy migrations assembly it never runs (§2.11). Settlement receipt: SPRINT_20260722_012 Decisions & Risks.

What actually landed: 88 byte-identical git mv renames (0 insertions / 0 deletions) into StellaOps.JobEngine.PersistenceScheduling/ from the Scheduler DAL, Packs/ from the PacksRegistry DAL — keeping their original namespaces, so not one of the 22 consumer using statements changed and the namespace rename stays JOB-10’s. Both legacy projects are frozen under src/__Obsoleted/JobEngine/ per CoC §15.4. The src/JobEngine/** tree collapsed to one jobengine ownership family in the same change, because the generator otherwise reported ten UNPINNED violations that were every one of them an intra-tree edge. Boundary gate afterwards: 40 keys, 17 → 14 violation pairs, 17 → 14 register pins, 0 unpinned / 0 stale / 0 grown.

6A. Doctor adoption (S7, 2026-08-05)

jobengine-web hosts doctor-check/v1 per SPRINT_20260722_009 DOC-1/DOC-3: the three standard checks against its OWN database, served on GET /doctor/jobengine-web/checks under the host’s own ops.health policy (the SDK never invents an auth posture), with registration wired and default-OFF until the JOB-9 grant.

jobengine-worker does not adopt, deliberately: doctor-check/v1 is a PULL contract, so a registrant needs an HTTP surface and a Worker host has none. Both roles are replicas of one service sharing one database and the standard checks are database checks, so the web role already covers the family. A worker-specific check (queue depth / lease health, once JOB-9 lands the dispatch loop) belongs hosted BY jobengine-web, not as a second registrant.

Correction owed to JOB-9 — the baseline’s RLS bypass advice does not work. §2.3’s owner note and the baseline comment say the owner “must be granted scheduler_admin or set app.tenant_id”. The grant half fails twice: BYPASSRLS is a role attribute and is not acquired through role membership; and SET ROLE scheduler_admin then fails on plain privileges, because the baseline creates that role with no grants at all. The working postures are the attribute on the owner role itself or the per-session app.tenant_id the application already uses. Measured, not reasoned — JobEngineRoleGrantTests. Related trap: a superuser connection skips RLS entirely, so any check run as the cluster superuser reports a posture it never measured.

7. What S1 must decide (decision record inputs)

  1. Merge confirmed per ADR-039 D14 (no deviation found by this S0). — CLOSED (JOB-2, owner, round 88).
  2. DC-35: P6 fenced lease adoption (recommendation §5.2). — CLOSED, applied at JOB-3 (§5.2).
  3. doctor_trends disposition (with 009). — STILL OPEN. JOB-3 carried it non-destructively (§2.1) so the decision stays available; 009 DOC-4b owns it, and JOB-9 must not copy it into a rehomed store without that ruling.
  4. metrics drop confirmation (reader dig came back EMPTY — see §2.1; the HLC chain verified live and carries). — CLOSED: re-verified at JOB-3 (IMetricsRepository has a definition, an implementation and a DI registration, and zero call sites); dropped.
  5. The X8 deviation ratification (already recorded in the sprint’s Decisions & Risks with the owner-veto path; S1 should absorb it). — CLOSED (owner ratified 2026-08-03).
  6. (added JOB-3) policy_jobs — zero code readers estate-wide. Carried per this design; JOB-4 to confirm the drop. — CLOSED (JOB-4, 2026-08-05): DROPPED. Zero readers AND zero writers; superseded by policy_run_jobs (§2.1).
  7. (added JOB-3) packs.pack_versions — no PacksRegistry reader (§2.2). Carried; JOB-4 to rule. — CLOSED (JOB-4, 2026-08-05): DROPPED. No reader, no writer, no type; the DC-14 grounding it was carried for was schema-only, which is now DC-14’s problem to solve honestly (§2.2).
  8. (added JOB-4) Notify/Scanner pin classification — the four producers are closed graphs, so the pins are a classification gap owned by 015 and 017, not a JobEngine repoint (§5.1). OPEN with those sprints.