feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records - #14262

Merged
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach
Sep 2, 2026
Merged

feat(runtime): give a declarative job's handler data reach so scheduled work can read and write records#14262
os-support-ai merged 3 commits into
mainfrom
claude/issue-14094-job-handler-data-reach

Conversation

@claude

@claudeclaudeBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Closes#14094

defineJob is the platform's only metadata shape for scheduled work. Its handler resolves out of defineStack({ functions }), and AppPlugin invoked it with { jobId, data, bundle } — no engine, no service registry, no logger, no session. The job registered, appeared in the metadata registry and the admin UI, was scheduled, ran on time, and did nothing. objectstack validate passed, and the only related boot warning covers a missing handler, not a handler with no reach.

What changed

packages/runtime/src/app-plugin.ts — the context the declarative-job wrapper 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 (new, packages/runtime/src/job-handler-context.ts, re-exported from @objectstack/runtime) is the type a handler can annotate against. content/docs/automation/jobs.mdx documents the context and warns against the module-scope route.

A job has no graph — this does not reopen the flow-script-node contract

FlowFunctionContext carries no engine either, and that 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 depend on exactly that. Nothing here changes what a script node receives.

A job has no graph. There is no node before it and none after. So the same emptiness that is a clean contract for a script node leaves a job unable to do the one thing jobs exist for. That asymmetry is the whole argument; a reader who collapses the two cases will read this as an inconsistency rather than as a gap.

Why the module-scope escape was not documented instead

Closing over a client bound from onEnable is available to a flow function and does not survive the shipped deployment path for a job:

  • objectstack build emits functions into a sibling runtime module whose only exports are { functions, meta };
  • the artifact JSON carries no onEnable;
  • mergeRuntimeModule (packages/runtime/src/load-artifact-bundle.ts) merges only functions.

So on an artifact-served boot the binding is never made, the module-scope slot stays empty, and the job runs against nothing — silently. Documenting that escape would make the failure harder to find, not easier. The regression suite therefore proves the write on both boot paths, and the artifact one goes through the real loadArtifactBundle, asserting on the way that the loaded bundle carries no onEnable.

Additive — IJobService is untouched

JobHandler is declared as taking { jobId: string; data?: unknown } and resolving void or a JobRunOutcome. The function AppPlugin hands to IJobService.schedule is a wrapper that satisfies it exactly; the new members are added inside that wrapper. Same shape as #6617's JobRunOutcome widening. No existing handler adapts, and no IJobService implementation grows a member. Pinned by two tests: a handler written against the pre-change context runs unchanged, and a third-party IJobService typed only at the contract schedules and drives the job to a real record write.

Measurements the dispatch asked for

Zone 2.3 — the card's probe re-run on current main. Reproduced exactly, at origin/main66ecc50a, on a real booted engine driving the real CronJobAdapter:

RUNS: 2
JOB CONTEXT KEYS: bundle, data, jobId
bundle -> object
data -> undefined
jobId -> string

Identical to the card's reading against published @objectstack/* 17.2.0.

Zone 2.1 — is the narrow route reachable without packages/spec? YES. Clause-②: no.packages/spec is not touched. Two readings carry it: the boundary type JobHandler constrains only the wrapper AppPlugin passes to IJobService.schedule, which is unchanged; and the bundle callable it invokes is typed as taking any and returning any (collectBundleFunctions), authored through functions, whose schema member is a bare z.function() — no context type constrains it. The producer of the extra members is AppPlugin, so the runtime is where the type belongs.

Zone 2.2 — ql versus the kernel's getService: settled by measurement, not by scope. The repo ships exactly onedefineJob declaration (examples/app-showcase, showcase_health_sweepsweepProjectHealth). It needs find + update and a logger, and nothing else — it reaches them today through a module-scope let host filled by bindShowcaseJobRuntime from onEnable, which is the escape above. The card's own reporting app takes an engine as an explicit argument. Zero measured pull for automation, email or queue from any job. getService would put the whole service registry on the job context permanently — a materially larger surface to support forever, and the place an AI-authored metadata app would reach for a service it has no declared relationship with. Adding it later is additive by exactly the argument above, so choosing the narrow member now forecloses nothing.

