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), the001_v1_scheduler_baseline.sql/001_v1_packsregistry_baseline.sqlembedded 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
| Current | Target | |
|---|---|---|
| Deployables | scheduler-web (embedded worker by default; separate Scheduler.Worker.Host exists), packsregistry-web, packsregistry-worker | jobengine-web + jobengine-worker (web/worker replica roles of ONE family) |
| Source | src/JobEngine/StellaOps.Scheduler.*, src/JobEngine/StellaOps.PacksRegistry* | src/JobEngine/StellaOps.JobEngine.WebService + .Worker — built at JOB-3, both born new (neither legacy host carries a target name and JOB-10 deletes both); namespaces StellaOps.JobEngine.* from birth |
| DAL | StellaOps.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.Persistence — built at JOB-3: fresh 001_* baseline over scheduler + scheduler_app + packs, database stellaops_jobengine, env STELLAOPS_POSTGRES_JOBENGINE_CONNECTION fail-closed |
| Worker SDKs | StellaOps.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)
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 forschedulerstands as an enumeration of that file, but the converged legacy schema holds 20 (the file dropsauditin its own last statement) and the code needs 21 (it also usesfailure_signatures, which no migration creates).
| Table | Purpose | P13 class | Disposition |
|---|---|---|---|
jobs | generic job rows (idempotency key, lease columns) | operational | REDESIGN — 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.) |
triggers | schedule triggers | operational | carry (semantics), re-author storage |
workers | worker heartbeat registry | operational/ephemeral | carry (wire contract with SDKs) |
locks | renewable lease, no fencing token (DC-35) | ephemeral | REDESIGN → P6 fenced lease (recommendation, §5.2) |
job_history | run history | audit/windowed | carry with retention header |
metrics | scheduler metrics rows | derived/windowed | DROP — dig-verified 2026-08-03: IMetricsRepository is DI-registered but has ZERO consumers estate-wide (dead substrate; the shift’s third dead-wiring find) |
schedules | schedule CRUD | operational | carry |
runs | run state machine (the Console JobEngine-compat read surface projects from it) | operational | carry |
impact_snapshots, run_summaries | run projections | derived | carry with documented rebuild path |
execution_logs | per-run logs | windowed | carry with retention header |
graph_jobs, graph_job_events | Cartographer build/overlay jobs — JobEngine-internal (Graph writes nothing; the worker CALLS Cartographer) | operational | carry |
policy_jobs | first cut of policy-run orchestration | operational | 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_jobs | scheduler-owned policy-run orchestration (NOT the X8 gate-eval queue — naming hazard §6) | operational | carry |
audit (+ audit_default partition) | partitioned audit | audit | 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_heads | HLC queue-chain machinery (mig061 lineage) | operational | carry — 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_exceptions | exception lifecycle rows | operational | carry |
doctor_trends | doctor trend history | derived/windowed | rehome 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_versions | release-script bundles | operational | scripts to release-orchestrator (row: release, release_orchestrator, release_orchestrator_agent, scripts → stellaops_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-001…scr-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/error | derived | ADD (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)
| Table | Purpose | P13 class | Disposition |
|---|---|---|---|
packs | task-pack registry (the runtime’s real home: version/version_count/latest_version, digest, signature, provenance_*, content) | operational | carry |
pack_versions | per-version pack catalog | operational | |
attestations | DSSE attestations | evidence | carry |
audit_log | pack audit | audit | carry |
lifecycles | per-pack state machine — DC-14 capability gating composes WITH it | operational | carry |
mirror_sources | air-gap mirror config | operational | carry |
parities | parity state | derived | carry 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 − locks − metrics − audit + 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.
| Decision | Rule applied |
|---|---|
Schema names scheduler / scheduler_app / packs reproduced, not renamed | S9 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.leases | DC-35 §5.2 recommendation, adopted. |
workers retained | DC-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 carried | Design 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:
| Path | Reaches | Prefix |
|---|---|---|
scheduler-web / Scheduler.Worker.Host → SchedulerPersistenceExtensions | StellaOps.Scheduler.Persistence | none (prefix-free) |
packsregistry-web/worker → PacksRegistryPersistenceExtensions | StellaOps.PacksRegistry.Persistence | none (prefix-free) |
platform-web central migrator → MigrationModulePlugins.cs | both, 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
IMigrationModulePluginAppDomain scan are history twice over — the two plugins were deleted at JOB-9 (below), and the whole plugin mechanism plus thestella system migrations-runCLI 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:
- Scheduler: schedule CRUD, runs, graph jobs, policy runs/simulations, resolver jobs (
MapPost("/jobs")family — the DC-13 home), failure signatures, workflow triggers, event webhooks, the JobEngine compatibility read surface (/api/v1/jobengine/jobs*,/dag/*— read-only projections overscheduler.runsfor the Console), worker visibility (/api/v1/scheduler/workers, read-only). - PacksRegistry: pack upload/download, signature rotation/re-envelope, attestations, lifecycle/parity, mirror config + sync, offline-seed export, compliance summary.
- DC-13 is wiring, not invention, and after round 61 it has zero external submission adopters to migrate: Policy’s gate-evaluation queue moved into
stellaops_policy(80756a1ef2; owner-visible deviation note in the 012 sprint), and Graph was dig-verified as writing nothing. DC-13 therefore = formalize the idempotency-key contract on the EXISTING resolver/jobsfamily (evaluation-class keys include corpus generation + policy bundle hash per round 18 — binding for any FUTURE evaluation-class adopter). ⚠ Remaining verification: per-route consumer sweep (CLI command groups + Console typed clients beyond the jobengine-compat pages) before S5 renames any prefix.DISCHARGED at JOB-5 (2026-08-05) — see §3.1. It found three things the S0 inventory did not record, two of which would have broken the window.
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 backends — OrchestratorHttpClient, 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
| Seam | State | Disposition |
|---|---|---|
| X8 Policy gate-eval queue | CLOSED 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 host | GONE (same commit) — scheduler-web is again the SOLE migration authority for scheduler | none |
graph_jobs / Cartographer | JobEngine-internal; worker calls Graph’s API | keep (runtime API seam already correct) |
scheduler.doctor_trends + estate doctor packs | 009 DOC-4b: estate-level packs + ADR-026-gated self-healing run HERE under the DC-14 privileged worker role | include in JOB-4 design; trend store rehoming decided with 009 |
notify.pack_approvals | approval STATE belongs here, approval NOTIFICATIONS to 015 | S0 disposition stands; execute with 015 |
| Worker SDKs (Go/Python) | wire clients of scheduler-web worker APIs | carry unchanged (wire compat is a JOB-5 acceptance) |
| Queue transport (Valkey Streams / NATS) | StellaOps.Scheduler.Queue pluggable | carry; P16 stream ownership rows already in matrix §6.1 |
5. Build-boundary + decisions
5.1 Foreign edges (2026-08-03 register)
scheduler-web → notify(witness:Notify.Models) — owned by 015; the merge must not carry this edge intojobengine-websilently: repoint onto the 015-decided contract seam or an event.scheduler-web → scanner(witness:Scheduler.Worker → Scanner.Surface.Env) — owned by 017; target = Scanner API/artifact seam.
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:
| Producer | Transitive ProjectReferences | Verdict |
|---|---|---|
StellaOps.Notify.Models | none | closed |
StellaOps.Notify.Queue | Notify.Models only | closed |
StellaOps.Scanner.Surface.Env | none | closed |
StellaOps.Scanner.Surface.FS | StellaOps.Cryptography, StellaOps.Facet — both classified domain-neutral-shared | closed |
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.
packsregistry-web/packsregistry-worker— zero foreign pins (clean graphs today; keep them clean through the merge).- Consumers OF JobEngine: after round 61,
platform → scheduler(central-migrator fan-in, dies with 026) anddoctor-web → scheduler(Scheduler.DoctorOperations, dies with 009 DOC-5) are the remaining inbound pins.
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:
scheduler.workersstays — the DC calls the heartbeat/capacity registry a legitimate separate concern, and the Go/Python worker SDK wire contract depends on it.- the per-job claim columns on
scheduler.jobs(lease_id,worker_id,lease_until) stay. DC-35 retires lock tables; single-writer claim columns on the work row are the accepted queue pattern and are exactly what Policy kept onpolicy.worker_resultsat the X8 closure. Cross-process coordination is the fenced lease.
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:
scheduler.failure_signatures— see §2.1.scheduler.insert_log_with_chain_update(TEXT, TEXT, TEXT, UUID, BYTEA, BYTEA, BYTEA) → BIGINT— called byPostgresSchedulerLogRepository.InsertWithChainUpdateAsync, which is theISchedulerLogRepositorythatHlcSchedulerServiceCollectionExtensionsactually registers. The collapsed baseline definesupsert_chain_headbut not this one, so the HLC enqueue path fails. Its signature is fixed by the call site and its semantics by thechain_headscontract (“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:
scheduler.job_idempotency_class(mechanical|evaluation), a declared column defaulting tomechanical, rather than a class inferred fromjob_typestrings.- 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
mechanicaland quietly opting out of the first CHECK. - The dedup key widened to
UNIQUE NULLS NOT DISTINCT (tenant_id, idempotency_key, corpus_generation, policy_bundle_hash).NULLS NOT DISTINCTis 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 howidempotency_keywas 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/resolver — POST /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)
- Keys
scheduler-web,packsregistry-web,packsregistry-worker→jobengine-web,jobengine-worker; images +build-service-publish.shkeys follow. Scheduler__Storage__Postgres__Scheduler__*(generic shared connection today) →STELLAOPS_POSTGRES_JOBENGINE_CONNECTION(DC-20 generated block), shrinking the DatabaseOwnership compose pins.- Policy’s
STELLAOPS_POLICY_ENGINE_Postgres__Scheduler__*keys are inert since round 61 — delete in the same window.
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):
own cluster— superseded 2026-08-14. JOB-8 originally staged a DEDICATED PostgreSQL cluster onjobengine-postgreson loopback127.1.1.4127.1.1.4; the ADR-039 P1 topology clarification (owner, 2026-08-10) retired that shape, andSPRINT_20260810_002PTC-8 reshaped the overlay before the JOB-9 window could create one. What the overlay stages now: the family owns the logical databasestellaops_jobengine+ thejobenginerole on the SHARED installation server (db.stella-ops.local), provisioned bytools/scripts/deploy/postgres/provision-service-database.sh, withREVOKE CONNECTrole hardening as the ownership boundary. Ownership is unchanged — one service, one database — and no init scripts are mounted, so startup convergence is still the contract (§2.7);- both roles consume ONE whole
STELLAOPS_POSTGRES_JOBENGINE_CONNECTION, required with no fallback. Passed through from the environment rather than assembled from parts inside the compose file (which is whatdocker-compose.vulnerabilities.ymldoes): an assembled connection is invisible to the DatabaseOwnership env-template lint, so the parts-built form is unverifiable by construction; jobengine-webhealthcheck on/health/ready; the doctor surfaceGET /doctor/jobengine-web/checksridesops.health;Doctor__Registration__Enableddefaults false with the remaining registration keys present as comments, so the window edits values rather than rediscovering the contract;- publish-helper keys + ownership-manifest entries land in ONE commit (the generator hard-checks the key mirror in both directions). The manifest gets a NEW
jobenginefamily covering only the three born-new project prefixes —StellaOps.JobEngine.Schedulingstays owned byscheduler, because the live scheduler-web host compiles it and re-owning the tree now would make a live deployable foreign to its own libraries. The three families collapse at JOB-10.
Deliberately NOT staged, with reasons:
- Plugin and pack volumes. The scheduler plugin mounts (
../etc/plugins/scheduler, the plugin-scratch volume) and the PacksRegistry content mounts are NOT carried into the overlay yet: the consolidated host does not run those surfaces, so mounting them would advertise a capability the image does not have. The scheduler mounts move with JOB-9 unchanged. The earlier statement that the PacksRegistry seed volume also moves “unchanged” is superseded by the 2026-08-19 owner ruling: JOB-12 stages two non-overlapping paths — the existing immutable/app/etc/plugins/packsregistry/packs:roseed root and the retained writable/var/lib/stellaops/packsregistry/uploadsvolume. Currentpacksregistry-weband futurejobengine-webuse the samePacksRegistry:Storage:ObjectStore:*keys. JOB-9 carries the physical upload volume and seed mount; it must not copy blobs, create a fresh volume, or mount one path in both modes. The full hand-off/collision/rollback contract is §2.2A and the PacksRegistry dossier. - Router microservice registration.
AddRouterMicroservice(serviceName: "jobengine")is JOB-9’s: registering the name is inseparable from the route swap (D-JOB5-1 — eight endpoint groups reach the service today only because the deployable is namedschedulerand the gateway wildcard derives the hostname from the first path segment).
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.Persistence — Scheduling/ 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)
- Merge confirmed per ADR-039 D14 (no deviation found by this S0). — CLOSED (JOB-2, owner, round 88).
- DC-35: P6 fenced lease adoption (recommendation §5.2). — CLOSED, applied at JOB-3 (§5.2).
doctor_trendsdisposition (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.metricsdrop confirmation (reader dig came back EMPTY — see §2.1; the HLC chain verified live and carries). — CLOSED: re-verified at JOB-3 (IMetricsRepositoryhas a definition, an implementation and a DI registration, and zero call sites); dropped.- 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).
- (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 bypolicy_run_jobs(§2.1). - (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). - (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.
