(motir-core) Route every emitter through `sendEvent`, and make the ESLint boundary enforce it
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.
The call sites, enumerated
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.
The change
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.
Scope boundary
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.
Acceptance criteria
git grep -n "inngest\.send(" -- '*.ts' '*.tsx'returns real calls in exactly two files:lib/jobs/sendEvent.tsandlib/jobs/dlq.ts.- A guard test asserts that set by reading the tree, and fails when a sixth call site is added. It counts CALLS, not occurrences of the string — the three prose mentions above must not make it pass or fail.
- ESLint refuses an import of
@/lib/jobs/clientfrom outsidelib/jobs/**,scripts/worker.tsandapp/api/inngest/**, asserted by a fixture the lint run rejects. - Each of the four
system.*events still reaches its job unchanged with an emptyMOTIR_POSTGRES_JOB_IDS: same event name, same payload shape, same best-effort behaviour when the transport throws. system.billing-seat-syncenqueues ajob_queuerow when its id IS in the routing set — the one job of the four this story is entitled to move.- No job handler is modified; the only line touched under
lib/jobs/definitions/is the enqueue atciRunnerFleet.ts:152.
Context refs
lib/jobs/sendEvent.ts— the contract to extend, and its post-commit best-effort headerlib/jobs/types.ts—WorkspaceScopedEventName, and the comment excludingsystem.*lib/billing/seatSync.ts·lib/ciFleet/bootDispatch.ts·lib/github/indexEnqueue.ts·lib/jobs/definitions/ciRunnerFleet.ts— the five call siteseslint.config.mjs—INNGEST_RESTRICTIONandJOB_ENGINE_RESTRICTION, and the per-surface overrideslib/jobs/dlq.ts— the replay path, already cutover-aware, which stays