Decide how the process that EMITS an event learns which jobs subscribe to it, and record the decision as an amendment to docs/decisions/job-queue-foundation.md.
dispatchEventToEngine resolves its fan-out with engineSubscribers(name), which filters a module-level Map in lib/jobs/engine/registry.ts. That Map is filled by registerEngineJob, called from defineJob — so it holds only the jobs whose definition MODULE has been evaluated in this process. The file says so itself:
COMPLETENESS DEPENDS ON IMPORT, identically to
schedules.ts: the table holds only jobs whose definition module has been evaluated.lib/jobs/registry.tsimports all of them, so any consumer MUST import that module first.
⚠️ AMENDED 2026-08-25, in this card's own run (branch
parent/MOTIR-3415-event-jobs-cutover)The grep below returns NINETEEN files at the ref this card itself names, not three. Re-measured at
b944dab5and again at18d60791; both return 19, so this is an enumeration that was WRONG WHEN WRITTEN, not drift since. The sixteen unlisted extras are all undertests/, andgit log --diff-filter=Adates every one of them before this card was created.The CONCLUSION is unaffected and stands. Exactly two of the nineteen are non-test —
app/api/inngest/route.tsandscripts/worker.ts— and those two are the load-bearing fact: no module on any production emit path imports the registry. The extras make the “why nothing caught it” argument STRONGER rather than weaker — seventeen suites evaluate the definition modules in their own process, so a great deal of the job suite's greenness is measured on a module graph that production never has.
docs/decisions/job-queue-foundation.md§11 records the correction in full. Planning bug filed under MOTIR-1465.
Verified on origin/main@b944dab5:
$ git grep -l "@/lib/jobs/registry'" origin/main -- '*.ts' '*.tsx'
app/api/inngest/route.ts
scripts/worker.ts
tests/jobs/engine-dispatcher.test.ts
lib/jobs/sendEvent.ts imports ./client, ./types and ./engine/dispatcher; the dispatcher imports ./engine/registry (the empty Map), and nothing in that graph reaches a definition module. So on a Next.js request path engineSubscribers returns [], no job_queue row is written, hasInngestSubscribers returns its safe default true, inngest.send() fires, and — for a job whose id IS in the routing set — defineJob's handler returns { skipped: 'routed-to-postgres-engine' }. The event runs on neither lane.
Why nothing caught it: the dispatcher test carries import '@/lib/jobs/registry' at line 24, and the E2E lane's inngest-cli dev -u http://localhost:PORT/api/inngest syncs the serve route at lane start, which evaluates the registry inside that same Next server process. Both make the registry complete by a route no production request takes. In production the serve route is evaluated only on a machine Inngest has synced against, and fly.toml sets min_machines_running = 2.
(a) Side-effect import. Add import '@/lib/jobs/registry'; to sendEvent.ts or the dispatcher. One line. Two costs to price rather than assume: every server bundle that emits an event pulls all 24 definition modules and their transitive service imports; and the definitions import services (lib/jobs/services.ts) which import sendEvent, so this closes an import CYCLE at the seam. Both are measurable — measure them, do not argue about them.
(b) A data-only subscriber MANIFEST. Split registration so the emit path needs only { id, trigger, cron, maxAttempts } and never the handler. The manifest module imports no services, so importing it eagerly from sendEvent is cheap and acyclic; the worker keeps importing the full registry for the handlers it must execute.
(c) A database-backed subscriber table, written at deploy or boot and read by the dispatcher. Removes the import question entirely, at the cost of a read on the emit path and a table that can go stale against the deployed code — the exact staleness cutover.ts refused an env-var cache to avoid.
Rung 2, read on origin/main@b944dab5: the emit path already needs no handler. dispatchEventToEngine reads only sub.id and sub.maxAttempts; hasInngestSubscribers reads only id and trigger. EngineJobDefinition.handler is the single field that drags the service graph in, and it is the one field neither emit-path caller touches. So (b) is not a new abstraction — it is the split the existing call sites already imply.
Rung 1, the mirror: Inngest resolves an event's subscribers server-side, from a function manifest synced at deploy. The emitting process sends a name and a payload and knows nothing about who consumes it. A manifest is the mirror product's own shape, not an invention.
(c) is rejected on the same reasoning lib/jobs/engine/cutover.ts already records for the routing set: a cached or replicated view of the deployed code's shape can go stale, and stale here means a job that runs on both engines or on neither.
ENDS at: the amendment merged in docs/decisions/job-queue-foundation.md, with the measurement that settled it. Writes no lib/ code — the implementation is the reachability card.
Also decides for lib/jobs/schedules.ts, which carries the identical import caveat and is MOTIR-3416's problem in the same shape. Say so in the amendment so the scheduled story inherits the answer instead of re-deriving it.
docs/decisions/job-queue-foundation.md, numbered after the last section present when the card runs — grep -nE '^## §' docs/decisions/job-queue-foundation.md first and take the next number, because a sibling PR may have added one. At authoring time the last was ## §10 — The risk this decision accepts, named.lib/jobs/registry.ts at a named ref, and why the dispatcher test and the E2E lane are both green.lib/jobs/schedules.ts.lib/ or tests/ is modified by this card.lib/jobs/engine/registry.ts — the Map, and its own header on import-completenesslib/jobs/engine/dispatcher.ts — engineSubscribers / hasInngestSubscribers, the two emit-path readerslib/jobs/sendEvent.ts — the emit path, and its import listscripts/worker.ts — the side-effect import, with the comment explaining why it looks unusedtests/jobs/engine-dispatcher.test.ts — line 24, why the suite is greendocs/decisions/job-queue-foundation.md — §4/§8, which name the dispatcher and the registry without deciding this