diff --git a/.changeset/job-handler-data-reach.md b/.changeset/job-handler-data-reach.md
new file mode 100644
index 0000000000..1a43d04a78
--- /dev/null
+++ b/.changeset/job-handler-data-reach.md
@@ -0,0 +1,51 @@
+---
+"@objectstack/runtime": minor
+---
+
+feat(runtime): give a declarative job's handler data reach (#14094)
+
+`defineJob` is the platform's only metadata shape for scheduled work. Its
+handler resolves out of `defineStack({ functions })`, and until now `AppPlugin`
+invoked it with `{ jobId, data, bundle }` — no engine, no logger, nothing to
+write with. The job registered, appeared in the admin UI, was scheduled, ran on
+time, and did nothing. `objectstack validate` passed and no author-time gate
+said otherwise.
+
+The context `AppPlugin` builds now also carries:
+
+- **`ql`** — the live ObjectQL engine, the same handle `defineStack({ onEnable })`
+ receives as `ctx.ql`;
+- **`logger`** — the plugin `Logger`, so a job's diagnostics land in the
+ platform's log stream instead of `console`.
+
+`JobHandlerContext` is exported from `@objectstack/runtime` for handlers that
+want to annotate their argument.
+
+**Why a job and a flow `script` node differ here.** `FlowFunctionContext` also
+carries no engine, and that is coherent for a flow function: the flow graph does
+the I/O around it (`get_record` before, `create_record` after), which is what
+the per-run write metrics count. A job has no graph — no node before it, none
+after — so the same emptiness leaves it unable to do the one thing scheduled
+work exists for. Nothing about a `script` node's contract changes.
+
+**Why the module-scope escape was not documented instead.** Closing over a
+client bound by `onEnable` does not survive the shipped deployment path:
+`objectstack build` emits `functions` into a sibling runtime module exporting
+only `{ functions, meta }`, the artifact JSON carries no `onEnable`, and
+`mergeRuntimeModule` merges only `functions` — so on an artifact-served boot the
+binding is never made and the handler runs against an empty slot, silently. The
+regression suite therefore proves the write on **both** boot paths: the
+TS-config path and a real artifact loaded through `loadArtifactBundle`.
+
+**Additive.** This widens the object passed to the handler, the same shape
+`JobRunOutcome` took. `IJobService`'s
+`JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched —
+the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
+satisfies it exactly, and the new members are added inside that wrapper. An
+existing handler that destructures `{ jobId }` or `{ jobId, data }` is unchanged
+byte for byte, and no `IJobService` implementation grows a member.
+
+The kernel's `getService` was considered and deliberately not added: it would
+put the whole service registry on a job's context permanently, and the measured
+population of shipped `defineJob` handlers needs the engine and nothing else.
+Adding it later is additive by exactly this argument, so nothing is foreclosed.
diff --git a/content/docs/automation/jobs.mdx b/content/docs/automation/jobs.mdx
index a3597f1b69..393afca17e 100644
--- a/content/docs/automation/jobs.mdx
+++ b/content/docs/automation/jobs.mdx
@@ -122,8 +122,48 @@ a long time with no function of that name anywhere in the app was skipped at
every boot, and the sweep never ran. If a job appears to do nothing, read the
boot log for its name before reading its schedule.
-At run time the handler is invoked with `{ jobId, data }`. What it returns
-decides how the run is recorded:
+## What the handler is given
+
+At run time the handler is invoked with a `JobHandlerContext`
+(`@objectstack/runtime`):
+
+| Member | What it is |
+|:---|:---|
+| `jobId` | the job's `name` — its identity everywhere |
+| `data` | the payload of a manual `trigger(name, data)` run; absent on a scheduled run |
+| `bundle` | the application's metadata bundle — declarations, not a data handle |
+| `ql` | the live ObjectQL engine — the same handle `defineStack({ onEnable })` receives |
+| `logger` | the platform logger, so a job's diagnostics are not `console` output |
+
+```typescript
+import type { JobHandlerContext } from '@objectstack/runtime';
+
+export async function sweepProjectHealth({ ql, jobId, logger }: JobHandlerContext) {
+ const stale = await ql.find('project', { where: { status: 'active' } });
+ for (const p of stale) await ql.update('project', { id: p.id, health: 'green' });
+ logger.info('sweep complete', { job: jobId, scanned: stale.length });
+}
+```
+
+`ql` is why a job does **not** follow the pure-function rule a flow `script`
+node follows. A `script` node returns a value and the flow graph does the I/O
+around it — a `get_record` before, a `create_record` after — so its context
+deliberately carries no engine. A job has no graph: no node before it, none
+after. Writing records on a timer is the thing scheduled work exists for, so
+the handler is handed the engine directly. A job's writes are still not counted
+by any caller, so declare its `functions` entry `effect: 'writes'` — that makes
+the run report "cannot say" rather than silently claiming it wrote nothing.
+
+
+ Do **not** reach the engine by having `onEnable` assign a module-scope global
+ the handler reads later. That binding does not survive a built artifact:
+ `objectstack build` emits your `functions` into a sibling runtime module, the
+ artifact JSON carries no `onEnable`, and only `functions` is merged back on
+ load — so on an artifact-served boot the global is never assigned and the job
+ runs against nothing, silently. Take `ql` from the context.
+
+
+What the handler returns decides how the run is recorded:
| The handler… | Recorded as | Retried? |
|:---|:---|:---|
diff --git a/packages/runtime/src/app-plugin.job-data-reach.test.ts b/packages/runtime/src/app-plugin.job-data-reach.test.ts
new file mode 100644
index 0000000000..8a4e33ff82
--- /dev/null
+++ b/packages/runtime/src/app-plugin.job-data-reach.test.ts
@@ -0,0 +1,464 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #14094 — a declarative job's handler must be able to READ AND WRITE A RECORD.
+ *
+ * ## What was measured before the fix
+ *
+ * A real booted stack with one `defineJob` whose handler records its own
+ * argument reported, on `origin/main` `66ecc50a` (and, in the filed card,
+ * against published `@objectstack/*` 17.2.0):
+ *
+ * ```
+ * JOB CONTEXT KEYS: bundle, data, jobId
+ * jobId -> string
+ * data -> undefined
+ * bundle -> object
+ * ```
+ *
+ * That is the whole context. No engine, no service registry, no logger. The
+ * platform ships exactly ONE metadata shape for scheduled work, resolves its
+ * handler out of `defineStack({ functions })`, and then hands that handler
+ * nothing to write with. The job registers, appears in the admin UI, is
+ * scheduled, runs on time, and does nothing — `objectstack validate` passes.
+ *
+ * ## Why the flow-`script`-node contract is NOT the same case
+ *
+ * `FlowFunctionContext` carries no engine either, and that is COHERENT for a
+ * flow function: the flow graph does the I/O around it (`get_record` before,
+ * `create_record` after), which is what #4354's per-run write metrics count.
+ * A JOB HAS NO GRAPH — no node before it, none after — so the same emptiness
+ * leaves it unable to do the one thing jobs exist for. Nothing here changes
+ * what a `script` node receives.
+ *
+ * ## Why the ARTIFACT path is tested, not just the TS-config path
+ *
+ * The documented escape (close over a client at module scope, bound from
+ * `defineStack({ onEnable })`) does not survive the shipped deployment path:
+ * `objectstack build` emits `{ functions, meta }` into a sibling runtime
+ * module, the artifact JSON carries no `onEnable`, and `mergeRuntimeModule`
+ * merges only `functions`. So the binding is never made on an artifact-served
+ * boot and the module-scope slot stays empty — silently. A fix proved only on
+ * the TS-config path would be a second escape with the same blind spot, so the
+ * artifact boot is driven through the REAL `loadArtifactBundle` here.
+ *
+ * ## The backend, and why it is sqlite `:memory:`
+ *
+ * These tests need *a* store — nothing here is about any one driver's behaviour.
+ * #5704 migrated this project's test backends to sqlite `:memory:` and ruled
+ * that only the two files in `scripts/driver-memory-census.ledger.json` keep
+ * `@objectstack/driver-memory`, each being one arm of a cross-family pin that
+ * genuinely cannot run on SQL. This file is neither, so it boots on the migrated
+ * backend. ⛔ Do not "simplify" it back onto the in-memory driver: that is an
+ * unledgered arrival and `pnpm check:driver-memory-census` refuses it (#6664).
+ * The card's own reproduction used the in-memory driver because that is what the
+ * reporter had in hand — a manual probe, never a constraint on this rig.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import type { PluginContext } from '@objectstack/core';
+import type { JobHandler } from '@objectstack/spec/contracts';
+import { defineJob } from '@objectstack/spec/system';
+import { ObjectQL } from '@objectstack/objectql';
+import { SqlDriver } from '@objectstack/driver-sql';
+import { CronJobAdapter } from '@objectstack/service-job';
+import { AppPlugin } from './app-plugin.js';
+import { loadArtifactBundle } from './load-artifact-bundle.js';
+import type { JobHandlerContext } from './job-handler-context.js';
+import {
+ captureExpectedReadRefusals,
+ type ExpectedReadRefusalCapture,
+} from './expected-read-refusal-noise.js';
+
+/** The record a scheduled sweep is supposed to be able to write. */
+const NOTE = {
+ name: 'sweep_note',
+ label: 'Sweep Note',
+ fields: {
+ title: { type: 'text' },
+ swept: { type: 'text' },
+ },
+};
+
+/**
+ * The three members the context carried BEFORE #14094 — pinned as a set so the
+ * widening is visible in the diff of anything that changes it, and so
+ * "existing handlers unchanged" is asserted rather than asserted-about.
+ */
+const PRE_14094_KEYS = ['bundle', 'data', 'jobId'] as const;
+/** What #14094 added, and nothing else. */
+const ADDED_KEYS = ['logger', 'ql'] as const;
+
+interface Harness {
+ engine: ObjectQL;
+ adapter: CronJobAdapter;
+ ctx: PluginContext;
+ fireReady: () => Promise;
+ errorLogs: () => string[];
+ warnLogs: () => string[];
+}
+
+const live: Array<{
+ engine?: ObjectQL;
+ adapter?: CronJobAdapter;
+ driver?: SqlDriver;
+ dir?: string;
+ noise?: ExpectedReadRefusalCapture;
+ /** The channels this test's path MUST have provoked — see `harness()`. */
+ requiredChannels?: readonly string[];
+}> = [];
+
+/**
+ * [#10629] This fixture provisions `sweep_note` and nothing else, so the
+ * engine's own single-tenant probe (`ObjectQL.probeInstallOrganizations`) reads
+ * a `sys_organization` that was never created. The probe is fail-soft by
+ * construction, but the driver and the engine each log the fault on the way out.
+ * Withheld and ASSERTED rather than muted — see `expected-read-refusal-noise.ts`.
+ */
+const ABSENT_TENANCY_TABLE = 'sys_organization';
+
+afterEach(async () => {
+ for (const entry of live.splice(0)) {
+ try { await entry.adapter?.destroy(); } catch { /* noop */ }
+ try { await entry.engine?.destroy(); } catch { /* noop */ }
+ try { await entry.driver?.disconnect(); } catch { /* noop */ }
+ if (entry.dir) rmSync(entry.dir, { recursive: true, force: true });
+ // A capture nobody asserts is a mute. The probe is memoised behind the
+ // FIRST data operation, so only the paths that actually touch the store
+ // provoke it — `silentChannels(required)` is the API's own answer to a
+ // table read on some of a file's paths and not others. The withholding
+ // is unconditional either way; only the must-have-fired set narrows.
+ if (entry.noise) {
+ expect(entry.noise.silentChannels(entry.requiredChannels ?? [ABSENT_TENANCY_TABLE])).toEqual([]);
+ }
+ }
+});
+
+/**
+ * A real engine over the MIGRATED test backend — sqlite `:memory:` (#5704) —
+ * with `sweep_note` provisioned and registered.
+ */
+async function bootEngine(): Promise<{ engine: ObjectQL; driver: SqlDriver; noise: ExpectedReadRefusalCapture }> {
+ const driver = new SqlDriver({
+ client: 'better-sqlite3',
+ connection: { filename: ':memory:' },
+ useNullAsDefault: true,
+ });
+ // Installed before the driver runs a statement and before the engine issues
+ // a read — the two sinks the expected refusal travels out on.
+ const noise = captureExpectedReadRefusals([ABSENT_TENANCY_TABLE]);
+ noise.captureDriver(driver);
+ await driver.initObjects([NOTE as never]);
+ const engine = new ObjectQL();
+ noise.captureEngine(engine);
+ engine.registerDriver(driver as never, true);
+ await engine.init();
+ engine.registry.registerObject(NOTE as never);
+ return { engine, driver, noise };
+}
+
+/**
+ * @param opts.touchesStore whether this test's path performs a data operation.
+ * `true` (the default) requires the tenancy probe to have fired and been
+ * withheld; a context-shape test that never reads or writes passes `false`,
+ * which keeps the withholding and drops only the must-have-fired requirement.
+ */
+async function harness(opts: { touchesStore?: boolean } = {}): Promise {
+ const { engine, driver, noise } = await bootEngine();
+ const adapter = new CronJobAdapter();
+ live.push({
+ engine, adapter, driver, noise,
+ requiredChannels: opts.touchesStore === false ? [] : [ABSENT_TENANCY_TABLE],
+ });
+
+ const readyHooks: Array<() => Promise> = [];
+ const ctx = {
+ logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() },
+ registerService: vi.fn(),
+ getService: vi.fn((name: string) => {
+ if (name === 'job') return adapter;
+ if (name === 'objectql') return engine;
+ return undefined;
+ }),
+ getServices: vi.fn(() => []),
+ hook: vi.fn((event: string, cb: () => Promise) => {
+ if (event === 'kernel:ready') readyHooks.push(cb);
+ }),
+ trigger: vi.fn(),
+ } as unknown as PluginContext;
+
+ return {
+ engine,
+ adapter,
+ ctx,
+ fireReady: async () => { for (const cb of readyHooks) await cb(); },
+ errorLogs: () => vi.mocked(ctx.logger.error).mock.calls.map(c => String(c[0])),
+ warnLogs: () => vi.mocked(ctx.logger.warn).mock.calls.map(c => String(c[0])),
+ };
+}
+
+/** The nightly-sweep shape the card is about: read some rows, write one back. */
+async function sweepHandler(jobCtx: JobHandlerContext): Promise {
+ const { ql, jobId, logger } = jobCtx;
+ const pending = await ql.find('sweep_note', { where: { swept: 'no' } }) as Array<{ id: string }>;
+ for (const row of pending) {
+ await ql.update('sweep_note', { id: row.id, swept: jobId });
+ }
+ logger.info('[test] sweep complete', { job: jobId, updated: pending.length });
+}
+
+const rowsOf = (result: unknown): Array> =>
+ Array.isArray(result) ? result as Array> : [];
+
+describe('#14094 — a declarative job handler has data reach (TS-config path)', () => {
+ it('reads and writes a record through the context it is invoked with', async () => {
+ const h = await harness();
+ await h.engine.insert('sweep_note', { title: 'first', swept: 'no' });
+ await h.engine.insert('sweep_note', { title: 'second', swept: 'no' });
+
+ const plugin = new AppPlugin({
+ id: 'com.test.job-reach',
+ jobs: [defineJob({
+ name: 'nightly_sweep',
+ schedule: { type: 'cron', expression: '0 1 * * *' },
+ handler: 'sweep',
+ })],
+ functions: { sweep: { handler: sweepHandler, effect: 'writes' } },
+ });
+
+ await plugin.start!(h.ctx);
+ await h.fireReady();
+ expect(await h.adapter.listJobs()).toContain('nightly_sweep');
+
+ // The job runs the way the scheduler runs it — no direct handler call.
+ await h.adapter.trigger('nightly_sweep');
+
+ // SUBSTANCE: the STORE changed. Not "the handler was called", not "the
+ // context had a key" — the rows a scheduled sweep exists to update.
+ const after = rowsOf(await h.engine.find('sweep_note', {}));
+ expect(after).toHaveLength(2);
+ expect(after.map(r => r.swept).sort()).toEqual(['nightly_sweep', 'nightly_sweep']);
+ expect(h.errorLogs()).toEqual([]);
+ });
+
+ it('the context is the pre-#14094 set PLUS exactly `ql` and `logger`', async () => {
+ // Reads nothing and writes nothing — this one is about the shape.
+ const h = await harness({ touchesStore: false });
+ const seen: Array> = [];
+
+ const plugin = new AppPlugin({
+ id: 'com.test.job-reach',
+ jobs: [defineJob({ name: 'probe_job', schedule: { type: 'interval', intervalMs: 60_000 }, handler: 'probe' })],
+ functions: { probe: async (c: Record) => { seen.push(c); } },
+ });
+ await plugin.start!(h.ctx);
+ await h.fireReady();
+ await h.adapter.trigger('probe_job');
+
+ expect(seen).toHaveLength(1);
+ const keys = Object.keys(seen[0]).sort();
+ expect(keys).toEqual([...PRE_14094_KEYS, ...ADDED_KEYS].sort());
+
+ // The pre-existing members keep their meaning — `jobId` is the job's
+ // name, `bundle` is the metadata bundle, `data` is the trigger payload.
+ expect(seen[0].jobId).toBe('probe_job');
+ expect(seen[0].bundle).toBeTypeOf('object');
+ expect(seen[0].data).toBeUndefined();
+
+ // And the added members are the LIVE handles, not placeholders.
+ expect(seen[0].ql).toBe(h.engine);
+ expect(seen[0].logger).toBe(h.ctx.logger);
+ });
+
+ it('`data` from a manual trigger still reaches the handler beside the new members', async () => {
+ const h = await harness({ touchesStore: false });
+ const seen: Array> = [];
+ const plugin = new AppPlugin({
+ id: 'com.test.job-reach',
+ jobs: [defineJob({ name: 'payload_job', schedule: { type: 'interval', intervalMs: 60_000 }, handler: 'probe' })],
+ functions: { probe: async (c: Record) => { seen.push(c); } },
+ });
+ await plugin.start!(h.ctx);
+ await h.fireReady();
+
+ await h.adapter.trigger('payload_job', { reason: 'manual' });
+
+ expect(seen[0].data).toEqual({ reason: 'manual' });
+ expect(seen[0].ql).toBe(h.engine);
+ });
+});
+
+describe('#14094 — the same reach on the ARTIFACT path', () => {
+ /**
+ * Builds on disk what `objectstack build` emits: a JSON artifact carrying
+ * NO `onEnable` plus a sibling ESM module exporting `{ functions, meta }`,
+ * and loads it through the REAL `loadArtifactBundle` / `mergeRuntimeModule`.
+ */
+ async function bootFromArtifact() {
+ const dir = mkdtempSync(join(tmpdir(), 'os-job-reach-14094-'));
+ const h = await harness();
+ live.push({ dir });
+
+ // The handler lives only in the sibling module — exactly as a built app
+ // ships it. It has no module-scope binding seam and no `onEnable` to
+ // fill one: everything it writes with comes from its argument.
+ writeFileSync(join(dir, 'runtime.mjs'), `
+export const functions = {
+ sweep: async ({ ql, jobId }) => {
+ const pending = await ql.find('sweep_note', { where: { swept: 'no' } });
+ for (const row of pending) await ql.update('sweep_note', { id: row.id, swept: jobId });
+ },
+};
+export const meta = { builtAt: '2026-09-01T00:00:00.000Z' };
+`, 'utf-8');
+
+ const artifact = {
+ manifest: { id: 'com.test.job-reach-artifact', name: 'Artifact Job Reach', version: '1.0.0' },
+ // What the builder lowers a callable to: a handler REF plus what the
+ // function declared about itself. The callable rides in the module.
+ functions: { sweep: { handler: 'sweep', effect: 'writes' } },
+ jobs: [JSON.parse(JSON.stringify(defineJob({
+ name: 'artifact_sweep',
+ schedule: { type: 'cron', expression: '0 2 * * *' },
+ handler: 'sweep',
+ })))],
+ runtimeModule: './runtime.mjs',
+ };
+ const artifactPath = join(dir, 'objectstack.artifact.json');
+ writeFileSync(artifactPath, JSON.stringify(artifact), 'utf-8');
+
+ const bundle = await loadArtifactBundle(artifactPath, { tag: '[test:14094]' });
+ return { dir, h, bundle };
+ }
+
+ it('an artifact-served job writes a record — the path where the module-scope escape silently fails', async () => {
+ const { h, bundle } = await bootFromArtifact();
+
+ // The premise of Zone 1.2, asserted rather than assumed: there is no
+ // `onEnable` on this boot path, so "have onEnable assign a module-scope
+ // global" is not available to the handler at all.
+ expect(bundle).not.toBeNull();
+ expect((bundle as Record).onEnable).toBeUndefined();
+ // The callable did arrive — via `functions`, the only thing merged.
+ expect(typeof (bundle as any).functions.sweep.handler).toBe('function');
+
+ await h.engine.insert('sweep_note', { title: 'artifact row', swept: 'no' });
+
+ const plugin = new AppPlugin(bundle);
+ await plugin.start!(h.ctx);
+ await h.fireReady();
+ expect(await h.adapter.listJobs()).toContain('artifact_sweep');
+
+ await h.adapter.trigger('artifact_sweep');
+
+ const after = rowsOf(await h.engine.find('sweep_note', {}));
+ expect(after).toHaveLength(1);
+ expect(after[0].swept).toBe('artifact_sweep');
+ expect(h.errorLogs()).toEqual([]);
+ });
+
+ it('FIRING CONTROL: the same artifact handler cannot write without the reach', async () => {
+ const { h, bundle } = await bootFromArtifact();
+ await h.engine.insert('sweep_note', { title: 'artifact row', swept: 'no' });
+
+ // The exact callable the artifact shipped, invoked with the context
+ // shape this card measured BEFORE the fix. If the assertion above ever
+ // passed vacuously — because something else wrote the row — this would
+ // pass too. It does not: with no `ql` the handler cannot even start.
+ const shipped = (bundle as any).functions.sweep.handler as (c: unknown) => Promise;
+ await expect(
+ shipped({ jobId: 'artifact_sweep', data: undefined, bundle }),
+ // Reaching through the absent handle — `undefined.find(...)` — is what
+ // "no data reach" IS. The message wording is V8's, so only the shape is
+ // pinned; the store assertion below is what carries the control.
+ ).rejects.toThrow(TypeError);
+
+ const after = rowsOf(await h.engine.find('sweep_note', {}));
+ expect(after[0].swept).toBe('no');
+ });
+});
+
+describe('#14094 — additivity (Zone 1.1)', () => {
+ it('a handler written against the PRE-#14094 context runs unchanged, byte for byte', async () => {
+ const h = await harness({ touchesStore: false });
+ const calls: Array<{ jobId: string; data?: unknown }> = [];
+
+ // Verbatim the shape `IJobService`'s `JobHandler` declares — the type an
+ // existing handler was written against. It names two members and knows
+ // nothing about `ql` / `logger`.
+ const legacy = async (context: { jobId: string; data?: unknown }): Promise => {
+ calls.push({ jobId: context.jobId, data: context.data });
+ };
+ // Still a `JobHandler`: this line is what a NARROWING would break.
+ const stillAJobHandler: JobHandler = legacy;
+ expect(typeof stillAJobHandler).toBe('function');
+
+ const plugin = new AppPlugin({
+ id: 'com.test.job-reach',
+ jobs: [defineJob({ name: 'legacy_job', schedule: { type: 'interval', intervalMs: 60_000 }, handler: 'legacy' })],
+ functions: { legacy },
+ });
+ await plugin.start!(h.ctx);
+ await h.fireReady();
+ await h.adapter.trigger('legacy_job');
+
+ expect(calls).toEqual([{ jobId: 'legacy_job', data: undefined }]);
+ expect(h.errorLogs()).toEqual([]);
+ expect(h.warnLogs()).toEqual([]);
+ });
+
+ it('`IJobService` is untouched: the function AppPlugin schedules still satisfies `JobHandler`', async () => {
+ const h = await harness();
+ const scheduled: Array<{ name: string; handler: JobHandler }> = [];
+ // A THIRD-PARTY IJobService implementation, typed at the contract and
+ // nothing wider. It compiles and runs against what AppPlugin hands it —
+ // the widening happens INSIDE that wrapper, never at this boundary.
+ const recording = {
+ schedule: async (name: string, _schedule: unknown, handler: JobHandler) => {
+ scheduled.push({ name, handler });
+ },
+ cancel: async () => { /* noop */ },
+ trigger: async (name: string, data?: unknown) => {
+ const entry = scheduled.find(s => s.name === name);
+ await entry?.handler({ jobId: name, data });
+ },
+ };
+ vi.mocked(h.ctx.getService).mockImplementation((name: string) => {
+ if (name === 'job') return recording as never;
+ if (name === 'objectql') return h.engine as never;
+ return undefined as never;
+ });
+
+ await h.engine.insert('sweep_note', { title: 'third party', swept: 'no' });
+ const plugin = new AppPlugin({
+ id: 'com.test.job-reach',
+ jobs: [defineJob({ name: 'third_party_job', schedule: { type: 'cron', expression: '0 3 * * *' }, handler: 'sweep' })],
+ functions: { sweep: { handler: sweepHandler, effect: 'writes' } },
+ });
+ await plugin.start!(h.ctx);
+ await h.fireReady();
+
+ expect(scheduled.map(s => s.name)).toEqual(['third_party_job']);
+ await recording.trigger('third_party_job');
+
+ const after = rowsOf(await h.engine.find('sweep_note', {}));
+ expect(after[0].swept).toBe('third_party_job');
+ });
+
+ it('FIRING CONTROL: the sweep handler fails on the pre-#14094 context', async () => {
+ const h = await harness();
+ await h.engine.insert('sweep_note', { title: 'control', swept: 'no' });
+
+ await expect(
+ (sweepHandler as unknown as (c: unknown) => Promise)({
+ jobId: 'nightly_sweep', data: undefined, bundle: {},
+ }),
+ ).rejects.toThrow(TypeError);
+
+ const after = rowsOf(await h.engine.find('sweep_note', {}));
+ expect(after[0].swept).toBe('no');
+ });
+});
diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts
index 7f6b0de188..fa5b2bf6bf 100644
--- a/packages/runtime/src/app-plugin.ts
+++ b/packages/runtime/src/app-plugin.ts
@@ -16,6 +16,7 @@ import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js';
import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js';
import { GLOBAL_ACTION_OBJECT_KEY } from './action-execution.js';
import { toBoundaryJobSchedule } from './job-schedule.js';
+import type { JobHandlerContext } from './job-handler-context.js';
import { countServerTiming, SEMCONV } from '@objectstack/observability';
import { resolveMetrics } from './observability/observability-service-plugin.js';
@@ -936,8 +937,25 @@ export class AppPlugin implements Plugin {
// bare cron string. Same seam, same place, as the
// retryPolicy/timeout threading just below.
toBoundaryJobSchedule(job.schedule, jobName),
+ // #14094: the handler is given DATA REACH. A job has no
+ // graph — no node before it, none after — so unlike a
+ // flow `script` node it cannot be a pure value-returner
+ // whose I/O the surrounding graph performs. `ql` is the
+ // same engine handle `defineStack({ onEnable })` gets, and
+ // it is the only route that survives the ARTIFACT path:
+ // an artifact carries no `onEnable` and `mergeRuntimeModule`
+ // merges only `functions`, so the module-scope-global
+ // escape is never bound on an artifact-served boot.
+ // Additive — see `JobHandlerContext` for the full argument.
async (jobCtx: any) => {
- await handler({ ...jobCtx, jobId: jobName, bundle: this.bundle });
+ const jobContext: JobHandlerContext = {
+ ...jobCtx,
+ jobId: jobName,
+ bundle: this.bundle,
+ ql,
+ logger: ctx.logger,
+ };
+ await handler(jobContext);
},
// #3494: thread the authored retryPolicy/timeout to the adapter
(job.retryPolicy || job.timeout)
diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts
index 7e11452716..e425a8a235 100644
--- a/packages/runtime/src/index.ts
+++ b/packages/runtime/src/index.ts
@@ -59,6 +59,10 @@ export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-rec
export { DefaultDatasourcePlugin } from './default-datasource-plugin.js';
export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js';
export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js';
+// #14094 — what a DECLARATIVE job's handler is invoked with. A job has no graph,
+// so unlike a flow `script` node it is given data reach (`ql`) instead of being a
+// pure value-returner whose I/O the surrounding graph performs.
+export type { JobHandlerContext } from './job-handler-context.js';
export { SeedLoaderService } from './seed-loader.js';
// Boot-summary seed outcome accumulator (#3415/#3430) — the single writer
// contract shared by AppPlugin and the marketplace rehydrate/heal path.
diff --git a/packages/runtime/src/job-handler-context.ts b/packages/runtime/src/job-handler-context.ts
new file mode 100644
index 0000000000..d9d964f3fe
--- /dev/null
+++ b/packages/runtime/src/job-handler-context.ts
@@ -0,0 +1,94 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The context a DECLARATIVE background job's handler is invoked with (#14094).
+ *
+ * ## Why this type exists at all — a job has no graph
+ *
+ * A `defineStack({ functions })` entry reached from a flow `script` node is
+ * handed `FlowFunctionContext`, which carries no engine handle. That emptiness
+ * is COHERENT for a flow function: the flow graph does the I/O around it — a
+ * `get_record` node before, a `create_record` node after — so the function
+ * stays a pure value-returner and #4354's per-run write metrics can count what
+ * the graph persisted. ⛔ Nothing here reopens that contract.
+ *
+ * A JOB has no graph. `defineJob` is the platform's ONLY metadata shape for
+ * scheduled work, its handler resolves out of the same `functions` map, and
+ * there is no node before it and none after. Until #14094 the context it
+ * received was `{ jobId, data, bundle }` — no engine, no logger, nothing to
+ * write with — so the one thing scheduled work exists to do (write records on a
+ * timer: a nightly sweep, a dispatcher, a reconciliation) had no supported
+ * route. The job registered, appeared in the admin UI, was scheduled, ran on
+ * time, and did nothing; `objectstack validate` passed and no author-time gate
+ * said otherwise.
+ *
+ * ## Why not "close over a client at module scope"
+ *
+ * That escape is real for a flow function and does NOT survive the shipped
+ * deployment path for a job. The only place an application is handed `ctx.ql`
+ * is `defineStack({ onEnable })`, so the escape is really "have `onEnable`
+ * assign a module-scope global the handler reads later" — and `objectstack
+ * build` emits `functions` into a sibling runtime module exporting only
+ * `{ functions, meta }`. The artifact JSON carries no `onEnable` and
+ * `mergeRuntimeModule` merges only `functions`, so on an artifact-served boot
+ * the binding is never made and the slot stays empty — silently.
+ * `app-plugin.job-data-reach.test.ts` pins both boot paths for exactly that
+ * reason.
+ *
+ * ## Additivity
+ *
+ * This is a WIDENING of the object passed to the handler, in the same shape
+ * #6617's `JobRunOutcome` widening took. `IJobService`'s
+ * `JobHandler = (context: { jobId: string; data?: unknown }) => …` is untouched
+ * — the function `AppPlugin` hands to `IJobService.schedule` is a wrapper that
+ * satisfies it exactly, and the members below are added INSIDE that wrapper.
+ * An existing handler that destructures `{ jobId }` or `{ jobId, data }` is
+ * unchanged byte for byte; no `IJobService` implementation grows a member.
+ */
+
+import type { IObjectQLEngine, Logger } from '@objectstack/spec/contracts';
+
+/**
+ * What `AppPlugin` invokes a declarative job's `functions` entry with.
+ *
+ * ```ts
+ * // objectstack.config.ts
+ * defineStack({
+ * jobs: [defineJob({ name: 'nightly_sweep', schedule: { type: 'cron', expression: '0 1 * * *' }, handler: 'sweep' })],
+ * functions: { sweep: { handler: sweep, effect: 'writes' } },
+ * });
+ *
+ * // the handler — no module-scope global, no onEnable binding seam
+ * async function sweep({ jobId, ql, logger }: JobHandlerContext) {
+ * const stale = await ql.find('task', { where: { status: 'open' } });
+ * for (const t of stale) await ql.update('task', { id: t.id, status: 'expired' });
+ * logger.info('swept', { job: jobId, count: stale.length });
+ * }
+ * ```
+ */
+export interface JobHandlerContext {
+ /** The job's `name` — its identity everywhere (#4667). */
+ jobId: string;
+ /** Payload of a manual `IJobService.trigger(name, data)` run; absent on a scheduled run. */
+ data?: unknown;
+ /**
+ * The application's metadata bundle, as `AppPlugin` holds it. Present since
+ * before #14094 and unchanged — declarations, not a data handle.
+ */
+ bundle: unknown;
+ /**
+ * The live ObjectQL engine — the SAME handle `defineStack({ onEnable })`
+ * receives as `ctx.ql`. This is the member #14094 added: the supported way
+ * for scheduled work to read and write records.
+ *
+ * A job's writes are not counted by any caller, so its `functions` entry
+ * should still declare `effect: 'writes'` (#4396) — that declaration makes
+ * the run report "cannot say" instead of silently claiming it wrote nothing.
+ */
+ ql: IObjectQLEngine;
+ /**
+ * The plugin logger — the SAME `Logger` `onEnable` receives — so a job's own
+ * diagnostics land in the platform's log stream instead of `console`.
+ */
+ logger: Logger;
+}