Skip to content

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

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-10746-job-fallback-must-not-fake-capability
Aug 23, 2026
Merged

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
os-zhuang merged 2 commits into
mainfrom
claude/issue-10746-job-fallback-must-not-fake-capability

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

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() or job off the pre-injection list. This PR takes job off the pre-injection list (CORE_FALLBACK_FACTORIES in packages/core/src/fallbacks/index.ts — the one map both preInjectCoreFallbacks() and validateSystemRequirements() consult; ServiceRequirementDef itself lives in packages/spec, off limits, and is untouched — job stays core criticality, which is what routes it into the loud missing-core-services warn). Reasons, in order of weight:

  1. A throwing schedule() only half-honors the rule.getService('job') would still resolve, and every consumer that probes typeof 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.
  2. Refusal leaves a husk. The fallback's only other real verb, trigger(), runs handlers that arrive only via schedule() — with schedule() refusing, every remaining member is pointless, but the service still occupies the slot.
  3. Absence is the already-tested state. Every production consumer of getService('job') has a modeled, documented no-job-service path, because they all run on LiteKernel, which injects no fallbacks: plugin-reports falls to its own setInterval; plugin-approvals goes SLA display-only; runtime app-plugin warns and skips declarative jobs; trigger-schedule / time-relative warn loudly; rearmSuspendedWaitTimers takes IJobService | undefined by signature. Verified by suite runs below — no consumer legitimately depends on job always resolving (boundary 2 fork not reached).
  4. Boot loudness comes free and at the ruled-correct level: validateSystemRequirements() already warns Core service missing, functionality may be degraded: job plus the degraded-capabilities summary — a functional degradation at warn, per the AGENTS.md log-level rule.

createMemoryJob stays 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.ts boots ObjectKernel + ObjectQLPlugin + ReportsServicePlugin({ dispatchIntervalMs: 5000 }) with no job plugin and counts engine.find('sys_report_schedule', …) on the engine instance the plugin captured at kernel:ready, over a 5600 ms window (one guaranteed tick boundary):

  • Before (origin/main + pin only):AssertionError: expected 0 to be greater than 00 reads in 5600 ms, with dispatcher registered with job service logged. The issue's measurement, reproduced.
  • After (this fix): the pin is green — the plugin falls through to its setInterval branch and sys_report_schedule is polled (read count is asserted greater than 0 over the same window). Full plugin-reports suite: 5 files, 76/76 tests pass.

The existing LiteKernel pin 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-on ObjectKernel boot pre-injects metadata/cache/queue/i18n but refuses getService('job').

Census 1 — the other four pre-injected fallbacks (report only, per the ruling)

Question: does any other CORE_FALLBACK_FACTORIES entry declare a capability it cannot honour?

Verdict: no — job was 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 __serviceInfo and visible to the caller:

fallbackprimary verbshonoured at call time?notes
createMemoryMetadataregister/get/listyes — everything registered is listable and readable backno persistence, declared
createMemoryCacheget/set/delete + TTLyes — stores, expires, true statsprocess-local, declared
createMemoryQueuepublish/subscribeyes — synchronous real delivery to real subscribersnear-miss examined: getQueueSize() answering 0 is a TRUE answer — synchronous delivery means nothing is ever buffered
createMemoryI18nt/loadTranslations/localesyes — really translates from loaded bundlesin-memory only, declared

No 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 its setInterval branch (pinned here).
  • plugin-approvals — SLA escalation clock: previously scheduled into the void (escalations silently never swept); now takes its documented "No job service → SLA stays display-only" path.
  • packages/runtime app-plugin — declarative bundle jobs: previously counted ok against the fake; now warns job service not registered — skipping declarative jobs.
  • trigger-schedule / time-relative trigger plugins: their job service not available startup warnings were unreachable (the fake resolved); now they print, and ScheduleTrigger's per-flow job service unavailable — flow not scheduled warn engages.
  • service-automation wait-timer re-arm: rearmSuspendedWaitTimers receives undefined (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.mdx asserted in four places that the kernel pre-injects an in-memory job fallback — 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, with job's core criticality explicitly unchanged (it is what makes the absence loud):

  1. The key-architecture principle's slot list — job now named beside auth as deliberately without a kernel fallback, with the one-line reason.
  2. Service Overview row 15 — ✅ Built-in (in-memory fallback)❌ Plugin Required, with the throw-and-warn behavior.
  3. The Infrastructure Services intro — cache/queue keep the fallback claim; job is called out as deliberately off CORE_FALLBACK_FACTORIES, with the remedy (install @objectstack/service-job, or register createMemoryJob() explicitly).
  4. The infrastructure table's job row — 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), the i18n fallback note (~31) and the i18n implementations row (~429: i18n stays pre-injected), the plugin-layer ASCII diagram (~54: lists job as plugin-delivered and claims no fallback — more accurate now, not less), and the scheduled-tasks provider row (~516: names service-job, no fallback claim). Swept the rest of hand-written content/docs for equally specific pre-injection claims about job: none (packages.mdx:272 is about the queue service's DB-backed sys_job_queue adapter — true and unrelated).

Verification

First commit 857b214d1 (all package suites and gates ran on that tree, each under the shared verify lock with VERDICT command-exit 0):

  • Dependency closures built first, then: @objectstack/core test 895/895 · @objectstack/plugin-reports76/76 (incl. the new pin) · @objectstack/runtime2706/2706 · @objectstack/objectql4049/4049 · @objectstack/plugin-approvals565/565 · @objectstack/trigger-schedule57/57 · typecheck green for plugin-reports / runtime / objectql (core is a DEBT-ledger package; its tsup DTS build passed)
  • Derived gate set (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: OK
  • Repo-wide pnpm 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-wide pnpm lint again (exit 0, 1m36s under the lock). Declared narrowing for the two heavy re-runs at 973336d6b: the package test suites and the check:type-check-debt --re-measure ratchet were not re-run there — git diff --stat 857b214d1..HEAD is exactly content/docs/kernel/services-checklist.mdx | 8 ++++---- (1 file), no tsc program or vitest suite takes .mdx as input, and the packages/ subtree is byte-identical to the tree those runs measured green.

  • Updated pins, intent preserved: the map-shape test now pins 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 it available.

Changeset

minor for @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 register createMemoryJob() 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

…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
@github-actions

github-actionsBot commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/core, touching 1 documentable anchor(s).

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

  • content/docs/kernel/services-checklist.mdx(via CORE_FALLBACK_FACTORIES (symbol))
What this run could not see
  • 1 changed file(s) yielded no anchor (packages/core/src/fallbacks/memory-job.ts) — pages documenting those are invisible to this run

Coarse fallback — 23 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 21756b3254ed61a6c38baa46642bd003952194b4packageMentionDocs.

Which tree this was computed on

This run read content/docs from 633e42fd524ac98efb17605223b5750d7624db6c — the merge of head 973336d6b90e85d5df2d6b5d2e493cf1316971a6 into base 21756b3254ed61a6c38baa46642bd003952194b4, 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 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

⚠️ 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 21756b3254ed61a6c38baa46642bd003952194b4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…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
@os-zhuang
os-zhuang marked this pull request as ready for review August 23, 2026 03:38
@os-zhuang
os-zhuang added this pull request to the merge queueAug 23, 2026
Merged via the queue into main with commit ee2ff45Aug 23, 2026
36 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-10746-job-fallback-must-not-fake-capability branch August 23, 2026 04:05
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-reports: the documented setInterval dispatcher fallback is unreachable on ObjectKernel, and the job fallback it takes instead never fires

2 participants

@os-zhuang@claude