Route every event emitter through sendEvent, so the per-job cutover switch is consulted for all of them — and widen the ESLint boundary so a new bypass cannot be added silently.
sendEvent is the only place the switch is read on the emit side. Five call sites emit events without going through it, so for the events they carry the switch does not exist. Measured on origin/main@b944dab5:
$ git grep -n "inngest\.send(" origin/main -- '*.ts' '*.tsx'
lib/billing/seatSync.ts:25 system.billing-seat-sync
lib/ciFleet/bootDispatch.ts:99 system.ci-runner-boot
lib/github/indexEnqueue.ts:37 system.code-graph-index
lib/github/indexEnqueue.ts:56 system.code-graph-refresh
lib/jobs/definitions/ciRunnerFleet.ts:152 system.ci-runner-boot
lib/jobs/dlq.ts:116 the REPLAY path — keep (see below)
lib/jobs/sendEvent.ts:82 the Inngest arm of sendEvent itself — keep
The remaining hits in that grep are prose: lib/jobs/client.ts:9 and lib/env.ts:13 are comments, and scripts/plan-seed/data/story-1.6.ts:147 is seed fixture text. Count real calls, not the word.
Why ESLint did not catch it. INNGEST_RESTRICTION in eslint.config.mjs restricts the group ['inngest', 'inngest/*'] — the SDK package. All five files import { inngest } from '@/lib/jobs/client', which is our own module and unrestricted. The boundary was drawn around the vendor package rather than around the client, so it holds against the import it was written for and not against the one people actually reach for.
1. A door for system events. sendEvent accepts only WorkspaceScopedEventName, and lib/jobs/types.ts says so deliberately: "SYSTEM events (the system.* namespace) are NOT dispatched through sendEvent at all." That exclusion exists to protect a real invariant — every business event carries an explicit tenant — and widening sendEvent to accept system.* would weaken it, because a system payload's workspaceId is optional.
So add a sibling rather than loosening the guard: sendSystemEvent(name, data) in the same module, typed to the system.* names, sharing the same dispatch body (engine first, then Inngest when subscribers remain), and skipping only the workspace-id assertion that does not apply to it. The invariant the type was protecting stays exactly as strong; the switch becomes reachable for four more jobs.
2. Convert the five call sites to sendEvent / sendSystemEvent. Each keeps its existing best-effort semantics — these are all POST-COMMIT notification side effects, and sendEvent already implements exactly that contract with the same try/catch and the same reasoning in its header. Do not add a second error-handling policy beside it.
3. Widen the boundary so this cannot recur. Add @/lib/jobs/client to the restricted paths outside lib/jobs/** (and scripts/worker.ts / app/api/inngest/**, which already carry exceptions), mirroring JOB_ENGINE_RESTRICTION's shape. ESLint alone is not sufficient — lib/jobs/definitions/ciRunnerFleet.ts sits INSIDE lib/jobs/**, where the rule permits the import — so add a guard test asserting inngest.send( is called in exactly two files: lib/jobs/sendEvent.ts and lib/jobs/dlq.ts.
This card DOES modify lib/jobs/definitions/ciRunnerFleet.ts — one enqueue line at 152 — and no job's behaviour changes as a result.
ENDS at: every emitter consulting the switch. Moves NO job onto the engine: MOTIR_POSTGRES_JOB_IDS is untouched, so all four system.* jobs keep running exactly where they run today. Making a switch reachable is not throwing it — the three supervisor jobs (system.code-graph-index, system.code-graph-refresh, system.ci-runner-boot) are MOTIR-3417's to move, and this card is what makes that possible for them.
KEEPS both remaining inngest.send calls. lib/jobs/dlq.ts:116 is the replay path and is already cutover-aware — it branches on routedToEngine(row.functionId) and enqueues a job_queue row for a migrated job before ever reaching the Inngest arm. lib/jobs/sendEvent.ts:82 is the Inngest transport itself. Neither is an abandoned path yet, so nothing is deleted here; the deletion of the transport belongs to MOTIR-3418, which owns the retirement.
git grep -n "inngest\.send(" -- '*.ts' '*.tsx' returns real calls in exactly two files: lib/jobs/sendEvent.ts and lib/jobs/dlq.ts.@/lib/jobs/client from outside lib/jobs/**, scripts/worker.ts and app/api/inngest/**, asserted by a fixture the lint run rejects.system.* events still reaches its job unchanged with an empty MOTIR_POSTGRES_JOB_IDS: same event name, same payload shape, same best-effort behaviour when the transport throws.system.billing-seat-sync enqueues a job_queue row when its id IS in the routing set — the one job of the four this story is entitled to move.lib/jobs/definitions/ is the enqueue at ciRunnerFleet.ts:152.lib/jobs/sendEvent.ts — the contract to extend, and its post-commit best-effort headerlib/jobs/types.ts — WorkspaceScopedEventName, and the comment excluding system.*lib/billing/seatSync.ts · lib/ciFleet/bootDispatch.ts · lib/github/indexEnqueue.ts · lib/jobs/definitions/ciRunnerFleet.ts — the five call siteseslint.config.mjs — INNGEST_RESTRICTION and JOB_ENGINE_RESTRICTION, and the per-surface overrideslib/jobs/dlq.ts — the replay path, already cutover-aware, which stays