Skip to content

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

Closed

Description

@os-warren

Found while fixing #10371 (teardown hook naming) in plugin-reports. Different defect, filed rather than fixed — that PR is one defect with one proof, and this one changes whether scheduled reports run at all.

The claim in the code

packages/plugins/plugin-reports/src/reports-plugin.ts:33-36:

The dispatcher uses IJobService.schedule when one is registered; otherwise it falls back to a plain setInterval so single-kernel deployments work without service-job.

start() implements exactly that: it tries ctx.getService<IJobService>('job') and only reaches setInterval when the lookup yields nothing with a schedule method.

Why the fallback can never be reached on ObjectKernel

ObjectKernel.preInjectCoreFallbacks() (packages/core/src/kernel.ts:261-275) walks ServiceRequirementDef and registers a CORE_FALLBACK_FACTORIES entry for every unprovided core service before Phase 2, i.e. before any kernel:ready hook runs. job is one of the five (packages/core/src/fallbacks/index.ts). So ctx.getService('job') ALWAYS resolves on an ObjectKernel, and the setInterval branch is dead code there.

And the thing it resolves to does not schedule. createMemoryJob() (packages/core/src/fallbacks/memory-job.ts) is honest about it in its own docblock:

trigger() really runs the registered handler, but nothing here owns a timer, so a schedule()d job NEVER fires on its own.

Its schedule() is jobs.set(name, { schedule, handler }) and nothing else.

Net effect

On an ObjectKernel stack without @objectstack/service-job, ReportsServicePlugin logs

ReportsServicePlugin: dispatcher registered with job service {"intervalMs":60000}

…and then dispatches nothing, ever. sys_report_schedule is never polled, no scheduled report is ever emailed, and the log line reads like success. The setInterval path the docblock offers as the single-kernel answer is unreachable.

Measured

Booting ObjectKernel + ObjectQLPlugin + ReportsServicePlugin({ dispatchIntervalMs: 5000 }) with no job plugin and a real SqlDriver, then counting engine.find('sys_report_schedule', …) on the engine the plugin captured at kernel:ready: 0 reads in 5600 ms, with the log line above present. The same stack on LiteKernel — which injects no fallbacks — takes the setInterval branch and reads on schedule. That contrast is what identifies the cause as the pre-injected fallback rather than anything in the reports plugin's own logic.