Zone 2.5 — no change to what a job sees on error. The wrapper's throw/reject path is untouched; retry and failure semantics are exactly as before.

Zone 2.4 — no collision with #14143.packages/runtime/src/action-execution.ts is not in the diff.

The test backend

The suite boots on sqlite :memory:, the backend #5704 migrated this project's test rigs to. It needs a store; nothing in it is about any one driver. The card's own reproduction used @objectstack/driver-memory because that is what the reporter had in hand — a manual probe, never a constraint on the rig — and reaching for it here made this an unledgered arrival that pnpm check:driver-memory-census refused (#6664). Migrated rather than ledgered: the census stays at 2 ruled consumers and scripts/driver-memory-census.ledger.json is untouched by this PR.

Provisioning sweep_note and nothing else makes the engine's single-tenant probe read an absent sys_organization, so the expected refusal is withheld and asserted through expected-read-refusal-noise.ts (#10629) rather than muted. That probe is memoised behind the first data operation, so the three context-shape tests that never touch the store declare it with harness({ touchesStore: false }) — the API's own silentChannels(required) narrowing. The withholding stays unconditional; only the must-have-fired set narrows.

Does content/docs/automation/jobs.mdx still tell the truth?

For everything this PR touches, yes — and the page is now pinned rather than merely asserted: the context table (jobId / data / bundle / ql / logger) is held exactly by the suite's key-set assertion, data is checked on both the scheduled and the manual-trigger path, and the Callout's claim about the artifact boot is the artifact test.

One row on that page is not true, and this PR neither introduced nor fixes it: under What the handler returns, "resolves { outcome: 'degraded', reason? } is recorded as degraded". Measured on the declarative path — schedule through a recording IJobService, capture the wrapper, call it:

HANDLER RESOLVED: {"outcome":"degraded","reason":"STORE_UNAVAILABLE"}
WRAPPER RESOLVED: undefined

The outcome is dropped before any adapter sees it. That is filed as #14256 with the measurement; the honest repair is its one-expression code fix, not a caveat in the page, which is why the page was left alone.

Verification

