Uh oh!
There was an error while loading. Please reload this page.
fix(core): the pre-injected job fallback must not fake capability — take job off the pre-injection list so scheduled reports actually run on ObjectKernel - #11205
Conversation
…fake capability
On an ObjectKernel without @objectstack/service-job, preInjectCoreFallbacks()
registered createMemoryJob() for the 'job' slot before Phase 2, so
getService('job') always resolved — and that fallback's schedule() records a
job and never fires it. Every 'prefer the platform job service, else own a
timer' consumer took the job-service branch and then silently never ran:
plugin-reports logged 'dispatcher registered with job service' and dispatched
nothing, ever (measured: 0 reads of sys_report_schedule in 5600 ms with the
success line present).
Per the maintainer ruling of 2026-08-22 (issue 10746, Option A — declare only
what you enforce), 'job' comes off the pre-injection list
(CORE_FALLBACK_FACTORIES). getService('job') now throws when no job plugin is
installed; every consumer's documented no-job-service path takes over, and
validateSystemRequirements() says the absence out loud at boot. createMemoryJob
stays exported for deliberate, explicit registration.
Acceptance pin: dispatcher-runs-on-object-kernel.test.ts boots ObjectKernel +
ObjectQLPlugin + ReportsServicePlugin with no job plugin and asserts
sys_report_schedule is actually polled — red before this fix (0 reads), green
after. Discovery-honesty gates keep the memory-job product in their inventory
via the still-exported factory.
Part of #10746
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 23 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 633e42fd524ac98efb17605223b5750d7624db6c && git checkout 633e42fd524ac98efb17605223b5750d7624db6c
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 21756b3254ed61a6c38baa46642bd003952194b4 973336d6b90e85d5df2d6b5d2e493cf1316971a6 && git checkout -B drift-repro 21756b3254ed61a6c38baa46642bd003952194b4 && git merge --no-ff 973336d6b90e85d5df2d6b5d2e493cf1316971a6
node scripts/docs-audit/affected-docs.mjs --json 21756b3254ed61a6c38baa46642bd003952194b4
|
…job fallback The page asserted in four places that the kernel pre-injects an in-memory job fallback — all four made false by taking job off the pre-injection list. Corrected: the key-architecture principle's slot list (job now named beside auth as deliberately without a kernel fallback), Service Overview row 15 (Plugin Required, with the throw-and-warn behavior), the Infrastructure Services intro (cache/queue keep the fallback claim; job's core criticality explicitly unchanged — it is what makes the absence loud), and the job row of the infrastructure table. Judged still true and left alone: the Framework legend (generic marker definition, names no job), the i18n fallback notes (i18n stays pre-injected), the plugin-layer ASCII diagram (lists job as plugin-delivered — no fallback claim), and the scheduled-tasks provider row (names service-job, claims no fallback). Part of #10746 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RfyXxZ2WPjcjhuXpiQQc3y
Uh oh!
There was an error while loading. Please reload this page.
Fixes#10746
Implements the maintainer ruling of 2026-08-22 (decision-inbox batch, 「接受所有」, this card = Option A): a fallback must not fake capability — the "declare only what you enforce" layering rule, explicitly rather than asking every plugin to consult the degraded health flag (Option B, declined by name; the 12:31Z triage note describing B is superseded per the 00:49Z precedence resolution).
Which of the two ruled shapes, and why
The ruling allowed either a loud refusal from the fallback's
schedule()orjoboff the pre-injection list. This PR takesjoboff the pre-injection list (CORE_FALLBACK_FACTORIESinpackages/core/src/fallbacks/index.ts— the one map bothpreInjectCoreFallbacks()andvalidateSystemRequirements()consult;ServiceRequirementDefitself lives inpackages/spec, off limits, and is untouched —jobstayscorecriticality, which is what routes it into the loud missing-core-services warn). Reasons, in order of weight:schedule()only half-honors the rule.getService('job')would still resolve, and every consumer that probestypeof job.schedule === 'function'(all five in this tree do) would still see the capability declared, failing only at call time. Removal deletes the false declaration at its root.trigger(), runs handlers that arrive only viaschedule()— withschedule()refusing, every remaining member is pointless, but the service still occupies the slot.getService('job')has a modeled, documented no-job-service path, because they all run onLiteKernel, which injects no fallbacks: plugin-reports falls to its ownsetInterval; plugin-approvals goes SLA display-only; runtime app-plugin warns and skips declarative jobs; trigger-schedule / time-relative warn loudly;rearmSuspendedWaitTimerstakesIJobService | undefinedby signature. Verified by suite runs below — no consumer legitimately depends onjobalways resolving (boundary 2 fork not reached).validateSystemRequirements()already warnsCore service missing, functionality may be degraded: jobplus the degraded-capabilities summary — a functional degradation atwarn, per the AGENTS.md log-level rule.createMemoryJobstays exported (public API, honest docblock, real manual-trigger use) for embedders who register it deliberately; its docblock and the map's now say why it is not pre-injected.Before / after on ObjectKernel (the acceptance evidence)
New pin
packages/plugins/plugin-reports/src/dispatcher-runs-on-object-kernel.test.tsbootsObjectKernel+ObjectQLPlugin+ReportsServicePlugin({ dispatchIntervalMs: 5000 })with no job plugin and countsengine.find('sys_report_schedule', …)on the engine instance the plugin captured atkernel:ready, over a 5600 ms window (one guaranteed tick boundary):AssertionError: expected 0 to be greater than 0— 0 reads in 5600 ms, withdispatcher registered with job servicelogged. The issue's measurement, reproduced.setIntervalbranch andsys_report_scheduleis polled (read count is asserted greater than 0 over the same window). Full plugin-reports suite: 5 files, 76/76 tests pass.The existing
LiteKernelpin from the teardown work deliberately does not substitute — it pins release-at-shutdown on the kernel real deployments do not use; that gap is why this defect was invisible. A core-side mechanism pin was also added (kernel.test.ts): a validation-onObjectKernelboot pre-injectsmetadata/cache/queue/i18nbut refusesgetService('job').Census 1 — the other four pre-injected fallbacks (report only, per the ruling)
Question: does any other
CORE_FALLBACK_FACTORIESentry declare a capability it cannot honour?Verdict: no —
jobwas structurally unique. Its contract is the only one requiring autonomous future action (a timer firing on its own later), which a passive in-memory object cannot honour at all. The other four honour every accepted call at call time, in-process; their degradation is scope (process-local, non-durable, no cross-instance fan-out), declared via__serviceInfoand visible to the caller:createMemoryMetadatacreateMemoryCachecreateMemoryQueuegetQueueSize()answering 0 is a TRUE answer — synchronous delivery means nothing is ever bufferedcreateMemoryI18nNo same-shape defect; the class does not widen within the pre-injection list, so no separate card is needed for it.
Census 2 — the consumer class the root fix repairs (zero consumer edits)
The issue's question 2 asked how wide the "prefer the platform job service, else own a timer" class is. Wider than plugin-reports — and all of it is repaired by this one root change, because each consumer's absence path now actually engages instead of scheduling into the void while logging success:
plugin-reports— dispatcher: now reaches itssetIntervalbranch (pinned here).plugin-approvals— SLA escalation clock: previously scheduled into the void (escalations silently never swept); now takes its documented "Nojobservice → SLA stays display-only" path.packages/runtimeapp-plugin — declarative bundle jobs: previously countedokagainst the fake; now warnsjob service not registered — skipping declarative jobs.trigger-schedule/time-relativetrigger plugins: theirjob service not availablestartup warnings were unreachable (the fake resolved); now they print, andScheduleTrigger's per-flowjob service unavailable — flow not scheduledwarn engages.service-automationwait-timer re-arm:rearmSuspendedWaitTimersreceivesundefined(modeled in its signature) instead of a scheduler that swallows one-shot wake-ups.No consumer code was touched; their suites were run as verification (below).
Docs correction (second commit,
973336d6b— requested by the PM after the docs-drift flag)content/docs/kernel/services-checklist.mdxasserted in four places that the kernel pre-injects an in-memoryjobfallback — all four made false by this PR, and left standing they would re-create the exact defect one layer up, in the page users meet first. Corrected, in the page's voice, withjob'scorecriticality explicitly unchanged (it is what makes the absence loud):jobnow named besideauthas deliberately without a kernel fallback, with the one-line reason.✅ Built-in (in-memory fallback)→❌ Plugin Required, with the throw-and-warn behavior.cache/queuekeep the fallback claim;jobis called out as deliberately offCORE_FALLBACK_FACTORIES, with the remedy (install@objectstack/service-job, or registercreateMemoryJob()explicitly).jobrow — fallback claim removed.Flagged lines judged still true and left alone: the⚠️ Framework legend (~25: generic marker definition for slots that have a fallback, names no
job), thei18nfallback note (~31) and the i18n implementations row (~429:i18nstays pre-injected), the plugin-layer ASCII diagram (~54: listsjobas plugin-delivered and claims no fallback — more accurate now, not less), and the scheduled-tasks provider row (~516: namesservice-job, no fallback claim). Swept the rest of hand-writtencontent/docsfor equally specific pre-injection claims aboutjob: none (packages.mdx:272is about the queue service's DB-backedsys_job_queueadapter — true and unrelated).Verification
First commit
857b214d1(all package suites and gates ran on that tree, each under the shared verify lock withVERDICT command-exit 0):@objectstack/coretest 895/895 ·@objectstack/plugin-reports76/76 (incl. the new pin) ·@objectstack/runtime2706/2706 ·@objectstack/objectql4049/4049 ·@objectstack/plugin-approvals565/565 ·@objectstack/trigger-schedule57/57 ·typecheckgreen for plugin-reports / runtime / objectql (core is a DEBT-ledger package; its tsup DTS build passed)node scripts/pm/dispatch-gates.mjs, no hand-fed paths): all exit 0. Key verdict lines:check-adr-0087-registration: this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen)·This diff introduces no major bump·check-engine-double-contract: OK — 384 pinned, 133 in the DEBT ledger, 2 exempt·check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured … none above its recorded number·check-nul-bytes: OKpnpm lint(eslint . --no-inline-config): exit 0.Final commit
973336d6b(docs-only delta; gate set re-derived — the docs file pulls in additional families) — all green at that head: the docs gates (check:doc-anchors,check:doc-authoring,check:docs-audit-scope,check:docs-redirects,check:published-readme-links,check:role-word,check-doc-frontmatter,check-affected-docs), the spec liveness family (check:empty-state,check:liveness,check:strictness-ledger,check:variant-docs), the lint-package doc gates with their own verdict lines (✓ check:doc-formula-expressions … 9 @example(s) judged clean·✅ 26 ObjectSchema.create example(s) … carry an os validate-clean security posture), the full cheap union re-run (changeset gates, nul-bytes, slot-lookup, etc.), and repo-widepnpm lintagain (exit 0, 1m36s under the lock). Declared narrowing for the two heavy re-runs at973336d6b: the package test suites and thecheck:type-check-debt --re-measureratchet were not re-run there —git diff --stat 857b214d1..HEADis exactlycontent/docs/kernel/services-checklist.mdx | 8 ++++----(1 file), no tsc program or vitest suite takes.mdxas input, and thepackages/subtree is byte-identical to the tree those runs measured green.job's deliberate absence; the discovery-honesty inventory gates (runtime + objectql) keep the memory-job product in their iteration via the still-exported factory, so however it reaches a slot, discovery must never call itavailable.Changeset
minorfor@objectstack/core(behavioral contract change on a composition seam; nothing authorable and no export changed — not a declared-breaking changeset, confirmed by the gate above). States FROM → TO and the one-line fix: install@objectstack/service-job, or registercreateMemoryJob()explicitly if the manual-trigger registry is genuinely wanted.Out-of-scope finding filed while running gates: #11204 (trigger-record-change TEST_DEBT graduation candidate) — not addressed here.
🤖 Generated with Claude Code
Generated by Claude Code