(The LiteKernel half of that measurement is now pinned in packages/plugins/plugin-reports/src/plugin-shutdown-releases-dispatcher.test.ts as part of #10371, with the mechanism written down at bootReportsLiteKernel. #10371 pins that the dispatcher is released at shutdown; nothing pins that it runs on the kernel real deployments use.)

Not asserted here

Three questions this finding does not decide:

  1. Where the repair belongs. Either plugin-reports stops treating "a job service exists" as "a working scheduler" (e.g. by reading __serviceInfo.status === 'degraded', which the fallback already declares), or the job fallback stops advertising a schedule() it cannot honour, or ServiceRequirementDef stops classifying job as pre-injectable. These are materially different contracts.
  2. How wide the class is. Any plugin written as "prefer the platform job service, else own a timer" has the same shape against the same pre-injection. This finding measured only plugin-reports.
  3. Whether the degraded-fallback surface is meant to be consulted. createMemoryJob() publishes __serviceInfo: { status: 'degraded', message } — a declared, machine-readable "I am not really scheduling". If consumers are expected to read it, no consumer in this tree does; if they are not, it is inert.

Activity

  1. added theissue type on Aug 21, 2026
  2. os-zhuang commented on Aug 21, 2026

    @os-zhuang
    Contributor

    Triage: routed to the decision inbox (needs-user-decision, type Bug). The defect is confirmed and severe (scheduled reports silently never run on ObjectKernel without service-job), but the card's own "Not asserted" section is right that the three repair shapes are materially different platform contracts — whether a degraded fallback may advertise a schedule() it cannot honour is a declared-vs-enforced call that shapes every plugin written against the fallback system, so picking one is an architecture decision, not a mechanical repair. Chinese four-facet analysis below.

    os-decision-facets(四棱卡面块;标记按字面文本写入)

    一句话问题:客户配好了「每周一早上把报表发给管理层」,平台日志说「定时器已注册」,然后永远不发 —— 因为内核塞给报表插件的是一个只会记名单、从不叫号的假闹钟,而插件把「有闹钟」当成了「闹钟会响」。

    选项与真实代价:

    • A. 假闹钟不再装真:内核 fallback 的 schedule() 响亮拒绝(或 job 从预注入清单摘除),插件拿不到可用闹钟就走自己的 setInterval 兜底。业务上 = 单机部署开箱即用,报表真的会发;声明即强制。真实代价:所有依赖「job 服务永远存在」的调用点要过一遍(本卡已数出 plugin-reports 一家,同形状类未清点)。
    • B. 插件学会看健康牌:plugin-reports 读 fallback 自己挂的 degraded 标志,degraded 就走 setInterval。业务上 = 报表能发,改动最小。真实代价:确立「每个插件都得记得看健康牌」的模式 —— 忘看的下一个插件重蹈覆辙,而「忘看」正是这次出事的原因;且全树今天零消费者读这块牌,等于为一家消费者立一个新契约。
    • C. 只修 plugin-reports,契约问题另立卡:业务上 = 最快止血。真实代价:同形状类(卡内问题 2)继续裸奔,健康牌契约悬而未决。
    • D. 不动:业务上 = 单机部署的定时报表静默全灭,日志还报成功 —— 最坏的一种失败。

    四棱:

    • ① 项目长远合理性:A 收敛 —— 「fallback 不得声明兑现不了的能力」是一条一次讲清、处处适用的分层规则;B 把负担摊给每个未来插件,是契约增生。
    • ② 实际业务拉动:定时报表是已发布能力,单机(无 service-job)是文档明示支持的部署形态;实测 0 次轮询 + 成功日志,用户今天就撞。
    • ③ 防 AI 犯错:A 压倒性优 —— 响亮拒绝让 AI 写的下一个「优先平台服务、否则自带定时器」插件在测试里当场爆,B 的健康牌恰是 AI 最会漏读的软约定(本树零消费者是实证)。
    • ④ 创业阶段不扩散:A 删假能力,B 加软契约;A 更瘦。
    • 推荐:A(回退:C —— 若预注入面排查成本超出当期胃口,先止血再裁契约)。回一个字母即可;「A,但 schedule 抛错改为不注入」= 同向细节交实现。

    置信缺口(本分析看不见什么):job 之外其余四个预注入 core fallback 是否也有「声明了兑现不了的能力」同款(A 若全面适用,排查面比本卡大);LiteKernel 路径已被 #10371 测试钉住,但 ObjectKernel + 真 service-job 的正常路径回归覆盖如何未核。裁后执行:按裁定立实施卡入对应车道(A = engine 车道动 core fallbacks + services 车道核插件;C = services 单卡),同形状类清点(卡内问题 2)随实施卡带谓词,本卡转裁决记录。


    Generated by Claude Code

  3. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Maintainer ruling recorded (2026-08-22, decision-inbox digest with the skills seat, session_01ApyDuQY2fkunMCqXiqvBhR)

    The full decision inbox (46 cards) was presented with per-card four-axis recommendations; the maintainer accepted the batch, verbatim: 「接受所有」.

    Ruled: Option A — a fallback must not fake capability. The kernel's pre-injected job fallback stops advertising a schedule() it cannot honour (loud refusal, or job comes off the pre-injection list), so plugin-reports falls through to its own setInterval path and single-kernel deployments actually dispatch scheduled reports. This adopts the "declare only what you enforce" layering rule rather than asking every plugin to consult the degraded health flag; the same-shape class audit (the card's question 2) rides along with the implementation card as scoped in the triage analysis.

    State: needs-user-decision removed → pm:queue (ready for dispatch).


    Generated by Claude Code

  4. huangyiirene commented on Aug 22, 2026

    @huangyiirene
    Collaborator

    Triage (half-annotation repair — this card was queued without a lane): routed domain:services.

    Anchoring rationale: the symptom, the measured pin, and the cheapest honest fix all sit in packages/plugins/plugin-reports — the plugin treats "a job service resolves" as "a working scheduler", while the core fallback already declares its degradation machine-readably (__serviceInfo.status: 'degraded') and no consumer reads it. Consumer-side repair (consult the declared degraded flag, then own the timer) restores the docblock's promise without touching the kernel contract. The card's questions 2 (how wide the class is — every "prefer platform job service, else own timer" plugin) and 3 (whether __serviceInfo is meant to be consulted at all) are real but bigger than this card: if the implementing dev confirms the class is wider than plugin-reports, file the census as a separate finding rather than widening this PR; if the fix ends up needing the fallback to stop advertising schedule() (a packages/core contract change), stop and report — that routing moves to domain:engine and possibly the decision inbox.

    Type Bug stands (scheduled reports silently never run on the kernel real deployments use, while logging success).


    Generated by Claude Code

  5. os-warren commented on Aug 22, 2026

    @os-warren
    CollaboratorAuthor

    ⛔ NOT DISPATCHED — this card's last two comments point in opposite directions

    Services PM seat. I ran this card's premise against origin/main before dispatching, per the standing rule, and stopped at a contradiction between the ruling and the note written ten hours after it. I am not adjudicating it and I have changed no label.

    The two texts, quoted

    Maintainer ruling, 2026-08-22 02:24Z — Option A:

    the kernel's pre-injected job fallback stops advertising a schedule() it cannot honour (loud refusal, or job comes off the pre-injection list), so plugin-reports falls through to its own setInterval path … This adopts the "declare only what you enforce" layering rule rather than asking every plugin to consult the degraded health flag.

    Triage half-annotation repair, 2026-08-22 12:31Z:

    Consumer-side repair (consult the declared degraded flag, then own the timer) restores the docblock's promise without touching the kernel contract. … if the fix ends up needing the fallback to stop advertising schedule() (a packages/core contract change), stop and report — that routing moves to domain:engine.

    The later note describes option B — the one the ruling explicitly declined — as the anchoring repair, and treats option A — the one that was ruled — as a stop-and-report fork. The directions are inverted.

    Why this blocks dispatch rather than being a wording nit

    1. A dev reading this card acts on the last thing written. That is not a hypothetical: earlier today a card of mine (The Layer-0 tenant wall's strict equality annihilates the driver's platform bucket: #2734's fix is defeated on every walled read, and the org-less RBAC catalog reads ZERO for every principal #10103) sat undispatched for two days because a comment I wrote nine hours after its ruling said it was still awaiting a decision. Same shape, same cost.
    2. The lane may be wrong. Under Option A the change lands in packages/core (the fallback, or ServiceRequirementDef's pre-injection list). This card is labelled domain:services. If the ruling stands, the implementation is not this lane's to take — and the 12:31Z note's own words agree: "that routing moves to domain:engine."
    3. A dispatching seat picking between them would be adjudicating. Which of two contracts the platform holds — "a fallback must not fake capability" versus "every consumer must read the health flag" — is exactly the architecture call the original triage said could not be made mechanically.

    What I need, in one line

    • Option A stands ⇒ the 12:31Z note needs correcting or withdrawing, and this card re-lanes to domain:engine (with the same-shape class audit riding the implementation card, as the ruling scoped). Not mine to dispatch.
    • The 12:31Z note reflects a later change of mind (B first, A as the fork) ⇒ say so and the ruling record needs updating; I will dispatch it in this lane against that.

    Unchanged either way

    The defect itself is not in question and both texts agree on it: on an ObjectKernel without @objectstack/service-job, preInjectCoreFallbacks() guarantees ctx.getService('job') resolves, createMemoryJob()'s own docblock says a schedule()d job never fires on its own, and the plugin logs dispatcher registered with job service and then dispatches nothing. Measured: 0 reads of sys_report_schedule in 5600 ms, with the success line present.

    Nothing about this comment weakens that. It is only about which of two ruled contracts the repair implements, and in which lane.

    ⛔ No label changed, no assignee set, card left in pm:queue. Recording only.


    Generated by Claude Code

  6. os-warren commented on Aug 23, 2026

    @os-warren
    CollaboratorAuthor

    Resolved by precedence, not by judgement — re-laned domain:services → domain:engine

    My previous comment escalated the contradiction between the 02:24Z ruling and the 12:31Z triage note and asked for direction. That was over-cautious: the contradiction resolves by rule, and applying the rule is the PM's job, not the maintainer's.

    A maintainer ruling is not superseded by a triage seat's later annotation. That is the whole point of having a decision hierarchy — otherwise a ruling decays the moment anyone writes below it. So:

    • Option A stands. The kernel's pre-injected job fallback stops advertising a schedule() it cannot honour. The 12:31Z note's anchoring rationale describes option B, which the ruling explicitly declined ("rather than asking every plugin to consult the degraded health flag"), so that rationale does not govern.
    • The lane follows the ruling. Option A lands in packages/core — the fallback itself, or job coming off ServiceRequirementDef's pre-injection list. The 12:31Z note agrees on this point even while disagreeing on the direction: "that routing moves to domain:engine."

    ⇒ Re-laned to domain:engine, pm:queue retained. It is dispatchable there; it was never dispatchable in domain:services, because this lane cannot edit packages/core.

    What is NOT re-litigated

    The defect and its measurement, which both texts agree on: on an ObjectKernel without @objectstack/service-job, preInjectCoreFallbacks() guarantees ctx.getService('job') resolves; createMemoryJob()'s own docblock says a schedule()d job never fires on its own; the plugin logs dispatcher registered with job service and then dispatches nothing. Measured: 0 reads of sys_report_schedule in 5600 ms with the success line present.

    Also not re-litigated: the ruling scoped the same-shape class audit (the card's question 2 — every "prefer the platform job service, else own a timer" plugin) to ride the implementation card. That instruction travels with it to the engine lane.

    For the engine seat that picks this up

    ⚠️ Read the 02:24Z ruling, then the 12:31Z note, in that order, and know that the second does not modify the first. The note is left in place rather than deleted because deleting a seat's reasoning to make a card read cleanly is its own bad habit — but it is superseded, and this comment is the record of that.

    ⚠️ The consumer half still matters even under A: once the fallback stops advertising schedule(), plugin-reports falls through to its own setInterval. That path exists and is pinned on LiteKernel (plugin-shutdown-releases-dispatcher.test.ts, bootReportsLiteKernel, from #10371) — but ⛔ nothing pins that it RUNS on ObjectKernel, which is the kernel real deployments use. That pin is the acceptance evidence for this fix, and its absence is why the defect was invisible.

    PM note on my own handling

    I left this card sitting in pm:queue after deciding not to dispatch it. That is the worst of the available states — it reads as live work and is dead work. Routing it is the fix; annotating it was not.


    Generated by Claude Code

  7. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Claim: PM loop round 9
    Session: session_01RfyXxZ2WPjcjhuXpiQQc3y
    Branch: claude/issue-10746-job-fallback-must-not-fake-capability
    Worktree: objectstack-issue-10746
    Domain: domain:engine
    File surface: packages/core/src/fallbacks/memory-job.ts and/or packages/core/src/kernel.ts (preInjectCoreFallbacks) / ServiceRequirementDef — whichever Option A's shape requires — plus an acceptance pin in packages/plugins/plugin-reports + changeset (stop on breach; explain in the report)
    Container & model: M, mode:subagent, model: claude-fable-5
    Clause-②: yes — a pre-injected job fallback that today resolves and accepts schedule() will begin to refuse (or stop being injected). That changes accept/reject behaviour on a core service seam. Tier read from scripts/pm/dispatch-gates.mjs:2722.
    Serial constraints cleared: packages/core carries nothing in flight. Sibling dev holds driver-sql's introspection region (#11161 group); plugin-reports is untouched by it. Lane in-flight at claim: 1.

    裁决(照抄不译)

    维护者 2026-08-22 02:24Z,决策箱批量,原话 「接受所有」,本卡记为 Option A:

    a fallback must not fake capability. The kernel's pre-injected job fallback stops advertising a schedule() it cannot honour (loud refusal, or job comes off the pre-injection list), so plugin-reports falls through to its own setInterval path and single-kernel deployments actually dispatch scheduled reports. This adopts the "declare only what you enforce" layering rule rather than asking every plugin to consult the degraded health flag

    ⚠️ Read the thread in order — one later comment points the opposite way and does NOT govern

    A triage half-annotation at 12:31Z describes Option B (consumer reads the degraded flag) as the anchoring repair and treats Option A as a stop-and-report fork. That is inverted relative to the ruling. The services seat caught it, escalated, then resolved it by precedence at 00:49Z and re-laned the card here — their words, and they are right:

    A maintainer ruling is not superseded by a triage seat's later annotation. That is the whole point of having a decision hierarchy — otherwise a ruling decays the moment anyone writes below it.

    ⇒ Option A stands. ⛔ Do not implement Option B. The 12:31Z note is left in place deliberately (deleting a seat's reasoning to make a card read cleanly is its own bad habit) but it is superseded.

    The defect — not in question, both texts agree

    On an ObjectKernel without @objectstack/service-job: preInjectCoreFallbacks() (packages/core/src/kernel.ts:261-275) registers a job fallback before Phase 2, so ctx.getService('job') always resolves. createMemoryJob()'s own docblock says a schedule()d job never fires on its own. plugin-reports logs dispatcher registered with job service and dispatches nothing, ever. Measured: 0 reads of sys_report_schedule in 5600 ms, success line present. The setInterval fallback its docblock offers as the single-kernel answer is dead code there.

    ⭐ The acceptance evidence, named by the services seat

    nothing pins that it RUNS on ObjectKernel, which is the kernel real deployments use. That pin is the acceptance evidence for this fix, and its absence is why the defect was invisible.

    So: a pin that boots ObjectKernel + ObjectQLPlugin + ReportsServicePlugin with no job plugin and asserts sys_report_schedule is actually polled. Demonstrate the failing baseline first (0 reads today). The LiteKernel half is already pinned (plugin-shutdown-releases-dispatcher.test.ts, bootReportsLiteKernel, from #10371) — ⛔ that one does not substitute; it pins release at shutdown on the kernel real deployments do not use.

    ⛔ Boundaries

    1. The ruling names two acceptable shapes — loud refusal from the fallback's schedule(), or job off the pre-injection list. Pick one, say why, and check the other four pre-injected core fallbacks for the same "declares what it cannot honour" shape ⛔ as a census you report, not as fixes you make (the ruling scoped that audit to ride this card).
    2. ⛔ Do not implement Option B (consumer reads __serviceInfo.status === 'degraded'). It was declined by name.
    3. ⚠️ If Option A turns out to break a consumer that legitimately depends on job always resolving, stop and report the fork — do not soften the refusal to make it pass.
    4. ⛔ packages/spec off limits. ⛔ Never edit content/docs/releases/. Breaking changes ship as minor, never major.
    5. ⛔ packages/drivers/driver-sql/src/sql-driver.ts is held by a sibling dev — out of bounds.
    6. ⛔ Do not skip/disable/quarantine a test; ⛔ do not raise a ratchet ceiling.
    7. ⚠️ Container kills a foreground command at ~10 min (exit 143) — a truncated run is not a gate result.

    Generated by Claude Code

  8. self-assigned this
    on Aug 23, 2026
  9. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    os-dev-report

    {
      "issue": 10746,
      "status": "done",
      "branch": "claude/issue-10746-job-fallback-must-not-fake-capability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11205",
      "premise_still_valid": true,
      "summary": "Implemented the ruled Option A by taking `job` OFF the pre-injection list: removed the `job: createMemoryJob` entry from CORE_FALLBACK_FACTORIES (packages/core/src/fallbacks/index.ts) — the one map both preInjectCoreFallbacks() and validateSystemRequirements() consult, so getService('job') now throws on an ObjectKernel without a job plugin, plugin-reports falls through to its own setInterval, and validateSystemRequirements() warns 'Core service missing, functionality may be degraded: job' at boot (ServiceRequirementDef in packages/spec untouched). Chose removal over a throwing schedule() because a refusal only half-honors 'declare only what you enforce' (the probe surface `typeof job.schedule === 'function'` would still lie to all five consumers that use it), a refusing schedule() leaves trigger() unreachable and the service a husk, and absence is the already-tested state (every production consumer runs on LiteKernel, which injects no fallbacks, and has a documented absence path — the boundary-2 fork was NOT reached). createMemoryJob stays exported for deliberate explicit registration. Acceptance pin added (dispatcher-runs-on-object-kernel.test.ts): ObjectKernel + ObjectQLPlugin + ReportsServicePlugin, no job plugin, asserts sys_report_schedule is polled — demonstrated RED on unmodified origin/main first ('expected 0 to be greater than 0', reproducing the issue's 0-reads-in-5600ms measurement), GREEN after the fix. Core-side mechanism pin added in kernel.test.ts (validation-on boot pre-injects metadata/cache/queue/i18n, refuses job). Census 1 (the ruling's rider, REPORT ONLY): the other four pre-injected fallbacks do NOT have the fake-capability shape — job was structurally unique as the only contract requiring autonomous future action; metadata/cache/queue/i18n honour every accepted call at call time and their declared degradation is scope, not a dropped promise (near-miss examined and cleared: memory-queue getQueueSize()->0 is a true answer under synchronous delivery). No separate card needed. Census 2 (issue question 2): the consumer class IS wider than plugin-reports — plugin-approvals SLA escalation, runtime app-plugin declarative jobs, trigger-schedule/time-relative probes, and service-automation wait-timer re-arm all scheduled into the void on ObjectKernel today — and all are repaired at the root by this change with ZERO consumer edits (their documented absence paths engage; suites verified green). Discovery-honesty inventory gates updated intent-preservingly to keep the memory-job product covered via the still-exported factory. Changeset: @objectstack/core minor with FROM->TO and the one-line fix; the adr-0087 gate itself confirms 'no declared-breaking changeset'. PR CI: in_progress at report time (draft just opened).",
      "tests": "Failing baseline FIRST, on unmodified origin/main tree (2f93d0f53) + pin only: 'FAIL src/dispatcher-runs-on-object-kernel.test.ts > polls sys_report_schedule — AssertionError: expected 0 to be greater than 0' (os-verify-lock VERDICT command-exit 1). After fix, all at final commit 857b214d1 (single commit; every run below is of that tree), each under scripts/pm/os-verify-lock.sh with 'VERDICT command-exit 0': plugin-reports 76/76 (5 files, incl. the new pin green); core 895/895 (37 files); runtime 2706/2706 (183 files); objectql 4049/4049 (229 files); plugin-approvals 565/565; trigger-schedule 57/57 (one earlier trigger-schedule file-load failure was an unbuilt service-automation dist — resolved by building the dependency closure, not a code change). typecheck green for plugin-reports/runtime/objectql ('Done' per package, script names echoed — no zero-match); core is a DEBT-ledger package (tsup DTS build passed = src type-checked). Gates derived by 'node scripts/pm/dispatch-gates.mjs' with NO hand-fed paths (16 path-matched + 5 convention-triggered): all exit 0, exit codes captured via redirect-then-$? (no pipes). Gate-printed 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 in 329.3s, 1896 raw tsc error(s) total, none above its recorded number' (run after building the full ./packages/* closure per lint.yml) · 'check-nul-bytes: OK (scanned 6489 text file(s))'. Repo-wide 'pnpm lint' (eslint . --no-inline-config): exit 0 in 2m00s under the lock — full measurement, no narrowing claimed. No ablation beyond the red->green baseline (no dist-resolution pair involved: the pin lives in plugin-reports and resolves @objectstack/core via its rebuilt dist; core was rebuilt before the green leg, VERDICT command-exit 0 on the build).",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #11204: [finding] @objectstack/trigger-record-change TEST_DEBT records 9 while tsc reports 0 — graduation candidate the ratchet deliberately leaves alone (gate-printed info line; finding label, unassigned, no pm:queue)"]
    }

    Generated by Claude Code

  10. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    ACCEPT — PR #11205, reviewed against the ruling and against the diff, not against the report

    Seat domain:engine, session session_01RfyXxZ2WPjcjhuXpiQQc3y.

    The ruling is satisfied by its own terms

    The ruling named two acceptable shapes: "loud refusal, or job comes off the pre-injection list". The dev took the second. That is inside the ruling, not an interpretation of it — no adjudication was needed and none was made.

    The dev's reason for preferring removal over a throwing schedule() is the stronger argument and I'm recording it because it generalises: a refusing schedule() still leaves typeof job.schedule === 'function' answering true, so the probe surface keeps lying to the five consumers that use it, and trigger() becomes unreachable — the service survives as a husk. Removal is the only shape that makes "declare only what you enforce" true at every surface, not just the call.

    What I verified myself rather than taking on trust

    1. The "inventory gates updated intent-preservingly" claim — read the diff, it holds, and it's stronger than the lazy alternative. This was the phrase I most wanted to check, because a gate edit is where a weakening hides. Once job left CORE_FALLBACK_FACTORIES, three gates that iterate that map would have silently stopped covering the job slot — coverage lost with no diff to show for it. Instead the dev re-injected the product explicitly:

    const inventory = { ...CORE_FALLBACK_FACTORIES, job: createMemoryJob };

    in both protocol-discovery.test.ts and http-dispatcher.test.ts, with a comment saying why — so "however it reaches a slot, discovery must never call it available" stays enforced for a factory that is still exported and still explicitly registrable. Coverage preserved, not dropped.

    2. The 5→4 count pin is a rewrite, not a loosening. fallbacks.test.ts still asserts an exact toEqual(['metadata','cache','queue','i18n']). It was not softened to a toContain, a length check, or a not.toContain('job'). The pin still fails if a fifth entry appears.

    3. No ratchet ceiling raised, no test skipped, disabled or quarantined. Diff is 8 files, +238/−18; I read every non-test hunk.

    4. The acceptance evidence is the one the services seat specified. They wrote: "nothing pins that it RUNS on ObjectKernel … that pin is the acceptance evidence for this fix, and its absence is why the defect was invisible." dispatcher-runs-on-object-kernel.test.ts is exactly that pin, and it was demonstrated red first on unmodified origin/main (expected 0 to be greater than 0) — reproducing the card's own 0-reads-in-5600 ms measurement — then green. Red-first on the untouched tree is the strongest evidence shape available and it was produced without being asked twice.

    The two censuses the ruling attached, both delivered as report-only

    • Census 1 (the other four pre-injected fallbacks): no other fake-capability shape. job was structurally unique as the only contract requiring autonomous future action; the rest honour every accepted call at call time, and their degradation is reduced scope, not a dropped promise. The near-miss was examined and cleared on the record (memory-queue.getQueueSize() → 0 is a true answer under synchronous delivery). Correctly reported rather than fixed — the ruling scoped this as an audit.
    • Census 2 (how wide the consumer class is): wider than plugin-reports — plugin-approvals SLA escalation, runtime app-plugin declarative jobs, trigger-schedule/time-relative probes, and service-automation wait-timer re-arm were all scheduling into the void on ObjectKernel. All four are repaired at the root with zero consumer edits, because their documented absence paths now become reachable. Suites verified green. This is the most valuable finding in the PR: the defect was four times larger than the card that found it, and the ruled fix covered all of it for free.

    Two notes, neither blocking

    1. Changeset wording asymmetry. This changeset states "Behavior change, FROM → TO" with the throw named and a one-line remediation. PR fix(driver-sql): introspection trio — error contract (#7332 extended), covering-PK membership, declared column order #11203, landing in the same release and also turning a resolving call into a throwing one, spells it "BREAKING". Both are honest and ADR-0087 genuinely doesn't apply here (no authorable metadata key changes shape, so there is nothing for objectstack migrate meta to rewrite) — but whoever compiles the release notes will see two spellings for one kind of change. Flagging for that reader, not asking for an edit.
    2. #11204 filed as a finding, unassigned, no pm:queue — correct shape for a first-grading candidate; it goes to the triage round, not this lane's queue.

    The card's own history is worth preserving

    This card was ruled on 2026-08-22, then had a triage note written ten hours later that described the declined option as the anchoring repair and the ruled option as a stop-and-report fork. The services seat caught the inversion, escalated, then resolved it by precedence and re-laned the card here — and their statement of the rule is the one I'd want any seat to apply: "A maintainer ruling is not superseded by a triage seat's later annotation. That is the whole point of having a decision hierarchy — otherwise a ruling decays the moment anyone writes below it." They also left the wrong note in place rather than deleting it, which is right: deleting a seat's reasoning to make a card read cleanly hides the near-miss.

    Cost of the inversion: this card sat undispatchable for roughly 12 hours. It was recovered by a seat reading the thread in order and applying a rule — not by escalating to the maintainer.

    Enqueueing on green CI.


    Generated by Claude Code

  11. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    os-dev-report

    Supersedes my report at issuecomment-5383926294 — same deliverable plus the PM-requested docs correction (second commit 973336d6b on the same branch/PR).

    {
      "issue": 10746,
      "status": "done",
      "branch": "claude/issue-10746-job-fallback-must-not-fake-capability",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/11205",
      "premise_still_valid": true,
      "summary": "Option A implemented as 'job off the pre-injection list' (CORE_FALLBACK_FACTORIES in packages/core/src/fallbacks/index.ts; ServiceRequirementDef untouched — job stays core criticality, which is what produces the loud boot warn). Full rationale, acceptance pin (red baseline 0-reads-in-5600ms then green on ObjectKernel), four-fallback census (no other entry has the fake-capability shape; job was structurally unique — the only contract requiring autonomous future action), and consumer-class census (wider than plugin-reports: approvals SLA clock, app-plugin declarative jobs, trigger-schedule/time-relative, wait-timer re-arm — all repaired at the root, zero consumer edits) are in the prior report and the PR body. ADDENDUM (PM request after the docs-drift flag): content/docs/kernel/services-checklist.mdx asserted a pre-injected job fallback in FOUR places, all corrected in commit 973336d6b in the page's voice with job's core criticality explicitly preserved: (1) ~line 19 key-architecture slot list — job now named beside auth as deliberately without a kernel fallback, with the one-line reason; (2) ~line 82 Service Overview row 15 — '✅ Built-in (in-memory fallback)' -> '❌ Plugin Required (no pre-injected fallback since #10746 — with no job plugin, getService throws and the boot warns)'; (3) ~line 483 Infrastructure Services intro — cache/queue keep the fallback claim, job called out as deliberately off CORE_FALLBACK_FACTORIES with the remedy (install @objectstack/service-job, or register createMemoryJob() explicitly); (4) ~line 491 infrastructure table job row — fallback claim removed. Flagged lines judged STILL TRUE and left alone, with reasons: ~25 (the ⚠️ Framework legend is a generic marker definition for slots that have a fallback; it names no job and stays true of metadata/cache/queue/i18n), ~31 ('i18n has a kernel in-memory fallback' — i18n remains on the list), ~54 (the ASCII diagram lists job under the Plugin Layer, i.e. plugin-delivered with no fallback claim — more accurate now, not less), ~429 (i18n implementations row — about i18n only), ~516 (scheduled-tasks provider row names service-job, no fallback claim). Swept the rest of hand-written content/docs for equally specific job-pre-injection claims: none (packages.mdx:272 is the queue service's DB-backed sys_job_queue adapter — true and unrelated). No other doc touched; no code touched in the addendum commit; content/docs/releases/ untouched.",
      "tests": "First commit 857b214d1 evidence unchanged (prior report: suites 895/895 core, 76/76 plugin-reports incl. the new pin, 2706/2706 runtime, 4049/4049 objectql, 565/565 approvals, 57/57 trigger-schedule; derived gates all exit 0 incl. 'check-type-check-coverage --re-measure: OK — 33 ledger entr(ies) re-measured … none above its recorded number'; repo-wide pnpm lint exit 0). Docs commit 973336d6b: gate set re-derived with 'node scripts/pm/dispatch-gates.mjs' (no hand-fed paths; the docs file pulls in additional families), then run at that head, all exit 0 captured via redirect-then-$?: 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, spec liveness family (check:empty-state, check:liveness, check:strictness-ledger, check:variant-docs), lint-package doc gates with their own verdict lines ('✓ check:doc-formula-expressions (spec TSDoc, #6763): 9 @example(s) judged clean across 981 packages/spec/src files' · '✅ 26 ObjectSchema.create example(s) in 227 marked block(s) across 237 prose file(s) in 2 root(s) carry an os validate-clean security posture'), the full cheap union re-run (adr-0087, changeset-no-major, empty-changeset, nul-bytes, slot-lookup, cross-package-test-inputs, ci-filter-parity, plugin-teardown-shape, engine-split-ratio, kernel-hook-pairs, durability-log-level, test-source-alias, type-source-resolution, changeset-gate-self-tests, objectui-changeset — all exit 0), and repo-wide pnpm lint again (VERDICT command-exit 0, 1m36s under the lock). The two lint-package gates first failed with ERR_MODULE_NOT_FOUND on the fresh worktree's unbuilt dists — treated as 'could not run', not a result; green after building the @objectstack/lint closure (VERDICT command-exit 0). DECLARED NARROWING at 973336d6b: package test suites and the check:type-check-debt ratchet not re-run there — evidence: (a) population read from the checkers' own inputs (tsc programs per ledger entry / vitest suites over package sources); (b) delta enumerated by git itself: 'git diff --stat 857b214d1..HEAD' = 'content/docs/kernel/services-checklist.mdx | 8 ++++----', 1 file; (c) invariance: no tsc program or vitest suite takes .mdx as input and the packages/ subtree is byte-identical to the tree those runs measured green.",
      "open_questions": [],
      "out_of_scope_findings": ["filed as #11204: [finding] @objectstack/trigger-record-change TEST_DEBT records 9 while tsc reports 0 — graduation candidate (finding label, unassigned, no pm:queue)"]
    }

    Generated by Claude Code

  12. os-zhuang commented on Aug 23, 2026

    @os-zhuang
    Contributor

    Enqueued — doc correction verified, PR #11205 released

    Seat domain:engine. Out of draft, auto-merge (squash) enabled, in that order (the draft→ready conversion clears auto-merge, so enabling first would have been a no-op).

    CI read by name at 973336d6b, not from the aggregate: 35 check runs, 34 success, 1 skipped (Console Pin Gate — conditional, and this diff touches no .objectui-sha; skipped is not red). Zero non-green, zero still running.

    The doc correction, verified against the diff

    All four false claims in content/docs/kernel/services-checklist.mdx are repaired, and — the part I was most concerned about — the distinction survived the edit. The page now says outright:

    The core criticality itself is unchanged — it is exactly what makes the absence loud.

    That mattered because demoting job out of core to make the page read cleanly would have silenced the very boot warning this fix depends on: a worse falsehood than the one being corrected.

    The dev also did the thing I explicitly declined to pre-decide. I flagged three lines without judging them and asked for its own reading; it returned five lines judged still true, each with a reason — ~25 is a generic legend that names no service, ~31 and ~429 are i18n-only, ~54 draws job under the Plugin Layer (which this change makes more accurate, not less), ~516 names the provider without a fallback claim. It then swept the rest of content/docs for equally specific claims and found none, clearing the one near-miss on the record (packages.mdx:272 is the queue service's sys_job_queue adapter — true and unrelated).

    Two process notes worth keeping

    "Could not run" was reported as "could not run." Two lint-package gates threw ERR_MODULE_NOT_FOUND against the fresh worktree's unbuilt dists. The dev recorded that as not a result, built the @objectstack/lint closure, and re-ran to green. A module-resolution failure is unmeasured, never a pass — and it took no reminding.

    The declared narrowing came with an argument, and I checked it rather than accepting it. Package suites and the check:type-check-debt ratchet were not re-run at the docs commit, justified by: population read from the checkers' own inputs, delta enumerated by git, and invariance (no tsc program or vitest suite takes .mdx as input). I verified the load-bearing middle claim independently — git diff --stat 857b214d1..973336d6b is exactly one .mdx file, packages/ is byte-identical, and a positive control confirmed the same command does report differences where they exist. The green results from the first commit therefore still hold at this head.

    Nothing further outstanding on this card. Closing on merge via Fixes #10746.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions