Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/job-fallback-must-not-fake-capability.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
---
'@objectstack/core': minor
---

`ObjectKernel` no longer pre-injects the in-memory `job` fallback for the `job` core-service slot — a fallback must not fake capability (#10746, maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a job and never fires it (it owns no timer), so pre-injecting it made every "prefer the platform job service, else own a timer" consumer take the job-service branch on a kernel without `@objectstack/service-job` and then silently never run: `plugin-reports` logged `dispatcher registered with job service` and dispatched nothing, ever.

Behavior change, FROM → TO: on an `ObjectKernel` without a registered `job` service, `getService('job')` FROM resolving a non-scheduling in-memory registry TO throwing `Service 'job' not found`. Consumers' documented no-job-service paths take over (`plugin-reports` falls through to its own `setInterval` and scheduled reports actually dispatch; schedule triggers and declarative jobs warn loudly instead of scheduling into the void), and the kernel says the absence out loud at boot: `Core service missing, functionality may be degraded: job`.

One-line fix if you relied on the old behavior: install `@objectstack/service-job` for real scheduling, or — if you deliberately want the manual-trigger in-memory registry — register it explicitly: `kernel.registerService('job', createMemoryJob())` (the factory is still exported from `@objectstack/core`).
8 changes: 4 additions & 4 deletions content/docs/kernel/services-checklist.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -16,7 +16,7 @@ package catalog.

The ObjectStack protocol defines **15 kernel services** registered via the `CoreServiceName` enum (v17 removed the never-implemented `graphql` entry and retired the never-filled `workflow` slot, #4451). Each service maps to a set of protocol methods governed by its per-domain contract (`DataProtocol`, `MetadataProtocol`, ...) — the transitional `ObjectStackProtocol` composition alias was dissolved in v17 (ADR-0076 D9); capability availability comes from the runtime discovery `services` registry.

**Key architecture principle**: the kernel guarantees only **data** and **metadata**, and even those are filled by packages (`@objectstack/objectql`, `@objectstack/metadata`) rather than baked in — the kernel's own contribution is an in-memory fallback for the `core` slots that have one (`metadata`, `cache`, `queue`, `job`, `i18n` — **not** `auth`). Everything else — including **auth** and **automation** — is delivered by plugins. `@objectstack/objectql` is an example kernel implementation to get the basic API running; production kernels will be rebuilt as separate plugins.
**Key architecture principle**: the kernel guarantees only **data** and **metadata**, and even those are filled by packages (`@objectstack/objectql`, `@objectstack/metadata`) rather than baked in — the kernel's own contribution is an in-memory fallback for the `core` slots that have one (`metadata`, `cache`, `queue`, `i18n` — **not** `auth`, and not `job`: an in-memory registry cannot fire a `schedule()`d job on its own, so pre-injecting one advertised a scheduler that never ran, and a fallback must not fake capability — #10746. The `job` slot stays empty, loudly, until `@objectstack/service-job` or an explicitly registered scheduler fills it). Everything else — including **auth** and **automation** — is delivered by plugins. `@objectstack/objectql` is an example kernel implementation to get the basic API running; production kernels will be rebuilt as separate plugins.

<Callout type="info">
**Legend**
Expand DownExpand Up@@ -79,7 +79,7 @@ The ObjectStack protocol defines **15 kernel services** registered via the `Core
| 12 | **search** | `optional` | — | ❌ Nothing ships | — |
| 13 | **cache** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-cache` |
| 14 | **queue** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-queue` |
| 15 | **job** | `core` | — | ✅ Built-in (in-memory fallback) | `@objectstack/service-job` |
| 15 | **job** | `core` | — | ❌ Plugin Required (no pre-injected fallback since #10746 — with no job plugin, `getService('job')` throws and the boot warns) | `@objectstack/service-job` |

<Callout type="info">
The Provider column mirrors `CORE_SERVICE_PROVIDER` in
Expand DownExpand Up@@ -480,15 +480,15 @@ AppPlugin will:

## 11–15. Infrastructure Services

`cache`, `queue`, and `job` are `core` services: like `i18n`, the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). The `optional` services (`storage`, `search`) stay disabled until a plugin provides them.
`cache`, `queue`, and `job` are `core` services. For `cache` and `queue` — like `i18n`the kernel auto-injects an in-memory fallback when no plugin registers them (see `CORE_FALLBACK_FACTORIES` in `packages/core/src/fallbacks/`). `job` is deliberately **not** on that list (#10746): an in-memory registry cannot fire a `schedule()`d job on its own, and a fallback must not fake capability — so with no job plugin installed, `getService('job')` throws, consumers take their documented no-scheduler paths (e.g. the reports dispatcher's own `setInterval`), and the boot warns `Core service missing, functionality may be degraded: job`. The `core` criticality itself is unchanged — it is exactly what makes the absence loud. Fill the slot with `@objectstack/service-job`, or register `createMemoryJob()` explicitly if a manual-`trigger()` registry is genuinely wanted. The `optional` services (`storage`, `search`) stay disabled until a plugin provides them.

| Service | Description |
|:--------|:------------|
| **storage** (deprecated v17 alias: `file-storage`, #9683) | Unified upload/download/delete via `@objectstack/service-storage`, which mounts `/api/v1/storage` itself. Adapters: local FS and S3 (the S3 adapter's `endpoint` + path-style options cover S3-compatible services such as MinIO and R2). |
| **search** | **Nothing ships.** `ISearchService` and the engine enum (`elasticsearch`, `meilisearch`, …) exist in `@objectstack/spec`, but no package implements the contract or registers the `search` slot, so `CORE_SERVICE_PROVIDER.search` is `null`. |
| **cache** | General-purpose cache. In-memory fallback; memory or Redis adapter via `@objectstack/service-cache`. |
| **queue** | Message queue. In-memory fallback; durable DB-backed adapter (`sys_job_queue`) via `@objectstack/service-queue` (no BullMQ/Redis adapter is shipped). |
| **job** | Scheduled task execution via `@objectstack/service-job`. In-memory fallback; interval, cron, and DB-backed adapters with concurrency policy. |
| **job** | Scheduled task execution via `@objectstack/service-job`interval, cron, and DB-backed adapters with concurrency policy. No pre-injected fallback (#10746): install the plugin, or the slot stays empty and the boot says so. |

---

Expand Down
10 changes: 8 additions & 2 deletions packages/core/src/fallbacks/fallbacks.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -7,8 +7,14 @@ import { CORE_FALLBACK_FACTORIES } from './index';
import { readServiceSelfInfo } from '@objectstack/spec/api';

describe('CORE_FALLBACK_FACTORIES', () => {
it('should have exactly 5 entries: metadata, cache, queue, job, i18n', () => {
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'job', 'i18n']);
// [#10746] `job` is deliberately OFF this list — a fallback must not fake
// capability (maintainer ruling 2026-08-22). `createMemoryJob().schedule()`
// records a job and never fires it, so pre-injecting it made consumers
// treat "a `job` service resolves" as "a working scheduler" and silently
// never run. The factory stays exported for deliberate, explicit use; the
// kernel must not hand it out as if it honoured `schedule()`.
it('should have exactly 4 entries: metadata, cache, queue, i18n — job deliberately absent (#10746)', () => {
expect(Object.keys(CORE_FALLBACK_FACTORIES)).toEqual(['metadata', 'cache', 'queue', 'i18n']);
});

// [#4058] Every kernel fallback must be readable through the ONE standard
Expand Down
24 changes: 20 additions & 4 deletions packages/core/src/fallbacks/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,6 @@

import { createMemoryCache } from './memory-cache.js';
import { createMemoryQueue } from './memory-queue.js';
import { createMemoryJob } from './memory-job.js';
import { createMemoryI18n } from './memory-i18n.js';
import { createMemoryMetadata } from './memory-metadata.js';

Expand All@@ -18,13 +17,30 @@ export {

/**
* Map of core-criticality service names to their in-memory fallback factories.
* Used by ObjectKernel.validateSystemRequirements() to auto-inject fallbacks
* when no real plugin provides the service.
* This IS the kernel's pre-injection list: `ObjectKernel.preInjectCoreFallbacks()`
* registers an entry for every unprovided `core` service before Phase 2, and
* `validateSystemRequirements()` consults the same map as its final check.
*
* [#10746] `job` is deliberately ABSENT — a fallback must not fake capability
* (maintainer ruling 2026-08-22). `createMemoryJob()`'s `schedule()` records a
* job and never fires it, so pre-injecting it made every "prefer the platform
* job service, else own a timer" consumer take the job-service branch and then
* silently never run: `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). With no
* entry here, `getService('job')` throws when no job plugin is installed,
* every consumer's documented no-job-service path becomes reachable (they all
* already run on `LiteKernel`, which injects no fallbacks), and the kernel
* says the absence out loud at boot: `validateSystemRequirements()` warns
* "Core service missing, functionality may be degraded: job". Do NOT re-add
* the entry to quiet that warning — install `@objectstack/service-job`, or
* register a real scheduler, instead. `createMemoryJob` stays exported below
* for embedders who deliberately want a manual-trigger job registry and have
* read its docblock.
*/
export const CORE_FALLBACK_FACTORIES: Record<string, () => Record<string, any>> = {
metadata: createMemoryMetadata,
cache: createMemoryCache,
queue: createMemoryQueue,
job: createMemoryJob,
i18n: createMemoryI18n,
};
12 changes: 8 additions & 4 deletions packages/core/src/fallbacks/memory-job.ts
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* In-memory job scheduler fallback.
* In-memory job registry — schedule/cancel/trigger bookkeeping with NO timer.
*
* Implements the IJobService contract with basic schedule/cancel/trigger
* operations. Used by ObjectKernel as an automatic fallback when no real
* job plugin (e.g. Agenda / BullMQ) is registered.
* [#10746] NOT pre-injected by ObjectKernel any more (it used to be, via
* `CORE_FALLBACK_FACTORIES`): a fallback must not fake capability (maintainer
* ruling 2026-08-22). Advertising a `schedule()` that records and never fires
* made every "prefer the platform job service, else own a timer" consumer
* take the job-service branch and then silently never run. The export remains
* for embedders who deliberately want a manual-trigger job registry — e.g. in
* tests that drive handlers via `trigger()` — and have read this docblock.
*
* [#4058] `degraded` (ADR-0076 D12), with the missing half named in the
* message rather than left for a deployer to discover: `trigger()` really runs
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/kernel.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1155,4 +1155,43 @@ describe('ObjectKernel', () => {
await kernel.shutdown();
});
});

