Repository navigation
A defineJob handler is invoked with { jobId, data, bundle } and no data reach, so the platform's only scheduled-work metadata shape cannot read or write a record #14094
Description
Activity
os-support-ai commented
on Sep 1, 2026 CollaboratorMore actionsClaim:
domain:engineexecution PM · sessionsession_01Q5WBDtaUnoz5XuJ6jk8pQ5· R12 · branchclaude/issue-14094-job-handler-data-reach· worktree../objectstack-14094-job-data-reachofforigin/main.pm:queue→pm:dispatched.
Zone 1 — BINDING. ⛔ NOT re-adjudicable.
1.1 — Additive only, and the card already proves it can be.
JobHandleris declared(context: { jobId: string; data?: unknown }) => …, so widening the context object leaves every existing handler unchanged byte for byte — the same shape #6617'sJobRunOutcomewidening took. ⛔ Do not change the declared parameter into something existing handlers must adapt to. If you find yourself needing to, stop and report.1.2 — ⛔ Do NOT "fix" this by documenting the module-scope escape. The card measures that the escape does not survive the artifact path at all:
objectstack buildemitsfunctionsinto a bundled runtime module exporting only{ functions, meta }, the artifact JSON carries noonEnable, andmergeRuntimeModulemerges onlyfunctions— so on an artifact-served boot the module-scope slot is never filled. Documenting an escape that silently does not work on the shipped deployment path makes the failure harder to find, not easier.1.3 — ⛔ Do NOT reopen the flow-
script-node contract.FlowFunctionContext's emptiness is coherent for a flow function, because the flow graph does the I/O around it (get_recordbefore,create_recordafter) and #4354's per-run metrics depend on exactly that. This card's argument is that a job has no graph — no node before, none after — so the same emptiness leaves it unable to do the one thing jobs exist for.⚠️ Keep that distinction intact in your PR body; a reader who collapses the two will read this as an inconsistency rather than a gap.
Zone 2 — PM mechanism assumptions.
⚠️ MEASURE. Falsification welcome.2.1 — I assume the narrow version is reachable without a
packages/specchange. The card says the fix "lands inpackages/runtime(and/or theIJobServicecontract inpackages/spec)" and then offers a narrow route that touches only the contextAppPluginbuilds — "it already hasqlin scope atonEnabletime in the same file".⚠️ This is the load-bearing assumption of the whole dispatch: if the narrow route works, this is a small additive PR; ifpackages/specis required, Clause ②'s path limb fires and this card needs contract review at tier, which this seat is below. ⭐ Measure it early and report it before you build.2.2 — I assume
qlalone may not be enough, and that widening togetServiceis a judgement call, not a free upgrade. The card offers both: one member (ql) versus the kernel'sgetService(which also unblocksautomation,email,queue).⚠️ getServicehands a job handler the whole service registry — a materially larger surface to support forever. ⛔ I am not ruling between them. Measure what the shippeddefineJobshapes actually need and let that decide; if the answer is genuinely "both are defensible", report the fork rather than picking on scope grounds alone.2.3 — I assume the measurement still reproduces. The card measured
JOB CONTEXT KEYS: bundle, data, jobIdwithdata -> undefinedafter 7 invocations on a real booted app.⚠️ Re-run it on currentorigin/main(1403d943) before you build — a card measured against published@objectstack/*17.2.0 is not automatically a reading ofmain.2.4 — I assume no collision with #14143. That card is also in
packages/runtimethis round, atsrc/action-execution.ts. Yours is theAppPluginjob-registration site.⚠️ Same package, different file — that is fine; the same file is not. If your diff reachesaction-execution.ts, stop and report.2.5 — I assume #14095 stays independent. The card itself says so ("they are independent and either can land first") — that one is about recognising a uniqueness violation once you can reach the store, this one is about reaching it at all. ⭐ Good dedup by the reporter;
⚠️ if your repair turns out to also change what a job sees on error, say so.
Zone 3 — advisory. ⛔ Not binding.
The card's own narrowest sketch:
async (jobCtx) => { await handler({ ...jobCtx, jobId: jobName, bundle: this.bundle, ql }); }
If that is all it takes, the PR is small and the value is high — the platform's only scheduled-work metadata shape currently registers, schedules, runs on time and does nothing, with
objectstack validatepassing and no author-time gate (AppPlugin's existing warning covers a missing handler, not a handler with no reach).STOP conditions
packages/specis required ⇒ Clause ② path limb fires ⇒ declare and report before building further.- The choice between
qlandgetServicecannot be settled by measurement ⇒ report the fork; do not decide it on scope grounds alone. - Your diff reaches
packages/runtime/src/action-execution.ts⇒ action dispatcher stampsctx.record.idafter a failed caller-scope load, so the natural authorization guard (if (!ctx.record?.id) refuse()) is always true on a row the caller cannot read #14143's surface ⇒ stop and report. - Any existing handler would have to change ⇒ you have left 1.1.
Verification expected
The card's own probe re-run and passing: a declared job's handler receives a context that can actually read and write a record, with existing handlers unchanged. ⭐ And prove it on the artifact path, not only the TS-config path — 1.2 exists because that is precisely where the current escape silently fails, so an artifact-boot test is what makes this a fix rather than a second escape. A firing positive control on any zero. Plus a changeset, push, and a DRAFT PR saying
Closes #14094.⛔ Report anything unmeasured as NOT MEASURED. ⛔ No test skipped, disabled, quarantined or re-baselined. ⛔ Never
git stash. ⛔ Never editcontent/docs/releases/. Worktree-first.Clause-②: yes — widens the public surface:
JobHandlerContextnewly exported from@objectstack/runtime, andql/loggeradded to what a declarative job handler is invoked with. Path limb: no (packages/specuntouched).
Generated by Claude Code
os-dev-report
{ "issue": 14094, "status": "done", "branch": "claude/issue-14094-job-handler-data-reach", "pr": "https://github.com/objectstack-ai/objectstack/pull/14262", "premise_still_valid": true, "summary": "Clause-2: no. packages/spec is NOT touched and was not required. AppPlugin's declarative-job wrapper now builds the handler context with two added members, ql (the live ObjectQL engine, the same handle defineStack({ onEnable }) receives) and logger, alongside the pre-existing jobId / data / bundle. The new JobHandlerContext type is declared in packages/runtime/src/job-handler-context.ts and re-exported from @objectstack/runtime; content/docs/automation/jobs.mdx documents the context and warns off the module-scope route. Zone 2.1 measured: JobHandler constrains only the wrapper AppPlugin hands to IJobService.schedule, which is unchanged, and the bundle callable it invokes is typed any-in/any-out (collectBundleFunctions) off a bare z.function() schema member, so no spec type constrains what the wrapper passes inward. Zone 2.2 settled by measurement, not scope: the repo ships exactly ONE defineJob (examples/app-showcase, showcase_health_sweep -> sweepProjectHealth); it needs find + update and a logger and nothing else, and the card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job, so getService was not added; adding it later is additive by the same 1.1 argument, so nothing is foreclosed. Zone 2.4: action-execution.ts is not in the diff. Zone 2.5: the throw/reject path is untouched, so nothing about what a job sees on error changed. Two adjacent defects were filed rather than folded in. NOTE: the platform appended its own attribution footer to the PR body on create, so that footer appears twice; the body is otherwise byte-identical to what was sent (verified by full read-back diff) and was left unedited rather than re-sanitized for a cosmetic fix.", "tests": "Gate union and all test runs below were taken AFTER the final commit, at 95f0cfbb. (1) REPRODUCTION on current main (66ecc50a), real engine + real CronJobAdapter, before any edit: 'RUNS: 2 / JOB CONTEXT KEYS: bundle, data, jobId / bundle -> object / data -> undefined / jobId -> string' — identical to the card's reading against published 17.2.0. (2) pnpm --filter @objectstack/runtime test -> 'Test Files 206 passed (206) / Tests 3048 passed (3048)' (the WHOLE package, no narrowing). (3) pnpm --filter @objectstack/service-job test -> 'Test Files 9 passed (9) / Tests 94 passed (94)'. (4) New suite packages/runtime/src/app-plugin.job-data-reach.test.ts, 8 tests: a declared job's handler reads and writes real records on the TS-config path; the SAME proof on the ARTIFACT path, driven through the real loadArtifactBundle over a temp-dir artifact JSON plus sibling runtime.mjs exporting { functions, meta }, asserting on the way that the loaded bundle carries NO onEnable (so the module-scope escape is unavailable) and that the callable arrived via the only merged key; a pre-change-shaped handler runs unchanged with calls == [{ jobId: 'legacy_job', data: undefined }] and zero warns/errors; a third-party IJobService typed only at the contract schedules and drives the job to a real write. (5) TWO FIRING POSITIVE CONTROLS, both observed red before being made to pass: the same shipped callable invoked with the pre-change context { jobId, data, bundle } rejects with a TypeError and the store is unchanged (swept column still 'no'), so neither write assertion above can be vacuous. (6) pnpm --filter @objectstack/runtime typecheck -> exit 0. NOT MEASURED for the new test file by that command: the package tsconfig excludes **/*.test.ts. Measured separately with the exclusion lifted: tsc --noEmit exit 0, and --listFiles hits for the test file = 1 under that config vs 0 under the package config, so the exclusion claim is a reading and not an assumption. Consequence for the ratchet: the file contributes 0 errors to @objectstack/runtime's TEST_DEBT entry (recorded 206). (7) GATES: 57 families derived by scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (the script's own change set, never a hand-rolled diff; family list re-derived after fetching the moved origin/main and IDENTICAL). 53 ran green. Every shrink-only RATCHET family was RE-RUN at 95f0cfbb and is green: check:type-check-coverage, check:cross-package-test-inputs, check:published-files, check:test-source-alias, check:where-matcher, check:objectql-double-limit, check:engine-double-contract, check:slot-lookup, check:query-options-erasure. pnpm check:nul-bytes green plus a direct control-byte scan of all six changed files (no hits). Exit codes were captured by redirecting to a file first, never after a pipe. FOUR families are NOT MEASURED, each refusing on a stated prerequisite rather than failing: check:type-check-debt (exit 3, PREREQUISITE NOT MET, needs the whole workspace built), check:dual-build-cjs-loads (exit 3, same), check-test-completeness.mjs (exit 3, grades a saved turbo test log CI tees and none exists locally), and scripts/pm/check-half-states.mjs (network-bound PM patrol, timed out at 200s). None of the four is a red and none is evidence of a finding; CI runs all four. check:skill-examples first refused for an unbuilt @objectstack/client-react, was made measurable by building that closure, and is green. (8) No ablation was run for this card: the fix is an additive widening with no guard to delete, and the two firing controls above already discriminate the fixed tree from the pre-fix context shape.", "mcp_calls": "3 — three mcp__github__search_issues calls (one positive control that correctly returned #14094, two dedup queries). Everything else went zero-quota or REST: the card and all its comments were read from the public issue page's embedded JSON payload, and the two findings plus the draft PR were written through the container's REST channel after a repo-scoped 200 probe.", "open_questions": [], "out_of_scope_findings": [ "filed as #14256: AppPlugin's declarative-job wrapper discards the handler's resolved JobRunOutcome (the arrow has a block body and the awaited value is dropped), so a defineJob handler resolving { outcome: 'degraded' } is recorded as success and #6617's degraded channel is unreachable from declarative jobs — adjacent to this seam but outbound rather than inbound, so filed rather than folded in", "filed as #14257: examples/app-showcase's showcase_health_sweep still reaches its engine through a module-scope global filled by bindShowcaseJobRuntime from onEnable, so the repo's only shipped defineJob silently does nothing on an artifact-served boot — now migratable to the context member this PR adds" ] }Generated by Claude Code
Generated by Claude Code
os-dev-report
{ "issue": 14094, "status": "done", "branch": "claude/issue-14094-job-handler-data-reach", "pr": "https://github.com/objectstack-ai/objectstack/pull/14262", "premise_still_valid": true, "summary": "Clause-2: no. packages/spec is NOT touched and was not required. AppPlugin's declarative-job wrapper now builds the handler context with two added members, ql (the live ObjectQL engine, the same handle defineStack({ onEnable }) receives) and logger, alongside the pre-existing jobId / data / bundle. JobHandlerContext is declared in packages/runtime/src/job-handler-context.ts and re-exported from @objectstack/runtime; content/docs/automation/jobs.mdx documents the context and warns off the module-scope route. Zone 2.1: JobHandler constrains only the wrapper handed to IJobService.schedule, which is unchanged, and the bundle callable is typed any-in/any-out off a bare z.function() member, so no spec type constrains what the wrapper passes inward. Zone 2.2 settled by measurement: the repo ships exactly ONE defineJob (examples/app-showcase), which needs find + update and a logger and nothing else; zero measured pull for automation, email or queue, so getService was not added, and adding it later is additive by the same 1.1 argument. Zone 2.4: action-execution.ts is not in the diff. Zone 2.5: the throw/reject path is untouched. CI ROUND 2 (head 95f0cfbb) failed one gate, check:driver-memory-census, and it is FIXED BY MIGRATION, not by a ledger entry: the suite now boots on sqlite ':memory:', the backend #5704 migrated this project's test rigs to. scripts/driver-memory-census.ledger.json is UNTOUCHED and the census is back at 2 ruled consumers. The card's reproduction used the in-memory driver because that is what the reporter had in hand; that was a manual probe, not a constraint on the rig, and my test needs a store rather than that store. Provisioning sweep_note alone makes the engine's tenancy probe read an absent sys_organization, so the expected refusal is withheld AND asserted via expected-read-refusal-noise.ts (#10629); the probe is memoised behind the first data operation, so the three context-shape tests that touch no store declare that through the capture API's own silentChannels(required) narrowing. LESSON: the census gate was never in the 57 families dispatch-gates derived for this card — it sits in that derivation's 'undetermined' bucket (38 families whose sources name no path at all, which the script labels NOT known irrelevant). A new test file that adds a DEPENDENCY IMPORT is exactly the change kind that bucket catches, so this round I ran all 38 as well. Note also: the platform appends its own attribution footer on create and on PATCH; the PR body now carries exactly one, with the session URL kept in prose, and a full read-back diff shows nothing else was altered.", "tests": "All readings below are at the final commit 72c20362 unless a date-stamped 'before' is stated. (1) REPRODUCTION of the card, on origin/main 66ecc50a, before any edit: 'RUNS: 2 / JOB CONTEXT KEYS: bundle, data, jobId / bundle -> object / data -> undefined / jobId -> string' — identical to the card's reading against published 17.2.0. (2) REPRODUCTION of the CI failure, locally, before the fix: pnpm check:driver-memory-census exit 1, 'x LEDGERED: packages/runtime/src/app-plugin.job-data-reach.test.ts:54 binds @objectstack/driver-memory (import) and the ledger does not cover it', census 13 bindings / 2 ruled consumers. AFTER the migration: exit 0, '12 module binding(s) in 12 file(s), 5 manifest declaration(s) — 2 ruled test consumer(s)', 'OK — every declaration is ledgered, every ledger entry is live'. git diff of the ledger is EMPTY. (3) pnpm --filter @objectstack/runtime test -> 'Test Files 206 passed (206) / Tests 3048 passed (3048)' — the WHOLE package, run both before and after the backend migration with identical totals. (4) pnpm --filter @objectstack/service-job test -> 'Test Files 9 passed (9) / Tests 94 passed (94)'. (5) New suite app-plugin.job-data-reach.test.ts, 8 tests, all green on the migrated backend: a declared job's handler reads and writes real records on the TS-config path; the SAME proof on the ARTIFACT path through the real loadArtifactBundle over a temp-dir artifact JSON plus sibling runtime.mjs exporting { functions, meta }, asserting on the way that the loaded bundle carries NO onEnable and that the callable arrived via the only merged key; a pre-change-shaped handler runs unchanged with calls == [{ jobId: 'legacy_job', data: undefined }] and zero warns/errors; a third-party IJobService typed only at the contract schedules and drives the job to a real write. (6) TWO FIRING POSITIVE CONTROLS, still firing after the migration: the same shipped callable invoked with the pre-change context rejects with a TypeError and the store is unchanged (swept still 'no'). (7) The noise capture is not a mute: silentChannels([sys_organization]) is asserted empty on all five store-touching paths, which is a positive reading that the probe fired and was withheld; the three shape-only tests pass touchesStore:false and are the reason the blanket form was measured red first. (8) pnpm --filter @objectstack/runtime typecheck -> exit 0. NOT MEASURED for the new test file by that command (the package tsconfig excludes **/*.test.ts). Measured with the exclusion lifted: tsc --noEmit exit 0, --listFiles hits for the file = 1 there vs 0 under the package config. It therefore contributes 0 errors to @objectstack/runtime's TEST_DEBT entry (recorded 206). (9) GATES: 57 path/kind-derived families (list re-derived after fetching the moved origin/main, IDENTICAL), 53 green, every shrink-only ratchet re-run at 72c20362: type-check-coverage, cross-package-test-inputs, published-files, test-source-alias, where-matcher, objectql-double-limit, engine-double-contract, slot-lookup, query-options-erasure. PLUS all 38 UNDETERMINED families this round: 36 green (including check:driver-memory-census), 2 refusing on an unbuilt sibling package (check:app-nav-i18n, @objectstack/client check:exported-any-returns — both print 'Nothing was measured' / 'would be a FALSE GREEN'). pnpm check:nul-bytes green plus a direct control-byte scan of every changed file. Exit codes captured by redirecting to a file first, never after a pipe. NOT MEASURED, each refusing on a stated prerequisite rather than failing: check:type-check-debt (exit 3, needs the whole workspace built), check:dual-build-cjs-loads (exit 3, same), check-test-completeness.mjs (exit 3, grades a saved turbo test log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol, timed out), and the two undetermined ones above. CI runs all of them. (10) DOCS VERDICT for content/docs/automation/jobs.mdx, measured not recalled — see open_questions for the one row that is NOT true. Everything this PR touches on that page is now pinned rather than asserted: the context table is held by the suite's exact-key-set assertion, `data` is checked on both the scheduled and the manual-trigger path, and the Callout's artifact claim IS the artifact test. (11) No ablation: the fix is an additive widening with no guard to delete, and the two firing controls already discriminate the fixed tree from the pre-fix context shape.", "mcp_calls": "3 — unchanged this round. Three mcp__github__search_issues calls in total (one positive control that correctly returned #14094, two dedup queries). The card and all its comments were read from the public issue page's embedded JSON payload; the two findings, the draft PR, the PR-body update and every comment went through the container's REST channel after a repo-scoped 200 probe.", "open_questions": [ { "question": "content/docs/automation/jobs.mdx carries one row that is NOT true, which this PR neither introduced nor fixes: under 'What the handler returns', 'resolves { outcome: degraded, reason? } is recorded as degraded'. Measured on the declarative path by scheduling through a recording IJobService and calling the captured wrapper: HANDLER RESOLVED {\"outcome\":\"degraded\",\"reason\":\"STORE_UNAVAILABLE\"} / WRAPPER RESOLVED undefined. The outcome is dropped before any adapter sees it. Should the page be corrected in this PR, or left for the code fix?", "options": [ "A — leave the page alone; the row states the contract, the defect is filed as #14256 with this measurement, and its one-expression fix makes the row true again", "B — add a caveat to the page now saying the degraded channel does not reach the adapter from a declarative job, and delete it when #14256 lands", "C — fold #14256's fix into this PR" ], "recommendation": "A — chosen and already acted on. Long-term soundness: the row is the CONTRACT, and a reference page that documents its own runtime's defect teaches the defect; B ships prose with a known expiry date and a second place to forget. Real business need: no measured consumer reads that row today (the repo's one shipped defineJob returns nothing), so the pull is for a correct contract, not for a caveat. AI-error-proofing: a handler author who reads B learns to avoid a channel that is about to work, which is the wrong lesson to encode. Startup scope discipline: C would put an outbound-semantics change in an inbound-reach card and widen this PR's verification surface to sys_job_run status recording. The measurement is on #14256 so the fix arrives with its evidence." } ], "out_of_scope_findings": [ "filed as #14256: AppPlugin's declarative-job wrapper discards the handler's resolved JobRunOutcome, so a defineJob handler resolving { outcome: 'degraded' } is recorded as success and #6617's degraded channel is unreachable from declarative jobs — now upgraded from a code reading to a measurement in a comment on the card, together with the jobs.mdx row it falsifies and the shape of the regression test the fix owes", "filed as #14257: examples/app-showcase's showcase_health_sweep still reaches its engine through a module-scope global filled by bindShowcaseJobRuntime from onEnable, so the repo's only shipped defineJob silently does nothing on an artifact-served boot — now migratable to the context member this PR adds" ] }
Generated by Claude Code
os-support-ai commented
on Sep 2, 2026 CollaboratorMore actions✅ Contract review — PASS (in-seat, at tier) · PR #14262 @
72c20362Reviewer:
domain:engineexecution PM, sessionsession_01Q5WBDtaUnoz5XuJ6jk8pQ5, reviewing the dev seat's contract increment. Provenance: maintainer ruling 2026-08-31 (in-seat review by an at-tier PM), re-authorised by the maintainer this shift: 「给你切换到 Fable了,你现在可以自己复审pr了」.Fuse reading, verbatim:
get_session→external_metadata.last_served_model = claude-fable-5-1;CONTRACT_REVIEW_TIER = 'claude-fable-5'.⚠️ Literal compare reads ≠; the served model is the newer Fable and the maintainer authorised explicitly. Recorded, not hidden.Carriers:
--pair 14262→ exit 0 with the gate hung on both.Clause-②: yes— content limb: public-surface widening (JobHandlerContextnewly exported from@objectstack/runtime;qlandloggeradded to what a declarative job handler is invoked with).⚠️ The dev declarednoreading only the path limb (packages/specuntouched — true); the content limb fires on the widening. Corrected here; the review covers it.① Derived judgments
change judgement AppPlugin's job wrapper now passes{ ...jobCtx, jobId, bundle, ql, logger }Correct, additive. IJobService/JobHandleruntouched; the wrapper still satisfies(context: { jobId; data? }) => …exactly; members added inside the wrapper. Same shape as #6617. Pinned: a pre-#14094 handler runs byte-for-byte unchanged; a contract-typed third-partyIJobServicestill drives the job.New exported type JobHandlerContextCorrect. Runtime is the producer of the extra members, so the type belongs in runtime, not spec. qlchosen overgetServiceAccepted — settled by measurement, not scope. The repo's one shipped defineJobneedsfind+update+ a logger; zero measured pull forautomation/email/queue;getServicewould put the whole registry on the job surface forever, and adding it later is additive by the same argument. Fork not foreclosed.Flow script-node contractNot reopened — the "a job has no graph" distinction is kept explicit in code, changeset and docs. Docs ( jobs.mdx)Updated, and now pinned by the suite's key-set assertion; the artifact-path Callout is the artifact test. The one untrue row on that page ( degradedoutcome dropped) is pre-existing and filed as #14256 with its measurement — correctly not papered over.② Semver
@objectstack/runtime: minor — new public type + new context members. Consistent with the changeset.③ Boundary flags
- Census gate red on the first head → repaired by migration, not by ledger: the test moved to sqlite
:memory:per driver-memory 测试面替代:项目内测试后端迁到 sqlite:memory:(#5499 重启条件 · memory 半边,维护者 2026-08-06 立项) #5704;scripts/driver-memory-census.ledger.jsonuntouched; the three remainingdriver-memorystrings are docblock prose explaining why it does not bind. Exactly the disposition the gate's own text demands. - Filed by the dev: AppPlugin's declarative-job wrapper discards the handler's
JobRunOutcome, sodefineJobcannot report a degraded run #14256 (outcome dropped by the wrapper), app-showcase's nightly health sweep reaches its engine through a module-scope global, so it silently does nothing on an artifact-served boot #14257 (showcase sweep still on the module-scope global). Correctly routed.
CI on
72c20362: 36 check runs, all success.mergeable_state: clean.Verdict: PASS. Same stroke: gate cleared on both carriers → ready → auto-merge. Landing record at MERGED, verified by content.
Generated by Claude Code
- Census gate red on the first head → repaired by migration, not by ledger: the test moved to sqlite
os-support-ai commented
on Sep 2, 2026 CollaboratorMore actions✅ LANDED — PR #14262 merged as
963e2f1d(merge queue, 2026-09-02T01:43Z), verified by content onorigin/mainpm:dispatched→ stripped. Card closed by the PR'sCloses #14094.Discriminating probe:
JobHandlerContext— absent on merge-base66ecc50a, present onorigin/maininpackages/runtime/src/index.ts;job-handler-contextimport present inapp-plugin.ts, whose last-touching commit on main is963e2f1d, this PR's merge.Fences held: 6 files;
packages/spec0 (IJobService/JobHandleruntouched — additivity pinned by two tests);packages/runtime/src/action-execution.tsnot in the diff (no collision with #14143's seat). Clause-② (content limb,yes— the dev'snoread only the path limb) reviewed in-seat at tier — PASS on72c20362; gate cleared on both carriers in the same stroke.Delivered: a declarative job's handler is invoked with
{ jobId, data, bundle, ql, logger }—qlis the same engine handledefineStack({ onEnable })receives; proven on both the TS-config and the artifact boot path (the one where the module-scope escape silently fails);docs/automation/jobsdocuments the context and warns against the module-scope route. Runtime minor.⭐ The one CI red of the round was here and was repaired the right way:
check-driver-memory-censusrefused the new test's@objectstack/driver-memoryimport; the test was migrated to sqlite:memory:per #5704 — the ledger stays at 2 ruled consumers, untouched.Follow-ups on the board (dev-filed): #14256 (the same wrapper drops a handler's
JobRunOutcome, sodegradedis recorded assuccess— the one untrue row onjobs.mdx, measured) · #14257 (app-showcase's nightly sweep still reaches its engine through the module-scope global; now migratable toctx.ql).
Generated by Claude Code
Found while building an ObjectStack application in
objectstack-ai/dulyagainst published@objectstack/*17.2.0. Filed here because the fix lands inpackages/runtime(and/or theIJobServicecontract inpackages/spec).Measured
Booted a real app (
createStandaloneStack+ObjectKernel+AppPlugin+JobServicePlugin, memory driver) with one declared job whose handler records its own argument:After 7 invocations:
That is the whole context. No engine, no service registry, no
getService, no logger, no session.bundleis the metadata bundle (AppPluginpassesthis.bundle; nothing is ever attached to it — the only assignment in the file isthis.bundle = bundle).The call site is unambiguous:
service-job—record.handler({ jobId: record.name, data })runtime'sAppPlugin—async (jobCtx) => { await handler({ ...jobCtx, jobId: jobName, bundle: this.bundle }); }Why this is a gap and not a design
A scheduled job is, overwhelmingly, a thing that writes records on a timer — a nightly sweep, a dispatcher, a reconciliation. The platform ships exactly one metadata shape for that (
defineJob), resolves its handler out ofdefineStack({ functions }), and then hands the handler nothing to write with.The
functionsmap's own contract says why this is not simply the pure-function rule applied consistently. FromFlowFunctionEffectSchema's TSDoc:For a flow
scriptnode that is coherent: the function is pure because the flow graph does the I/O around it (get_recordbefore,create_recordafter), and #4354's per-run metrics depend on exactly that. A job has no graph. There is no node before or after it. So the same emptiness that is a clean contract for a script node leaves a job with no supported way to do the one thing jobs exist to do.The documented escape — closing over a client at module scope — is available to a flow function and is not reliably available to a job, for two reasons measured in the same app:
ctx.qlisdefineStack({ onEnable }). So the escape is not "close over a client", it is "have the config assign a module-scope global that the job handler reads later" — action at a distance, in the one job whose failure is silent.objectstack buildemitsfunctionsinto a bundled runtime module whose only exports are{ functions, meta }; the artifact JSON carries noonEnable, andmergeRuntimeModulemerges onlyfunctions. So on an artifact-served boot the binding is never made and the module-scope slot stays empty.What the failure looks like in practice
Nothing. The job is registered, appears in the metadata registry and the admin UI, is scheduled by the job service, runs on time, and does nothing — or throws, if the application chose to make the absence loud. There is no author-time gate:
objectstack validatepasses, andAppPlugin's only related warning (job handler not found in bundle.functions — skipping) covers a missing handler, not a handler with no reach.Suggested direction
Give the job handler context the reach its job implies, in the same shape the rest of the platform already uses. The narrowest version is one member on the context
AppPluginbuilds — it already hasqlin scope atonEnabletime in the same file:A wider version passes the kernel's
getService, which also unblocks a job that needsautomation,emailorqueue— all of which are equally unreachable today.Either is additive:
JobHandleris declared(context: { jobId: string; data?: unknown }) => …, so existing handlers are unchanged byte for byte, exactly as #6617'sJobRunOutcomewidening was.Not the same as #14095
Filed alongside #14095, which is about recognising a uniqueness violation once you can reach the store. This one is about reaching it at all; they are independent and either can land first.
Provenance
Reported by a developer agent implementing
objectstack-ai/duly#2(the dispatcher job — idempotent task generation). The application does not work around it silently: the dispatcher takes its engine as an explicit argument, exposes one namedbindDispatchEngineseam, and its job handler refuses loudly with a message naming the wiring rather than reporting a clean run in which nothing was dispatched.Unassigned and untriaged, per the single-producer rule for
domain:*.