Union run after the final commit, at 72c20362.

  • pnpm --filter @objectstack/runtime test206 files, 3048 tests, all passed (the whole package, not a subset), both before and after the backend migration.
  • pnpm --filter @objectstack/service-job test — 9 files, 94 tests passed (the IJobService side, untouched and confirmed so).
  • pnpm --filter @objectstack/runtime typecheck — clean. ⚠️ That config excludes **/*.test.ts, so it says nothing about the new test file; measured separately with the exclusion lifted (tsc --noEmit, exit 0, --listFiles confirming the file is in the program — 1 hit there versus 0 under the package config).
  • New suite packages/runtime/src/app-plugin.job-data-reach.test.ts: 8 tests, including two firing positive controls — the same shipped callable invoked with the pre-change context rejects and the store is unchanged, so neither passing write assertion can be vacuous.
  • pnpm check:driver-memory-census — reproduced red on the first head, green at 72c20362, ledger untouched.
  • 53 of the 57 gate families derived by scripts/pm/dispatch-gates.mjs ran green, ratchets re-run at 72c20362. Plus all 38 families in that derivation's undetermined bucket — 36 green, 2 refusing on an unbuilt sibling package. That bucket is where the census gate lives: it declares no path population, so no path derivation can name it for any card, and running only the derived list is what let this one reach CI.

Not measured locally, each refusing on a stated prerequisite rather than failing: check:type-check-debt and check:dual-build-cjs-loads (exit 3 — both need the whole workspace built), check-test-completeness (exit 3 — grades a saved turbo log CI tees), scripts/pm/check-half-states.mjs (network-bound PM patrol), and, from the undetermined bucket, check:app-nav-i18n and @objectstack/client's check:exported-any-returns (both refuse rather than compute a false green over an unbuilt package). CI runs all of them.

Filed, not fixed

Independent of #14095, exactly as the card says: that one is about recognising a uniqueness violation once you can reach the store; this one is about reaching it at all.

Authored by Claude Code, session session_01Q5WBDtaUnoz5XuJ6jk8pQ5 (https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5).


Generated by Claude Code

`defineJob` is the platform's only metadata shape for scheduled work, and
`AppPlugin` invoked its handler 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.
The context AppPlugin builds now also carries `ql` (the same ObjectQL handle
`defineStack({ onEnable })` receives) and `logger`. `JobHandlerContext` is
exported from `@objectstack/runtime`.
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 graph
performs. Nothing about the `script` node contract changes.
Additive: `IJobService`'s `JobHandler` is untouched; the members are added
inside the wrapper AppPlugin hands to `schedule`, so an existing handler is
unchanged byte for byte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@github-actionsgithub-actionsBot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 1, 2026
@github-actions

github-actionsBot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s). ⚠️1 changed file(s) yielded no anchor (packages/runtime/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/jobs.mdx(via JobHandlerContext (symbol, a top-level interface), jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/data-modeling/import-mappings.mdx(via jobId (symbol, a field of interface JobHandlerContext))
  • content/docs/protocol/objectql/state-machine.mdx(via jobId (symbol, a field of interface JobHandlerContext))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/runtime/src/index.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: jobId (6 routes)
  • 3 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346packageMentionDocs.

Which tree this was computed on

This run read content/docs from 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 — the merge of head 72c20362c0663aa27bbfb0cf834cfa3ce144528e into base 035951faf99c42c258d470102da7346f309f7346, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 45e39eb79396d9b4150fc3c4f332e9dd601ecb90 && git checkout 45e39eb79396d9b4150fc3c4f332e9dd601ecb90
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 035951faf99c42c258d470102da7346f309f7346 72c20362c0663aa27bbfb0cf834cfa3ce144528e && git checkout -B drift-repro 035951faf99c42c258d470102da7346f309f7346 && git merge --no-ff 72c20362c0663aa27bbfb0cf834cfa3ce144528e
node scripts/docs-audit/affected-docs.mjs --json 035951faf99c42c258d470102da7346f309f7346

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 035951faf99c42c258d470102da7346f309f7346 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…memory: backend
#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
cannot run on SQL. This suite is neither — it needs *a* store, not that store —
so `pnpm check:driver-memory-census` was right to refuse it as an unledgered
arrival (#6664). Migrated rather than ledgered: the census stays at 2 ruled
consumers and the ledger is untouched.
The card's own reproduction used the in-memory driver because that is what the
reporter had in hand; that was a manual probe, never a constraint on this rig.
Provisioning `sweep_note` and nothing else makes the engine's single-tenant
probe read an absent `sys_organization`, so the expected refusal is withheld and
asserted through `expected-read-refusal-noise.ts` (#10629) rather than muted.
The probe is memoised behind the first data operation, so the three
context-shape tests that never touch the store declare that with
`harness({ touchesStore: false })` — the withholding is unconditional either
way, only the must-have-fired set narrows.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q5WBDtaUnoz5XuJ6jk8pQ5
@os-support-ai
os-support-ai marked this pull request as ready for review September 2, 2026 00:59
@os-support-ai
os-support-ai added this pull request to the merge queueSep 2, 2026
Merged via the queue into main with commit 963e2f1Sep 2, 2026
44 checks passed
@os-support-ai
os-support-ai deleted the claude/issue-14094-job-handler-data-reach branch September 2, 2026 01:43
os-musk pushed a commit that referenced this pull request Sep 2, 2026
…ve published surface
The diff widens the published surface additively: `@objectstack/metadata`'s
entry gains a named type (`MetadataKeyedItem`) and `MetadataLoader` gains an
optional member (`loadManyKeyed?`). This repo's precedent for additive
public-surface widening is `minor`, not `patch` (R12: #14262's
`job-handler-data-reach.md` and #14247, both `"@objectstack/runtime": minor`).
Front matter only; the changeset body is byte-identical.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-support-ai@claude