describe('Core fallback pre-injection (#10746)', () => {
// The suite-level kernel sets `skipSystemValidation: true`, which skips
// pre-injection entirely — this pin needs the real path, so it boots
// its own kernel with validation ON (the production default).
it('pre-injects the honest core fallbacks but NOT job — a fallback must not fake capability', async () => {
const k = new ObjectKernel({
logger: { level: 'error' },
gracefulShutdown: false,
});
// `data` is `required` criticality; provide it so bootstrap
// survives validateSystemRequirements().
const dataProvider: Plugin = {
name: 'test.data-provider',
version: '1.0.0',
init: async (ctx: PluginContext) => {
ctx.registerService('data', { find: async () => [] });
},
};
await k.use(dataProvider);
await k.bootstrap();
try {
// The remaining core slots still pre-inject before Phase 2.
for (const slot of ['metadata', 'cache', 'queue', 'i18n']) {
expect(k.getService(slot), `fallback for '${slot}'`).toBeDefined();
}
// `job` must NOT resolve: `createMemoryJob()`'s `schedule()`
// records a job and never fires it, so handing it out made
// every "prefer the platform job service" consumer schedule
// into the void while logging success (maintainer ruling
// 2026-08-22: declare only what you enforce). Absence is the
// honest answer — consumers' documented no-job-service paths
// (setInterval fallbacks, loud warns) take over.
expect(() => k.getService('job')).toThrow(/Service 'job' not found/);
} finally {
await k.shutdown();
}
});
});
});
15 changes: 12 additions & 3 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,7 +2,7 @@

import { describe, it, expect, beforeEach } from 'vitest';
import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol';
import { createMemoryMetadata, CORE_FALLBACK_FACTORIES } from '@objectstack/core';
import { createMemoryMetadata, createMemoryJob, CORE_FALLBACK_FACTORIES } from '@objectstack/core';
import { ObjectQL } from './engine.js';

describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () => {
Expand DownExpand Up@@ -232,7 +232,12 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
it('reports every CORE_FALLBACK_FACTORIES product as degraded, never available (#3898)', async () => {
expect(Object.keys(CORE_FALLBACK_FACTORIES).length).toBeGreaterThan(0);

for (const [slot, factory] of Object.entries(CORE_FALLBACK_FACTORIES)) {
// [#10746] `job` came OFF the pre-injection list (a fallback must not
// fake capability), but `createMemoryJob` stays exported for deliberate
// registration — so its product stays in this gate's inventory: however
// it reaches a slot, discovery must never call it `available`.
const inventory = { ...CORE_FALLBACK_FACTORIES, job: createMemoryJob };
for (const [slot, factory] of Object.entries(inventory)) {
const mockServices = new Map<string, any>();
mockServices.set(slot, factory());

Expand DownExpand Up@@ -382,9 +387,13 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
});

it('never advertises a route for a cache/queue/job fallback either (#4318)', async () => {
// [#10746] `job` is off the pre-injection map but stays explicitly
// registrable, so the slot keeps its fallback-occupant coverage here.
const factoryFor = (slot: string) =>
slot === 'job' ? createMemoryJob : CORE_FALLBACK_FACTORIES[slot];
for (const slot of ['cache', 'queue', 'job']) {
const mockServices = new Map<string, any>();
mockServices.set(slot, CORE_FALLBACK_FACTORIES[slot]());
mockServices.set(slot, factoryFor(slot)());

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const reported = (await protocol.getDiscovery()).services[slot];
Expand Down
Loading
Loading