From c938b50f7c66e7a05bc188e6933222a9841f62cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Tue, 13 Oct 2026 11:31:43 +0200 Subject: [PATCH 01/16] fix(scaffold): make the split-service host set its name and boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated host set SERVICE_MANIFEST but not SERVICE_NAME, which createApp requires with a manifest, so it never booted; its example manifest (identity,wallet) also left out wallet's dependencies. It now bakes the service name as SERVICE_NAME, passes the identity tables as authSchema like the consumer template, uses identity,mail as the example, and drops dependencies its main.ts never imports. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- packages/core/generators/src/config.ts | 2 +- tools/create/create-service.ts | 34 ++++++++++++++++---------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/core/generators/src/config.ts b/packages/core/generators/src/config.ts index 6e258029e..62efc957d 100644 --- a/packages/core/generators/src/config.ts +++ b/packages/core/generators/src/config.ts @@ -487,7 +487,7 @@ export default function generator(plop: PlopTypes.NodePlopAPI): void { }); plop.setGenerator('service', { - description: 'Thin single-purpose service host (bakes SERVICE_MANIFEST)', + description: 'Thin single-purpose service host (bakes SERVICE_MANIFEST and SERVICE_NAME)', prompts: [ { type: 'input', diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index ed38e09c4..21a35983c 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -8,11 +8,15 @@ * * Usage: * pnpm create:service - * pnpm create:service wallet identity,wallet + * pnpm create:service accounts identity,mail + * + * The manifest lists every module its modules depend on and every module that binds a port + * they require; the boot names whatever is missing. The host bakes the service name as its + * SERVICE_NAME - its own Redis Streams consumer group. * * After scaffolding: `pnpm install`, build deps, then * pnpm -F @openora/-service dev # uses the baked-in manifest - * SERVICE_MANIFEST=identity,wallet,chat pnpm -F @openora/-service dev # override + * SERVICE_MANIFEST=identity,mail,audit pnpm -F @openora/-service dev # override */ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -23,7 +27,7 @@ const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..'); const [, , rawName, rawManifest] = process.argv; if (!rawName || !rawManifest) { console.error('Usage: pnpm create:service '); - console.error('Example: pnpm create:service wallet identity,wallet'); + console.error('Example: pnpm create:service accounts identity,mail'); process.exit(1); } @@ -65,17 +69,13 @@ const files: Record = { build: 'tsc', dev: 'node --import tsx --watch src/main.ts', start: 'node dist/main.js', - typecheck: 'tsc --noEmit', + 'check:types': 'tsc --noEmit', }, dependencies: { - '@orpc/server': '1.14.3', '@openora/core': 'workspace:*', - amqplib: '^2.0.1', - bullmq: '^5.77.6', - zod: '4.4.3', }, devDependencies: { - tsx: '4.22.2', + tsx: '4.23.1', typescript: '6.0.3', }, }, @@ -95,16 +95,22 @@ const files: Record = { ) + '\n', 'src/main.ts': `import { createApp, loadExtensions } from '@openora/core/server'; +import { user, session, account, verification, twoFactor } from '@openora/core/pam/schema/identity'; // ${name} service: boots only the modules it owns. The module code is shared with -// the monolith (root extensions.config.ts) - this host just narrows it via the -// SERVICE_MANIFEST seam. Override the default at runtime with SERVICE_MANIFEST. +// the monolith (root extensions.config.ts) - createApp narrows it via the +// SERVICE_MANIFEST seam. Override the defaults at runtime with SERVICE_MANIFEST and +// SERVICE_NAME; the name is this service's own consumer group. process.env['SERVICE_MANIFEST'] ??= '${manifest.join(',')}'; +process.env['SERVICE_NAME'] ??= '${name}'; async function bootstrap() { const plugins = await loadExtensions(); // Routes come from the loaded plugins; createApp exposes runtime API docs. - const { listen } = await createApp({ plugins }); + const { listen } = await createApp({ + plugins, + authSchema: { user, session, account, verification, twoFactor }, + }); await listen(); } @@ -122,7 +128,9 @@ for (const [rel, content] of Object.entries(files)) { console.log(`Created apps/${name} (${pkgName}) booting modules: ${manifest.join(', ')}`); console.log('Next:'); console.log(' pnpm install'); -console.log(` pnpm -F ${pkgName} dev # boots the baked-in manifest`); +console.log( + ` DATABASE_URL=... REDIS_URL=... CORS_ORIGINS=... pnpm -F ${pkgName} dev # boots the baked-in manifest`, +); console.log(` SERVICE_MANIFEST=... pnpm -F ${pkgName} dev # override at runtime`); console.log('To exclude these modules from the monolith, drop them from extensions.config.ts'); console.log( From 9671ce406b53e8f3807468b44cfe5da9ac77b627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Tue, 13 Oct 2026 11:31:54 +0200 Subject: [PATCH 02/16] fix(core)!: apply the service manifest to any plugin list and name missing dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SERVICE_MANIFEST was applied only by loadExtensions(), while createApp read it just to require SERVICE_NAME. A host that passes its own plugin list therefore booted every module under the split service's own consumer group, so every broker event was handled once more across the cluster. createApp now selects the manifest's modules from whatever list it is given, and loadExtensions() no longer filters. A manifest that leaves out a module the selected ones depend on - directly or through another left-out module, infra overlays' dependencies included - fails the boot with one error naming them all and a manifest line to paste. The rule that a manifest lists its modules' dependencies and port providers is back in the messaging rule. BREAKING CHANGE: a process that sets SERVICE_MANIFEST and passes its own plugin list now boots that subset, or refuses to boot over missing dependencies, instead of every module. Consumer groups the old full boot created under the split SERVICE_NAME are stale; remove them with XGROUP DESTROY. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../rules/messaging-and-microservices.md | 1 + AGENTS.md | 1 + ...readiness-manifest-outbox-command-ports.md | 2 + .../0033-analytics-as-a-read-only-domain.md | 2 + docs/guides/downstream-consumer.md | 10 +++ .../core/src/contracts/adapters/broker.ts | 5 +- .../__tests__/service-manifest.test.ts | 82 ++++++++++++++++++- .../src/server/plugin-host/load-extensions.ts | 16 +--- .../src/server/plugin-host/load-plugins.ts | 26 +++--- .../server/plugin-host/service-manifest.ts | 65 ++++++++++++++- .../runtime/__tests__/create-app.test.ts | 53 +++++++++++- .../runtime/__tests__/fixtures/base-plugin.ts | 12 +++ .../__tests__/fixtures/feature-plugin.ts | 15 ++++ .../runtime/__tests__/fixtures/leaf-plugin.ts | 8 ++ .../core/src/server/runtime/create-app.ts | 24 ++++-- 15 files changed, 287 insertions(+), 35 deletions(-) create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/base-plugin.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/feature-plugin.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/leaf-plugin.ts diff --git a/.rulesync/rules/messaging-and-microservices.md b/.rulesync/rules/messaging-and-microservices.md index 2abc6f2b2..dd60cfbed 100644 --- a/.rulesync/rules/messaging-and-microservices.md +++ b/.rulesync/rules/messaging-and-microservices.md @@ -36,5 +36,6 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/AGENTS.md b/AGENTS.md index 371df3dad..4223eb472 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,6 +132,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md index a301586d3..6d5e1ad7b 100644 --- a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md +++ b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md @@ -4,6 +4,8 @@ **Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default). **Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope). +> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included - and the boot names all that is missing. `pnpm create:service` now sets `SERVICE_NAME` too. + ## Context ADR-0010/0016 made the inter-module _event_ transport swappable so a module can be diff --git a/docs/adr/0033-analytics-as-a-read-only-domain.md b/docs/adr/0033-analytics-as-a-read-only-domain.md index efa8b8b10..2ae5da00d 100644 --- a/docs/adr/0033-analytics-as-a-read-only-domain.md +++ b/docs/adr/0033-analytics-as-a-read-only-domain.md @@ -3,6 +3,8 @@ **Date**: 2026-07-25 **Status**: Accepted +> **Update (2026-10-13)**: `SERVICE_MANIFEST=analytics` alone does not boot: the manifest must also list the modules analytics depends on (wallet, identity, profile, gaming) and theirs. The boot names every missing one ([ADR-0017](./0017-extraction-readiness-manifest-outbox-command-ports.md)). + ## Context A financial-analytics dashboard is requested (deposits/withdrawals by diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index 93cd337da..9b28c7086 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -73,6 +73,16 @@ await listen(); Downstream consumers create their own thin entrypoint that calls `createApp` and bring their own `extensions.config.ts`. See `tools/templates/consumer/apps/api/src/main.ts.tpl` for the reference. +### Running more than one process + +Replicas of one API share a `SERVICE_NAME` (unset means `monolith`) and split the work: each event +and each job reaches one of them. + +- **Fewer modules in a process.** `SERVICE_MANIFEST` lists the module ids a process boots, and + `createApp` applies it to the `plugins` you pass. List every module your modules depend on and + every module that binds a port they require; the boot names whatever is missing. A process + with a manifest needs a `SERVICE_NAME` of its own. + ## Seeding reference data (production) Migrations carry **structure only** (DDL: tables, indexes, constraints) - never data. diff --git a/packages/core/src/contracts/adapters/broker.ts b/packages/core/src/contracts/adapters/broker.ts index be7d5b010..8051d24ac 100644 --- a/packages/core/src/contracts/adapters/broker.ts +++ b/packages/core/src/contracts/adapters/broker.ts @@ -33,8 +33,9 @@ export type BrokerHandler = (envelope: EventEnvelope) => void | Promise; export type SubscribeOptions = { /** - * Forward hint for grouped consumers: Kafka consumer group / RabbitMQ queue group. - * When omitted, the adapter creates an exclusive per-process queue (fan-out). + * Overrides the consumer group (Kafka consumer group / RabbitMQ queue group). `EventBus` + * never sets it, so module handlers join the deployment's group - `SERVICE_NAME` in the + * Redis Streams driver - and each event reaches one replica per service. */ consumerGroup?: string; }; diff --git a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts index 843df1acb..20f2daac6 100644 --- a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts @@ -1,5 +1,9 @@ -import { describe, it, expect } from 'vitest'; -import { applyServiceManifest, parseServiceManifest } from '../service-manifest.js'; +import { describe, it, expect, vi } from 'vitest'; +import { + applyServiceManifest, + parseServiceManifest, + selectServiceEntries, +} from '../service-manifest.js'; import type { PluginEntry } from '../load-plugins.js'; const entries: PluginEntry[] = [ @@ -38,3 +42,77 @@ describe('applyServiceManifest', () => { expect(() => applyServiceManifest(entries, ['nope'])).toThrow(/unknown module id/); }); }); + +describe('selectServiceEntries', () => { + const graph: Record = { + audit: [], + identity: ['audit'], + wallet: ['identity', 'audit'], + gaming: ['wallet'], + rabbitmq: [], + bullmq: [], + }; + const all: PluginEntry[] = [ + { id: 'audit', path: 'a' }, + { id: 'identity', path: 'b' }, + { id: 'wallet', path: 'c' }, + { id: 'gaming', path: 'd' }, + { id: 'bullmq', path: 'e', kind: 'infra' }, + ]; + const readerOf = (deps: Record) => + vi.fn(async (entry: PluginEntry) => deps[entry.id] ?? []); + + it('returns every entry without a manifest and reads none of them', async () => { + const reader = readerOf(graph); + + expect(await selectServiceEntries(all, null, reader)).toBe(all); + expect(reader).not.toHaveBeenCalled(); + }); + + it('boots a manifest that lists its dependencies, reading only what it selects', async () => { + const reader = readerOf(graph); + + const selected = await selectServiceEntries(all, ['audit', 'identity', 'wallet'], reader); + + expect(selected.map((e) => e.id)).toEqual(['audit', 'identity', 'wallet', 'bullmq']); + expect(reader.mock.calls.map(([e]) => e.id).sort()).toEqual( + ['audit', 'bullmq', 'identity', 'wallet'].sort(), + ); + }); + + it('names every left-out dependency, transitive ones included, in one error', async () => { + const run = selectServiceEntries(all, ['gaming'], readerOf(graph)); + + await expect(run).rejects.toThrow( + /SERVICE_MANIFEST=gaming leaves out[\s\S]*- wallet \(required by gaming\)[\s\S]*- identity \(required by wallet\)[\s\S]*- audit \(required by wallet, identity\)[\s\S]*Add them: SERVICE_MANIFEST=gaming,wallet,identity,audit/, + ); + }); + + it("counts an infra overlay's dependencies", async () => { + const run = selectServiceEntries(all, ['audit'], readerOf({ ...graph, bullmq: ['identity'] })); + + await expect(run).rejects.toThrow(/- identity \(required by bullmq\)/); + }); + + it('flags a dependency the plugin list does not have at all', async () => { + const run = selectServiceEntries(all, ['audit'], readerOf({ audit: ['ghost'] })); + + await expect(run).rejects.toThrow(/- ghost \(required by audit\) - not in the plugin list/); + await expect(run).rejects.not.toThrow(/Add them/); + }); + + it('stops on a cycle among left-out modules', async () => { + const cyclic = { gaming: ['wallet'], wallet: ['identity'], identity: ['wallet'] }; + + await expect(selectServiceEntries(all, ['gaming'], readerOf(cyclic))).rejects.toThrow( + /Add them: SERVICE_MANIFEST=gaming,wallet,identity$/m, + ); + }); + + it('still refuses an unknown id before reading anything', async () => { + const reader = readerOf(graph); + + await expect(selectServiceEntries(all, ['nope'], reader)).rejects.toThrow(/unknown module id/); + expect(reader).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/server/plugin-host/load-extensions.ts b/packages/core/src/server/plugin-host/load-extensions.ts index 365aa403a..7e08fe423 100644 --- a/packages/core/src/server/plugin-host/load-extensions.ts +++ b/packages/core/src/server/plugin-host/load-extensions.ts @@ -1,13 +1,12 @@ import { resolve, dirname, isAbsolute } from 'node:path'; import { accessSync } from 'node:fs'; -import { applyServiceManifest, parseServiceManifest } from './service-manifest.js'; import type { PluginEntry } from './load-plugins.js'; /** * Load the extensions.config.ts registry from disk and resolve plugin paths. * Resolves via EXTENSIONS_CONFIG env var or walks up from process.cwd(). - * Optional `filter` lets a composition root drop entries before the service - * manifest is applied. + * Optional `filter` lets a composition root drop entries. `SERVICE_MANIFEST` is + * applied by `createApp`, to this list or any other it is given. */ export async function loadExtensions( filter?: (entries: PluginEntry[]) => PluginEntry[], @@ -27,16 +26,7 @@ export async function loadExtensions( path: isAbsolute(entry.path) ? entry.path : resolve(configDir, entry.path), })); - const filtered = filter ? filter(resolved) : resolved; - - const manifest = parseServiceManifest(process.env['SERVICE_MANIFEST']); - const selected = applyServiceManifest(filtered, manifest); - if (manifest !== null) { - process.stdout.write( - `SERVICE_MANIFEST active: booting ${selected.map((e) => e.id).join(', ')}\n`, - ); - } - return selected; + return filter ? filter(resolved) : resolved; } function findConfigUpwards(start: string): string { diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index 552b9d2bc..6ab52cbbe 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -17,7 +17,7 @@ export type PluginEntry = { kind?: 'module' | 'infra'; }; -function validateEntries(entries: unknown): asserts entries is PluginEntry[] { +export function validateEntries(entries: unknown): asserts entries is PluginEntry[] { if (!Array.isArray(entries)) { throw new Error( `extensions.config.ts must export \`extensions\` as an array of { id, path }. Got ${typeof entries}.`, @@ -90,6 +90,17 @@ export function topoSort(plugins: Plugin[]): Plugin(entry: PluginEntry): Promise> { + const mod = (await import(entry.path)) as { default?: Plugin }; + const plugin = mod.default; + if (!plugin || typeof plugin.register !== 'function') { + throw new Error(`Plugin at "${entry.path}" does not default-export a valid plugin`); + } + assertEntryMatchesPlugin(entry, plugin); + return plugin; +} + export async function loadPlugins( entries: PluginEntry[], container: Container, @@ -98,13 +109,7 @@ export async function loadPlugins( const plugins: Plugin[] = []; for (const entry of entries) { - const mod = (await import(entry.path)) as { default?: Plugin }; - const plugin = mod.default; - if (!plugin || typeof plugin.register !== 'function') { - throw new Error(`Plugin at "${entry.path}" does not default-export a valid plugin`); - } - assertEntryMatchesPlugin(entry, plugin); - plugins.push(plugin); + plugins.push(await importPlugin(entry)); } const ordered = topoSort(plugins); @@ -146,8 +151,9 @@ export function assertRequiredPorts( throw new Error( `[plugin-host] Required ports are unbound at boot:\n${unbound.join('\n')}\n` + `A domain reaches another domain only through a port the other domain binds. ` + - `Install the package that provides each port (eg @openora/wallet binds WALLET_COMMANDS) ` + - `or remove the plugin that requires it. See ADR-0024.`, + `Load the module that binds each port (eg wallet binds WALLET_COMMANDS) - with ` + + `SERVICE_MANIFEST set, add it to the manifest - or remove the plugin that requires it. ` + + `See ADR-0024.`, ); } } diff --git a/packages/core/src/server/plugin-host/service-manifest.ts b/packages/core/src/server/plugin-host/service-manifest.ts index 8b322b6ed..49586f18f 100644 --- a/packages/core/src/server/plugin-host/service-manifest.ts +++ b/packages/core/src/server/plugin-host/service-manifest.ts @@ -1,4 +1,4 @@ -import type { PluginEntry } from './load-plugins.js'; +import { validateEntries, type PluginEntry } from './load-plugins.js'; export function parseServiceManifest(raw: string | undefined): string[] | null { if (raw === undefined) { @@ -31,3 +31,66 @@ export function applyServiceManifest( const wanted = new Set(manifest); return entries.filter((e) => e.kind === 'infra' || wanted.has(e.id)); } + +/** Reads the `dependsOn` of the plugin an entry names. */ +export type DependsOnReader = (entry: PluginEntry) => Promise; + +/** + * The entries a process boots: all of them without a manifest, otherwise the manifest's modules + * plus every infra overlay. A manifest that leaves out a module the selected ones depend on - + * directly or through another left-out module - fails with one error that names them all, so a + * split is fixed in one edit rather than one boot per missing module. Only the selected entries + * are read on the way to success; a left-out one is read only to follow its own dependencies. + */ +export async function selectServiceEntries( + entries: PluginEntry[], + manifest: readonly string[] | null, + dependsOnOf: DependsOnReader, +): Promise { + if (manifest === null) { + return entries; + } + validateEntries(entries); + const selected = applyServiceManifest(entries, manifest); + + const selectedIds = new Set(selected.map((e) => e.id)); + const byId = new Map(entries.map((e) => [e.id, e])); + const missing = new Map(); + const toRead = [...selected]; + // Grows while it is walked: a left-out dependency is read for its own dependencies. + for (const entry of toRead) { + for (const dep of await dependsOnOf(entry)) { + if (selectedIds.has(dep)) { + continue; + } + const requiredBy = missing.get(dep); + if (requiredBy) { + requiredBy.push(entry.id); + continue; + } + missing.set(dep, [entry.id]); + const left = byId.get(dep); + if (left) { + toRead.push(left); + } + } + } + if (missing.size === 0) { + return selected; + } + + const lines = [...missing].map(([id, requiredBy]) => + byId.has(id) + ? ` - ${id} (required by ${requiredBy.join(', ')})` + : ` - ${id} (required by ${requiredBy.join(', ')}) - not in the plugin list either`, + ); + const addable = [...missing.keys()].filter((id) => byId.has(id)); + throw new Error( + `SERVICE_MANIFEST=${manifest.join(',')} leaves out modules the selected ones depend on:\n` + + `${lines.join('\n')}\n` + + (addable.length > 0 + ? `Add them: SERVICE_MANIFEST=${[...manifest, ...addable].join(',')}\n` + : '') + + 'A process also needs the module that binds each port its modules require.', + ); +} diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index f602fe072..011a46a1c 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, afterEach } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { MESSAGE_BROKER, JOB_QUEUE, @@ -9,8 +10,9 @@ import { type McpKernel, } from '@openora/core/contracts'; import { redisUrlForWorker } from '@openora/core/testing'; -import { mock } from '../../../testing/mock.js'; +import { makeJobQueue, mock } from '../../../testing/mock.js'; import { AUTH_SESSION, type SessionResolver } from '../../auth/index.js'; +import type { PluginEntry } from '../../plugin-host/index.js'; import { createApp } from '../create-app.js'; import { getCurrentClientMeta } from '../../kernel/request-context.js'; @@ -220,6 +222,53 @@ describe('createApp - service name for the Redis Streams consumer group', () => }); }); +describe('createApp - SERVICE_MANIFEST on the plugin list it is given', () => { + const keys = ['REDIS_URL', 'SERVICE_MANIFEST', 'SERVICE_NAME'] as const; + const saved = keys.map((key) => [key, process.env[key]] as const); + + afterEach(() => { + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + const fixture = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}-plugin.ts`, import.meta.url)); + // A static list, as a consumer that never calls loadExtensions() passes it. + const plugins: PluginEntry[] = [ + { id: 'fixture-base', path: fixture('base') }, + { id: 'fixture-feature', path: fixture('feature') }, + { id: 'fixture-leaf', path: fixture('leaf') }, + ]; + + /** Boots the fixtures under a manifest and returns the queues whose workers were registered. */ + async function bootQueues(manifest: string) { + process.env['REDIS_URL'] = redisUrlForWorker(); + process.env['SERVICE_MANIFEST'] = manifest; + process.env['SERVICE_NAME'] = 'fixture'; + const jobs = makeJobQueue(); + const created = await createApp({ plugins, databaseUrl: DUMMY_DATABASE_URL }, (container) => { + container.register(JOB_QUEUE, () => jobs); + }); + await created.close(); + return vi.mocked(jobs.registerWorker).mock.calls.map(([registration]) => registration.queue); + } + + it('boots only the modules the manifest selects', async () => { + expect(await bootQueues('fixture-base')).toEqual(['fixture-base.daily', 'fixture-base.weekly']); + }); + + it('names every module the manifest leaves out in one boot error', async () => { + await expect(bootQueues('fixture-leaf')).rejects.toThrow( + /fixture-feature \(required by fixture-leaf\)[\s\S]*fixture-base \(required by fixture-feature\)[\s\S]*SERVICE_MANIFEST=fixture-leaf,fixture-feature,fixture-base/, + ); + }); +}); + describe('createApp - the player-activity stamp', () => { it('lands before the request that refreshed it returns', async () => { const saved = process.env['REDIS_URL']; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/base-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/base-plugin.ts new file mode 100644 index 000000000..feafa016c --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/base-plugin.ts @@ -0,0 +1,12 @@ +import * as z from 'zod'; +import { queue, type TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export default { + id: 'fixture-base', + register(ctx) { + for (const name of ['fixture-base.daily', 'fixture-base.weekly']) { + ctx.jobs.worker({ queue: queue(name), schema: z.object({}), handler: async () => {} }); + } + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/feature-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/feature-plugin.ts new file mode 100644 index 000000000..d8ccc2417 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/feature-plugin.ts @@ -0,0 +1,15 @@ +import * as z from 'zod'; +import { queue, type TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export default { + id: 'fixture-feature', + dependsOn: ['fixture-base'], + register(ctx) { + ctx.jobs.worker({ + queue: queue('fixture-feature.sync'), + schema: z.object({}), + handler: async () => {}, + }); + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/leaf-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/leaf-plugin.ts new file mode 100644 index 000000000..331c88581 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/leaf-plugin.ts @@ -0,0 +1,8 @@ +import type { TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export default { + id: 'fixture-leaf', + dependsOn: ['fixture-feature'], + register() {}, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 1b83040d9..e18be1c95 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -51,7 +51,9 @@ import { } from '@openora/core/contracts'; import { DrizzleService, DRIZZLE, DrizzleOutboxWriter, OutboxRelay } from '../db/index.js'; import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/index.js'; -import { loadPlugins, type PluginEntry } from '../plugin-host/index.js'; +import { loadPlugins, parseServiceManifest, type PluginEntry } from '../plugin-host/index.js'; +import { importPlugin } from '../plugin-host/load-plugins.js'; +import { selectServiceEntries } from '../plugin-host/service-manifest.js'; import { createMcpKernel } from '../mcp/index.js'; import { authorizeWithAdminGuard } from '../mcp/authorize.js'; import type { McpHttpTransport } from '../mcp/http-transport.js'; @@ -121,7 +123,7 @@ function resolveServiceName(): string { if (serviceName) { return serviceName; } - if (process.env['SERVICE_MANIFEST']) { + if (parseServiceManifest(process.env['SERVICE_MANIFEST']) !== null) { throw new Error( '[create-app] SERVICE_MANIFEST is set but SERVICE_NAME is not. A split service needs a stable ' + 'name of its own: it becomes the MESSAGE_BROKER consumer group, and every service sharing a ' + @@ -341,8 +343,9 @@ async function captureRawBody(req: Request): Promise { } /** - * The single composition root: wires the DI container, boots every plugin in - * `config.plugins`, and mounts the resulting oRPC router on a Hono app. + * The single composition root: wires the DI container, boots the plugins in + * `config.plugins` that `SERVICE_MANIFEST` selects (every one when it is unset), and + * mounts the resulting oRPC router on a Hono app. * Production is distributed-only (ADR-0030, superseding ADR-0010/0014/0016/0028): * `REDIS_URL` auto-binds `JOB_QUEUE`/`RATE_LIMITER`/`CACHE`/`MESSAGE_BROKER`/ * `REALTIME_TRANSPORT` to their Redis-backed drivers - the broker being the @@ -453,7 +456,18 @@ export async function createApp( // first running every plugin's side effects. const corsOrigins = resolveCorsOrigins(config.cors); - const registry = await loadPlugins(config.plugins, container); + const bootLogger = createLogger('create-app'); + const manifest = parseServiceManifest(process.env['SERVICE_MANIFEST']); + const plugins = await selectServiceEntries( + config.plugins, + manifest, + async (entry) => (await importPlugin(entry)).dependsOn ?? [], + ); + if (manifest !== null) { + bootLogger.info({ modules: plugins.map((e) => e.id) }, 'SERVICE_MANIFEST active'); + } + + const registry = await loadPlugins(plugins, container); await configure?.(container); assertDurableSeamsBound(container); From 5ef3b5b2c4e0ed02e363afba40e2ab1d4e47497b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Tue, 13 Oct 2026 11:32:10 +0200 Subject: [PATCH 03/16] feat(core): choose which job queues a process consumes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every process consumed every queue, so a job that runs for minutes and mostly waits on an outside service took a share of every API replica. A manifest service cannot take it over: its own SERVICE_NAME gives it its own copy of every event, and a handler that needs the in-process MCP kernel needs every module that contributes tools. JOB_WORKERS picks the queues a process consumes: names, prefix* patterns and !exclusions; unset is every queue, as before. createApp applies it when it registers workers, so it holds for any queue driver, and logs the queues it consumes and skips. A malformed value, or an inclusion that matches no registered queue, fails the boot; an exclusion that matches nothing is allowed, since the module behind it may be absent. To keep a queue off the API replicas, run one more full instance with the same SERVICE_NAME and JOB_WORKERS=, and give the replicas JOB_WORKERS=!. ADR-0044 (Proposed) records the decision. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../rules/messaging-and-microservices.md | 1 + AGENTS.md | 1 + ...-job-queue-and-realtime-transport-seams.md | 2 + ...readiness-manifest-outbox-command-ports.md | 2 +- .../0044-per-process-job-worker-selection.md | 87 +++++++++++++++ docs/guides/downstream-consumer.md | 5 + docs/platform/architecture.md | 5 +- docs/platform/system-design.md | 19 ++-- .../core/src/contracts/adapters/job-queue.ts | 9 +- .../src/server/plugin-host/define-plugin.ts | 2 +- .../create-app-job-workers.int.test.ts | 75 +++++++++++++ .../runtime/__tests__/create-app.test.ts | 50 +++++++-- .../runtime/__tests__/job-workers.test.ts | 98 +++++++++++++++++ .../core/src/server/runtime/create-app.ts | 20 +++- .../core/src/server/runtime/job-workers.ts | 104 ++++++++++++++++++ 15 files changed, 454 insertions(+), 26 deletions(-) create mode 100644 docs/adr/0044-per-process-job-worker-selection.md create mode 100644 packages/core/src/server/runtime/__tests__/create-app-job-workers.int.test.ts create mode 100644 packages/core/src/server/runtime/__tests__/job-workers.test.ts create mode 100644 packages/core/src/server/runtime/job-workers.ts diff --git a/.rulesync/rules/messaging-and-microservices.md b/.rulesync/rules/messaging-and-microservices.md index dd60cfbed..fa5ccb247 100644 --- a/.rulesync/rules/messaging-and-microservices.md +++ b/.rulesync/rules/messaging-and-microservices.md @@ -37,5 +37,6 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. - `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing. +- `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/AGENTS.md b/AGENTS.md index 4223eb472..d9a3e7836 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. - `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing. +- `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/docs/adr/0014-job-queue-and-realtime-transport-seams.md b/docs/adr/0014-job-queue-and-realtime-transport-seams.md index d710e8f7b..e5082faf0 100644 --- a/docs/adr/0014-job-queue-and-realtime-transport-seams.md +++ b/docs/adr/0014-job-queue-and-realtime-transport-seams.md @@ -7,6 +7,8 @@ > **Update (2026-07-25)**: [ADR-0039](./0039-tests-run-the-production-seams.md) deleted `InProcessJobQueue` - the test suite binds `BullMqJobQueue` too. `InProcessRealtimeTransport` stays, but as `createApp`'s production default rather than a test double, since core ships no realtime driver. The seam shapes are unchanged. > > **Update (2026-08-31)**: [ADR-0031](./0031-realtime-transport-sse-and-http-over-web-sockets.md) shipped `RedisPubSubRealtimeTransport` and deleted `InProcessRealtimeTransport` too - `REALTIME_TRANSPORT` now auto-binds on `REDIS_URL` exactly like `JOB_QUEUE`, with no in-process fallback either. Both seams from this ADR are now fully superseded on the production-default question; the seam shapes and the client-push-is-not-inter-module decision are still unchanged. +> +> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0044](./0044-per-process-job-worker-selection.md)). ## Context diff --git a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md index 6d5e1ad7b..d8c5be8a1 100644 --- a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md +++ b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md @@ -4,7 +4,7 @@ **Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default). **Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope). -> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included - and the boot names all that is missing. `pnpm create:service` now sets `SERVICE_NAME` too. +> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included - and the boot names all that is missing. `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0044](./0044-per-process-job-worker-selection.md). ## Context diff --git a/docs/adr/0044-per-process-job-worker-selection.md b/docs/adr/0044-per-process-job-worker-selection.md new file mode 100644 index 000000000..eefecaa14 --- /dev/null +++ b/docs/adr/0044-per-process-job-worker-selection.md @@ -0,0 +1,87 @@ +# ADR-0044: Per-process job worker selection + +**Date**: 2026-10-13 +**Status**: Proposed +**Relates to**: ADR-0014 (job-queue seam), ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0038 (routers before workers), ADR-0042 (MCP kernel). + +## Context + +`createApp` registers a worker for every queue a loaded module declares, so every process +consumes every queue. That is right for a homogeneous fleet of replicas, but not for a job that +runs for minutes and mostly waits on an outside service - an agent run waiting on a model, a +large export. Such jobs take a share of every API replica's capacity and stretch its deploys, +since a replica drains its workers before it stops. + +The service manifest cannot move them. A manifest service has its own `SERVICE_NAME`, so it +receives its own copy of every event, and a module pulled into it for its dependencies runs its +handlers a second time across the cluster. A job whose handler needs the full container cannot +live in a narrow service anyway: the MCP kernel (ADR-0042) is built in process from the tools and +action types of the modules that process loaded, so an agent run needs every module that +contributes them. + +## Decision + +**`JOB_WORKERS` picks the queues a process consumes.** `createApp` reads it at boot and +registers a worker only for the queues it selects; the filter sits in `createApp`, so it holds +for any queue driver. Entries are separated by commas or spaces: + +| Value | Consumes | +| ------------------------- | ---------------------------------------- | +| unset, blank, `*`, `all` | every queue (the default, as before) | +| `none` | no queue | +| `reports.daily` | that queue | +| `reports.*` | every queue whose name starts `reports.` | +| `!pattern` | not the queues the pattern matches | +| only exclusions | every queue but the excluded ones | +| inclusions and exclusions | the included queues minus the excluded | + +A `*` anywhere but the end of a pattern, a bare `!`, `none` beside another entry, or `*` beside +a named queue fails the boot. So does an inclusion that matches no registered queue: a typo +would otherwise leave a worker process idle while its queue backs up. An exclusion that matches +nothing is allowed, because the module that registers the queue may not be installed. The boot +log lists the queues consumed and skipped. + +It is environment-only, like `SERVICE_MANIFEST` and `SERVICE_NAME`: which queues a process takes +is deployment topology, and one image runs as either kind of process. + +**Schedules are unchanged.** Every process still upserts its modules' schedules while building +routers (ADR-0038); the scheduler id dedupes them, and the selection only decides who consumes +the jobs they produce. + +**The deployment pattern is an extra full instance.** To keep `reports.*` off the API replicas, +run one more instance of the same image, with the same plugin list and the same `SERVICE_NAME`, +and `JOB_WORKERS=reports.*`; give the replicas `JOB_WORKERS=!reports.*`. Sharing the +`SERVICE_NAME` makes the worker compete with the replicas for events instead of receiving a copy, +and keeps it in the same realtime and presence namespace. It serves HTTP like any instance but +needs no ingress. + +### Alternatives considered + +- **A process role (`SERVICE_ROLE=api|worker`).** Fixes one topology and cannot say which + queues. +- **Role metadata on each worker registration.** A module cannot know how it is deployed. +- **A worker-only process without routers.** Router factories are boot hooks that workers rely + on (ADR-0038), and the kernel needs every module loaded. +- **A manifest service for the queue.** Its own `SERVICE_NAME` means its own copy of every event. +- **Pausing workers in the driver.** Driver-specific, and the decision belongs to the host. + +## Consequences + +**Positive:** + +- An operator can keep long-running jobs off the API replicas without code changes. +- Unset behaves exactly as before. + +**Negative / trade-offs:** + +- Every queue needs a consumer somewhere in the cluster; core cannot check that across + processes, so a queue excluded everywhere backs up silently. Queue-depth monitoring catches it. +- Processes that share a `SERVICE_NAME` must load the same modules. The streams driver acks an + event once per group, so a process without a handler for it loses the event for that group. + +**Neutral:** + +- Roll out the worker instance first, then the replicas with the exclusion; jobs enqueued in + between wait in Redis. +- `/health` does not report the selection: it is a public contract, and the boot log is the + record. diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index 9b28c7086..ec5c8396a 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -82,6 +82,11 @@ and each job reaches one of them. `createApp` applies it to the `plugins` you pass. List every module your modules depend on and every module that binds a port they require; the boot names whatever is missing. A process with a manifest needs a `SERVICE_NAME` of its own. +- **Fewer queues in a process.** `JOB_WORKERS` picks the job queues a process consumes: names, + `prefix*` patterns, `!` to exclude. To keep a long-running queue off the API replicas, run one + more instance with the same plugins and `SERVICE_NAME` and `JOB_WORKERS=`, and give the + replicas `JOB_WORKERS=!`. Every queue needs a consumer somewhere. See + [ADR-0044](../adr/0044-per-process-job-worker-selection.md). ## Seeding reference data (production) diff --git a/docs/platform/architecture.md b/docs/platform/architecture.md index ac059e1f0..a15e1804a 100644 --- a/docs/platform/architecture.md +++ b/docs/platform/architecture.md @@ -102,7 +102,7 @@ Solid arrows are runtime/build dependencies; dashed arrows are **adapter seams** **Vendor adapters** - concrete implementations of a module's adapter interfaces (PSP, KYC vendor, igaming aggregator, chat), shipped as separate packages. The interface is the seam; the implementation is swappable. For the pooling/sweeping/reconciliation shape a custody-style payment vendor adds, see [docs/adapters/custody.md](../adapters/custody.md#custody-topology). -**Background jobs** - long-running work runs off the request path in a BullMQ worker shipped as an overlay plugin (`/scaffold-plugin -worker`): a module emits an event via the typed `EventBus`, the worker plugin subscribes in `register(ctx)` and processes the job. There is no standalone worker app. +**Background jobs** - long-running work runs off the request path on `JOB_QUEUE`: a module registers a worker with `ctx.jobs.worker` and enqueues to it. Every process consumes every queue by default; `JOB_WORKERS` narrows that, so a long-running queue can live on an extra instance that boots the same plugins while the API replicas skip it. See [ADR-0044](../adr/0044-per-process-job-worker-selection.md). **SDK & consumption surface** @@ -185,5 +185,6 @@ for the full direction; the shape: scaffolds a thin host); point its broker at the shared stream and its events keep flowing. The `no-cross-module-schema-read` lint warning flags the remaining shared-table couplings to retire first. Scale the stateless Hono API horizontally; - scale async work by moving consumers to their own services. See + scale async work by moving consumers to their own services, or narrow the queues a + process consumes with `JOB_WORKERS`. See [ADR-0017](../adr/0017-extraction-readiness-manifest-outbox-command-ports.md). diff --git a/docs/platform/system-design.md b/docs/platform/system-design.md index 250636a3d..92b0de554 100644 --- a/docs/platform/system-design.md +++ b/docs/platform/system-design.md @@ -276,15 +276,20 @@ sequenceDiagram ```mermaid flowchart TB CODE["Single codebase · extensions.config.ts"] - CODE --> MONO["SERVICE_MANIFEST unset to MONOLITH
all 15 modules, in-process seams"] - CODE --> SPLIT["SERVICE_MANIFEST=identity,wallet,...
per-module thin host (pnpm create:service)"] - SPLIT --> S1["identity svc"] - SPLIT --> S2["wallet svc"] - SPLIT --> S3["casino svc"] - S1 & S2 & S3 ---|"durable broker (AMQP_URL)
events cross process · outbox at-least-once"| RMQ[("RabbitMQ/Kafka")] - S1 & S2 & S3 --- PG[("PostgreSQL")] + CODE --> MONO["SERVICE_MANIFEST unset: every module
API replicas: JOB_WORKERS=!reports.*"] + CODE --> WORKER["same plugins and SERVICE_NAME, no ingress
JOB_WORKERS=reports.*"] + CODE --> SPLIT["SERVICE_MANIFEST=wallet,identity,audit,...
own SERVICE_NAME (pnpm create:service)"] + MONO & WORKER & SPLIT ---|"Redis Streams events · BullMQ jobs (REDIS_URL)"| REDIS[("Redis")] + MONO & WORKER & SPLIT --- PG[("PostgreSQL")] ``` +A manifest lists every module its modules depend on and every module that binds a port they +require; `createApp` applies it to any plugin list and names what is missing. A split service +needs a `SERVICE_NAME` of its own - its consumer group - while an instance that only takes a +share of the queues keeps the replicas' `SERVICE_NAME`, so it competes for events rather than +receiving a copy. `JOB_WORKERS` picks the queues a process consumes +([ADR-0044](../adr/0044-per-process-job-worker-selection.md)). + ## Reference - domain -> modules -> tables -> routes `docs/catalog.json` is the generated surface: every module, table, route, adapter port and diff --git a/packages/core/src/contracts/adapters/job-queue.ts b/packages/core/src/contracts/adapters/job-queue.ts index b2f7ca3ff..d6e979abe 100644 --- a/packages/core/src/contracts/adapters/job-queue.ts +++ b/packages/core/src/contracts/adapters/job-queue.ts @@ -1,9 +1,8 @@ /** * Background-job seam. Modules enqueue durable, retryable, schedulable work - * through this adapter, so the queue driver is swappable: the default binding is - * an in-process queue (zero dependencies - good for `pnpm dev`, seed and tests); - * a downstream operator binds a durable driver (BullMQ/Redis is the reference - * overlay) to JOB_QUEUE without touching modules. Delivery is at-least-once, so + * through this adapter, so the queue driver is swappable: `createApp` binds the + * BullMQ driver on `REDIS_URL`, and an operator may bind another durable driver to + * JOB_QUEUE without touching modules. Delivery is at-least-once, so * handlers MUST be idempotent (use `idempotencyKey` + a DB guard for money jobs). * Distinct from MESSAGE_BROKER: the broker is "something happened" fan-out (the * EventBus facade); JOB_QUEUE is "execute this unit of work later, with delivery @@ -112,7 +111,7 @@ export type JobQueueAdapter = { repeat: RepeatOptions, ): Promise; unschedule(queue: QueueName, scheduleId: string): Promise; - /** Start consuming a queue. Called once per worker overlay during boot. */ + /** Start consuming a queue. `createApp` calls it at boot for each queue this process consumes (`JOB_WORKERS`). */ registerWorker(registration: WorkerRegistration): void; /** Graceful drain: stop accepting, finish in-flight, close connections. */ close(): Promise; diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index ca056f826..5c9036f08 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -84,7 +84,7 @@ export type ModuleRegistry = { on(event: string, handler: EventHandler): void; getAll(): Map; }; - // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect). See ADR-0014. + // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect), unless JOB_WORKERS leaves the queue to another process. See ADR-0014, ADR-0044. jobs: { worker(registration: WorkerRegistration): void; getAll(): WorkerRegistration[]; diff --git a/packages/core/src/server/runtime/__tests__/create-app-job-workers.int.test.ts b/packages/core/src/server/runtime/__tests__/create-app-job-workers.int.test.ts new file mode 100644 index 000000000..c7820e86b --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/create-app-job-workers.int.test.ts @@ -0,0 +1,75 @@ +import { fileURLToPath } from 'node:url'; +import { describe, it, expect, vi, beforeAll, afterEach, afterAll } from 'vitest'; +import { Queue } from 'bullmq'; +import { JOB_QUEUE, queue } from '@openora/core/contracts'; +import { createTestRedis, redisUrlForWorker, type TestRedis } from '@openora/core/testing'; +import { createApp, type CreatedApp } from '../create-app.js'; + +// Nothing here runs a query; the pool connects lazily. +const DUMMY_DATABASE_URL = 'postgres://test:test@127.0.0.1:1/create_app_test'; +const POLL = { timeout: 5000, interval: 20 }; + +let redis: TestRedis; +let app: CreatedApp | null = null; +const rawQueues: Queue[] = []; +const saved = { redis: process.env['REDIS_URL'], workers: process.env['JOB_WORKERS'] }; + +function rawQueue(name: string): Queue { + const q = new Queue(name, { + connection: { url: redisUrlForWorker(), maxRetriesPerRequest: null }, + }); + rawQueues.push(q); + return q; +} + +beforeAll(async () => { + redis = await createTestRedis(); +}); + +afterEach(async () => { + await app?.close(); + app = null; + await Promise.allSettled(rawQueues.map((q) => q.close())); + rawQueues.length = 0; + await redis.flush(); + for (const [key, value] of [ + ['REDIS_URL', saved.redis], + ['JOB_WORKERS', saved.workers], + ] as const) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +afterAll(async () => { + await redis.quit(); +}); + +describe('createApp - JOB_WORKERS on the real queue driver', () => { + it('consumes the selected queue and leaves the other one waiting for another process', async () => { + process.env['REDIS_URL'] = redisUrlForWorker(); + process.env['JOB_WORKERS'] = 'fixture-base.daily'; + const path = fileURLToPath(new URL('./fixtures/base-plugin.ts', import.meta.url)); + app = await createApp({ + plugins: [{ id: 'fixture-base', path }], + databaseUrl: DUMMY_DATABASE_URL, + }); + const jobs = app.container.get(JOB_QUEUE); + + await jobs.enqueue(queue('fixture-base.weekly'), {}); + await jobs.enqueue(queue('fixture-base.daily'), {}); + + const daily = rawQueue('fixture-base.daily'); + const weekly = rawQueue('fixture-base.weekly'); + await vi.waitFor( + async () => expect(await daily.getJobCounts()).toMatchObject({ completed: 1 }), + POLL, + ); + // The daily job ran, so this process's workers are live; give the weekly queue the same chance. + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(await weekly.getJobCounts()).toMatchObject({ waiting: 1, completed: 0, active: 0 }); + }); +}); diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index 011a46a1c..6fadd9bfb 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -222,8 +222,8 @@ describe('createApp - service name for the Redis Streams consumer group', () => }); }); -describe('createApp - SERVICE_MANIFEST on the plugin list it is given', () => { - const keys = ['REDIS_URL', 'SERVICE_MANIFEST', 'SERVICE_NAME'] as const; +describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is given', () => { + const keys = ['REDIS_URL', 'SERVICE_MANIFEST', 'SERVICE_NAME', 'JOB_WORKERS'] as const; const saved = keys.map((key) => [key, process.env[key]] as const); afterEach(() => { @@ -245,11 +245,19 @@ describe('createApp - SERVICE_MANIFEST on the plugin list it is given', () => { { id: 'fixture-leaf', path: fixture('leaf') }, ]; - /** Boots the fixtures under a manifest and returns the queues whose workers were registered. */ - async function bootQueues(manifest: string) { + /** Boots the fixtures under `env` and returns the queues whose workers were registered. */ + async function bootQueues(env: { SERVICE_MANIFEST?: string; JOB_WORKERS?: string }) { process.env['REDIS_URL'] = redisUrlForWorker(); - process.env['SERVICE_MANIFEST'] = manifest; - process.env['SERVICE_NAME'] = 'fixture'; + for (const key of ['SERVICE_MANIFEST', 'SERVICE_NAME', 'JOB_WORKERS'] as const) { + delete process.env[key]; + } + if (env.SERVICE_MANIFEST) { + process.env['SERVICE_MANIFEST'] = env.SERVICE_MANIFEST; + process.env['SERVICE_NAME'] = 'fixture'; + } + if (env.JOB_WORKERS) { + process.env['JOB_WORKERS'] = env.JOB_WORKERS; + } const jobs = makeJobQueue(); const created = await createApp({ plugins, databaseUrl: DUMMY_DATABASE_URL }, (container) => { container.register(JOB_QUEUE, () => jobs); @@ -259,14 +267,40 @@ describe('createApp - SERVICE_MANIFEST on the plugin list it is given', () => { } it('boots only the modules the manifest selects', async () => { - expect(await bootQueues('fixture-base')).toEqual(['fixture-base.daily', 'fixture-base.weekly']); + expect(await bootQueues({ SERVICE_MANIFEST: 'fixture-base' })).toEqual([ + 'fixture-base.daily', + 'fixture-base.weekly', + ]); }); it('names every module the manifest leaves out in one boot error', async () => { - await expect(bootQueues('fixture-leaf')).rejects.toThrow( + await expect(bootQueues({ SERVICE_MANIFEST: 'fixture-leaf' })).rejects.toThrow( /fixture-feature \(required by fixture-leaf\)[\s\S]*fixture-base \(required by fixture-feature\)[\s\S]*SERVICE_MANIFEST=fixture-leaf,fixture-feature,fixture-base/, ); }); + + it('registers only the workers JOB_WORKERS selects', async () => { + expect(await bootQueues({ JOB_WORKERS: 'fixture-base.*,!fixture-base.weekly' })).toEqual([ + 'fixture-base.daily', + ]); + expect(await bootQueues({ JOB_WORKERS: '!fixture-base.*' })).toEqual(['fixture-feature.sync']); + }); + + it('boots with an exclusion that matches no queue', async () => { + expect(await bootQueues({ JOB_WORKERS: '!absent.*' })).toHaveLength(3); + }); + + it('fails the boot on an inclusion that matches no queue', async () => { + await expect(bootQueues({ JOB_WORKERS: 'fixture-bsae.*' })).rejects.toThrow( + /does not register: fixture-bsae\.\*/, + ); + }); + + it('fails the boot on a malformed JOB_WORKERS', async () => { + await expect(bootQueues({ JOB_WORKERS: 'none,fixture-base.daily' })).rejects.toThrow( + /"none" stands alone/, + ); + }); }); describe('createApp - the player-activity stamp', () => { diff --git a/packages/core/src/server/runtime/__tests__/job-workers.test.ts b/packages/core/src/server/runtime/__tests__/job-workers.test.ts new file mode 100644 index 000000000..2dfa5e1ef --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/job-workers.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { parseJobWorkers, selectJobWorkers } from '../job-workers.js'; + +const registered = [ + { queue: 'reports.daily' }, + { queue: 'reports.weekly' }, + { queue: 'reportsx' }, + { queue: 'promo-sweep' }, + { queue: 'mail' }, +]; + +const consumedBy = (raw: string) => + selectJobWorkers(registered, parseJobWorkers(raw)).consumed.map((r) => r.queue); + +describe('parseJobWorkers', () => { + it('reads unset or blank as every queue, with no selection at all', () => { + expect(parseJobWorkers(undefined)).toBeNull(); + expect(parseJobWorkers('')).toBeNull(); + expect(parseJobWorkers(' ')).toBeNull(); + }); + + it('reads the keywords and patterns, split on commas or spaces', () => { + expect(parseJobWorkers('*')).toEqual({ include: null, exclude: [] }); + expect(parseJobWorkers('all')).toEqual({ include: null, exclude: [] }); + expect(parseJobWorkers('none')).toEqual({ include: [], exclude: [] }); + expect(parseJobWorkers('mail reports.*,,promo-*')).toEqual({ + include: ['mail', 'reports.*', 'promo-*'], + exclude: [], + }); + expect(parseJobWorkers('!reports.*')).toEqual({ include: null, exclude: ['reports.*'] }); + expect(parseJobWorkers('*,!mail')).toEqual({ include: null, exclude: ['mail'] }); + expect(parseJobWorkers('reports.*,!reports.weekly')).toEqual({ + include: ['reports.*'], + exclude: ['reports.weekly'], + }); + }); + + it.each([ + ['none,mail', /"none" stands alone/], + ['*,mail', /exclude with "!"/], + ['re*ports', /only at its end/], + ['*mail', /only at its end/], + ['!', /names no queue/], + ])('refuses %s', (raw, error) => { + expect(() => parseJobWorkers(raw)).toThrow(error); + }); +}); + +describe('selectJobWorkers', () => { + it('consumes every queue without a selection', () => { + expect(selectJobWorkers(registered, null)).toEqual({ + consumed: registered, + skipped: [], + idleExclusions: [], + }); + }); + + it('matches a name exactly and a prefix pattern by its prefix', () => { + expect(consumedBy('mail')).toEqual(['mail']); + expect(consumedBy('reports.*')).toEqual(['reports.daily', 'reports.weekly']); + expect(consumedBy('promo-*')).toEqual(['promo-sweep']); + }); + + it('consumes every queue but the excluded ones when only exclusions are given', () => { + expect(consumedBy('!reports.*')).toEqual(['reportsx', 'promo-sweep', 'mail']); + expect(consumedBy('!*')).toEqual([]); + }); + + it('takes the included queues minus the excluded ones', () => { + expect(consumedBy('reports.*,mail,!reports.weekly')).toEqual(['reports.daily', 'mail']); + }); + + it('consumes nothing with none, and reports what it skips', () => { + const { consumed, skipped } = selectJobWorkers(registered, parseJobWorkers('none')); + + expect(consumed).toEqual([]); + expect(skipped).toEqual(registered); + }); + + it('fails on an inclusion no registered queue matches, naming it and the queues', () => { + expect(() => selectJobWorkers(registered, parseJobWorkers('reprots.*,mail'))).toThrow( + /does not register: reprots\.\*\. Registered queues: reports\.daily, .*mail/, + ); + }); + + it('allows an exclusion nothing matches and reports it as idle', () => { + const selected = selectJobWorkers(registered, parseJobWorkers('!absent.*,!mail')); + + expect(selected.idleExclusions).toEqual(['absent.*']); + expect(selected.consumed.map((r) => r.queue)).not.toContain('mail'); + }); + + it('keeps two workers on one queue together', () => { + const twice = [{ queue: 'mail' }, { queue: 'mail' }, { queue: 'reportsx' }]; + + expect(selectJobWorkers(twice, parseJobWorkers('mail')).consumed).toHaveLength(2); + }); +}); diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index e18be1c95..058e00c47 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -66,6 +66,7 @@ import { } from './client-address.js'; import { loadPlatformConfig, resolvePlatformConfigPath } from '../kernel/platform-config-loader.js'; import type { CoreTokenCatalog } from './core-token-catalog.js'; +import { parseJobWorkers, selectJobWorkers } from './job-workers.js'; // Path prefixes safe to cache at the HTTP layer: public, non-personalized reads // only (public CMS content and the game catalogue). NOTHING @@ -345,7 +346,8 @@ async function captureRawBody(req: Request): Promise { /** * The single composition root: wires the DI container, boots the plugins in * `config.plugins` that `SERVICE_MANIFEST` selects (every one when it is unset), and - * mounts the resulting oRPC router on a Hono app. + * mounts the resulting oRPC router on a Hono app. `JOB_WORKERS` picks the job queues + * this process consumes (every one when it is unset). * Production is distributed-only (ADR-0030, superseding ADR-0010/0014/0016/0028): * `REDIS_URL` auto-binds `JOB_QUEUE`/`RATE_LIMITER`/`CACHE`/`MESSAGE_BROKER`/ * `REALTIME_TRANSPORT` to their Redis-backed drivers - the broker being the @@ -370,6 +372,7 @@ export async function createApp( config.trustedProxies, process.env['TRUSTED_PROXIES'], ); + const jobWorkerSelection = parseJobWorkers(process.env['JOB_WORKERS']); if (config.databaseUrl) { process.env['DATABASE_URL'] = config.databaseUrl; @@ -468,6 +471,19 @@ export async function createApp( } const registry = await loadPlugins(plugins, container); + // Before any router factory runs, so a mistyped queue fails the boot before a schedule is set. + const workers = selectJobWorkers(registry.jobs.getAll(), jobWorkerSelection); + if (jobWorkerSelection !== null) { + const queuesOf = (list: typeof workers.consumed) => [...new Set(list.map((w) => w.queue))]; + bootLogger.info( + { + consumed: queuesOf(workers.consumed), + skipped: queuesOf(workers.skipped), + idleExclusions: workers.idleExclusions, + }, + 'JOB_WORKERS active', + ); + } await configure?.(container); assertDurableSeamsBound(container); @@ -513,7 +529,7 @@ export async function createApp( // Built now so a broken tool or action-type factory fails boot, not the first call. const mcpTransport = await loadMcpTransport(container, container.get(MCP_KERNEL)); - for (const registration of registry.jobs.getAll()) { + for (const registration of workers.consumed) { jobQueue.registerWorker(registration); } diff --git a/packages/core/src/server/runtime/job-workers.ts b/packages/core/src/server/runtime/job-workers.ts new file mode 100644 index 000000000..c6417bb95 --- /dev/null +++ b/packages/core/src/server/runtime/job-workers.ts @@ -0,0 +1,104 @@ +/** + * Which job queues this process consumes, from `JOB_WORKERS`. `include: null` is every queue, + * `[]` none; a pattern is a queue name or a prefix ending in `*`. + */ +export type JobWorkerSelection = { + include: readonly string[] | null; + exclude: readonly string[]; +}; + +export type SelectedJobWorkers = { + consumed: T[]; + skipped: T[]; + /** Exclusions no registered queue matched - allowed, an optional module may be absent. */ + idleExclusions: string[]; +}; + +function invalid(entry: string, rule: string): Error { + return new Error(`[create-app] JOB_WORKERS: "${entry}" ${rule}.`); +} + +function assertPattern(pattern: string, entry: string): void { + if (pattern.length === 0) { + throw invalid(entry, 'names no queue'); + } + if (pattern.slice(0, -1).includes('*')) { + throw invalid(entry, 'may use "*" only at its end, as a prefix'); + } +} + +/** + * Parses `JOB_WORKERS`: comma- or space-separated queue names and `prefix*` patterns, `!` before + * one to exclude it, `*`/`all` for every queue and `none` for none. Unset or blank is null - + * every queue, as before the variable existed. Only exclusions mean every queue but those. + */ +export function parseJobWorkers(raw: string | undefined): JobWorkerSelection | null { + const entries = (raw ?? '').split(/[,\s]+/).filter(Boolean); + if (entries.length === 0) { + return null; + } + + const include: string[] = []; + const exclude: string[] = []; + let all = false; + let none = false; + for (const entry of entries) { + if (entry.startsWith('!')) { + const pattern = entry.slice(1); + assertPattern(pattern, entry); + exclude.push(pattern); + } else if (entry === '*' || entry === 'all') { + all = true; + } else if (entry === 'none') { + none = true; + } else { + assertPattern(entry, entry); + include.push(entry); + } + } + if (none && entries.length > 1) { + throw invalid(entries.join(','), 'combines "none" with other entries; "none" stands alone'); + } + if (all && include.length > 0) { + throw invalid(entries.join(','), 'combines every queue with named ones; exclude with "!"'); + } + + return { include: none ? [] : include.length > 0 ? include : null, exclude }; +} + +function matches(pattern: string, queue: string): boolean { + return pattern.endsWith('*') ? queue.startsWith(pattern.slice(0, -1)) : queue === pattern; +} + +/** + * Splits the registered workers into the ones this process consumes and the ones it leaves to + * another. A queue named for inclusion that nothing registers fails the boot: a typo would + * otherwise leave a worker process idle while its queue backs up. + */ +export function selectJobWorkers( + registrations: readonly T[], + selection: JobWorkerSelection | null, +): SelectedJobWorkers { + if (selection === null) { + return { consumed: [...registrations], skipped: [], idleExclusions: [] }; + } + + const queues = [...new Set(registrations.map((r) => r.queue))]; + const unmatched = (selection.include ?? []).filter((p) => !queues.some((q) => matches(p, q))); + if (unmatched.length > 0) { + throw new Error( + `[create-app] JOB_WORKERS names queues this process does not register: ` + + `${unmatched.join(', ')}. Registered queues: ${queues.join(', ') || '(none)'}. ` + + 'Fix the pattern, or load the module that registers the queue (check SERVICE_MANIFEST).', + ); + } + + const consumes = (queue: string): boolean => + (selection.include === null || selection.include.some((p) => matches(p, queue))) && + !selection.exclude.some((p) => matches(p, queue)); + return { + consumed: registrations.filter((r) => consumes(r.queue)), + skipped: registrations.filter((r) => !consumes(r.queue)), + idleExclusions: selection.exclude.filter((p) => !queues.some((q) => matches(p, q))), + }; +} From 33cab66d1cd19ac0de854f3ea77e453e0e7ed3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Tue, 13 Oct 2026 11:32:17 +0200 Subject: [PATCH 04/16] docs: propose that dependency-only modules provide but do not consume MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A split service loads the modules its own depend on, and those still wire their event handlers, workers, schedules and routers - under the service's own consumer group, so audit's handlers, for one, would run once per service. ADR-0045 (Proposed) records the direction - only the modules a manifest selects consume - for a follow-up. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- ...only-modules-provide-but-do-not-consume.md | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 docs/adr/0045-dependency-only-modules-provide-but-do-not-consume.md diff --git a/docs/adr/0045-dependency-only-modules-provide-but-do-not-consume.md b/docs/adr/0045-dependency-only-modules-provide-but-do-not-consume.md new file mode 100644 index 000000000..06b4a294d --- /dev/null +++ b/docs/adr/0045-dependency-only-modules-provide-but-do-not-consume.md @@ -0,0 +1,58 @@ +# ADR-0045: Dependency-only modules provide but do not consume + +**Date**: 2026-10-13 +**Status**: Proposed +**Relates to**: ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0044 (job worker selection). + +## Context + +A service manifest (ADR-0017) must list every module its modules depend on, and every module +that binds a port they require. Almost every module depends on `audit` or `identity`, so most +split services load them. A loaded module registers everything: its providers, but also its +event handlers, job workers, schedules, routers and MCP tools. Each split service has its own +`SERVICE_NAME`, so it receives its own copy of every event (ADR-0030). A module that two +services load only as a dependency therefore handles each event twice across the cluster - audit's +handlers, for one, would write each audit row once per service. Dedupe keyed on the consumer +does not help: the two copies run under different groups. + +Nothing ships split today, so nothing duplicates yet; extracting the first module would. + +## Decision + +Proposed, not implemented. + +**Separate the modules a process selects from the ones it only requires.** The selected modules +are the manifest's and the infra overlays; the required ones are the `dependsOn` closure and the +port providers. Every module still runs `register()`, so its providers bind and its services +work for the selected ones. `createApp` wires consumption only for registrations owned by a +selected module: event handlers, job workers, schedules and mounted routers. + +**The registry records the owner of each registration.** `ModuleRegistryImpl` already records it +for MCP tools and action types (`setOwner`); `events.on`, `jobs.worker` and `routers.add` would +carry it too. Router factories stay boot hooks that every module runs (ADR-0038); only the +mounting is filtered. Schedules move from router factories to a declarative, owned +`ctx.jobs.schedule`, so only a selected owner upserts them. + +**Required modules could then be pulled in automatically**, instead of failing the boot, since +loading one no longer changes what the service consumes. + +### Alternatives considered + +- **A consumer group per owning module**, so a module's handlers run once per cluster wherever it + is loaded. Infra overlays deliberately run per service, and it renames every durable group. + +## Consequences + +**Positive:** + +- No duplicate side effects across split services, and shorter manifests. + +**Negative / trade-offs:** + +- A breaking change for any deployment that relies on a dependency's handlers running in a + service that does not select it. +- Moving schedules out of router factories touches every module that schedules. + +**Neutral:** + +- A monolith is unaffected: every module is selected. From cdb9c1710c65b3777035f09053391331193ed891 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 14 Oct 2026 10:14:13 +0200 Subject: [PATCH 05/16] fix(core): start a worker that a router factory registers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JOB_WORKERS selection copied the registered workers before the router factories ran, so a worker registered from a factory never started, even with JOB_WORKERS unset. The selection now runs at the registration loop, after the factories; an inclusion that matches no queue still fails the boot. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../runtime/__tests__/create-app.test.ts | 18 ++++++++++--- .../__tests__/fixtures/late-worker-plugin.ts | 17 ++++++++++++ .../core/src/server/runtime/create-app.ts | 26 +++++++++---------- 3 files changed, 45 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/late-worker-plugin.ts diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index 6fadd9bfb..b9d9ac4ed 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -246,7 +246,10 @@ describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is ]; /** Boots the fixtures under `env` and returns the queues whose workers were registered. */ - async function bootQueues(env: { SERVICE_MANIFEST?: string; JOB_WORKERS?: string }) { + async function bootQueues( + env: { SERVICE_MANIFEST?: string; JOB_WORKERS?: string }, + list: PluginEntry[] = plugins, + ) { process.env['REDIS_URL'] = redisUrlForWorker(); for (const key of ['SERVICE_MANIFEST', 'SERVICE_NAME', 'JOB_WORKERS'] as const) { delete process.env[key]; @@ -259,8 +262,8 @@ describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is process.env['JOB_WORKERS'] = env.JOB_WORKERS; } const jobs = makeJobQueue(); - const created = await createApp({ plugins, databaseUrl: DUMMY_DATABASE_URL }, (container) => { - container.register(JOB_QUEUE, () => jobs); + const created = await createApp({ plugins: list, databaseUrl: DUMMY_DATABASE_URL }, (c) => { + c.register(JOB_QUEUE, () => jobs); }); await created.close(); return vi.mocked(jobs.registerWorker).mock.calls.map(([registration]) => registration.queue); @@ -301,6 +304,15 @@ describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is /"none" stands alone/, ); }); + + it('starts a worker a router factory registers', async () => { + const late = [{ id: 'fixture-late-worker', path: fixture('late-worker') }]; + + expect(await bootQueues({}, late)).toEqual(['fixture-late-worker.run']); + expect(await bootQueues({ JOB_WORKERS: 'fixture-late-worker.*' }, late)).toEqual([ + 'fixture-late-worker.run', + ]); + }); }); describe('createApp - the player-activity stamp', () => { diff --git a/packages/core/src/server/runtime/__tests__/fixtures/late-worker-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/late-worker-plugin.ts new file mode 100644 index 000000000..c320fe555 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/late-worker-plugin.ts @@ -0,0 +1,17 @@ +import * as z from 'zod'; +import { queue, type TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export default { + id: 'fixture-late-worker', + register(ctx) { + ctx.routers.add('fixture-late-worker', () => { + ctx.jobs.worker({ + queue: queue('fixture-late-worker.run'), + schema: z.object({}), + handler: async () => {}, + }); + return {}; + }); + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 058e00c47..6cf034245 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -471,19 +471,6 @@ export async function createApp( } const registry = await loadPlugins(plugins, container); - // Before any router factory runs, so a mistyped queue fails the boot before a schedule is set. - const workers = selectJobWorkers(registry.jobs.getAll(), jobWorkerSelection); - if (jobWorkerSelection !== null) { - const queuesOf = (list: typeof workers.consumed) => [...new Set(list.map((w) => w.queue))]; - bootLogger.info( - { - consumed: queuesOf(workers.consumed), - skipped: queuesOf(workers.skipped), - idleExclusions: workers.idleExclusions, - }, - 'JOB_WORKERS active', - ); - } await configure?.(container); assertDurableSeamsBound(container); @@ -529,6 +516,19 @@ export async function createApp( // Built now so a broken tool or action-type factory fails boot, not the first call. const mcpTransport = await loadMcpTransport(container, container.get(MCP_KERNEL)); + // After the router factories: a factory may register a worker too. + const workers = selectJobWorkers(registry.jobs.getAll(), jobWorkerSelection); + if (jobWorkerSelection !== null) { + const queuesOf = (list: typeof workers.consumed) => [...new Set(list.map((w) => w.queue))]; + bootLogger.info( + { + consumed: queuesOf(workers.consumed), + skipped: queuesOf(workers.skipped), + idleExclusions: workers.idleExclusions, + }, + 'JOB_WORKERS active', + ); + } for (const registration of workers.consumed) { jobQueue.registerWorker(registration); } From 5126e0c80b1dd7f9229b5914c5cb5bcd7a4c08c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 14 Oct 2026 10:21:13 +0200 Subject: [PATCH 06/16] feat(core): log the optional ports a boot leaves unbound MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit identity and mail resolve some ports with c.has() and run without them, so a split service of identity and mail skipped the country check on sign-in and sent regulatory mails without an audit record, and nothing at boot said so. A plugin now lists such ports in optionalPorts with what it skips without each, and loadPlugins logs a warning for every one left unbound. identity declares the geo check, player provisioning and MCP token revocation; mail the audit writer and play eligibility. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../rules/messaging-and-microservices.md | 2 +- AGENTS.md | 2 +- docs/guides/downstream-consumer.md | 6 ++-- docs/platform/system-design.md | 3 +- docs/standards/module-structure.md | 2 ++ packages/core/src/mail/plugin.ts | 4 +++ packages/core/src/pam/identity/plugin.ts | 8 ++++++ .../__tests__/required-ports.test.ts | 28 +++++++++++++++++-- .../src/server/plugin-host/define-plugin.ts | 9 ++++++ packages/core/src/server/plugin-host/index.ts | 1 + .../src/server/plugin-host/load-plugins.ts | 26 +++++++++++++++++ 11 files changed, 84 insertions(+), 7 deletions(-) diff --git a/.rulesync/rules/messaging-and-microservices.md b/.rulesync/rules/messaging-and-microservices.md index fa5ccb247..6e84c57fa 100644 --- a/.rulesync/rules/messaging-and-microservices.md +++ b/.rulesync/rules/messaging-and-microservices.md @@ -36,7 +36,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. -- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips - a missing geo check or audit record does not stop the boot. - `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/AGENTS.md b/AGENTS.md index d9a3e7836..e0e68d6a0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. -- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips - a missing geo check or audit record does not stop the boot. - `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index ec5c8396a..f81bfb770 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -80,8 +80,10 @@ and each job reaches one of them. - **Fewer modules in a process.** `SERVICE_MANIFEST` lists the module ids a process boots, and `createApp` applies it to the `plugins` you pass. List every module your modules depend on and - every module that binds a port they require; the boot names whatever is missing. A process - with a manifest needs a `SERVICE_NAME` of its own. + every module that binds a port they require; the boot names whatever is missing. A module + still boots without an optional port, so the boot log also lists each one the manifest leaves + unbound and what its module skips without it (eg identity's country check). A process with a + manifest needs a `SERVICE_NAME` of its own. - **Fewer queues in a process.** `JOB_WORKERS` picks the job queues a process consumes: names, `prefix*` patterns, `!` to exclude. To keep a long-running queue off the API replicas, run one more instance with the same plugins and `SERVICE_NAME` and `JOB_WORKERS=`, and give the diff --git a/docs/platform/system-design.md b/docs/platform/system-design.md index 92b0de554..59fd371b6 100644 --- a/docs/platform/system-design.md +++ b/docs/platform/system-design.md @@ -284,7 +284,8 @@ flowchart TB ``` A manifest lists every module its modules depend on and every module that binds a port they -require; `createApp` applies it to any plugin list and names what is missing. A split service +require; `createApp` applies it to any plugin list and names what is missing. It also logs each +optional port a manifest leaves unbound, with what the module skips without it. A split service needs a `SERVICE_NAME` of its own - its consumer group - while an instance that only takes a share of the queues keeps the replicas' `SERVICE_NAME`, so it competes for events rather than receiving a copy. `JOB_WORKERS` picks the queues a process consumes diff --git a/docs/standards/module-structure.md b/docs/standards/module-structure.md index 15fc03800..3c89f6f54 100644 --- a/docs/standards/module-structure.md +++ b/docs/standards/module-structure.md @@ -62,6 +62,8 @@ await tags.evaluate(tx, { playerId }); Canonical seams to copy: `wallet -> tag` via `TAG_EVALUATION_COMMANDS` (optional port, resolved lazily with `c.has(...)` in the router factory), `compliance -> pam` via the `kyc.status.changed` domain event. +A port whose absence skips a check or a record - a geo check, an audit write - goes in the plugin's `optionalPorts` with what the module skips without it (`identity`, `mail`). The boot logs each one left unbound, so a deployment that leaves out its binder says so. + Money + any needed-now mutation stay synchronous/transactional, never over events - use a command port: caller passes its own `tx` (`WALLET_COMMANDS.debit(tx, ...)`), atomic in-process yet splittable later; declare `dependsOn: ['']`. Cross-module schema reads are sanctioned but each one is an extraction blocker. ADR-0017. **Never write a deep (`../../`+) relative import that leaves your own top-level dir under `packages/core/src/` - reach every other zone through the package's own `@openora/core/*` subpath.** A relative import is only for staying inside your own module/zone (`./schema/index.js`, `../db/index.js`). The moment a `..` would cross into another domain, slice, or engine zone, it's a `@openora/core/*` import instead: diff --git a/packages/core/src/mail/plugin.ts b/packages/core/src/mail/plugin.ts index c2b25051f..dc17ec6be 100644 --- a/packages/core/src/mail/plugin.ts +++ b/packages/core/src/mail/plugin.ts @@ -19,6 +19,10 @@ const logger = createLogger('mail'); export default { id: 'mail', + optionalPorts: [ + { port: AUDIT_WRITER, without: 'regulatory mails go out without an audit record' }, + { port: PLAY_ELIGIBILITY, without: 'welcome mails never offer promotions' }, + ], register(ctx) { const encryptionSecret = process.env['AUTH_SECRET'] ?? ''; if (encryptionSecret.length < MIN_MAIL_ENCRYPTION_SECRET_LENGTH) { diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index 63c65dee6..a7f399354 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -111,6 +111,14 @@ function makeAdminSecurity(c: IdentityContainer) { export default { id: 'identity', requiresPorts: [MAIL_DISPATCH], + optionalPorts: [ + { port: GEO_CHECK_COMMANDS, without: 'registration and sign-in skip the country check' }, + { port: PLAYER_PROVISIONING, without: 'registration is refused' }, + { + port: MCP_TOKEN_REVOCATION, + without: "ending an admin's sessions leaves their MCP tokens valid", + }, + ], register(ctx) { // Built once and shared by ADMIN_SECURITY_POLICY and the router below - two // separate `makeAdminSecurity(c)` calls would construct two independent diff --git a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts index 9bc0906c0..9034418e3 100644 --- a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts @@ -1,11 +1,12 @@ import { describe, it, expect } from 'vitest'; import { Container } from '../../kernel/index.js'; import { createToken, type TokenCatalog } from '@openora/core/contracts'; -import { assertRequiredPorts } from '../load-plugins.js'; +import { assertRequiredPorts, unboundOptionalPorts } from '../load-plugins.js'; import type { Plugin } from '../define-plugin.js'; const WALLET_COMMANDS = createToken<{ debit: () => void }>('WALLET_COMMANDS'); -const catalog = { WALLET_COMMANDS } satisfies TokenCatalog; +const GEO_CHECK = createToken<{ check: () => void }>('GEO_CHECK'); +const catalog = { WALLET_COMMANDS, GEO_CHECK } satisfies TokenCatalog; const consumer: Plugin = { id: 'gaming', @@ -31,3 +32,26 @@ describe('assertRequiredPorts (ADR-0024 boot fail-fast)', () => { expect(() => assertRequiredPorts([plain], container)).not.toThrow(); }); }); + +describe('unboundOptionalPorts', () => { + const identity: Plugin = { + id: 'identity', + optionalPorts: [{ port: GEO_CHECK, without: 'sign-in skips the country check' }], + register: () => {}, + }; + + it('names the plugin, the port and what the plugin skips without it', () => { + const container = new Container(); + + expect(unboundOptionalPorts([identity], container)).toEqual([ + { plugin: 'identity', port: 'GEO_CHECK', without: 'sign-in skips the country check' }, + ]); + }); + + it('is empty once the port is bound', () => { + const container = new Container(); + container.register(GEO_CHECK, () => ({ check: () => {} })); + + expect(unboundOptionalPorts([identity], container)).toEqual([]); + }); +}); diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index 5c9036f08..b337384c8 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -110,10 +110,19 @@ export type ModuleRegistry = { export type PluginContext = ModuleRegistry; +/** A port a plugin resolves with `c.has()`: it runs without one, minus what the port backs. */ +export type OptionalPort = { + port: C[keyof C]; + /** What the plugin skips while the port is unbound, as the boot log states it. */ + without: string; +}; + export type Plugin = { id: string; dependsOn?: readonly string[]; // Verified once after all plugins register - a missing port fails fast. See ADR-0024. requiresPorts?: readonly (C[keyof C] & Token)[]; + // Checked at the same point; an unbound one is logged, not fatal. + optionalPorts?: readonly OptionalPort[]; register(ctx: PluginContext): void | Promise; }; diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index d85f0486e..2102813b7 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -1,5 +1,6 @@ export type { Plugin, + OptionalPort, ModuleRegistry, PluginContext, RouterFactory, diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index 6ab52cbbe..88374a769 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -1,8 +1,11 @@ import type { Container } from '../kernel/index.js'; +import { createLogger } from '../kernel/logger.js'; import type { TokenCatalog } from '@openora/core/contracts'; import type { Plugin } from './define-plugin.js'; import { ModuleRegistryImpl } from './module-registry.js'; +const logger = createLogger('plugin-host'); + export type PluginEntry = { id: string; /** @@ -122,10 +125,33 @@ export async function loadPlugins( } assertRequiredPorts(ordered, container); + for (const { plugin, port, without } of unboundOptionalPorts(ordered, container)) { + logger.warn({ plugin, port }, `${plugin} runs without ${port}: ${without}`); + } return registry; } +/** + * The optional ports no plugin bound. A plugin boots without one but skips what it backs - a + * check, an audit record - so the boot logs each, typically when a SERVICE_MANIFEST leaves out + * the module that binds it. + */ +export function unboundOptionalPorts( + plugins: Plugin[], + container: Container, +): { plugin: string; port: string; without: string }[] { + return plugins.flatMap((plugin) => + (plugin.optionalPorts ?? []) + .filter(({ port }) => !container.has(port)) + .map(({ port, without }) => ({ + plugin: plugin.id, + port: String(port.description ?? port), + without, + })), + ); +} + /** * Boot-time fail-fast for cross-domain ports (ADR-0024). A domain depends ONLY on * the foundation and reaches another domain through a port token the other domain From beea7f2c86469bb4514fde2c086fbb19c3ebf7a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Wed, 14 Oct 2026 10:24:51 +0200 Subject: [PATCH 07/16] fix(scaffold): warn what a split service duplicates and skips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A generated service and the monolith both load identity, so identity's handlers ran in both, and the override the scaffold suggested added audit, which would write every audit row twice. The scaffold now says so after it generates the host, points at the boot log's optional-port warnings, and no longer suggests adding audit. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- tools/create/create-service.ts | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index 21a35983c..41288bb21 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -16,7 +16,7 @@ * * After scaffolding: `pnpm install`, build deps, then * pnpm -F @openora/-service dev # uses the baked-in manifest - * SERVICE_MANIFEST=identity,mail,audit pnpm -F @openora/-service dev # override + * SERVICE_MANIFEST= pnpm -F @openora/-service dev # override */ import { existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -136,3 +136,16 @@ console.log('To exclude these modules from the monolith, drop them from extensio console.log( 'and run them only via this host (set REDIS_URL or bind MESSAGE_BROKER in an overlay; AMQP_URL only enables the outbox).', ); +console.warn('Before you deploy it:'); +console.warn( + ' - A module this service and another process both load handles each event in both, under each', +); +console.warn( + ' SERVICE_NAME - audit writes every row twice. Until ADR-0045 lands, split modules no other process loads.', +); +console.warn( + ' - The boot logs each optional port the manifest leaves unbound and what its module skips', +); +console.warn( + ' without it (eg identity without compliance skips the country check). Read those lines.', +); From f1fc1c3fded5b50ae8ebc5aa4706266ec7f65b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 16 Oct 2026 09:28:01 +0200 Subject: [PATCH 08/16] fix(core): fail a split boot closed on a control it would skip MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under SERVICE_MANIFEST a split service could boot without the module behind a money, compliance or authorization control and skip it in silence: wallet without compliance took deposits and bets past the RG limits, and admin routes without iam fell back to the static role table, so an admin whose role was revoked in the database could still approve a withdrawal. A plugin now lists such ports in controlPorts, with the module that binds each. A full boot logs an unbound one, as before. A manifest boot refuses to start while one is unbound and the plugin list has its module, naming the port, what its module skips and a manifest line that adds the binding modules and what they need in turn. AdminGuard holds ADMIN_PERMISSION_RESOLVER (iam) and ADMIN_SECURITY_POLICY (identity) to the same rule once admin routes resolve it. An install whose plugin list lacks the module has the control nowhere and boots as before. Declared: RG_LIMITS (wallet, gaming), GAME_GEO_CHECK (gaming), GEO_CHECK_COMMANDS (identity, bonus, notifications), MCP_TOKEN_REVOCATION (identity) and AUDIT_WRITER (mail). Ports whose absence already fails closed - no KYC policy, no risk tags, no bonus grants, no play eligibility - are left as they are. The container now clears a token's resolving mark when its factory throws, so a refusing factory refuses again with its own error rather than as a circular dependency. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../rules/messaging-and-microservices.md | 2 +- AGENTS.md | 2 +- ...readiness-manifest-outbox-command-ports.md | 2 +- .../0036-rg-limit-enforcement-and-cooldown.md | 4 + .../0044-per-process-job-worker-selection.md | 20 ++- docs/guides/downstream-consumer.md | 7 +- docs/platform/system-design.md | 4 +- docs/standards/module-structure.md | 2 +- packages/core/src/casino/gaming/plugin.ts | 8 + .../src/engagement/notifications/plugin.ts | 7 + packages/core/src/mail/plugin.ts | 10 +- packages/core/src/pam/identity/plugin.ts | 11 +- packages/core/src/promo/bonus/plugin.ts | 7 + .../server/kernel/__tests__/container.test.ts | 11 ++ packages/core/src/server/kernel/container.ts | 12 +- .../__tests__/required-ports.test.ts | 45 +++++- .../__tests__/service-manifest.test.ts | 93 ++++++++++- .../src/server/plugin-host/define-plugin.ts | 12 ++ packages/core/src/server/plugin-host/index.ts | 1 + .../src/server/plugin-host/load-plugins.ts | 44 ++++-- .../server/plugin-host/service-manifest.ts | 147 +++++++++++++++--- .../runtime/__tests__/create-app.test.ts | 87 ++++++++++- .../__tests__/fixtures/admin-plugin.ts | 14 ++ .../runtime/__tests__/fixtures/iam-plugin.ts | 12 ++ .../__tests__/fixtures/identity-plugin.ts | 12 ++ .../__tests__/fixtures/limited-plugin.ts | 11 ++ .../__tests__/fixtures/limits-plugin.ts | 11 ++ .../core/src/server/runtime/create-app.ts | 92 ++++++++--- packages/core/src/wallet/plugin.ts | 3 + tools/create/create-service.ts | 14 +- 30 files changed, 627 insertions(+), 80 deletions(-) create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/admin-plugin.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/iam-plugin.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/identity-plugin.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/limited-plugin.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/limits-plugin.ts diff --git a/.rulesync/rules/messaging-and-microservices.md b/.rulesync/rules/messaging-and-microservices.md index 6e84c57fa..fccac85d5 100644 --- a/.rulesync/rules/messaging-and-microservices.md +++ b/.rulesync/rules/messaging-and-microservices.md @@ -36,7 +36,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. -- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips - a missing geo check or audit record does not stop the boot. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or, once admin routes resolve `AdminGuard`, its permission resolver or security policy is unbound and the plugin list has the module that binds it - so a split that loads identity also loads compliance and iam. Without a manifest nothing changes. - `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/AGENTS.md b/AGENTS.md index e0e68d6a0..3f88d202c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. -- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips - a missing geo check or audit record does not stop the boot. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or, once admin routes resolve `AdminGuard`, its permission resolver or security policy is unbound and the plugin list has the module that binds it - so a split that loads identity also loads compliance and iam. Without a manifest nothing changes. - `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md index d8c5be8a1..361d8c70a 100644 --- a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md +++ b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md @@ -4,7 +4,7 @@ **Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default). **Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope). -> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included - and the boot names all that is missing. `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0044](./0044-per-process-job-worker-selection.md). +> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included - and the boot names all that is missing. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy once admin routes resolve it, must be bound whenever the plugin list has the module that binds them ([ADR-0044](./0044-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0044](./0044-per-process-job-worker-selection.md). ## Context diff --git a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md index 33357cae5..baf29fa4b 100644 --- a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md +++ b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md @@ -8,6 +8,10 @@ refused server-side with a typed error. The paragraph below asserting the admin "still writes a limit outright in either direction" no longer holds; everything else in this ADR (the enforcement points, the deposit/wager gates, the player-path cool-down, and the concurrency reasoning) is unchanged. +**Update (2026-10-16)**: `RG_LIMITS` stays optional for its consumers in a full boot, but wallet +and gaming list it in `controlPorts`, so a `SERVICE_MANIFEST` boot that loads either refuses to +start without the compliance module while the plugin list has it: another process of the install +enforces the limits there ([ADR-0044](./0044-per-process-job-worker-selection.md)). ## Context diff --git a/docs/adr/0044-per-process-job-worker-selection.md b/docs/adr/0044-per-process-job-worker-selection.md index eefecaa14..41f2b39a7 100644 --- a/docs/adr/0044-per-process-job-worker-selection.md +++ b/docs/adr/0044-per-process-job-worker-selection.md @@ -17,7 +17,9 @@ receives its own copy of every event, and a module pulled into it for its depend handlers a second time across the cluster. A job whose handler needs the full container cannot live in a narrow service anyway: the MCP kernel (ADR-0042) is built in process from the tools and action types of the modules that process loaded, so an agent run needs every module that -contributes them. +contributes them. Nor does a manifest service stay narrow once it touches money: it fails closed +on the controls the rest of the install enforces (below), so one that loads identity also loads +compliance and iam. ## Decision @@ -55,6 +57,22 @@ and `JOB_WORKERS=reports.*`; give the replicas `JOB_WORKERS=!reports.*`. Sharing and keeps it in the same realtime and presence namespace. It serves HTTP like any instance but needs no ingress. +**A manifest fails closed on controls.** Some ports back a money, compliance, authorization or +audit control and are read with `c.has()`. An install without the module that binds one has +nothing to enforce, so a full boot runs without it, as before. A split service is different: the +module runs in another process of the same install, and a split that skipped the control would +let through what that process refuses. A plugin declares such a port in `controlPorts`, with the +module that binds it, and a `SERVICE_MANIFEST` boot refuses to start while one is unbound and the +plugin list has that module. The error names the port and what its module skips, and prints the +manifest with the modules to add and what they need in turn. `AdminGuard` holds its own ports to +the same rule when admin routes resolve it: without `ADMIN_PERMISSION_RESOLVER` (iam) it decides +on the static role table, so an admin whose role was revoked still acts, and without +`ADMIN_SECURITY_POLICY` (identity) it skips the 2FA enrolment and session checks. Declared today: +`RG_LIMITS` (wallet, gaming), `GAME_GEO_CHECK` (gaming), `GEO_CHECK_COMMANDS` (identity, bonus, +notifications), `MCP_TOKEN_REVOCATION` (identity) and `AUDIT_WRITER` (mail). A port whose absence +already fails closed - no KYC policy means every gated withdrawal needs KYC, no risk tags send +auto-approval to a human - is not a control port. + ### Alternatives considered - **A process role (`SERVICE_ROLE=api|worker`).** Fixes one topology and cannot say which diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index f81bfb770..13eb8d6f2 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -82,8 +82,11 @@ and each job reaches one of them. `createApp` applies it to the `plugins` you pass. List every module your modules depend on and every module that binds a port they require; the boot names whatever is missing. A module still boots without an optional port, so the boot log also lists each one the manifest leaves - unbound and what its module skips without it (eg identity's country check). A process with a - manifest needs a `SERVICE_NAME` of its own. + unbound and what its module skips without it. A control - an RG limit, a country check, an + audit record, the database grants behind admin routes - is different: while your plugin list + has the module that enforces it, a manifest that leaves it out refuses to boot and prints the + modules to add. In practice a process that loads identity also loads compliance and iam. A + process with a manifest needs a `SERVICE_NAME` of its own. - **Fewer queues in a process.** `JOB_WORKERS` picks the job queues a process consumes: names, `prefix*` patterns, `!` to exclude. To keep a long-running queue off the API replicas, run one more instance with the same plugins and `SERVICE_NAME` and `JOB_WORKERS=`, and give the diff --git a/docs/platform/system-design.md b/docs/platform/system-design.md index 59fd371b6..ff589b619 100644 --- a/docs/platform/system-design.md +++ b/docs/platform/system-design.md @@ -285,7 +285,9 @@ flowchart TB A manifest lists every module its modules depend on and every module that binds a port they require; `createApp` applies it to any plugin list and names what is missing. It also logs each -optional port a manifest leaves unbound, with what the module skips without it. A split service +optional port a manifest leaves unbound, with what the module skips without it, and refuses to +boot over a control port - an RG limit, a geo check, an audit record, AdminGuard's database +grants - whose module the plugin list has but the manifest leaves out. A split service needs a `SERVICE_NAME` of its own - its consumer group - while an instance that only takes a share of the queues keeps the replicas' `SERVICE_NAME`, so it competes for events rather than receiving a copy. `JOB_WORKERS` picks the queues a process consumes diff --git a/docs/standards/module-structure.md b/docs/standards/module-structure.md index 3c89f6f54..2b372d66f 100644 --- a/docs/standards/module-structure.md +++ b/docs/standards/module-structure.md @@ -62,7 +62,7 @@ await tags.evaluate(tx, { playerId }); Canonical seams to copy: `wallet -> tag` via `TAG_EVALUATION_COMMANDS` (optional port, resolved lazily with `c.has(...)` in the router factory), `compliance -> pam` via the `kyc.status.changed` domain event. -A port whose absence skips a check or a record - a geo check, an audit write - goes in the plugin's `optionalPorts` with what the module skips without it (`identity`, `mail`). The boot logs each one left unbound, so a deployment that leaves out its binder says so. +A port whose absence only degrades a feature, or already fails closed, goes in the plugin's `optionalPorts` with what the module skips without it; the boot logs each one left unbound. A port whose absence skips a money, compliance, authorization or audit control - an RG limit, a geo check, an audit record, a token revocation - goes in `controlPorts` instead, with the module that binds it (`wallet`, `gaming`, `identity`, `mail`). A full boot logs it the same way, since an install without that module has nothing to enforce; a `SERVICE_MANIFEST` boot refuses to start while it is unbound and the plugin list has that module, because another process of the install enforces the control. Money + any needed-now mutation stay synchronous/transactional, never over events - use a command port: caller passes its own `tx` (`WALLET_COMMANDS.debit(tx, ...)`), atomic in-process yet splittable later; declare `dependsOn: ['']`. Cross-module schema reads are sanctioned but each one is an extraction blocker. ADR-0017. diff --git a/packages/core/src/casino/gaming/plugin.ts b/packages/core/src/casino/gaming/plugin.ts index 3395cc457..1b8954486 100644 --- a/packages/core/src/casino/gaming/plugin.ts +++ b/packages/core/src/casino/gaming/plugin.ts @@ -55,6 +55,14 @@ export default { id: 'gaming', requiresPorts: [PLAY_ELIGIBILITY, IDENTITY_READER], dependsOn: ['wallet'], + controlPorts: [ + { port: RG_LIMITS, without: 'a round starts over a wager limit', boundBy: 'compliance' }, + { + port: GAME_GEO_CHECK, + without: 'a game starts in a country it is restricted in', + boundBy: 'compliance', + }, + ], register(ctx) { ctx.provide(GAME_ADAPTER, () => new MockGameAdapter()); ctx.provide(RNG_ADAPTER, () => new MockRngAdapter()); diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 53167ff03..ab1f39a81 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -502,6 +502,13 @@ export function buildChatRoomScheduledForDeletionNotification(payload: { export default { id: 'notifications', + controlPorts: [ + { + port: GEO_CHECK_COMMANDS, + without: 'welcome mails offer promotions without the country check', + boundBy: 'compliance', + }, + ], register(ctx) { const logger = createLogger('notifications'); diff --git a/packages/core/src/mail/plugin.ts b/packages/core/src/mail/plugin.ts index dc17ec6be..69be46b83 100644 --- a/packages/core/src/mail/plugin.ts +++ b/packages/core/src/mail/plugin.ts @@ -19,9 +19,13 @@ const logger = createLogger('mail'); export default { id: 'mail', - optionalPorts: [ - { port: AUDIT_WRITER, without: 'regulatory mails go out without an audit record' }, - { port: PLAY_ELIGIBILITY, without: 'welcome mails never offer promotions' }, + optionalPorts: [{ port: PLAY_ELIGIBILITY, without: 'welcome mails never offer promotions' }], + controlPorts: [ + { + port: AUDIT_WRITER, + without: 'regulatory mails go out without an audit record', + boundBy: 'audit', + }, ], register(ctx) { const encryptionSecret = process.env['AUTH_SECRET'] ?? ''; diff --git a/packages/core/src/pam/identity/plugin.ts b/packages/core/src/pam/identity/plugin.ts index a7f399354..c6394c578 100644 --- a/packages/core/src/pam/identity/plugin.ts +++ b/packages/core/src/pam/identity/plugin.ts @@ -111,12 +111,17 @@ function makeAdminSecurity(c: IdentityContainer) { export default { id: 'identity', requiresPorts: [MAIL_DISPATCH], - optionalPorts: [ - { port: GEO_CHECK_COMMANDS, without: 'registration and sign-in skip the country check' }, - { port: PLAYER_PROVISIONING, without: 'registration is refused' }, + optionalPorts: [{ port: PLAYER_PROVISIONING, without: 'registration is refused' }], + controlPorts: [ + { + port: GEO_CHECK_COMMANDS, + without: 'registration and sign-in skip the country check', + boundBy: 'compliance', + }, { port: MCP_TOKEN_REVOCATION, without: "ending an admin's sessions leaves their MCP tokens valid", + boundBy: 'iam', }, ], register(ctx) { diff --git a/packages/core/src/promo/bonus/plugin.ts b/packages/core/src/promo/bonus/plugin.ts index 1c5da9f4e..878dd0af5 100644 --- a/packages/core/src/promo/bonus/plugin.ts +++ b/packages/core/src/promo/bonus/plugin.ts @@ -96,6 +96,13 @@ export default { id: 'bonus', dependsOn: ['audit'], requiresPorts: [PLAY_ELIGIBILITY], + controlPorts: [ + { + port: GEO_CHECK_COMMANDS, + without: 'public offers are listed to visitors from refused countries', + boundBy: 'compliance', + }, + ], register(ctx) { ctx.provide(BONUS_GRANTS, (c) => new GrantService(c.get(AUDIT_WRITER))); ctx.provide(BONUS_GRANT_LEDGER, () => bonusGrantLedger); diff --git a/packages/core/src/server/kernel/__tests__/container.test.ts b/packages/core/src/server/kernel/__tests__/container.test.ts index 070d88d9b..0d3c7abb5 100644 --- a/packages/core/src/server/kernel/__tests__/container.test.ts +++ b/packages/core/src/server/kernel/__tests__/container.test.ts @@ -64,6 +64,17 @@ describe('Container', () => { expect(() => c.get(A)).toThrow(/Circular dependency/); }); + it('rethrows a refusing factory on every get, not as a circular dependency', () => { + const TOKEN = createToken('refuses'); + const c = new Container(); + c.register(TOKEN, () => { + throw new Error('refused'); + }); + + expect(() => c.get(TOKEN)).toThrow('refused'); + expect(() => c.get(TOKEN)).toThrow('refused'); + }); + it('runs disposers in reverse registration order', async () => { const c = new Container(); const order: string[] = []; diff --git a/packages/core/src/server/kernel/container.ts b/packages/core/src/server/kernel/container.ts index a93596ffa..03dc4c818 100644 --- a/packages/core/src/server/kernel/container.ts +++ b/packages/core/src/server/kernel/container.ts @@ -74,10 +74,14 @@ export class Container { } this.resolving.add(token); - const instance = factory(this) as TokenValue; - this.resolving.delete(token); - this.instances.set(token, instance); - return instance; + try { + const instance = factory(this) as TokenValue; + this.instances.set(token, instance); + return instance; + } finally { + // A factory that refuses keeps refusing with its own error, not a circular dependency. + this.resolving.delete(token); + } } onDispose(fn: () => void | Promise): void { diff --git a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts index 9034418e3..8e6d69f5a 100644 --- a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts @@ -1,12 +1,13 @@ import { describe, it, expect } from 'vitest'; import { Container } from '../../kernel/index.js'; import { createToken, type TokenCatalog } from '@openora/core/contracts'; -import { assertRequiredPorts, unboundOptionalPorts } from '../load-plugins.js'; +import { assertRequiredPorts, unboundControlPorts, unboundOptionalPorts } from '../load-plugins.js'; import type { Plugin } from '../define-plugin.js'; const WALLET_COMMANDS = createToken<{ debit: () => void }>('WALLET_COMMANDS'); const GEO_CHECK = createToken<{ check: () => void }>('GEO_CHECK'); -const catalog = { WALLET_COMMANDS, GEO_CHECK } satisfies TokenCatalog; +const RG_LIMITS = createToken<{ checkDeposit: () => void }>('RG_LIMITS'); +const catalog = { WALLET_COMMANDS, GEO_CHECK, RG_LIMITS } satisfies TokenCatalog; const consumer: Plugin = { id: 'gaming', @@ -55,3 +56,43 @@ describe('unboundOptionalPorts', () => { expect(unboundOptionalPorts([identity], container)).toEqual([]); }); }); + +describe('unboundControlPorts', () => { + const wallet: Plugin = { + id: 'wallet', + optionalPorts: [{ port: GEO_CHECK, without: 'nothing that matters here' }], + controlPorts: [ + { port: RG_LIMITS, without: 'deposit limits go unenforced', boundBy: 'compliance' }, + ], + register: () => {}, + }; + + it('names the plugin, the port, what it skips and the module that binds the port', () => { + const container = new Container(); + + expect(unboundControlPorts([wallet], container)).toEqual([ + { + owner: 'wallet', + port: 'RG_LIMITS', + without: 'deposit limits go unenforced', + boundBy: 'compliance', + }, + ]); + }); + + it('is empty once the port is bound', () => { + const container = new Container(); + container.register(RG_LIMITS, () => ({ checkDeposit: () => {} })); + + expect(unboundControlPorts([wallet], container)).toEqual([]); + }); + + it('are logged with the optional ports', () => { + const container = new Container(); + + expect(unboundOptionalPorts([wallet], container).map(({ port }) => port)).toEqual([ + 'GEO_CHECK', + 'RG_LIMITS', + ]); + }); +}); diff --git a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts index 20f2daac6..55d023438 100644 --- a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts @@ -1,10 +1,12 @@ import { describe, it, expect, vi } from 'vitest'; import { applyServiceManifest, + assertControlsBound, + assertModuleControlsBound, parseServiceManifest, selectServiceEntries, } from '../service-manifest.js'; -import type { PluginEntry } from '../load-plugins.js'; +import type { PluginEntry, UnboundControl } from '../load-plugins.js'; const entries: PluginEntry[] = [ { id: 'identity', path: 'a' }, @@ -116,3 +118,92 @@ describe('selectServiceEntries', () => { expect(reader).not.toHaveBeenCalled(); }); }); + +describe('assertModuleControlsBound', () => { + const all: PluginEntry[] = [ + { id: 'audit', path: 'a' }, + { id: 'identity', path: 'b' }, + { id: 'wallet', path: 'c' }, + { id: 'compliance', path: 'd' }, + { id: 'gaming', path: 'e' }, + { id: 'iam', path: 'f' }, + { id: 'bullmq', path: 'g', kind: 'infra' }, + ]; + // What each module needs beside it in a split: dependencies and the modules behind its controls. + const needs: Record = { + identity: ['audit'], + wallet: ['identity', 'audit', 'compliance'], + compliance: ['identity', 'wallet', 'gaming', 'audit'], + gaming: ['wallet', 'compliance', 'iam'], + iam: ['identity', 'audit'], + }; + const needsOf = vi.fn(async (entry: PluginEntry) => needs[entry.id] ?? []); + const rgLimits: UnboundControl = { + owner: 'wallet', + port: 'RG_LIMITS', + without: 'deposit and wager limits go unenforced', + boundBy: 'compliance', + }; + + it('refuses the boot, naming the module behind the port and all it needs in turn', async () => { + const run = assertModuleControlsBound( + all, + ['wallet', 'identity', 'audit'], + [rgLimits], + needsOf, + ); + + await expect(run).rejects.toThrow( + /- wallet runs without RG_LIMITS \(bound by compliance\): deposit and wager limits go unenforced\nAdd them: SERVICE_MANIFEST=wallet,identity,audit,compliance,gaming,iam\n/, + ); + }); + + it('boots an install whose plugin list has no module behind the port', async () => { + const withoutCompliance = all.filter((e) => e.id !== 'compliance'); + + await expect( + assertModuleControlsBound(withoutCompliance, ['wallet'], [rgLimits], needsOf), + ).resolves.toBeUndefined(); + }); + + it('boots when every control is bound', async () => { + await expect(assertModuleControlsBound(all, ['wallet'], [], needsOf)).resolves.toBeUndefined(); + }); +}); + +describe('assertControlsBound', () => { + const entries: PluginEntry[] = [ + { id: 'wallet', path: 'a' }, + { id: 'iam', path: 'b' }, + ]; + const resolver: UnboundControl = { + owner: 'AdminGuard', + port: 'ADMIN_PERMISSION_RESOLVER', + without: 'grants come from the static role table', + boundBy: 'iam', + }; + + it('names the subject, the port and the module to add', () => { + expect(() => + assertControlsBound(entries, ['wallet'], 'serves admin routes without its ports', [resolver]), + ).toThrow( + /SERVICE_MANIFEST=wallet serves admin routes without its ports:\n {2}- AdminGuard runs without ADMIN_PERMISSION_RESOLVER \(bound by iam\)[\s\S]*Add them: SERVICE_MANIFEST=wallet,iam/, + ); + }); + + it('still refuses, offering nothing to add, when the manifest selects the module', () => { + const refuse = () => + assertControlsBound(entries, ['wallet', 'iam'], 'serves admin routes', [resolver]); + + expect(refuse).toThrow(/AdminGuard runs without ADMIN_PERMISSION_RESOLVER/); + expect(refuse).not.toThrow(/Add them/); + }); + + it('passes when the plugin list has no module behind the port', () => { + expect(() => + assertControlsBound([{ id: 'wallet', path: 'a' }], ['wallet'], 'serves admin routes', [ + resolver, + ]), + ).not.toThrow(); + }); +}); diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index b337384c8..e824adcbe 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -117,6 +117,16 @@ export type OptionalPort = { without: string; }; +/** + * An optional port behind a money, compliance, authorization or audit control. An install + * without the module that binds it has nothing to enforce, so a full boot runs without it. A + * split service runs beside a process that has that module, so it may not. + */ +export type ControlPort = OptionalPort & { + /** The module that binds the port, named when a SERVICE_MANIFEST leaves the port unbound. */ + boundBy: string; +}; + export type Plugin = { id: string; dependsOn?: readonly string[]; @@ -124,5 +134,7 @@ export type Plugin = { requiresPorts?: readonly (C[keyof C] & Token)[]; // Checked at the same point; an unbound one is logged, not fatal. optionalPorts?: readonly OptionalPort[]; + // Logged like optionalPorts, but an unbound one fails a SERVICE_MANIFEST boot. + controlPorts?: readonly ControlPort[]; register(ctx: PluginContext): void | Promise; }; diff --git a/packages/core/src/server/plugin-host/index.ts b/packages/core/src/server/plugin-host/index.ts index 2102813b7..bfad3c1ae 100644 --- a/packages/core/src/server/plugin-host/index.ts +++ b/packages/core/src/server/plugin-host/index.ts @@ -1,6 +1,7 @@ export type { Plugin, OptionalPort, + ControlPort, ModuleRegistry, PluginContext, RouterFactory, diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index 88374a769..b656d7709 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -1,7 +1,7 @@ import type { Container } from '../kernel/index.js'; import { createLogger } from '../kernel/logger.js'; import type { TokenCatalog } from '@openora/core/contracts'; -import type { Plugin } from './define-plugin.js'; +import type { ControlPort, Plugin } from './define-plugin.js'; import { ModuleRegistryImpl } from './module-registry.js'; const logger = createLogger('plugin-host'); @@ -132,23 +132,47 @@ export async function loadPlugins( return registry; } +function portName(port: symbol) { + return String(port.description ?? port); +} + /** - * The optional ports no plugin bound. A plugin boots without one but skips what it backs - a - * check, an audit record - so the boot logs each, typically when a SERVICE_MANIFEST leaves out - * the module that binds it. + * The optional and control ports no plugin bound. A plugin boots without one but skips what it + * backs - a check, an audit record - so the boot logs each, typically when a SERVICE_MANIFEST + * leaves out the module that binds it. */ export function unboundOptionalPorts( plugins: Plugin[], container: Container, ): { plugin: string; port: string; without: string }[] { return plugins.flatMap((plugin) => - (plugin.optionalPorts ?? []) + [...(plugin.optionalPorts ?? []), ...(plugin.controlPorts ?? [])] .filter(({ port }) => !container.has(port)) - .map(({ port, without }) => ({ - plugin: plugin.id, - port: String(port.description ?? port), - without, - })), + .map(({ port, without }) => ({ plugin: plugin.id, port: portName(port), without })), + ); +} + +/** A control port nothing bound, with what its owner skips and the module that binds it. */ +export type UnboundControl = { owner: string; port: string; without: string; boundBy: string }; + +/** The ports among `controls` that `container` has no binding for, named for `owner`. */ +export function unboundControls( + owner: string, + controls: readonly ControlPort[], + container: Container, +): UnboundControl[] { + return controls + .filter(({ port }) => !container.has(port)) + .map(({ port, without, boundBy }) => ({ owner, port: portName(port), without, boundBy })); +} + +/** The `controlPorts` of `plugins` that nothing bound. */ +export function unboundControlPorts( + plugins: Plugin[], + container: Container, +): UnboundControl[] { + return plugins.flatMap((plugin) => + unboundControls(plugin.id, plugin.controlPorts ?? [], container), ); } diff --git a/packages/core/src/server/plugin-host/service-manifest.ts b/packages/core/src/server/plugin-host/service-manifest.ts index 49586f18f..79e3152eb 100644 --- a/packages/core/src/server/plugin-host/service-manifest.ts +++ b/packages/core/src/server/plugin-host/service-manifest.ts @@ -1,4 +1,4 @@ -import { validateEntries, type PluginEntry } from './load-plugins.js'; +import { validateEntries, type PluginEntry, type UnboundControl } from './load-plugins.js'; export function parseServiceManifest(raw: string | undefined): string[] | null { if (raw === undefined) { @@ -32,34 +32,25 @@ export function applyServiceManifest( return entries.filter((e) => e.kind === 'infra' || wanted.has(e.id)); } -/** Reads the `dependsOn` of the plugin an entry names. */ +/** Reads the ids of the modules that the plugin an entry names needs beside it. */ export type DependsOnReader = (entry: PluginEntry) => Promise; /** - * The entries a process boots: all of them without a manifest, otherwise the manifest's modules - * plus every infra overlay. A manifest that leaves out a module the selected ones depend on - - * directly or through another left-out module - fails with one error that names them all, so a - * split is fixed in one edit rather than one boot per missing module. Only the selected entries - * are read on the way to success; a left-out one is read only to follow its own dependencies. + * Every module that `start` needs, directly or through another, and that `selectedIds` leaves + * out, each with the ids that need it. A left-out module the plugin list has is read for its own + * needs; one it lacks is only named. */ -export async function selectServiceEntries( - entries: PluginEntry[], - manifest: readonly string[] | null, - dependsOnOf: DependsOnReader, -): Promise { - if (manifest === null) { - return entries; - } - validateEntries(entries); - const selected = applyServiceManifest(entries, manifest); - - const selectedIds = new Set(selected.map((e) => e.id)); - const byId = new Map(entries.map((e) => [e.id, e])); +async function leftOut( + start: readonly PluginEntry[], + selectedIds: ReadonlySet, + byId: ReadonlyMap, + needsOf: DependsOnReader, +): Promise> { const missing = new Map(); - const toRead = [...selected]; - // Grows while it is walked: a left-out dependency is read for its own dependencies. + const toRead = [...start]; + // Grows while it is walked: a left-out module is read for its own needs. for (const entry of toRead) { - for (const dep of await dependsOnOf(entry)) { + for (const dep of await needsOf(entry)) { if (selectedIds.has(dep)) { continue; } @@ -75,6 +66,29 @@ export async function selectServiceEntries( } } } + return missing; +} + +/** + * The entries a process boots: all of them without a manifest, otherwise the manifest's modules + * plus every infra overlay. A manifest that leaves out a module the selected ones depend on - + * directly or through another left-out module - fails with one error that names them all, so a + * split is fixed in one edit rather than one boot per missing module. Only the selected entries + * are read on the way to success; a left-out one is read only to follow its own dependencies. + */ +export async function selectServiceEntries( + entries: PluginEntry[], + manifest: readonly string[] | null, + dependsOnOf: DependsOnReader, +): Promise { + if (manifest === null) { + return entries; + } + validateEntries(entries); + const selected = applyServiceManifest(entries, manifest); + + const byId = new Map(entries.map((e) => [e.id, e])); + const missing = await leftOut(selected, new Set(selected.map((e) => e.id)), byId, dependsOnOf); if (missing.size === 0) { return selected; } @@ -94,3 +108,90 @@ export async function selectServiceEntries( 'A process also needs the module that binds each port its modules require.', ); } + +/** + * The unbound controls a manifest boot refuses, and the modules that bind them which it leaves + * out. Only a control whose module the plugin list has is refused: that module then runs in + * another process of the same install, which enforces the control, so this process would let + * through what that one refuses. A list without the module has the control nowhere, and the + * boot goes on as a full one would. + */ +function refusedControls( + entries: readonly PluginEntry[], + manifest: readonly string[], + unbound: readonly UnboundControl[], +) { + const listed = new Set(entries.map((e) => e.id)); + const refused = unbound.filter(({ boundBy }) => listed.has(boundBy)); + const selectedIds = new Set(applyServiceManifest([...entries], manifest).map((e) => e.id)); + const binders = [...new Set(refused.map(({ boundBy }) => boundBy))].filter( + (id) => !selectedIds.has(id), + ); + return { refused, binders, selectedIds }; +} + +function controlsError( + manifest: readonly string[], + subject: string, + refused: readonly UnboundControl[], + add: readonly string[], +) { + const lines = refused.map( + ({ owner, port, without, boundBy }) => + ` - ${owner} runs without ${port} (bound by ${boundBy}): ${without}`, + ); + return new Error( + `SERVICE_MANIFEST=${manifest.join(',')} ${subject}:\n` + + `${lines.join('\n')}\n` + + (add.length > 0 ? `Add them: SERVICE_MANIFEST=${[...manifest, ...add].join(',')}\n` : '') + + 'A split service may not skip a control the rest of its install enforces: load the ' + + 'module that binds each port, or bind the port in an overlay.', + ); +} + +/** + * Fails a SERVICE_MANIFEST boot on an unbound control whose module the plugin list has, naming + * the owner, the port, what the owner skips and the module that binds it. `subject` says what + * the controls guard, after the manifest: "serves admin routes without ...". + */ +export function assertControlsBound( + entries: readonly PluginEntry[], + manifest: readonly string[], + subject: string, + unbound: readonly UnboundControl[], +): void { + const { refused, binders } = refusedControls(entries, manifest, unbound); + if (refused.length > 0) { + throw controlsError(manifest, subject, refused, binders); + } +} + +/** + * `assertControlsBound` for the selected modules' `controlPorts`. Its manifest line also adds + * every module the binding ones need in turn, as `needsOf` reads it, so a split is fixed in one + * edit, as with a missing dependency. + */ +export async function assertModuleControlsBound( + entries: PluginEntry[], + manifest: readonly string[], + unbound: readonly UnboundControl[], + needsOf: DependsOnReader, +): Promise { + const { refused, binders, selectedIds } = refusedControls(entries, manifest, unbound); + if (refused.length === 0) { + return; + } + const byId = new Map(entries.map((e) => [e.id, e])); + const needed = await leftOut( + binders.flatMap((id) => byId.get(id) ?? []), + new Set([...selectedIds, ...binders]), + byId, + needsOf, + ); + throw controlsError( + manifest, + 'leaves unbound controls the rest of the install enforces', + refused, + [...binders, ...[...needed.keys()].filter((id) => byId.has(id))], + ); +} diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index b9d9ac4ed..52d5519d0 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -12,8 +12,11 @@ import { import { redisUrlForWorker } from '@openora/core/testing'; import { makeJobQueue, mock } from '../../../testing/mock.js'; import { AUTH_SESSION, type SessionResolver } from '../../auth/index.js'; +import type { Container } from '../../kernel/index.js'; import type { PluginEntry } from '../../plugin-host/index.js'; import { createApp } from '../create-app.js'; +import type { CoreTokenCatalog } from '../core-token-catalog.js'; +import { FIXTURE_LIMITS } from './fixtures/limits-plugin.js'; import { getCurrentClientMeta } from '../../kernel/request-context.js'; // A syntactically valid but unreachable DB url - fine here because nothing in this @@ -22,6 +25,9 @@ import { getCurrentClientMeta } from '../../kernel/request-context.js'; // no session cookie (getSessionFromCtx short-circuits on a missing cookie). const DUMMY_DATABASE_URL = 'postgres://test:test@127.0.0.1:1/create_app_test'; +const fixture = (name: string) => + fileURLToPath(new URL(`./fixtures/${name}-plugin.ts`, import.meta.url)); + describe('createApp - distributed-only durable seams (ADR-0030)', () => { it('throws a clear, actionable error when no durable seam is bound', async () => { await expect(createApp({ plugins: [], databaseUrl: DUMMY_DATABASE_URL })).rejects.toThrow( @@ -236,8 +242,6 @@ describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is } }); - const fixture = (name: string) => - fileURLToPath(new URL(`./fixtures/${name}-plugin.ts`, import.meta.url)); // A static list, as a consumer that never calls loadExtensions() passes it. const plugins: PluginEntry[] = [ { id: 'fixture-base', path: fixture('base') }, @@ -315,6 +319,85 @@ describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is }); }); +describe('createApp - a split service fails closed on a control it would skip', () => { + const keys = ['REDIS_URL', 'SERVICE_MANIFEST', 'SERVICE_NAME'] as const; + const saved = keys.map((key) => [key, process.env[key]] as const); + + afterEach(() => { + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + }); + + const entry = (id: string, name: string): PluginEntry => ({ id, path: fixture(name) }); + const limited = entry('fixture-limited', 'limited'); + const limits = entry('fixture-limits', 'limits'); + const admin = entry('fixture-admin', 'admin'); + const iam = entry('iam', 'iam'); + const identity = entry('identity', 'identity'); + + /** Boots `plugins` under `manifest` (none: a full boot) and closes the app again. */ + async function boot( + plugins: PluginEntry[], + manifest?: string, + configure?: (container: Container) => void, + ) { + process.env['REDIS_URL'] = redisUrlForWorker(); + delete process.env['SERVICE_MANIFEST']; + delete process.env['SERVICE_NAME']; + if (manifest) { + process.env['SERVICE_MANIFEST'] = manifest; + process.env['SERVICE_NAME'] = 'fixture'; + } + const created = await createApp({ plugins, databaseUrl: DUMMY_DATABASE_URL }, (c) => { + c.register(JOB_QUEUE, () => makeJobQueue()); + configure?.(c); + }); + await created.close(); + } + + it("refuses a module's unbound control port, naming the module that binds it", async () => { + await expect(boot([limited, limits], 'fixture-limited')).rejects.toThrow( + /- fixture-limited runs without FIXTURE_LIMITS \(bound by fixture-limits\): fixture limits go unenforced\nAdd them: SERVICE_MANIFEST=fixture-limited,fixture-limits/, + ); + }); + + it('boots once the manifest has that module, or the composition root binds the port', async () => { + await expect( + boot([limited, limits], 'fixture-limited,fixture-limits'), + ).resolves.toBeUndefined(); + await expect( + boot([limited, limits], 'fixture-limited', (c) => + c.registerUnsafe(FIXTURE_LIMITS, () => ({ check: () => true })), + ), + ).resolves.toBeUndefined(); + }); + + it('refuses admin routes without the ports AdminGuard checks them with', async () => { + await expect(boot([admin, iam, identity], 'fixture-admin')).rejects.toThrow( + /SERVICE_MANIFEST=fixture-admin serves admin routes[\s\S]*- AdminGuard runs without ADMIN_PERMISSION_RESOLVER \(bound by iam\)[\s\S]*- AdminGuard runs without ADMIN_SECURITY_POLICY \(bound by identity\)[\s\S]*Add them: SERVICE_MANIFEST=fixture-admin,iam,identity/, + ); + }); + + it('boots admin routes once the manifest has iam and identity', async () => { + await expect( + boot([admin, iam, identity], 'fixture-admin,iam,identity'), + ).resolves.toBeUndefined(); + }); + + it('boots a full install unchanged, the controls left unbound', async () => { + await expect(boot([limited, admin])).resolves.toBeUndefined(); + }); + + it('boots a manifest whose plugin list has no module behind the controls', async () => { + await expect(boot([limited, admin], 'fixture-limited,fixture-admin')).resolves.toBeUndefined(); + }); +}); + describe('createApp - the player-activity stamp', () => { it('lands before the request that refreshed it returns', async () => { const saved = process.env['REDIS_URL']; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/admin-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/admin-plugin.ts new file mode 100644 index 000000000..9d749ebb9 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/admin-plugin.ts @@ -0,0 +1,14 @@ +import type { TokenCatalog } from '@openora/core/contracts'; +import { ADMIN_GUARD } from '../../../auth/index.js'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +// Resolves the guard in its router factory, as every module with admin routes does. +export default { + id: 'fixture-admin', + register(ctx) { + ctx.routers.add('fixture-admin', (c) => { + c.get(ADMIN_GUARD); + return {}; + }); + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/iam-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/iam-plugin.ts new file mode 100644 index 000000000..2564dc2bc --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/iam-plugin.ts @@ -0,0 +1,12 @@ +import { ADMIN_PERMISSION_RESOLVER, type TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export default { + id: 'iam', + register(ctx) { + ctx.provide(ADMIN_PERMISSION_RESOLVER, () => ({ + getGrants: async () => [], + isSuperAdmin: async () => false, + })); + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/identity-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/identity-plugin.ts new file mode 100644 index 000000000..215da4bb5 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/identity-plugin.ts @@ -0,0 +1,12 @@ +import { ADMIN_SECURITY_POLICY, type TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export default { + id: 'identity', + register(ctx) { + ctx.provide(ADMIN_SECURITY_POLICY, () => ({ + assertEnrolled: async () => {}, + assertSessionIntact: async () => {}, + })); + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/limited-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/limited-plugin.ts new file mode 100644 index 000000000..af1a53c55 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/limited-plugin.ts @@ -0,0 +1,11 @@ +import type { TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; +import { FIXTURE_LIMITS } from './limits-plugin.js'; + +export default { + id: 'fixture-limited', + controlPorts: [ + { port: FIXTURE_LIMITS, without: 'fixture limits go unenforced', boundBy: 'fixture-limits' }, + ], + register() {}, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/limits-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/limits-plugin.ts new file mode 100644 index 000000000..08eabe964 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/limits-plugin.ts @@ -0,0 +1,11 @@ +import { createToken, type TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export const FIXTURE_LIMITS = createToken<{ check: () => boolean }>('FIXTURE_LIMITS'); + +export default { + id: 'fixture-limits', + register(ctx) { + ctx.provide(FIXTURE_LIMITS, () => ({ check: () => true })); + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 6cf034245..5ca18094e 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -51,9 +51,18 @@ import { } from '@openora/core/contracts'; import { DrizzleService, DRIZZLE, DrizzleOutboxWriter, OutboxRelay } from '../db/index.js'; import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/index.js'; -import { loadPlugins, parseServiceManifest, type PluginEntry } from '../plugin-host/index.js'; -import { importPlugin } from '../plugin-host/load-plugins.js'; -import { selectServiceEntries } from '../plugin-host/service-manifest.js'; +import { + loadPlugins, + parseServiceManifest, + type ControlPort, + type PluginEntry, +} from '../plugin-host/index.js'; +import { importPlugin, unboundControlPorts, unboundControls } from '../plugin-host/load-plugins.js'; +import { + assertControlsBound, + assertModuleControlsBound, + selectServiceEntries, +} from '../plugin-host/service-manifest.js'; import { createMcpKernel } from '../mcp/index.js'; import { authorizeWithAdminGuard } from '../mcp/authorize.js'; import type { McpHttpTransport } from '../mcp/http-transport.js'; @@ -199,6 +208,29 @@ function requestMeta(c: Context, trustedProxies: TrustedProxies) { }; } +// The ports AdminGuard reads with has(). An install without iam or identity runs without them +// everywhere; a split service whose install has them must not, or an admin whose role was +// revoked in the database, or who never enrolled 2FA, still acts through it. +const ADMIN_GUARD_CONTROLS = [ + { + port: ADMIN_PERMISSION_RESOLVER, + boundBy: 'iam', + without: + 'grants come from the static role table, so an admin whose role was revoked still acts', + }, + { + port: ADMIN_SECURITY_POLICY, + boundBy: 'identity', + without: 'admins skip the 2FA enrolment and session-integrity checks', + }, +] as const satisfies readonly ControlPort[]; + +/** What a module needs beside it in a split service: its dependencies and its controls' modules. */ +async function needsInSplit(entry: PluginEntry) { + const plugin = await importPlugin(entry); + return [...(plugin.dependsOn ?? []), ...(plugin.controlPorts ?? []).map((p) => p.boundBy)]; +} + const MCP_MAX_BODY_BYTES = 1_048_576; const MCP_SDK_PACKAGE = '@modelcontextprotocol/sdk'; const MCP_SDK_VERSION = '1.29.0'; @@ -346,8 +378,10 @@ async function captureRawBody(req: Request): Promise { /** * The single composition root: wires the DI container, boots the plugins in * `config.plugins` that `SERVICE_MANIFEST` selects (every one when it is unset), and - * mounts the resulting oRPC router on a Hono app. `JOB_WORKERS` picks the job queues - * this process consumes (every one when it is unset). + * mounts the resulting oRPC router on a Hono app. Under a manifest, a control port - a + * selected module's `controlPorts`, or AdminGuard's once admin routes resolve it - left + * unbound while the plugin list has the module that binds it fails the boot. + * `JOB_WORKERS` picks the job queues this process consumes (every one when it is unset). * Production is distributed-only (ADR-0030, superseding ADR-0010/0014/0016/0028): * `REDIS_URL` auto-binds `JOB_QUEUE`/`RATE_LIMITER`/`CACHE`/`MESSAGE_BROKER`/ * `REALTIME_TRANSPORT` to their Redis-backed drivers - the broker being the @@ -373,6 +407,7 @@ export async function createApp( process.env['TRUSTED_PROXIES'], ); const jobWorkerSelection = parseJobWorkers(process.env['JOB_WORKERS']); + const manifest = parseServiceManifest(process.env['SERVICE_MANIFEST']); if (config.databaseUrl) { process.env['DATABASE_URL'] = config.databaseUrl; @@ -432,20 +467,28 @@ export async function createApp( // no second createAuth() over the same DB. authSchema is injected (not imported) because // the engine is domain-agnostic (ADR-0019/0025). container.register(AUTH_SESSION, (c) => new SessionResolver(c.get(DRIZZLE), config.authSchema)); - container.register( - ADMIN_GUARD, - (c) => - new AdminGuard( - c.get(DRIZZLE), - c.get(AUTH_SESSION), - // has() avoids throwing on an unbound token so boot works without the iam module. - c.has(ADMIN_PERMISSION_RESOLVER) ? c.get(ADMIN_PERMISSION_RESOLVER) : undefined, - c.get(EVENT_BUS), - // has() avoids throwing on an unbound token so boot works without the identity module. - c.has(IDENTITY_READER) ? c.get(IDENTITY_READER) : undefined, - c.has(ADMIN_SECURITY_POLICY) ? c.get(ADMIN_SECURITY_POLICY) : undefined, - ), - ); + container.register(ADMIN_GUARD, (c) => { + // Router factories resolve the guard at boot, so a split service that serves admin routes + // without the guard's controls fails to boot here; one resolved later refuses the same way. + if (manifest !== null) { + assertControlsBound( + config.plugins, + manifest, + 'serves admin routes without the ports AdminGuard checks them with', + unboundControls('AdminGuard', ADMIN_GUARD_CONTROLS, c), + ); + } + return new AdminGuard( + c.get(DRIZZLE), + c.get(AUTH_SESSION), + // has() avoids throwing on an unbound token so boot works without the iam module. + c.has(ADMIN_PERMISSION_RESOLVER) ? c.get(ADMIN_PERMISSION_RESOLVER) : undefined, + c.get(EVENT_BUS), + // has() avoids throwing on an unbound token so boot works without the identity module. + c.has(IDENTITY_READER) ? c.get(IDENTITY_READER) : undefined, + c.has(ADMIN_SECURITY_POLICY) ? c.get(ADMIN_SECURITY_POLICY) : undefined, + ); + }); if (config.igaming) { const igaming = config.igaming; container.register(IGAMING_CONFIG, () => igaming); @@ -460,7 +503,6 @@ export async function createApp( const corsOrigins = resolveCorsOrigins(config.cors); const bootLogger = createLogger('create-app'); - const manifest = parseServiceManifest(process.env['SERVICE_MANIFEST']); const plugins = await selectServiceEntries( config.plugins, manifest, @@ -474,6 +516,16 @@ export async function createApp( await configure?.(container); assertDurableSeamsBound(container); + // After configure(), so a port the composition root binds counts. + if (manifest !== null) { + const loaded = await Promise.all(plugins.map((e) => importPlugin(e))); + await assertModuleControlsBound( + config.plugins, + manifest, + unboundControlPorts(loaded, container), + needsInSplit, + ); + } // Sealed after configure() so neither an overlay, the composition root nor a holder of the // returned container can replace the wrapper that enforces IAM and audit, and bound before diff --git a/packages/core/src/wallet/plugin.ts b/packages/core/src/wallet/plugin.ts index f7b0eae67..05006d286 100644 --- a/packages/core/src/wallet/plugin.ts +++ b/packages/core/src/wallet/plugin.ts @@ -89,6 +89,9 @@ export default { // registered - so both ports are bound by then regardless of load order. id: 'wallet', dependsOn: ['identity', 'audit'], + controlPorts: [ + { port: RG_LIMITS, without: 'deposit and wager limits go unenforced', boundBy: 'compliance' }, + ], register(ctx) { // Set inside the router factory (container access); the job worker's handler // closes over this ref, same shape as pam/tag's evalSvc - subscriptions/workers diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index 41288bb21..1b57ff006 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -10,8 +10,9 @@ * pnpm create:service * pnpm create:service accounts identity,mail * - * The manifest lists every module its modules depend on and every module that binds a port - * they require; the boot names whatever is missing. The host bakes the service name as its + * The manifest lists every module its modules depend on, every module that binds a port they + * require, and every module behind a control they would otherwise skip (`controlPorts`, admin + * routes); the boot names whatever is missing. The host bakes the service name as its * SERVICE_NAME - its own Redis Streams consumer group. * * After scaffolding: `pnpm install`, build deps, then @@ -144,8 +145,13 @@ console.warn( ' SERVICE_NAME - audit writes every row twice. Until ADR-0045 lands, split modules no other process loads.', ); console.warn( - ' - The boot logs each optional port the manifest leaves unbound and what its module skips', + ' - A manifest that leaves out a module behind a control (RG limits, country checks, audit', ); console.warn( - ' without it (eg identity without compliance skips the country check). Read those lines.', + ' records, admin grants) refuses to boot and prints the modules to add: a service that', ); +console.warn(' loads identity also loads compliance and iam.'); +console.warn( + ' - The boot logs each other optional port the manifest leaves unbound and what its module', +); +console.warn(' skips without it. Read those lines.'); From c44cfe0f011c1468183a6488a845404c76a9739b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 16 Oct 2026 12:28:04 +0200 Subject: [PATCH 09/16] fix(core): name everything a split manifest leaves out in one boot error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manifest boot found what it missed in three places: dependsOn before loading, control ports after configure(), and AdminGuard's ports only when a router factory resolved the guard. SERVICE_MANIFEST=wallet took three boots to fix. A split that served no admin routes booted without iam and then failed every request that resolved the guard lazily (MCP authorize, for one), since the container does not cache a factory that throws. A manifest boot now checks dependencies before loading and the rest once its modules registered and configure() ran: required ports, the selected modules' controlPorts, and AdminGuard's ports on every manifest boot, admin routes or not. Whatever it finds fails the boot with one error that names each missing module and why, and one "Add them" line that also adds what those modules need in turn - their dependencies, the binders of their required ports and the modules behind their controls - so one edit fixes the manifest. The guard is then built once, at boot. Which module binds a required port is learned only after the boot has failed, by registering each plugin of the list into a container of its own. A plugin whose register() throws there - a module this process does not configure - counts with what it bound before the throw, and the error names it when that leaves a required port without a known binder. loadPlugins() takes requiredPorts: 'defer' so a manifest boot can fold required ports into that error; a full boot asserts them as before. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../rules/messaging-and-microservices.md | 2 +- AGENTS.md | 2 +- ...readiness-manifest-outbox-command-ports.md | 2 +- .../0044-per-process-job-worker-selection.md | 28 +- docs/platform/system-design.md | 9 +- .../__tests__/required-ports.test.ts | 26 +- .../__tests__/service-manifest.test.ts | 297 +++++++++----- .../src/server/plugin-host/load-plugins.ts | 38 +- .../server/plugin-host/service-manifest.ts | 361 +++++++++++------- .../runtime/__tests__/create-app.test.ts | 56 ++- .../__tests__/fixtures/dispatch-plugin.ts | 12 + .../__tests__/fixtures/split-plugin.ts | 14 + .../core/src/server/runtime/create-app.ts | 101 +++-- 13 files changed, 582 insertions(+), 366 deletions(-) create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/dispatch-plugin.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/split-plugin.ts diff --git a/.rulesync/rules/messaging-and-microservices.md b/.rulesync/rules/messaging-and-microservices.md index fccac85d5..0dabd0ded 100644 --- a/.rulesync/rules/messaging-and-microservices.md +++ b/.rulesync/rules/messaging-and-microservices.md @@ -36,7 +36,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. -- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or, once admin routes resolve `AdminGuard`, its permission resolver or security policy is unbound and the plugin list has the module that binds it - so a split that loads identity also loads compliance and iam. Without a manifest nothing changes. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or `AdminGuard`'s permission resolver or security policy - checked on every manifest boot, which builds the guard before it serves - is unbound and the plugin list has the module that binds it. Whatever is missing fails the boot once, with one manifest line adding every module needed and theirs; with the shipped modules every split carries iam, identity, audit, mail, compliance, wallet, gaming, player-management, exchange-rate and chat. Without a manifest nothing changes. - `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/AGENTS.md b/AGENTS.md index 3f88d202c..d126df220 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa - Money and any needed-now answer never travel over events. Use a synchronous, transactional command port. - `emit()` is best-effort. Use `emitInTransaction()` only when the transactional outbox is explicitly enabled by `OUTBOX_ENABLED`, `AMQP_URL`, or `RABBITMQ_URL`; those AMQP variables enable the outbox but do not bind an AMQP broker. - A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. -- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot names what is missing, and logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or, once admin routes resolve `AdminGuard`, its permission resolver or security policy is unbound and the plugin list has the module that binds it - so a split that loads identity also loads compliance and iam. Without a manifest nothing changes. +- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or `AdminGuard`'s permission resolver or security policy - checked on every manifest boot, which builds the guard before it serves - is unbound and the plugin list has the module that binds it. Whatever is missing fails the boot once, with one manifest line adding every module needed and theirs; with the shipped modules every split carries iam, identity, audit, mail, compliance, wallet, gaming, player-management, exchange-rate and chat. Without a manifest nothing changes. - `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued. - The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required. - `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels are prefixed with `SERVICE_NAME` for the same cross-deployment isolation reason as the streams broker's consumer group. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever. diff --git a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md index 361d8c70a..0e644f5ff 100644 --- a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md +++ b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md @@ -4,7 +4,7 @@ **Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default). **Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope). -> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included - and the boot names all that is missing. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy once admin routes resolve it, must be bound whenever the plugin list has the module that binds them ([ADR-0044](./0044-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0044](./0044-per-process-job-worker-selection.md). +> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0044](./0044-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0044](./0044-per-process-job-worker-selection.md). ## Context diff --git a/docs/adr/0044-per-process-job-worker-selection.md b/docs/adr/0044-per-process-job-worker-selection.md index 41f2b39a7..6e8e8ea74 100644 --- a/docs/adr/0044-per-process-job-worker-selection.md +++ b/docs/adr/0044-per-process-job-worker-selection.md @@ -63,15 +63,20 @@ nothing to enforce, so a full boot runs without it, as before. A split service i module runs in another process of the same install, and a split that skipped the control would let through what that process refuses. A plugin declares such a port in `controlPorts`, with the module that binds it, and a `SERVICE_MANIFEST` boot refuses to start while one is unbound and the -plugin list has that module. The error names the port and what its module skips, and prints the -manifest with the modules to add and what they need in turn. `AdminGuard` holds its own ports to -the same rule when admin routes resolve it: without `ADMIN_PERMISSION_RESOLVER` (iam) it decides -on the static role table, so an admin whose role was revoked still acts, and without -`ADMIN_SECURITY_POLICY` (identity) it skips the 2FA enrolment and session checks. Declared today: -`RG_LIMITS` (wallet, gaming), `GAME_GEO_CHECK` (gaming), `GEO_CHECK_COMMANDS` (identity, bonus, -notifications), `MCP_TOKEN_REVOCATION` (identity) and `AUDIT_WRITER` (mail). A port whose absence -already fails closed - no KYC policy means every gated withdrawal needs KYC, no risk tags send -auto-approval to a human - is not a control port. +plugin list has that module. Declared today: `RG_LIMITS` (wallet, gaming), `GAME_GEO_CHECK` +(gaming), `GEO_CHECK_COMMANDS` (identity, bonus, notifications), `MCP_TOKEN_REVOCATION` (identity) +and `AUDIT_WRITER` (mail). A port whose absence already fails closed - no KYC policy means every +gated withdrawal needs KYC, no risk tags send auto-approval to a human - is not a control port. +`AdminGuard` holds its own ports to the same rule on every manifest boot, which builds the guard +before it serves, admin routes or not: without `ADMIN_PERMISSION_RESOLVER` (iam) it decides on the +static role table, so an admin whose role was revoked still acts, and without +`ADMIN_SECURITY_POLICY` (identity) it skips the 2FA enrolment and session checks. + +**A manifest is fixed in one edit.** A manifest boot fails once for everything the manifest leaves +out - a dependency, the module that binds a required port, the module behind a control - naming +each with what its module skips, and prints one manifest line that adds them all and what they +need in turn. Which module binds a required port is learned by registering each plugin of the +list on its own, only once the boot has already failed. ### Alternatives considered @@ -96,6 +101,11 @@ auto-approval to a human - is not a control port. processes, so a queue excluded everywhere backs up silently. Queue-depth monitoring catches it. - Processes that share a `SERVICE_NAME` must load the same modules. The streams driver acks an event once per group, so a process without a handler for it loses the event for that group. +- When a manifest boot fails, every plugin in the list registers once more, each into a + container of its own, to learn which binds which port. A `register()` that does more than + declare - logs, reads configuration and throws - does so again. One that throws counts as + binding what it bound before the throw, and the error names it when a required port is left + without a known binder. **Neutral:** diff --git a/docs/platform/system-design.md b/docs/platform/system-design.md index ff589b619..abc81f714 100644 --- a/docs/platform/system-design.md +++ b/docs/platform/system-design.md @@ -284,10 +284,11 @@ flowchart TB ``` A manifest lists every module its modules depend on and every module that binds a port they -require; `createApp` applies it to any plugin list and names what is missing. It also logs each -optional port a manifest leaves unbound, with what the module skips without it, and refuses to -boot over a control port - an RG limit, a geo check, an audit record, AdminGuard's database -grants - whose module the plugin list has but the manifest leaves out. A split service +require; `createApp` applies it to any plugin list. It logs each optional port a manifest leaves +unbound, with what the module skips without it, and refuses to boot over a control port - an RG +limit, a geo check, an audit record, AdminGuard's database grants - whose module the plugin list +has but the manifest leaves out. Whatever is missing fails the boot once, with one manifest line +that adds it all. A split service needs a `SERVICE_NAME` of its own - its consumer group - while an instance that only takes a share of the queues keeps the replicas' `SERVICE_NAME`, so it competes for events rather than receiving a copy. `JOB_WORKERS` picks the queues a process consumes diff --git a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts index 8e6d69f5a..7a8944f61 100644 --- a/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/required-ports.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from 'vitest'; import { Container } from '../../kernel/index.js'; import { createToken, type TokenCatalog } from '@openora/core/contracts'; -import { assertRequiredPorts, unboundControlPorts, unboundOptionalPorts } from '../load-plugins.js'; +import { assertRequiredPorts, unboundOptionalPorts } from '../load-plugins.js'; import type { Plugin } from '../define-plugin.js'; const WALLET_COMMANDS = createToken<{ debit: () => void }>('WALLET_COMMANDS'); @@ -57,7 +57,7 @@ describe('unboundOptionalPorts', () => { }); }); -describe('unboundControlPorts', () => { +describe('unboundOptionalPorts with control ports', () => { const wallet: Plugin = { id: 'wallet', optionalPorts: [{ port: GEO_CHECK, without: 'nothing that matters here' }], @@ -67,27 +67,7 @@ describe('unboundControlPorts', () => { register: () => {}, }; - it('names the plugin, the port, what it skips and the module that binds the port', () => { - const container = new Container(); - - expect(unboundControlPorts([wallet], container)).toEqual([ - { - owner: 'wallet', - port: 'RG_LIMITS', - without: 'deposit limits go unenforced', - boundBy: 'compliance', - }, - ]); - }); - - it('is empty once the port is bound', () => { - const container = new Container(); - container.register(RG_LIMITS, () => ({ checkDeposit: () => {} })); - - expect(unboundControlPorts([wallet], container)).toEqual([]); - }); - - it('are logged with the optional ports', () => { + it('logs an unbound control port with the optional ones', () => { const container = new Container(); expect(unboundOptionalPorts([wallet], container).map(({ port }) => port)).toEqual([ diff --git a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts index 55d023438..cc2fe3d16 100644 --- a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts @@ -1,12 +1,15 @@ import { describe, it, expect, vi } from 'vitest'; +import { createToken, type Token, type TokenCatalog } from '@openora/core/contracts'; import { applyServiceManifest, - assertControlsBound, - assertModuleControlsBound, + assertServiceComplete, parseServiceManifest, selectServiceEntries, + type HostControl, + type ServiceManifestCheck, } from '../service-manifest.js'; -import type { PluginEntry, UnboundControl } from '../load-plugins.js'; +import type { PluginEntry } from '../load-plugins.js'; +import type { Plugin } from '../define-plugin.js'; const entries: PluginEntry[] = [ { id: 'identity', path: 'a' }, @@ -45,165 +48,247 @@ describe('applyServiceManifest', () => { }); }); -describe('selectServiceEntries', () => { - const graph: Record = { - audit: [], - identity: ['audit'], - wallet: ['identity', 'audit'], - gaming: ['wallet'], - rabbitmq: [], - bullmq: [], - }; - const all: PluginEntry[] = [ - { id: 'audit', path: 'a' }, - { id: 'identity', path: 'b' }, - { id: 'wallet', path: 'c' }, - { id: 'gaming', path: 'd' }, - { id: 'bullmq', path: 'e', kind: 'infra' }, - ]; - const readerOf = (deps: Record) => - vi.fn(async (entry: PluginEntry) => deps[entry.id] ?? []); +const MAIL = createToken('MAIL'); +const RG_LIMITS = createToken('RG_LIMITS'); +const PERMISSIONS = createToken('PERMISSIONS'); +const catalog = { MAIL, RG_LIMITS, PERMISSIONS } satisfies TokenCatalog; +type Catalog = typeof catalog; - it('returns every entry without a manifest and reads none of them', async () => { - const reader = readerOf(graph); +const plugin = (id: string, shape: Partial> = {}): Plugin => ({ + id, + register: () => {}, + ...shape, +}); +const binds = + (...ports: Token[]): Plugin['register'] => + (ctx) => { + for (const port of ports) { + ctx.provide(port, () => ({})); + } + }; - expect(await selectServiceEntries(all, null, reader)).toBe(all); - expect(reader).not.toHaveBeenCalled(); +const ADMIN_GUARD_CONTROL: HostControl = { + owner: 'AdminGuard', + port: PERMISSIONS, + without: 'grants come from the static role table', + boundBy: 'iam', +}; + +/** A plugin list of `plugins` (those named in `infra` as overlays), checked against `manifest`. */ +function checkOf( + plugins: Plugin[], + manifest: string[], + { + infra = [], + bound = [], + hostControls = [], + }: { infra?: string[]; bound?: symbol[]; hostControls?: HostControl[] } = {}, +) { + const byId = new Map(plugins.map((p) => [p.id, p])); + const readPlugin = vi.fn(async (entry: PluginEntry) => { + const found = byId.get(entry.id); + if (!found) { + throw new Error(`no fixture plugin ${entry.id}`); + } + return found; }); + const check: ServiceManifestCheck = { + entries: plugins.map((p) => ({ + id: p.id, + path: p.id, + ...(infra.includes(p.id) ? { kind: 'infra' as const } : {}), + })), + manifest, + readPlugin, + hostControls, + isBound: (port) => bound.includes(port), + }; + return { check, readPlugin }; +} + +describe('selectServiceEntries', () => { + const graph = [ + plugin('audit'), + plugin('identity', { dependsOn: ['audit'] }), + plugin('wallet', { dependsOn: ['identity', 'audit'] }), + plugin('gaming', { dependsOn: ['wallet'] }), + plugin('bullmq'), + ]; it('boots a manifest that lists its dependencies, reading only what it selects', async () => { - const reader = readerOf(graph); + const { check, readPlugin } = checkOf(graph, ['audit', 'identity', 'wallet'], { + infra: ['bullmq'], + }); - const selected = await selectServiceEntries(all, ['audit', 'identity', 'wallet'], reader); + const selected = await selectServiceEntries(check); expect(selected.map((e) => e.id)).toEqual(['audit', 'identity', 'wallet', 'bullmq']); - expect(reader.mock.calls.map(([e]) => e.id).sort()).toEqual( + expect(readPlugin.mock.calls.map(([e]) => e.id).sort()).toEqual( ['audit', 'bullmq', 'identity', 'wallet'].sort(), ); }); it('names every left-out dependency, transitive ones included, in one error', async () => { - const run = selectServiceEntries(all, ['gaming'], readerOf(graph)); + const { check } = checkOf(graph, ['gaming']); - await expect(run).rejects.toThrow( - /SERVICE_MANIFEST=gaming leaves out[\s\S]*- wallet \(required by gaming\)[\s\S]*- identity \(required by wallet\)[\s\S]*- audit \(required by wallet, identity\)[\s\S]*Add them: SERVICE_MANIFEST=gaming,wallet,identity,audit/, + await expect(selectServiceEntries(check)).rejects.toThrow( + /SERVICE_MANIFEST=gaming leaves out[\s\S]*- wallet \(required by gaming\)[\s\S]*- identity \(required by wallet\)[\s\S]*- audit \(required by wallet, identity\)[\s\S]*Add them: SERVICE_MANIFEST=gaming,wallet,identity,audit$/m, ); }); it("counts an infra overlay's dependencies", async () => { - const run = selectServiceEntries(all, ['audit'], readerOf({ ...graph, bullmq: ['identity'] })); + const overlay = graph.map((p) => (p.id === 'bullmq' ? { ...p, dependsOn: ['identity'] } : p)); + const { check } = checkOf(overlay, ['audit'], { infra: ['bullmq'] }); - await expect(run).rejects.toThrow(/- identity \(required by bullmq\)/); + await expect(selectServiceEntries(check)).rejects.toThrow(/- identity \(required by bullmq\)/); }); it('flags a dependency the plugin list does not have at all', async () => { - const run = selectServiceEntries(all, ['audit'], readerOf({ audit: ['ghost'] })); + const { check } = checkOf([plugin('audit', { dependsOn: ['ghost'] })], ['audit']); + const run = selectServiceEntries(check); await expect(run).rejects.toThrow(/- ghost \(required by audit\) - not in the plugin list/); await expect(run).rejects.not.toThrow(/Add them/); }); it('stops on a cycle among left-out modules', async () => { - const cyclic = { gaming: ['wallet'], wallet: ['identity'], identity: ['wallet'] }; + const cyclic = [ + plugin('gaming', { dependsOn: ['wallet'] }), + plugin('wallet', { dependsOn: ['identity'] }), + plugin('identity', { dependsOn: ['wallet'] }), + ]; - await expect(selectServiceEntries(all, ['gaming'], readerOf(cyclic))).rejects.toThrow( + await expect(selectServiceEntries(checkOf(cyclic, ['gaming']).check)).rejects.toThrow( /Add them: SERVICE_MANIFEST=gaming,wallet,identity$/m, ); }); it('still refuses an unknown id before reading anything', async () => { - const reader = readerOf(graph); + const { check, readPlugin } = checkOf(graph, ['nope']); - await expect(selectServiceEntries(all, ['nope'], reader)).rejects.toThrow(/unknown module id/); - expect(reader).not.toHaveBeenCalled(); + await expect(selectServiceEntries(check)).rejects.toThrow(/unknown module id/); + expect(readPlugin).not.toHaveBeenCalled(); }); }); -describe('assertModuleControlsBound', () => { - const all: PluginEntry[] = [ - { id: 'audit', path: 'a' }, - { id: 'identity', path: 'b' }, - { id: 'wallet', path: 'c' }, - { id: 'compliance', path: 'd' }, - { id: 'gaming', path: 'e' }, - { id: 'iam', path: 'f' }, - { id: 'bullmq', path: 'g', kind: 'infra' }, +describe('the one error a manifest boot fails with', () => { + const install = [ + plugin('audit'), + plugin('identity', { requiresPorts: [MAIL] }), + plugin('mail', { dependsOn: ['audit'], register: binds(MAIL) }), + plugin('wallet', { + dependsOn: ['identity'], + controlPorts: [{ port: RG_LIMITS, without: 'limits go unenforced', boundBy: 'compliance' }], + }), + plugin('compliance', { dependsOn: ['wallet', 'gaming'], register: binds(RG_LIMITS) }), + plugin('gaming', { dependsOn: ['wallet'] }), + plugin('iam', { dependsOn: ['identity'], register: binds(PERMISSIONS) }), ]; - // What each module needs beside it in a split: dependencies and the modules behind its controls. - const needs: Record = { - identity: ['audit'], - wallet: ['identity', 'audit', 'compliance'], - compliance: ['identity', 'wallet', 'gaming', 'audit'], - gaming: ['wallet', 'compliance', 'iam'], - iam: ['identity', 'audit'], - }; - const needsOf = vi.fn(async (entry: PluginEntry) => needs[entry.id] ?? []); - const rgLimits: UnboundControl = { - owner: 'wallet', - port: 'RG_LIMITS', - without: 'deposit and wager limits go unenforced', - boundBy: 'compliance', - }; + const withoutModules = (...ids: string[]) => install.filter((p) => !ids.includes(p.id)); + + it('adds every module a left-out dependency, a required port and a control need', async () => { + const { check } = checkOf(install, ['wallet'], { hostControls: [ADMIN_GUARD_CONTROL] }); + + await expect(selectServiceEntries(check)).rejects.toThrow( + 'SERVICE_MANIFEST=wallet leaves out what this service needs:\n' + + ' - identity (required by wallet, iam)\n' + + ' - wallet runs without RG_LIMITS (bound by compliance): limits go unenforced\n' + + ' - identity requires MAIL (bound by mail)\n' + + ' - AdminGuard runs without PERMISSIONS (bound by iam): grants come from the static role table\n' + + ' - gaming (required by compliance)\n' + + ' - audit (required by mail)\n' + + 'Add them: SERVICE_MANIFEST=wallet,identity,compliance,mail,iam,gaming,audit\n' + + 'A split service may not skip a control the rest of its install enforces', + ); + }); + + it('refuses an unbound required port, naming the module that binds it', async () => { + const { check } = checkOf(install, ['identity']); - it('refuses the boot, naming the module behind the port and all it needs in turn', async () => { - const run = assertModuleControlsBound( - all, - ['wallet', 'identity', 'audit'], - [rgLimits], - needsOf, + await expect(assertServiceComplete(check)).rejects.toThrow( + /- identity requires MAIL \(bound by mail\)\n {2}- audit \(required by mail\)\nAdd them: SERVICE_MANIFEST=identity,mail,audit\nA split service loads every module/, ); + }); - await expect(run).rejects.toThrow( - /- wallet runs without RG_LIMITS \(bound by compliance\): deposit and wager limits go unenforced\nAdd them: SERVICE_MANIFEST=wallet,identity,audit,compliance,gaming,iam\n/, + it('refuses an unbound control the plugin list has, adding its module and what it needs', async () => { + const { check } = checkOf(install, ['wallet', 'identity', 'mail', 'audit'], { bound: [MAIL] }); + + await expect(assertServiceComplete(check)).rejects.toThrow( + /- wallet runs without RG_LIMITS \(bound by compliance\): limits go unenforced\n {2}- gaming \(required by compliance\)\nAdd them: SERVICE_MANIFEST=wallet,identity,mail,audit,compliance,gaming\n/, ); }); - it('boots an install whose plugin list has no module behind the port', async () => { - const withoutCompliance = all.filter((e) => e.id !== 'compliance'); + it("refuses the host's controls on a split that needs nothing else", async () => { + const { check } = checkOf(install, ['audit'], { hostControls: [ADMIN_GUARD_CONTROL] }); - await expect( - assertModuleControlsBound(withoutCompliance, ['wallet'], [rgLimits], needsOf), - ).resolves.toBeUndefined(); + await expect(assertServiceComplete(check)).rejects.toThrow( + /- AdminGuard runs without PERMISSIONS \(bound by iam\)[\s\S]*Add them: SERVICE_MANIFEST=audit,iam,identity,mail\n/, + ); }); - it('boots when every control is bound', async () => { - await expect(assertModuleControlsBound(all, ['wallet'], [], needsOf)).resolves.toBeUndefined(); + it('boots, reading only what it selects, once every port is bound', async () => { + const { check, readPlugin } = checkOf(install, ['wallet', 'identity', 'mail', 'audit'], { + bound: [MAIL, RG_LIMITS, PERMISSIONS], + hostControls: [ADMIN_GUARD_CONTROL], + }); + + await expect(assertServiceComplete(check)).resolves.toBeUndefined(); + expect(readPlugin.mock.calls.map(([e]) => e.id)).toEqual([ + 'audit', + 'identity', + 'mail', + 'wallet', + ]); }); -}); -describe('assertControlsBound', () => { - const entries: PluginEntry[] = [ - { id: 'wallet', path: 'a' }, - { id: 'iam', path: 'b' }, - ]; - const resolver: UnboundControl = { - owner: 'AdminGuard', - port: 'ADMIN_PERMISSION_RESOLVER', - without: 'grants come from the static role table', - boundBy: 'iam', - }; + it('boots a control whose module the plugin list does not have', async () => { + const { check } = checkOf( + withoutModules('compliance', 'iam'), + ['wallet', 'identity', 'mail', 'audit'], + { bound: [MAIL], hostControls: [ADMIN_GUARD_CONTROL] }, + ); + + await expect(assertServiceComplete(check)).resolves.toBeUndefined(); + }); - it('names the subject, the port and the module to add', () => { - expect(() => - assertControlsBound(entries, ['wallet'], 'serves admin routes without its ports', [resolver]), - ).toThrow( - /SERVICE_MANIFEST=wallet serves admin routes without its ports:\n {2}- AdminGuard runs without ADMIN_PERMISSION_RESOLVER \(bound by iam\)[\s\S]*Add them: SERVICE_MANIFEST=wallet,iam/, + it('still refuses, offering nothing to add, when the selected module does not bind it', async () => { + const unbinding = install.map((p) => (p.id === 'compliance' ? { ...p, register: binds() } : p)); + const { check } = checkOf( + unbinding, + ['wallet', 'identity', 'mail', 'audit', 'compliance', 'gaming'], + { bound: [MAIL] }, ); + const run = assertServiceComplete(check); + + await expect(run).rejects.toThrow(/- wallet runs without RG_LIMITS \(bound by compliance\)/); + await expect(run).rejects.not.toThrow(/Add them/); }); - it('still refuses, offering nothing to add, when the manifest selects the module', () => { - const refuse = () => - assertControlsBound(entries, ['wallet', 'iam'], 'serves admin routes', [resolver]); + it('names the module that failed to register when no binder of a required port is known', async () => { + const unconfigured = install.map((p) => + p.id === 'mail' + ? { + ...p, + register: () => { + throw new Error('mail: AUTH_SECRET is too short'); + }, + } + : p, + ); + const { check } = checkOf(unconfigured, ['identity']); - expect(refuse).toThrow(/AdminGuard runs without ADMIN_PERMISSION_RESOLVER/); - expect(refuse).not.toThrow(/Add them/); + await expect(assertServiceComplete(check)).rejects.toThrow( + /- identity requires MAIL, which no module in the plugin list binds[\s\S]*failed to register, so the ports they bind are unknown:\n {2}- mail: mail: AUTH_SECRET is too short$/, + ); }); - it('passes when the plugin list has no module behind the port', () => { - expect(() => - assertControlsBound([{ id: 'wallet', path: 'a' }], ['wallet'], 'serves admin routes', [ - resolver, - ]), - ).not.toThrow(); + it('names a required port no module in the plugin list binds', async () => { + const { check } = checkOf(withoutModules('mail'), ['identity']); + const run = assertServiceComplete(check); + + await expect(run).rejects.toThrow( + /- identity requires MAIL, which no module in the plugin list binds/, + ); + await expect(run).rejects.not.toThrow(/Add them/); }); }); diff --git a/packages/core/src/server/plugin-host/load-plugins.ts b/packages/core/src/server/plugin-host/load-plugins.ts index b656d7709..8d56bc7d1 100644 --- a/packages/core/src/server/plugin-host/load-plugins.ts +++ b/packages/core/src/server/plugin-host/load-plugins.ts @@ -1,7 +1,7 @@ import type { Container } from '../kernel/index.js'; import { createLogger } from '../kernel/logger.js'; import type { TokenCatalog } from '@openora/core/contracts'; -import type { ControlPort, Plugin } from './define-plugin.js'; +import type { Plugin } from './define-plugin.js'; import { ModuleRegistryImpl } from './module-registry.js'; const logger = createLogger('plugin-host'); @@ -104,9 +104,15 @@ export async function importPlugin(entry: PluginEntry): return plugin; } +/** + * Imports `entries`, registers them in dependency order and checks their ports. With + * `requiredPorts: 'defer'` an unbound required port is left to the caller, which a + * SERVICE_MANIFEST boot folds into one error with everything else the manifest leaves out. + */ export async function loadPlugins( entries: PluginEntry[], container: Container, + { requiredPorts = 'assert' }: { requiredPorts?: 'assert' | 'defer' } = {}, ): Promise> { validateEntries(entries); const plugins: Plugin[] = []; @@ -124,7 +130,9 @@ export async function loadPlugins( registry.setOwner(null); } - assertRequiredPorts(ordered, container); + if (requiredPorts === 'assert') { + assertRequiredPorts(ordered, container); + } for (const { plugin, port, without } of unboundOptionalPorts(ordered, container)) { logger.warn({ plugin, port }, `${plugin} runs without ${port}: ${without}`); } @@ -132,7 +140,7 @@ export async function loadPlugins( return registry; } -function portName(port: symbol) { +export function portName(port: symbol) { return String(port.description ?? port); } @@ -152,30 +160,6 @@ export function unboundOptionalPorts( ); } -/** A control port nothing bound, with what its owner skips and the module that binds it. */ -export type UnboundControl = { owner: string; port: string; without: string; boundBy: string }; - -/** The ports among `controls` that `container` has no binding for, named for `owner`. */ -export function unboundControls( - owner: string, - controls: readonly ControlPort[], - container: Container, -): UnboundControl[] { - return controls - .filter(({ port }) => !container.has(port)) - .map(({ port, without, boundBy }) => ({ owner, port: portName(port), without, boundBy })); -} - -/** The `controlPorts` of `plugins` that nothing bound. */ -export function unboundControlPorts( - plugins: Plugin[], - container: Container, -): UnboundControl[] { - return plugins.flatMap((plugin) => - unboundControls(plugin.id, plugin.controlPorts ?? [], container), - ); -} - /** * Boot-time fail-fast for cross-domain ports (ADR-0024). A domain depends ONLY on * the foundation and reaches another domain through a port token the other domain diff --git a/packages/core/src/server/plugin-host/service-manifest.ts b/packages/core/src/server/plugin-host/service-manifest.ts index 79e3152eb..341df7447 100644 --- a/packages/core/src/server/plugin-host/service-manifest.ts +++ b/packages/core/src/server/plugin-host/service-manifest.ts @@ -1,4 +1,8 @@ -import { validateEntries, type PluginEntry, type UnboundControl } from './load-plugins.js'; +import type { TokenCatalog } from '@openora/core/contracts'; +import { Container } from '../kernel/index.js'; +import type { ControlPort, Plugin } from './define-plugin.js'; +import { portName, validateEntries, type PluginEntry } from './load-plugins.js'; +import { ModuleRegistryImpl } from './module-registry.js'; export function parseServiceManifest(raw: string | undefined): string[] | null { if (raw === undefined) { @@ -32,166 +36,251 @@ export function applyServiceManifest( return entries.filter((e) => e.kind === 'infra' || wanted.has(e.id)); } -/** Reads the ids of the modules that the plugin an entry names needs beside it. */ -export type DependsOnReader = (entry: PluginEntry) => Promise; +/** A control the host process holds a manifest boot to, beside its plugins' `controlPorts`. */ +export type HostControl = ControlPort & { + /** What runs without the port, as the boot error names it. */ + owner: string; +}; -/** - * Every module that `start` needs, directly or through another, and that `selectedIds` leaves - * out, each with the ids that need it. A left-out module the plugin list has is read for its own - * needs; one it lacks is only named. - */ -async function leftOut( - start: readonly PluginEntry[], - selectedIds: ReadonlySet, - byId: ReadonlyMap, - needsOf: DependsOnReader, -): Promise> { - const missing = new Map(); - const toRead = [...start]; - // Grows while it is walked: a left-out module is read for its own needs. - for (const entry of toRead) { - for (const dep of await needsOf(entry)) { - if (selectedIds.has(dep)) { - continue; - } - const requiredBy = missing.get(dep); - if (requiredBy) { - requiredBy.push(entry.id); - continue; - } - missing.set(dep, [entry.id]); - const left = byId.get(dep); - if (left) { - toRead.push(left); - } - } +/** What a SERVICE_MANIFEST boot is checked against. */ +export type ServiceManifestCheck = { + /** The whole plugin list: the manifest's modules and every one it leaves out. */ + entries: readonly PluginEntry[]; + manifest: readonly string[]; + readPlugin: (entry: PluginEntry) => Promise>; + hostControls: readonly HostControl[]; + /** Whether the process has bound the port so far. */ + isBound: (port: C[keyof C]) => boolean; +}; + +type MissingModule = { kind: 'module'; id: string; requiredBy: string[]; listed: boolean }; + +type Finding = + | MissingModule + | { kind: 'required'; owner: string; port: string; boundBy: string | undefined } + | { kind: 'control'; owner: string; port: string; boundBy: string; without: string }; + +function findingLine(finding: Finding) { + switch (finding.kind) { + case 'module': + return finding.listed + ? ` - ${finding.id} (required by ${finding.requiredBy.join(', ')})` + : ` - ${finding.id} (required by ${finding.requiredBy.join(', ')}) - not in the plugin list either`; + case 'required': + return finding.boundBy === undefined + ? ` - ${finding.owner} requires ${finding.port}, which no module in the plugin list binds` + : ` - ${finding.owner} requires ${finding.port} (bound by ${finding.boundBy})`; + case 'control': + return ` - ${finding.owner} runs without ${finding.port} (bound by ${finding.boundBy}): ${finding.without}`; } - return missing; } /** - * The entries a process boots: all of them without a manifest, otherwise the manifest's modules - * plus every infra overlay. A manifest that leaves out a module the selected ones depend on - - * directly or through another left-out module - fails with one error that names them all, so a - * split is fixed in one edit rather than one boot per missing module. Only the selected entries - * are read on the way to success; a left-out one is read only to follow its own dependencies. + * The modules that bind each of `ports`, in plugin-list order. Each plugin registers into a + * container of its own, so a later binding hides no earlier one. A plugin whose registration + * throws - a module this process does not load may lack its configuration - counts with what it + * bound before the throw, and is listed in `failed` with the error. */ -export async function selectServiceEntries( - entries: PluginEntry[], - manifest: readonly string[] | null, - dependsOnOf: DependsOnReader, -): Promise { - if (manifest === null) { - return entries; - } - validateEntries(entries); - const selected = applyServiceManifest(entries, manifest); - - const byId = new Map(entries.map((e) => [e.id, e])); - const missing = await leftOut(selected, new Set(selected.map((e) => e.id)), byId, dependsOnOf); - if (missing.size === 0) { - return selected; +async function portBinders( + plugins: readonly Plugin[], + ports: readonly C[keyof C][], +) { + const binders = new Map(); + const failed: string[] = []; + for (const plugin of plugins) { + const container = new Container(); + const registry = new ModuleRegistryImpl(container); + registry.setOwner(plugin.id); + try { + await plugin.register(registry); + } catch (err) { + failed.push(` - ${plugin.id}: ${err instanceof Error ? err.message : String(err)}`); + } + for (const port of ports.filter((p) => container.has(p))) { + binders.set(port, [...(binders.get(port) ?? []), plugin.id]); + } } - - const lines = [...missing].map(([id, requiredBy]) => - byId.has(id) - ? ` - ${id} (required by ${requiredBy.join(', ')})` - : ` - ${id} (required by ${requiredBy.join(', ')}) - not in the plugin list either`, - ); - const addable = [...missing.keys()].filter((id) => byId.has(id)); - throw new Error( - `SERVICE_MANIFEST=${manifest.join(',')} leaves out modules the selected ones depend on:\n` + - `${lines.join('\n')}\n` + - (addable.length > 0 - ? `Add them: SERVICE_MANIFEST=${[...manifest, ...addable].join(',')}\n` - : '') + - 'A process also needs the module that binds each port its modules require.', - ); + return { binders, failed }; } /** - * The unbound controls a manifest boot refuses, and the modules that bind them which it leaves - * out. Only a control whose module the plugin list has is refused: that module then runs in - * another process of the same install, which enforces the control, so this process would let - * through what that one refuses. A list without the module has the control nowhere, and the - * boot goes on as a full one would. + * Everything the manifest leaves out. From the selected modules it follows each `dependsOn`, + * the module that binds each required port nothing bound, and the module behind each unbound + * control the plugin list has - the host's controls included - then does the same for every + * module that adds, until nothing new is needed. `added` is what to append to the manifest. */ -function refusedControls( - entries: readonly PluginEntry[], - manifest: readonly string[], - unbound: readonly UnboundControl[], +function shortfall( + check: ServiceManifestCheck, + plugins: ReadonlyMap>, + binders: ReadonlyMap, ) { - const listed = new Set(entries.map((e) => e.id)); - const refused = unbound.filter(({ boundBy }) => listed.has(boundBy)); - const selectedIds = new Set(applyServiceManifest([...entries], manifest).map((e) => e.id)); - const binders = [...new Set(refused.map(({ boundBy }) => boundBy))].filter( - (id) => !selectedIds.has(id), + const selected = new Set( + applyServiceManifest([...check.entries], check.manifest).map((e) => e.id), ); - return { refused, binders, selectedIds }; + const loaded = new Set(selected); + const added: string[] = []; + const findings: Finding[] = []; + const missing = new Map(); + + const load = (id: string) => { + if (plugins.has(id) && !loaded.has(id)) { + loaded.add(id); + added.push(id); + } + }; + const bound = (port: C[keyof C]) => + check.isBound(port) || (binders.get(port) ?? []).some((id) => loaded.has(id)); + const requireModule = (id: string, by: string) => { + const known = missing.get(id); + if (known) { + known.requiredBy.push(by); + return; + } + const finding: MissingModule = { + kind: 'module', + id, + requiredBy: [by], + listed: plugins.has(id), + }; + missing.set(id, finding); + findings.push(finding); + load(id); + }; + const requirePort = (owner: string, port: C[keyof C]) => { + if (bound(port)) { + return; + } + const boundBy = binders.get(port)?.at(-1); + findings.push({ kind: 'required', owner, port: portName(port), boundBy }); + if (boundBy !== undefined) { + load(boundBy); + } + }; + const requireControl = (owner: string, { port, without, boundBy }: ControlPort) => { + if (bound(port) || !plugins.has(boundBy)) { + return; + } + findings.push({ kind: 'control', owner, port: portName(port), boundBy, without }); + load(boundBy); + }; + + const dependenciesRead = new Set(); + const portsRead = new Set(); + let hostRead = false; + for (;;) { + // A Set's iterator also visits what is added while it runs, so this closes over dependsOn. + for (const id of loaded) { + if (dependenciesRead.has(id)) { + continue; + } + dependenciesRead.add(id); + for (const dep of plugins.get(id)?.dependsOn ?? []) { + if (!selected.has(dep)) { + requireModule(dep, id); + } + } + } + const unread = [...loaded].filter((id) => !portsRead.has(id)); + if (unread.length === 0 && hostRead) { + return { findings, added }; + } + for (const id of unread) { + portsRead.add(id); + const plugin = plugins.get(id); + for (const port of plugin?.requiresPorts ?? []) { + requirePort(id, port); + } + for (const control of plugin?.controlPorts ?? []) { + requireControl(id, control); + } + } + if (!hostRead) { + hostRead = true; + for (const control of check.hostControls) { + requireControl(control.owner, control); + } + } + } } -function controlsError( - manifest: readonly string[], - subject: string, - refused: readonly UnboundControl[], - add: readonly string[], -) { - const lines = refused.map( - ({ owner, port, without, boundBy }) => - ` - ${owner} runs without ${port} (bound by ${boundBy}): ${without}`, - ); +/** + * The one error a manifest boot fails with: every module the manifest leaves out and why, and a + * manifest line that adds them all, so one edit fixes it. Reads and registers every plugin in + * the list, each on its own, to learn which module binds which port. + */ +async function shortfallError(check: ServiceManifestCheck) { + const plugins = new Map>(); + for (const entry of check.entries) { + plugins.set(entry.id, await check.readPlugin(entry)); + } + const ports = [ + ...[...plugins.values()].flatMap((plugin) => [ + ...(plugin.requiresPorts ?? []), + ...(plugin.controlPorts ?? []).map(({ port }) => port), + ]), + ...check.hostControls.map(({ port }) => port), + ]; + const { binders, failed } = await portBinders([...plugins.values()], ports); + const { findings, added } = shortfall(check, plugins, binders); + const manifest = check.manifest.join(','); + const unknownBinder = findings.some((f) => f.kind === 'required' && f.boundBy === undefined); return new Error( - `SERVICE_MANIFEST=${manifest.join(',')} ${subject}:\n` + - `${lines.join('\n')}\n` + - (add.length > 0 ? `Add them: SERVICE_MANIFEST=${[...manifest, ...add].join(',')}\n` : '') + - 'A split service may not skip a control the rest of its install enforces: load the ' + - 'module that binds each port, or bind the port in an overlay.', + `SERVICE_MANIFEST=${manifest} leaves out what this service needs:\n` + + `${findings.map(findingLine).join('\n')}\n` + + (added.length > 0 ? `Add them: SERVICE_MANIFEST=${[manifest, ...added].join(',')}\n` : '') + + (findings.some(({ kind }) => kind === 'control') + ? 'A split service may not skip a control the rest of its install enforces: load the ' + + 'module that binds each port, or bind the port in an overlay.' + : 'A split service loads every module its modules depend on and every module that ' + + 'binds a port they require.') + + (unknownBinder && failed.length > 0 + ? `\nThese modules failed to register, so the ports they bind are unknown:\n${failed.join('\n')}` + : ''), ); } /** - * Fails a SERVICE_MANIFEST boot on an unbound control whose module the plugin list has, naming - * the owner, the port, what the owner skips and the module that binds it. `subject` says what - * the controls guard, after the manifest: "serves admin routes without ...". + * The entries a manifest boot loads: the manifest's modules plus every infra overlay. A + * manifest that leaves out a module they depend on fails before anything registers, with the + * error `assertServiceComplete` gives. Only the selected entries are read on the way to success. */ -export function assertControlsBound( - entries: readonly PluginEntry[], - manifest: readonly string[], - subject: string, - unbound: readonly UnboundControl[], -): void { - const { refused, binders } = refusedControls(entries, manifest, unbound); - if (refused.length > 0) { - throw controlsError(manifest, subject, refused, binders); +export async function selectServiceEntries(check: ServiceManifestCheck) { + validateEntries(check.entries); + const selected = applyServiceManifest([...check.entries], check.manifest); + const ids = new Set(selected.map((e) => e.id)); + for (const entry of selected) { + const { dependsOn = [] } = await check.readPlugin(entry); + if (dependsOn.some((dep) => !ids.has(dep))) { + throw await shortfallError(check); + } } + return selected; } /** - * `assertControlsBound` for the selected modules' `controlPorts`. Its manifest line also adds - * every module the binding ones need in turn, as `needsOf` reads it, so a split is fixed in one - * edit, as with a missing dependency. + * Fails a manifest boot, once its modules registered and the composition root bound its own + * ports, while a selected module's required port is unbound, or a control - a selected module's + * `controlPorts` entry or one of the host's - is unbound and the plugin list has the module + * that binds it. That module then runs in another process of the same install, which enforces + * the control, so this one would let through what that one refuses. A list without the module + * has the control nowhere, and the boot goes on as a full one would. The error names everything + * the manifest leaves out, with a manifest line that boots. */ -export async function assertModuleControlsBound( - entries: PluginEntry[], - manifest: readonly string[], - unbound: readonly UnboundControl[], - needsOf: DependsOnReader, -): Promise { - const { refused, binders, selectedIds } = refusedControls(entries, manifest, unbound); - if (refused.length === 0) { - return; - } - const byId = new Map(entries.map((e) => [e.id, e])); - const needed = await leftOut( - binders.flatMap((id) => byId.get(id) ?? []), - new Set([...selectedIds, ...binders]), - byId, - needsOf, - ); - throw controlsError( - manifest, - 'leaves unbound controls the rest of the install enforces', - refused, - [...binders, ...[...needed.keys()].filter((id) => byId.has(id))], +export async function assertServiceComplete( + check: ServiceManifestCheck, +) { + const listed = new Set(check.entries.map((e) => e.id)); + const selected = await Promise.all( + applyServiceManifest([...check.entries], check.manifest).map((e) => check.readPlugin(e)), ); + const controls = [ + ...selected.flatMap((plugin) => plugin.controlPorts ?? []), + ...check.hostControls, + ]; + const refused = + selected.some((plugin) => (plugin.requiresPorts ?? []).some((port) => !check.isBound(port))) || + controls.some(({ port, boundBy }) => !check.isBound(port) && listed.has(boundBy)); + if (refused) { + throw await shortfallError(check); + } } diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index 52d5519d0..474dc14bc 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -11,7 +11,7 @@ import { } from '@openora/core/contracts'; import { redisUrlForWorker } from '@openora/core/testing'; import { makeJobQueue, mock } from '../../../testing/mock.js'; -import { AUTH_SESSION, type SessionResolver } from '../../auth/index.js'; +import { ADMIN_GUARD, AUTH_SESSION, type SessionResolver } from '../../auth/index.js'; import type { Container } from '../../kernel/index.js'; import type { PluginEntry } from '../../plugin-host/index.js'; import { createApp } from '../create-app.js'; @@ -339,6 +339,7 @@ describe('createApp - a split service fails closed on a control it would skip', const admin = entry('fixture-admin', 'admin'); const iam = entry('iam', 'iam'); const identity = entry('identity', 'identity'); + const base = entry('fixture-base', 'base'); /** Boots `plugins` under `manifest` (none: a full boot) and closes the app again. */ async function boot( @@ -379,7 +380,7 @@ describe('createApp - a split service fails closed on a control it would skip', it('refuses admin routes without the ports AdminGuard checks them with', async () => { await expect(boot([admin, iam, identity], 'fixture-admin')).rejects.toThrow( - /SERVICE_MANIFEST=fixture-admin serves admin routes[\s\S]*- AdminGuard runs without ADMIN_PERMISSION_RESOLVER \(bound by iam\)[\s\S]*- AdminGuard runs without ADMIN_SECURITY_POLICY \(bound by identity\)[\s\S]*Add them: SERVICE_MANIFEST=fixture-admin,iam,identity/, + /- AdminGuard runs without ADMIN_PERMISSION_RESOLVER \(bound by iam\)[\s\S]*- AdminGuard runs without ADMIN_SECURITY_POLICY \(bound by identity\)[\s\S]*Add them: SERVICE_MANIFEST=fixture-admin,iam,identity/, ); }); @@ -389,6 +390,57 @@ describe('createApp - a split service fails closed on a control it would skip', ).resolves.toBeUndefined(); }); + it('refuses a split that serves no admin routes without them too', async () => { + await expect(boot([base, iam, identity], 'fixture-base')).rejects.toThrow( + /- AdminGuard runs without ADMIN_PERMISSION_RESOLVER \(bound by iam\)[\s\S]*Add them: SERVICE_MANIFEST=fixture-base,iam,identity/, + ); + }); + + it('builds AdminGuard at boot under a manifest, so a guard that cannot be built fails the boot', async () => { + const broken = (c: Container) => + c.register(ADMIN_GUARD, () => { + throw new Error('fixture guard cannot be built'); + }); + + await expect(boot([base], 'fixture-base', broken)).rejects.toThrow( + 'fixture guard cannot be built', + ); + await expect(boot([base], undefined, broken)).resolves.toBeUndefined(); + }); + + it('names a missing dependency, required port, control and guard port in one error whose manifest boots', async () => { + const plugins = [ + entry('fixture-split', 'split'), + entry('fixture-feature', 'feature'), + base, + entry('fixture-leaf', 'leaf'), + entry('fixture-dispatch', 'dispatch'), + limits, + iam, + identity, + ]; + + const refused = await boot(plugins, 'fixture-split').then( + () => new Error('booted'), + (err: unknown) => (err instanceof Error ? err : new Error(String(err))), + ); + + expect(refused.message).toContain( + 'SERVICE_MANIFEST=fixture-split leaves out what this service needs:\n' + + ' - fixture-feature (required by fixture-split, fixture-leaf)\n' + + ' - fixture-base (required by fixture-feature)\n' + + ' - fixture-split requires FIXTURE_DISPATCH (bound by fixture-dispatch)\n' + + ' - fixture-split runs without FIXTURE_LIMITS (bound by fixture-limits): fixture limits go unenforced\n', + ); + expect(refused.message).toMatch(/- AdminGuard runs without ADMIN_PERMISSION_RESOLVER/); + expect(refused.message).toMatch(/- fixture-leaf \(required by fixture-dispatch\)/); + const suggested = /^Add them: SERVICE_MANIFEST=(\S+)$/m.exec(refused.message)?.[1]; + expect(suggested).toBe( + 'fixture-split,fixture-feature,fixture-base,fixture-dispatch,fixture-limits,iam,identity,fixture-leaf', + ); + await expect(boot(plugins, suggested)).resolves.toBeUndefined(); + }); + it('boots a full install unchanged, the controls left unbound', async () => { await expect(boot([limited, admin])).resolves.toBeUndefined(); }); diff --git a/packages/core/src/server/runtime/__tests__/fixtures/dispatch-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/dispatch-plugin.ts new file mode 100644 index 000000000..26b333de0 --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/dispatch-plugin.ts @@ -0,0 +1,12 @@ +import { createToken, type TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; + +export const FIXTURE_DISPATCH = createToken<{ send: () => void }>('FIXTURE_DISPATCH'); + +export default { + id: 'fixture-dispatch', + dependsOn: ['fixture-leaf'], + register(ctx) { + ctx.provide(FIXTURE_DISPATCH, () => ({ send: () => {} })); + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/__tests__/fixtures/split-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/split-plugin.ts new file mode 100644 index 000000000..8a6ec5d7e --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/split-plugin.ts @@ -0,0 +1,14 @@ +import type { TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; +import { FIXTURE_DISPATCH } from './dispatch-plugin.js'; +import { FIXTURE_LIMITS } from './limits-plugin.js'; + +export default { + id: 'fixture-split', + dependsOn: ['fixture-feature'], + requiresPorts: [FIXTURE_DISPATCH], + controlPorts: [ + { port: FIXTURE_LIMITS, without: 'fixture limits go unenforced', boundBy: 'fixture-limits' }, + ], + register() {}, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index 5ca18094e..3ce445b84 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -51,17 +51,13 @@ import { } from '@openora/core/contracts'; import { DrizzleService, DRIZZLE, DrizzleOutboxWriter, OutboxRelay } from '../db/index.js'; import { AdminGuard, ADMIN_GUARD, SessionResolver, AUTH_SESSION } from '../auth/index.js'; +import { loadPlugins, parseServiceManifest, type PluginEntry } from '../plugin-host/index.js'; +import { importPlugin } from '../plugin-host/load-plugins.js'; import { - loadPlugins, - parseServiceManifest, - type ControlPort, - type PluginEntry, -} from '../plugin-host/index.js'; -import { importPlugin, unboundControlPorts, unboundControls } from '../plugin-host/load-plugins.js'; -import { - assertControlsBound, - assertModuleControlsBound, + assertServiceComplete, selectServiceEntries, + type HostControl, + type ServiceManifestCheck, } from '../plugin-host/service-manifest.js'; import { createMcpKernel } from '../mcp/index.js'; import { authorizeWithAdminGuard } from '../mcp/authorize.js'; @@ -213,23 +209,19 @@ function requestMeta(c: Context, trustedProxies: TrustedProxies) { // revoked in the database, or who never enrolled 2FA, still acts through it. const ADMIN_GUARD_CONTROLS = [ { + owner: 'AdminGuard', port: ADMIN_PERMISSION_RESOLVER, boundBy: 'iam', without: 'grants come from the static role table, so an admin whose role was revoked still acts', }, { + owner: 'AdminGuard', port: ADMIN_SECURITY_POLICY, boundBy: 'identity', without: 'admins skip the 2FA enrolment and session-integrity checks', }, -] as const satisfies readonly ControlPort[]; - -/** What a module needs beside it in a split service: its dependencies and its controls' modules. */ -async function needsInSplit(entry: PluginEntry) { - const plugin = await importPlugin(entry); - return [...(plugin.dependsOn ?? []), ...(plugin.controlPorts ?? []).map((p) => p.boundBy)]; -} +] as const satisfies readonly HostControl[]; const MCP_MAX_BODY_BYTES = 1_048_576; const MCP_SDK_PACKAGE = '@modelcontextprotocol/sdk'; @@ -378,9 +370,11 @@ async function captureRawBody(req: Request): Promise { /** * The single composition root: wires the DI container, boots the plugins in * `config.plugins` that `SERVICE_MANIFEST` selects (every one when it is unset), and - * mounts the resulting oRPC router on a Hono app. Under a manifest, a control port - a - * selected module's `controlPorts`, or AdminGuard's once admin routes resolve it - left - * unbound while the plugin list has the module that binds it fails the boot. + * mounts the resulting oRPC router on a Hono app. A manifest that leaves out a module its + * modules depend on, the module that binds a port they require, or the module behind a + * control - a selected module's `controlPorts`, or AdminGuard's - that the plugin list has, + * fails the boot with one error naming all of them; a manifest boot resolves AdminGuard + * before it serves. * `JOB_WORKERS` picks the job queues this process consumes (every one when it is unset). * Production is distributed-only (ADR-0030, superseding ADR-0010/0014/0016/0028): * `REDIS_URL` auto-binds `JOB_QUEUE`/`RATE_LIMITER`/`CACHE`/`MESSAGE_BROKER`/ @@ -467,28 +461,20 @@ export async function createApp( // no second createAuth() over the same DB. authSchema is injected (not imported) because // the engine is domain-agnostic (ADR-0019/0025). container.register(AUTH_SESSION, (c) => new SessionResolver(c.get(DRIZZLE), config.authSchema)); - container.register(ADMIN_GUARD, (c) => { - // Router factories resolve the guard at boot, so a split service that serves admin routes - // without the guard's controls fails to boot here; one resolved later refuses the same way. - if (manifest !== null) { - assertControlsBound( - config.plugins, - manifest, - 'serves admin routes without the ports AdminGuard checks them with', - unboundControls('AdminGuard', ADMIN_GUARD_CONTROLS, c), - ); - } - return new AdminGuard( - c.get(DRIZZLE), - c.get(AUTH_SESSION), - // has() avoids throwing on an unbound token so boot works without the iam module. - c.has(ADMIN_PERMISSION_RESOLVER) ? c.get(ADMIN_PERMISSION_RESOLVER) : undefined, - c.get(EVENT_BUS), - // has() avoids throwing on an unbound token so boot works without the identity module. - c.has(IDENTITY_READER) ? c.get(IDENTITY_READER) : undefined, - c.has(ADMIN_SECURITY_POLICY) ? c.get(ADMIN_SECURITY_POLICY) : undefined, - ); - }); + container.register( + ADMIN_GUARD, + (c) => + new AdminGuard( + c.get(DRIZZLE), + c.get(AUTH_SESSION), + // has() avoids throwing on an unbound token so boot works without the iam module. + c.has(ADMIN_PERMISSION_RESOLVER) ? c.get(ADMIN_PERMISSION_RESOLVER) : undefined, + c.get(EVENT_BUS), + // has() avoids throwing on an unbound token so boot works without the identity module. + c.has(IDENTITY_READER) ? c.get(IDENTITY_READER) : undefined, + c.has(ADMIN_SECURITY_POLICY) ? c.get(ADMIN_SECURITY_POLICY) : undefined, + ), + ); if (config.igaming) { const igaming = config.igaming; container.register(IGAMING_CONFIG, () => igaming); @@ -503,28 +489,31 @@ export async function createApp( const corsOrigins = resolveCorsOrigins(config.cors); const bootLogger = createLogger('create-app'); - const plugins = await selectServiceEntries( - config.plugins, - manifest, - async (entry) => (await importPlugin(entry)).dependsOn ?? [], - ); - if (manifest !== null) { + const manifestCheck: ServiceManifestCheck | null = + manifest === null + ? null + : { + entries: config.plugins, + manifest, + readPlugin: (entry) => importPlugin(entry), + hostControls: ADMIN_GUARD_CONTROLS, + isBound: (port) => container.has(port), + }; + const plugins = manifestCheck ? await selectServiceEntries(manifestCheck) : config.plugins; + if (manifestCheck) { bootLogger.info({ modules: plugins.map((e) => e.id) }, 'SERVICE_MANIFEST active'); } - const registry = await loadPlugins(plugins, container); + const registry = await loadPlugins(plugins, container, { + requiredPorts: manifestCheck ? 'defer' : 'assert', + }); await configure?.(container); assertDurableSeamsBound(container); // After configure(), so a port the composition root binds counts. - if (manifest !== null) { - const loaded = await Promise.all(plugins.map((e) => importPlugin(e))); - await assertModuleControlsBound( - config.plugins, - manifest, - unboundControlPorts(loaded, container), - needsInSplit, - ); + if (manifestCheck) { + await assertServiceComplete(manifestCheck); + container.get(ADMIN_GUARD); } // Sealed after configure() so neither an overlay, the composition root nor a holder of the From 2a2d009e9b5ac8ca23f230daba5530276a551fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 16 Oct 2026 12:28:11 +0200 Subject: [PATCH 10/16] fix(scaffold): give create:service an example that boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The usage example, identity,mail, did not boot: identity's controls pull in compliance and iam, mail's pulls in audit, and compliance depends on wallet, gaming, player-management and exchange-rate. The example now lives in tools/create/service-example.json, which the scaffold prints, and is the manifest the first boot of SERVICE_MANIFEST=wallet tells to use. An E2E boots it against the real plugin list and checks that the wallet boot still suggests exactly it, so a module that gains a dependency or a control fails the test instead of leaving the example stale. It keeps its app open across its tests, as the other suites do: BullMQ disconnects a connection still initialising when its queue closes, rejecting commands it never awaited, so an app closed straight after boot leaks unhandled rejections. The scaffold's warning now says every split loads iam and identity, and what follows from them. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../service-manifest-boot.e2e.test.ts | 73 +++++++++++++++++++ tools/create/create-service.ts | 25 +++++-- tools/create/service-example.json | 15 ++++ 3 files changed, 105 insertions(+), 8 deletions(-) create mode 100644 packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts create mode 100644 tools/create/service-example.json diff --git a/packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts b/packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts new file mode 100644 index 000000000..eb4bbfa33 --- /dev/null +++ b/packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts @@ -0,0 +1,73 @@ +import { readFileSync } from 'node:fs'; +import * as z from 'zod'; +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { loadExtensions } from '@openora/core/server'; +import { setupTestDb, bootTestApp, type TestDb, type TestApp } from '../index.js'; + +const ServiceExampleSchema = z.object({ name: z.string(), manifest: z.array(z.string()).min(1) }); +const createServiceExample = ServiceExampleSchema.parse( + JSON.parse( + readFileSync(new URL('../../../../tools/create/service-example.json', import.meta.url), 'utf8'), + ), +); +const SUGGESTED_MANIFEST = /^Add them: SERVICE_MANIFEST=(\S+)$/m; +const SPLIT_ENV = ['SERVICE_MANIFEST', 'SERVICE_NAME'] as const; + +let db: TestDb; +let split: TestApp; + +/** Boots every installed module under `manifest`, as a split service named `split-e2e`. */ +async function bootSplit(manifest: string) { + const saved = SPLIT_ENV.map((key) => [key, process.env[key]] as const); + process.env['SERVICE_MANIFEST'] = manifest; + process.env['SERVICE_NAME'] = 'split-e2e'; + try { + return await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); + } finally { + for (const [key, value] of saved) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + } +} + +beforeAll(async () => { + process.env['BETTER_AUTH_SECRET'] ??= 'e2e-test-better-auth-secret-please-change-000000'; + process.env['AUTH_SECRET'] ??= process.env['BETTER_AUTH_SECRET']; + process.env['WITHDRAWAL_PIN_HMAC_SECRET'] ??= 'e2e-test-withdrawal-pin-hmac-secret-000000'; + process.env['NODE_ENV'] ??= 'test'; + + db = await setupTestDb(); + split = await bootSplit(createServiceExample.manifest.join(',')); +}, 60_000); + +afterAll(async () => { + await split?.close(); + await db?.dispose(); +}); + +describe('a split service booted from the pnpm create:service example', () => { + it('serves its modules, guards their admin routes, and serves none it leaves out', async () => { + expect((await split.app.request('/health')).status).toBe(200); + expect((await split.app.request('/compliance/country-rules')).status).toBe(401); + expect((await split.app.request('/cms/pages')).status).toBe(404); + }); + + it('is the manifest the first boot of SERVICE_MANIFEST=wallet tells to use', async () => { + const refusal = await bootSplit('wallet').then( + async (booted) => { + await booted.close(); + return 'SERVICE_MANIFEST=wallet booted'; + }, + (err: unknown) => (err instanceof Error ? err.message : String(err)), + ); + + expect(refusal).toMatch(/- identity \(required by wallet/); + expect(refusal).toMatch(/- wallet runs without RG_LIMITS \(bound by compliance\)/); + expect(refusal).toMatch(/- identity requires MAIL_DISPATCH \(bound by mail\)/); + expect(SUGGESTED_MANIFEST.exec(refusal)?.[1]).toBe(createServiceExample.manifest.join(',')); + }); +}); diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index 1b57ff006..ff0348187 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -8,12 +8,13 @@ * * Usage: * pnpm create:service - * pnpm create:service accounts identity,mail * - * The manifest lists every module its modules depend on, every module that binds a port they - * require, and every module behind a control they would otherwise skip (`controlPorts`, admin - * routes); the boot names whatever is missing. The host bakes the service name as its - * SERVICE_NAME - its own Redis Streams consumer group. + * Run without arguments, it prints an example that boots (service-example.json, which an E2E + * boots too). The manifest lists every module its modules depend on, every module that binds a + * port they require, and every module behind a control they would otherwise skip + * (`controlPorts`, AdminGuard's); a boot that misses any prints one manifest line adding all of + * them. The host bakes the service name as its SERVICE_NAME - its own Redis Streams consumer + * group. * * After scaffolding: `pnpm install`, build deps, then * pnpm -F @openora/-service dev # uses the baked-in manifest @@ -24,11 +25,14 @@ import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '../..'); +const example = JSON.parse( + readFileSync(join(repoRoot, 'tools/create/service-example.json'), 'utf8'), +) as { name: string; manifest: string[] }; const [, , rawName, rawManifest] = process.argv; if (!rawName || !rawManifest) { console.error('Usage: pnpm create:service '); - console.error('Example: pnpm create:service accounts identity,mail'); + console.error(`Example: pnpm create:service ${example.name} ${example.manifest.join(',')}`); process.exit(1); } @@ -148,9 +152,14 @@ console.warn( ' - A manifest that leaves out a module behind a control (RG limits, country checks, audit', ); console.warn( - ' records, admin grants) refuses to boot and prints the modules to add: a service that', + ' records, admin grants) refuses to boot and prints one manifest line with every module to', +); +console.warn( + ' add. Every split loads iam and identity for AdminGuard, and identity brings compliance,', +); +console.warn( + ' which depends on wallet, gaming, player-management and exchange-rate: about ten modules.', ); -console.warn(' loads identity also loads compliance and iam.'); console.warn( ' - The boot logs each other optional port the manifest leaves unbound and what its module', ); diff --git a/tools/create/service-example.json b/tools/create/service-example.json new file mode 100644 index 000000000..cc292bbfa --- /dev/null +++ b/tools/create/service-example.json @@ -0,0 +1,15 @@ +{ + "name": "payments", + "manifest": [ + "wallet", + "identity", + "audit", + "compliance", + "mail", + "iam", + "player-management", + "gaming", + "exchange-rate", + "chat" + ] +} From 300345205ce0a7b9f6396d439467bc24f5e0f6ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 16 Oct 2026 12:28:12 +0200 Subject: [PATCH 11/16] docs: say how close a split stays to the monolith and how add-ons pick queues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide and ADR-0044 said a split that loads identity also loads compliance and iam. That undersold it: every manifest boot checks AdminGuard's ports, so every split loads iam and identity, and with them audit and mail; identity's controls pull in compliance, which depends on wallet, gaming, player-management and exchange-rate, and player-management on chat. Both now say a split carries those ten modules and ends up close to the monolith; the guide adds that until ADR-0045 lands each of them handles every event in the split and again in the rest of the install. ADR-0044 also says how an add-on keeps its jobs off the replicas: the selection filters only workers registered through ctx.jobs.worker, so an add-on registers there under a queue-name prefix of its own, and a worker registered on JOB_QUEUE directly runs in every process. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .../0044-per-process-job-worker-selection.md | 14 ++++++++--- docs/guides/downstream-consumer.md | 23 +++++++++++++------ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/docs/adr/0044-per-process-job-worker-selection.md b/docs/adr/0044-per-process-job-worker-selection.md index 6e8e8ea74..21e6b79de 100644 --- a/docs/adr/0044-per-process-job-worker-selection.md +++ b/docs/adr/0044-per-process-job-worker-selection.md @@ -17,9 +17,11 @@ receives its own copy of every event, and a module pulled into it for its depend handlers a second time across the cluster. A job whose handler needs the full container cannot live in a narrow service anyway: the MCP kernel (ADR-0042) is built in process from the tools and action types of the modules that process loaded, so an agent run needs every module that -contributes them. Nor does a manifest service stay narrow once it touches money: it fails closed -on the controls the rest of the install enforces (below), so one that loads identity also loads -compliance and iam. +contributes them. Nor does a manifest service stay narrow: it fails closed on the controls the +rest of the install enforces (below). Every split loads iam and identity for AdminGuard, and with +them audit and mail; identity's controls pull in compliance, and compliance depends on wallet, +gaming, player-management and exchange-rate, player-management on chat. With the shipped modules +a split carries those ten before the module it was made for, which is close to the monolith. ## Decision @@ -57,6 +59,12 @@ and `JOB_WORKERS=reports.*`; give the replicas `JOB_WORKERS=!reports.*`. Sharing and keeps it in the same realtime and presence namespace. It serves HTTP like any instance but needs no ingress. +**Add-ons follow the same rule.** The selection filters the workers registered through +`ctx.jobs.worker`, so an add-on moves its long-running jobs off the replicas only if it registers +them there, under a queue-name prefix of its own (`agents.*`, say) that one pattern selects. A +worker an add-on registers on `JOB_QUEUE` itself bypasses the selection and runs in every +process. + **A manifest fails closed on controls.** Some ports back a money, compliance, authorization or audit control and are read with `c.has()`. An install without the module that binds one has nothing to enforce, so a full boot runs without it, as before. A split service is different: the diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index 13eb8d6f2..4a3705adc 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -80,13 +80,22 @@ and each job reaches one of them. - **Fewer modules in a process.** `SERVICE_MANIFEST` lists the module ids a process boots, and `createApp` applies it to the `plugins` you pass. List every module your modules depend on and - every module that binds a port they require; the boot names whatever is missing. A module - still boots without an optional port, so the boot log also lists each one the manifest leaves - unbound and what its module skips without it. A control - an RG limit, a country check, an - audit record, the database grants behind admin routes - is different: while your plugin list - has the module that enforces it, a manifest that leaves it out refuses to boot and prints the - modules to add. In practice a process that loads identity also loads compliance and iam. A - process with a manifest needs a `SERVICE_NAME` of its own. + every module that binds a port they require. A module still boots without an optional port, so + the boot log also lists each one the manifest leaves unbound and what its module skips without + it. A control - an RG limit, a country check, an audit record, the database grants and 2FA + checks behind admin routes - is different: while your plugin list has the module that enforces + it, a manifest that leaves it out refuses to boot. Whatever is missing, the boot fails once, + naming all of it, with one manifest line that adds every module needed and what those need in + turn. A process with a manifest needs a `SERVICE_NAME` of its own. + + With the shipped modules a split ends up close to the monolith. Every manifest boot checks + AdminGuard's ports, so every split loads iam and identity, and with them audit and mail. + Identity's controls pull in compliance, and compliance depends on wallet, gaming, + player-management and exchange-rate; player-management depends on chat. A split is those ten + modules plus the ones you meant to move, and until + [ADR-0045](../adr/0045-dependency-only-modules-provide-but-do-not-consume.md) lands each of the + ten handles every event once in the split and once in the rest of the install. + - **Fewer queues in a process.** `JOB_WORKERS` picks the job queues a process consumes: names, `prefix*` patterns, `!` to exclude. To keep a long-running queue off the API replicas, run one more instance with the same plugins and `SERVICE_NAME` and `JOB_WORKERS=`, and give the From 160bf80ff522d31628c154e59194be74e1df370f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Mon, 19 Oct 2026 12:08:33 +0200 Subject: [PATCH 12/16] fix(core): register no plugin twice on a refused manifest boot and close what it opened MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A failed SERVICE_MANIFEST boot registered every listed plugin a second time, each into a container of its own, to learn which module binds which port, so a register() with an effect outside the registry would act differently on that path. No core plugin's does: each only declares into the registry. mail and identity read a secret and throw, which the boot error already reports. audit, chat and notifications built their logger inside register(); it now sits at module scope, as in the other modules. A downstream plugin can still open a connection or start a timer there, and the contract was written down nowhere. - The boot error no longer registers the plugins the boot already registered: what they bound is in the process's container, which the check reads. Every other plugin registers once, so no register() runs twice in one process. - createApp disposes its container when the boot fails. The caller gets no app to close, so a refused manifest boot left the Redis client createApp opened, and any connection configure() bound, connected and reconnecting, which keeps alive a host that does not exit on the error. - Plugin.register states the contract: declare only, and open a resource in a provide() or router factory that closes it with onDispose(). ADR-0044, the plugin-author rule, the scaffold commands and template say the same, and the error-tracking guide's example, which initialised the vendor SDK in register(), now does it in the factory. - A unit test runs every core plugin through the refused boot's registration pass and fails on a timer, an open handle, a process listener, a global or a process.env change. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .rulesync/commands/scaffold-plugin.md | 2 +- .rulesync/subagents/plugin-author.md | 2 + docs/adapters/error-tracking.md | 26 +++-- .../0044-per-process-job-worker-selection.md | 19 ++-- .../core/generators/src/templates/plugin.hbs | 3 +- packages/core/src/audit/plugin.ts | 4 +- packages/core/src/engagement/chat/plugin.ts | 3 +- .../src/engagement/notifications/plugin.ts | 4 +- .../__tests__/register-side-effects.test.ts | 97 +++++++++++++++++++ .../__tests__/service-manifest.test.ts | 14 +++ .../src/server/plugin-host/define-plugin.ts | 8 ++ .../server/plugin-host/service-manifest.ts | 12 ++- .../runtime/__tests__/create-app.test.ts | 18 ++++ .../__tests__/fixtures/counted-plugin.ts | 20 ++++ .../core/src/server/runtime/create-app.ts | 28 +++++- .../commands/scaffold-plugin.md | 5 +- 16 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts create mode 100644 packages/core/src/server/runtime/__tests__/fixtures/counted-plugin.ts diff --git a/.rulesync/commands/scaffold-plugin.md b/.rulesync/commands/scaffold-plugin.md index c220645d6..d9253630b 100644 --- a/.rulesync/commands/scaffold-plugin.md +++ b/.rulesync/commands/scaffold-plugin.md @@ -6,7 +6,7 @@ description: 'Generate a new overlay extension plugin under extensions//. Run `pnpm gen plugin $ARGUMENTS` in the repo root. Variants: `pnpm gen adapter ` for a vendor-adapter swap; `pnpm gen job-worker ` for a background-job worker. -The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`: +The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0044). - `ctx.provide(TOKEN, factory)` - bind/override an adapter or service. Last registration wins, so an overlay loaded after the default-binding module replaces the default - keep the order in `extensions.config.ts` intentional. Sealed compliance tokens (`@openora/core/compliance`) are rejected at compile time + runtime. - `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes. diff --git a/.rulesync/subagents/plugin-author.md b/.rulesync/subagents/plugin-author.md index 77eacdaf1..64442c998 100644 --- a/.rulesync/subagents/plugin-author.md +++ b/.rulesync/subagents/plugin-author.md @@ -45,6 +45,8 @@ ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output); // agen ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })); // action type: a change a model may only propose ``` +`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0044). + Agent tools and action types are served only through the `MCP_KERNEL` token, which checks the IAM grant of the admin behind the run, validates input, returns only the allow-listed output keys and declared error codes, and audits every tool call and action execution. A shape it cannot serve (eg a tool input that is not a top-level `z.object`, or an unbounded string) fails boot with the fix. `execute` must perform nothing on a replay while its effect still holds, and answer `already_applied`. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Contract types: `packages/core/src/contracts/adapters/mcp.ts`; why: ADR-0042. No decorators, no controllers - `{ id, dependsOn, register } satisfies Plugin` wired by the functional Container (ADR-0009). DB tables: a `pgTable` in the plugin's own `schema/index.ts`, then `pnpm regen`. diff --git a/docs/adapters/error-tracking.md b/docs/adapters/error-tracking.md index 5be5dbb9c..e8e3d6d6a 100644 --- a/docs/adapters/error-tracking.md +++ b/docs/adapters/error-tracking.md @@ -63,19 +63,25 @@ export default { if (!dsn) { return; // self-disabling: no DSN -> platform stays logs-only } - Sentry.init({ dsn }); - ctx.provide(ERROR_TRACKING, () => ({ - captureException(error, context) { - Sentry.captureException(error, { - ...(context?.userId ? { user: { id: context.userId } } : {}), - tags: { ...context?.tags, ...(context?.traceId ? { trace_id: context.traceId } : {}) }, - ...(context?.extra ? { extra: context.extra } : {}), - }); - }, - })); + ctx.provide(ERROR_TRACKING, () => { + Sentry.init({ dsn }); + return { + captureException(error, context) { + Sentry.captureException(error, { + ...(context?.userId ? { user: { id: context.userId } } : {}), + tags: { ...context?.tags, ...(context?.traceId ? { trace_id: context.traceId } : {}) }, + ...(context?.extra ? { extra: context.extra } : {}), + }); + }, + }; + }); }, } as const satisfies Plugin; ``` +`Sentry.init` runs in the factory, which `createApp` resolves once for the container that serves, +not in `register()`: a failed `SERVICE_MANIFEST` boot registers plugins into a container it then +drops (ADR-0044). Initialising in a file preloaded with `node --import` works as well. + Register it last in `extensions.config.ts` (a `kind: 'infra'` overlay). A PostHog/Rollbar shop writes the equivalent overlay against the same port - core is unchanged. diff --git a/docs/adr/0044-per-process-job-worker-selection.md b/docs/adr/0044-per-process-job-worker-selection.md index 21e6b79de..effbf01b7 100644 --- a/docs/adr/0044-per-process-job-worker-selection.md +++ b/docs/adr/0044-per-process-job-worker-selection.md @@ -83,8 +83,13 @@ static role table, so an admin whose role was revoked still acts, and without **A manifest is fixed in one edit.** A manifest boot fails once for everything the manifest leaves out - a dependency, the module that binds a required port, the module behind a control - naming each with what its module skips, and prints one manifest line that adds them all and what they -need in turn. Which module binds a required port is learned by registering each plugin of the -list on its own, only once the boot has already failed. +need in turn. Which module binds a port is learned only once the boot has already failed: what +the modules the boot registered bound is in its container, and every other plugin of the list +registers once, on its own, into a container that is then dropped. That is why `register()` +only declares into the registry - a timer, connection, file read, process listener, global or +`process.env` write there would run in that pass too - and a resource opens in a `provide()` or +router factory, which only the container that serves runs. A failed boot disposes its container, +closing what the boot had opened. ### Alternatives considered @@ -109,11 +114,11 @@ list on its own, only once the boot has already failed. processes, so a queue excluded everywhere backs up silently. Queue-depth monitoring catches it. - Processes that share a `SERVICE_NAME` must load the same modules. The streams driver acks an event once per group, so a process without a handler for it loses the event for that group. -- When a manifest boot fails, every plugin in the list registers once more, each into a - container of its own, to learn which binds which port. A `register()` that does more than - declare - logs, reads configuration and throws - does so again. One that throws counts as - binding what it bound before the throw, and the error names it when a required port is left - without a known binder. +- When a manifest boot fails, every plugin of the list the boot had not registered registers + once, to learn what it binds. A `register()` that does more than declare - logs, reads + configuration and throws - does so in that pass. One that throws counts as binding what it + bound before the throw, and the error names it when a required port is left without a known + binder. **Neutral:** diff --git a/packages/core/generators/src/templates/plugin.hbs b/packages/core/generators/src/templates/plugin.hbs index 4c807ed8c..41917073f 100644 --- a/packages/core/generators/src/templates/plugin.hbs +++ b/packages/core/generators/src/templates/plugin.hbs @@ -3,7 +3,8 @@ import type { CoreTokenCatalog, Plugin } from '@openora/core/server'; // Overlay plugin. Registered in extensions.config.ts (auto-wired in the OSS repo; // in a consumer repo add it to your apps/api/src/extensions.config.ts). // -// In register(ctx) - the typed ModuleRegistry - you can: +// register(ctx) only declares - open a timer or connection in a provide()/router factory, +// closed with c.onDispose(). In register(ctx) - the typed ModuleRegistry - you can: // ctx.provide(TOKEN, (c) => new Impl()) - bind/rebind a DI token (last wins) // ctx.routers.add(namespace, (c) => router) - mount an oRPC router // ctx.events.on(topic, (payload) => ...) - subscribe to a domain event diff --git a/packages/core/src/audit/plugin.ts b/packages/core/src/audit/plugin.ts index 5b120e2e5..1dfa227a8 100644 --- a/packages/core/src/audit/plugin.ts +++ b/packages/core/src/audit/plugin.ts @@ -1487,11 +1487,11 @@ const SUBSCRIBED_TOPICS: DomainEventName[] = [ 'social.friend_request.cancelled', ] as const; +const logger = createLogger('audit'); + export default { id: 'audit', register(ctx) { - const logger = createLogger('audit'); - // Subscriptions are wired before router factories run (create-app.ts boot order), // so svcRef is null at registration but set before any real event arrives. let svcRef: AuditService | null = null; diff --git a/packages/core/src/engagement/chat/plugin.ts b/packages/core/src/engagement/chat/plugin.ts index 85a502745..72b1db422 100644 --- a/packages/core/src/engagement/chat/plugin.ts +++ b/packages/core/src/engagement/chat/plugin.ts @@ -67,11 +67,12 @@ const RoomPurgeJobSchema = z.object({ roomId: UuidSchema }); const AccountClosedJobSchema = z.object({ userId: UuidSchema, closedAt: z.iso.datetime() }); const AccountReopenedJobSchema = z.object({ userId: UuidSchema }); +const logger = createLogger('chat'); + export default { id: 'chat', dependsOn: ['identity', 'audit'], register(ctx) { - const logger = createLogger('chat'); let expiryRef: ChatModerationExpiryService | undefined; let inviteRef: ChatRoomInviteService | undefined; ctx.provide(CHAT_REALTIME_TRANSPORT, (c) => c.get(REALTIME_TRANSPORT)); diff --git a/packages/core/src/engagement/notifications/plugin.ts b/packages/core/src/engagement/notifications/plugin.ts index 40af0df51..2d1903be3 100644 --- a/packages/core/src/engagement/notifications/plugin.ts +++ b/packages/core/src/engagement/notifications/plugin.ts @@ -522,6 +522,8 @@ export function buildChatRoomScheduledForDeletionNotification(payload: { }; } +const logger = createLogger('notifications'); + export default { id: 'notifications', controlPorts: [ @@ -532,8 +534,6 @@ export default { }, ], register(ctx) { - const logger = createLogger('notifications'); - // Subscriptions are wired before router factories run (boot order), so these refs are // null at registration but set from the router factory before any real event arrives. let svcRef: NotificationsService | null = null; diff --git a/packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts b/packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts new file mode 100644 index 000000000..d3f0d6649 --- /dev/null +++ b/packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts @@ -0,0 +1,97 @@ +import { readdirSync } from 'node:fs'; +import { basename, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { TokenCatalog } from '@openora/core/contracts'; +import { importPlugin } from '../load-plugins.js'; +import { selectServiceEntries } from '../service-manifest.js'; + +const CORE_SRC = fileURLToPath(new URL('../../../', import.meta.url)); +const CONFIGURED_SECRET = 'register-side-effects-secret-0000000000000000'; +const TIMERS = ['setTimeout', 'setInterval', 'setImmediate'] as const; + +async function loadCorePlugins() { + const paths = readdirSync(CORE_SRC, { recursive: true, encoding: 'utf8' }) + .filter((path) => basename(path) === 'plugin.ts') + .map((path) => join(CORE_SRC, path)); + return Promise.all( + paths.map(async (path) => { + const { default: plugin } = (await import(path)) as { default: { id: string } }; + const entry = { id: plugin.id, path }; + return { entry, plugin: await importPlugin(entry) }; + }), + ); +} + +function processState() { + return { + env: { ...process.env }, + listeners: Object.fromEntries( + process.eventNames().map((event) => [String(event), process.listenerCount(event)]), + ), + globals: Reflect.ownKeys(globalThis).map(String).sort(), + }; +} + +function activeResources() { + return process + .getActiveResourcesInfo() + .reduce>( + (counts, type) => ({ ...counts, [type]: (counts[type] ?? 0) + 1 }), + {}, + ); +} + +function grown(before: Record, after: Record) { + return Object.entries(after) + .filter(([type, count]) => count > (before[type] ?? 0)) + .map(([type]) => type); +} + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +describe('the registration pass of a failed SERVICE_MANIFEST boot', () => { + it('registers every core plugin once, leaving no timer, handle, listener, global or env change behind', async () => { + vi.stubEnv('AUTH_SECRET', CONFIGURED_SECRET); + vi.stubEnv('WITHDRAWAL_PIN_HMAC_SECRET', CONFIGURED_SECRET); + const loaded = await loadCorePlugins(); + const registers = loaded.map(({ plugin }) => vi.spyOn(plugin, 'register')); + const state = processState(); + const resources = activeResources(); + const timers = TIMERS.map((name) => vi.spyOn(globalThis, name)); + + await expect( + selectServiceEntries({ + entries: loaded.map(({ entry }) => entry), + manifest: ['wallet'], + readPlugin: (entry) => importPlugin(entry), + hostControls: [], + isBound: () => false, + }), + ).rejects.toThrow(/^SERVICE_MANIFEST=wallet leaves out/); + + expect(loaded.map(({ plugin }) => plugin.id)).toEqual( + expect.arrayContaining([ + 'audit', + 'identity', + 'mail', + 'notifications', + 'wallet', + 'compliance', + ]), + ); + expect( + Object.fromEntries( + loaded.map(({ plugin }, i) => [plugin.id, registers[i]?.mock.results.map((r) => r.type)]), + ), + ).toEqual(Object.fromEntries(loaded.map(({ plugin }) => [plugin.id, ['return']]))); + for (const timer of timers) { + expect(timer).not.toHaveBeenCalled(); + } + expect(grown(resources, activeResources())).toEqual([]); + expect(processState()).toEqual(state); + }, 60_000); +}); diff --git a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts index cc2fe3d16..d212c8e8f 100644 --- a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts @@ -282,6 +282,20 @@ describe('the one error a manifest boot fails with', () => { ); }); + it('registers none of the plugins the boot registered a second time', async () => { + const registers = new Map(install.map(({ id, register }) => [id, vi.fn(register)])); + const { check } = checkOf( + install.map((p) => ({ ...p, register: registers.get(p.id) ?? p.register })), + ['identity'], + ); + + await expect( + assertServiceComplete({ ...check, registered: new Set(['identity']) }), + ).rejects.toThrow(/- identity requires MAIL \(bound by mail\)/); + expect(registers.get('identity')).not.toHaveBeenCalled(); + expect(registers.get('mail')).toHaveBeenCalledOnce(); + }); + it('names a required port no module in the plugin list binds', async () => { const { check } = checkOf(withoutModules('mail'), ['identity']); const run = assertServiceComplete(check); diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index 14bb37ea6..fcc72e905 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -167,5 +167,13 @@ export type Plugin = { optionalPorts?: readonly OptionalPort[]; // Logged like optionalPorts, but an unbound one fails a SERVICE_MANIFEST boot. controlPorts?: readonly ControlPort[]; + /** + * Declares into `ctx` what the plugin contributes, and does nothing else: no timer, connection, + * file or network read, process listener, global, or `process.env` write. A SERVICE_MANIFEST + * boot that fails registers every plugin it has not registered yet, each into a container of + * its own that is then dropped, to learn which binds which port. Open a resource in a + * `provide()` or router factory, closing it with `c.onDispose()`: those run only for the + * container that serves. Reading configuration and throwing on a bad value is fine. + */ register(ctx: PluginContext): void | Promise; }; diff --git a/packages/core/src/server/plugin-host/service-manifest.ts b/packages/core/src/server/plugin-host/service-manifest.ts index 341df7447..54f410ac3 100644 --- a/packages/core/src/server/plugin-host/service-manifest.ts +++ b/packages/core/src/server/plugin-host/service-manifest.ts @@ -51,6 +51,11 @@ export type ServiceManifestCheck = { hostControls: readonly HostControl[]; /** Whether the process has bound the port so far. */ isBound: (port: C[keyof C]) => boolean; + /** + * The plugins this process has registered. What they bound is in its container, which + * `isBound` reads, so the boot error does not register them a second time. + */ + registered?: ReadonlySet; }; type MissingModule = { kind: 'module'; id: string; requiredBy: string[]; listed: boolean }; @@ -205,8 +210,8 @@ function shortfall( /** * The one error a manifest boot fails with: every module the manifest leaves out and why, and a - * manifest line that adds them all, so one edit fixes it. Reads and registers every plugin in - * the list, each on its own, to learn which module binds which port. + * manifest line that adds them all, so one edit fixes it. Reads every plugin in the list and + * registers each one the process has not, on its own, to learn which module binds which port. */ async function shortfallError(check: ServiceManifestCheck) { const plugins = new Map>(); @@ -220,7 +225,8 @@ async function shortfallError(check: ServiceManifestChec ]), ...check.hostControls.map(({ port }) => port), ]; - const { binders, failed } = await portBinders([...plugins.values()], ports); + const unregistered = [...plugins.values()].filter(({ id }) => !check.registered?.has(id)); + const { binders, failed } = await portBinders(unregistered, ports); const { findings, added } = shortfall(check, plugins, binders); const manifest = check.manifest.join(','); const unknownBinder = findings.some((f) => f.kind === 'required' && f.boundBy === undefined); diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index 2068b0c48..328c003aa 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -31,6 +31,7 @@ import { import type { PluginEntry } from '../../plugin-host/index.js'; import { createApp } from '../create-app.js'; import type { CoreTokenCatalog } from '../core-token-catalog.js'; +import { countedRegistrations } from './fixtures/counted-plugin.js'; import { FIXTURE_LIMITS } from './fixtures/limits-plugin.js'; import { heardTriggers } from './fixtures/trigger-listener-plugin.js'; import { getCurrentClientMeta } from '../../kernel/request-context.js'; @@ -520,6 +521,23 @@ describe('createApp - a split service fails closed on a control it would skip', ); }); + it('registers each loaded plugin once and closes the connection it opened when it refuses', async () => { + const counted = entry('fixture-counted', 'counted'); + const openSockets = () => + process.getActiveResourcesInfo().filter((type) => type === 'TCPSocketWrap').length; + const socketsBefore = openSockets(); + countedRegistrations().count = 0; + + await expect(boot([counted, limits], 'fixture-counted')).rejects.toThrow( + /- fixture-counted runs without FIXTURE_LIMITS \(bound by fixture-limits\)/, + ); + + expect(countedRegistrations().count).toBe(1); + await vi.waitFor(() => expect(openSockets()).toBeLessThanOrEqual(socketsBefore), { + timeout: 2_000, + }); + }); + it('boots once the manifest has that module, or the composition root binds the port', async () => { await expect( boot([limited, limits], 'fixture-limited,fixture-limits'), diff --git a/packages/core/src/server/runtime/__tests__/fixtures/counted-plugin.ts b/packages/core/src/server/runtime/__tests__/fixtures/counted-plugin.ts new file mode 100644 index 000000000..447b24e4a --- /dev/null +++ b/packages/core/src/server/runtime/__tests__/fixtures/counted-plugin.ts @@ -0,0 +1,20 @@ +import type { TokenCatalog } from '@openora/core/contracts'; +import type { Plugin } from '../../../plugin-host/define-plugin.js'; +import { FIXTURE_LIMITS } from './limits-plugin.js'; + +/** How often this plugin registered, on globalThis: the host imports this file by path, not the test. */ +export function countedRegistrations() { + const holder = globalThis as { __countedRegistrations?: { count: number } }; + holder.__countedRegistrations ??= { count: 0 }; + return holder.__countedRegistrations; +} + +export default { + id: 'fixture-counted', + controlPorts: [ + { port: FIXTURE_LIMITS, without: 'fixture limits go unenforced', boundBy: 'fixture-limits' }, + ], + register() { + countedRegistrations().count += 1; + }, +} satisfies Plugin; diff --git a/packages/core/src/server/runtime/create-app.ts b/packages/core/src/server/runtime/create-app.ts index fa889a491..1a9f056e8 100644 --- a/packages/core/src/server/runtime/create-app.ts +++ b/packages/core/src/server/runtime/create-app.ts @@ -389,12 +389,32 @@ async function captureRawBody(req: Request): Promise { * `container.dispose()` (via `close()`) runs disposers in REVERSE registration * order, so DRIZZLE is registered before JOB_QUEUE to guarantee workers drain * before the DB closes. A router namespace registered by more than one plugin - * throws at boot, not at request time. + * throws at boot, not at request time. A boot that fails disposes the container before it + * rethrows: the caller gets no app to close, so what the boot opened - the Redis client, a + * connection `configure` bound - is closed here. */ export async function createApp( config: CreateAppConfig, configure?: (container: Container) => void | Promise, ): Promise { + const container = new Container(); + try { + return await bootApp(container, config, configure); + } catch (err) { + await container + .dispose() + .catch((disposeErr: unknown) => + createLogger('create-app').error({ err: disposeErr }, 'closing a failed boot failed'), + ); + throw err; + } +} + +async function bootApp( + container: Container, + config: CreateAppConfig, + configure?: (container: Container) => void | Promise, +) { // Parsed first so a malformed entry fails the boot before anything is bound. const trustedProxies = resolveTrustedProxies( config.trustedProxies, @@ -407,7 +427,6 @@ export async function createApp( process.env['DATABASE_URL'] = config.databaseUrl; } - const container = new Container(); container.register(DRIZZLE, () => { const svc = new DrizzleService(); container.onDispose(() => svc.dispose()); @@ -512,7 +531,10 @@ export async function createApp( assertDurableSeamsBound(container); // After configure(), so a port the composition root binds counts. if (manifestCheck) { - await assertServiceComplete(manifestCheck); + await assertServiceComplete({ + ...manifestCheck, + registered: new Set(plugins.map(({ id }) => id)), + }); container.get(ADMIN_GUARD); } diff --git a/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md b/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md index 89c37d4e3..eb91959a1 100644 --- a/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md +++ b/tools/templates/consumer/__dot__rulesync/commands/scaffold-plugin.md @@ -7,7 +7,10 @@ Run `pnpm gen plugin $ARGUMENTS` in the repo root. For a vendor swap use `pnpm gen adapter `; for a react-sdk page use `pnpm gen page `. After the generator finishes, open `apps/api/src/extensions//plugin.ts` and implement -`register(ctx)`: +`register(ctx)`. It only declares: a timer, connection, file or network read, process listener, +global or `process.env` write goes in a `provide()` or router factory, closed with +`c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it +then drops. - `ctx.provide(TOKEN, factory)` - bind an adapter/service (sealed compliance tokens are rejected at compile + runtime). From 6a9f9667d85d7bef70996c067d251998fa70c3a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Tue, 20 Oct 2026 13:36:12 +0200 Subject: [PATCH 13/16] docs(repo): renumber the job worker and dependency-only ADRs to 0045 and 0046 dev took ADR-0044 for usage telemetry, so check:refs failed on the duplicate number after the merge. Co-Authored-By: Claude Opus 5.5 (1M context) --- .rulesync/commands/scaffold-plugin.md | 2 +- .rulesync/subagents/plugin-author.md | 2 +- docs/adapters/error-tracking.md | 2 +- docs/adr/0014-job-queue-and-realtime-transport-seams.md | 2 +- ...0017-extraction-readiness-manifest-outbox-command-ports.md | 2 +- docs/adr/0036-rg-limit-enforcement-and-cooldown.md | 2 +- ...-selection.md => 0045-per-process-job-worker-selection.md} | 2 +- ...046-dependency-only-modules-provide-but-do-not-consume.md} | 4 ++-- docs/guides/downstream-consumer.md | 4 ++-- docs/platform/architecture.md | 2 +- docs/platform/system-design.md | 2 +- packages/core/src/server/plugin-host/define-plugin.ts | 2 +- tools/create/create-service.ts | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) rename docs/adr/{0044-per-process-job-worker-selection.md => 0045-per-process-job-worker-selection.md} (99%) rename docs/adr/{0045-dependency-only-modules-provide-but-do-not-consume.md => 0046-dependency-only-modules-provide-but-do-not-consume.md} (96%) diff --git a/.rulesync/commands/scaffold-plugin.md b/.rulesync/commands/scaffold-plugin.md index d9253630b..02cf61081 100644 --- a/.rulesync/commands/scaffold-plugin.md +++ b/.rulesync/commands/scaffold-plugin.md @@ -6,7 +6,7 @@ description: 'Generate a new overlay extension plugin under extensions//. Run `pnpm gen plugin $ARGUMENTS` in the repo root. Variants: `pnpm gen adapter ` for a vendor-adapter swap; `pnpm gen job-worker ` for a background-job worker. -The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0044). +The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0045). - `ctx.provide(TOKEN, factory)` - bind/override an adapter or service. Last registration wins, so an overlay loaded after the default-binding module replaces the default - keep the order in `extensions.config.ts` intentional. Sealed compliance tokens (`@openora/core/compliance`) are rejected at compile time + runtime. - `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes. diff --git a/.rulesync/subagents/plugin-author.md b/.rulesync/subagents/plugin-author.md index 64442c998..7594b55b0 100644 --- a/.rulesync/subagents/plugin-author.md +++ b/.rulesync/subagents/plugin-author.md @@ -45,7 +45,7 @@ ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output); // agen ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })); // action type: a change a model may only propose ``` -`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0044). +`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0045). Agent tools and action types are served only through the `MCP_KERNEL` token, which checks the IAM grant of the admin behind the run, validates input, returns only the allow-listed output keys and declared error codes, and audits every tool call and action execution. A shape it cannot serve (eg a tool input that is not a top-level `z.object`, or an unbounded string) fails boot with the fix. `execute` must perform nothing on a replay while its effect still holds, and answer `already_applied`. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Contract types: `packages/core/src/contracts/adapters/mcp.ts`; why: ADR-0042. diff --git a/docs/adapters/error-tracking.md b/docs/adapters/error-tracking.md index e8e3d6d6a..f71c462fe 100644 --- a/docs/adapters/error-tracking.md +++ b/docs/adapters/error-tracking.md @@ -81,7 +81,7 @@ export default { `Sentry.init` runs in the factory, which `createApp` resolves once for the container that serves, not in `register()`: a failed `SERVICE_MANIFEST` boot registers plugins into a container it then -drops (ADR-0044). Initialising in a file preloaded with `node --import` works as well. +drops (ADR-0045). Initialising in a file preloaded with `node --import` works as well. Register it last in `extensions.config.ts` (a `kind: 'infra'` overlay). A PostHog/Rollbar shop writes the equivalent overlay against the same port - core is unchanged. diff --git a/docs/adr/0014-job-queue-and-realtime-transport-seams.md b/docs/adr/0014-job-queue-and-realtime-transport-seams.md index e5082faf0..1b067b1ee 100644 --- a/docs/adr/0014-job-queue-and-realtime-transport-seams.md +++ b/docs/adr/0014-job-queue-and-realtime-transport-seams.md @@ -8,7 +8,7 @@ > > **Update (2026-08-31)**: [ADR-0031](./0031-realtime-transport-sse-and-http-over-web-sockets.md) shipped `RedisPubSubRealtimeTransport` and deleted `InProcessRealtimeTransport` too - `REALTIME_TRANSPORT` now auto-binds on `REDIS_URL` exactly like `JOB_QUEUE`, with no in-process fallback either. Both seams from this ADR are now fully superseded on the production-default question; the seam shapes and the client-push-is-not-inter-module decision are still unchanged. > -> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0044](./0044-per-process-job-worker-selection.md)). +> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0045](./0045-per-process-job-worker-selection.md)). ## Context diff --git a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md index 0e644f5ff..8bc021b7a 100644 --- a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md +++ b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md @@ -4,7 +4,7 @@ **Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default). **Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope). -> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0044](./0044-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0044](./0044-per-process-job-worker-selection.md). +> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0045](./0045-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0045](./0045-per-process-job-worker-selection.md). ## Context diff --git a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md index baf29fa4b..c26fb5f74 100644 --- a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md +++ b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md @@ -11,7 +11,7 @@ and the concurrency reasoning) is unchanged. **Update (2026-10-16)**: `RG_LIMITS` stays optional for its consumers in a full boot, but wallet and gaming list it in `controlPorts`, so a `SERVICE_MANIFEST` boot that loads either refuses to start without the compliance module while the plugin list has it: another process of the install -enforces the limits there ([ADR-0044](./0044-per-process-job-worker-selection.md)). +enforces the limits there ([ADR-0045](./0045-per-process-job-worker-selection.md)). ## Context diff --git a/docs/adr/0044-per-process-job-worker-selection.md b/docs/adr/0045-per-process-job-worker-selection.md similarity index 99% rename from docs/adr/0044-per-process-job-worker-selection.md rename to docs/adr/0045-per-process-job-worker-selection.md index effbf01b7..904d7389a 100644 --- a/docs/adr/0044-per-process-job-worker-selection.md +++ b/docs/adr/0045-per-process-job-worker-selection.md @@ -1,4 +1,4 @@ -# ADR-0044: Per-process job worker selection +# ADR-0045: Per-process job worker selection **Date**: 2026-10-13 **Status**: Proposed diff --git a/docs/adr/0045-dependency-only-modules-provide-but-do-not-consume.md b/docs/adr/0046-dependency-only-modules-provide-but-do-not-consume.md similarity index 96% rename from docs/adr/0045-dependency-only-modules-provide-but-do-not-consume.md rename to docs/adr/0046-dependency-only-modules-provide-but-do-not-consume.md index 06b4a294d..994e11cf3 100644 --- a/docs/adr/0045-dependency-only-modules-provide-but-do-not-consume.md +++ b/docs/adr/0046-dependency-only-modules-provide-but-do-not-consume.md @@ -1,8 +1,8 @@ -# ADR-0045: Dependency-only modules provide but do not consume +# ADR-0046: Dependency-only modules provide but do not consume **Date**: 2026-10-13 **Status**: Proposed -**Relates to**: ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0044 (job worker selection). +**Relates to**: ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0045 (job worker selection). ## Context diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index c5953747b..68ae13f0a 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -95,14 +95,14 @@ and each job reaches one of them. Identity's controls pull in compliance, and compliance depends on wallet, gaming, player-management and exchange-rate; player-management depends on chat. A split is those ten modules plus the ones you meant to move, and until - [ADR-0045](../adr/0045-dependency-only-modules-provide-but-do-not-consume.md) lands each of the + [ADR-0046](../adr/0046-dependency-only-modules-provide-but-do-not-consume.md) lands each of the ten handles every event once in the split and once in the rest of the install. - **Fewer queues in a process.** `JOB_WORKERS` picks the job queues a process consumes: names, `prefix*` patterns, `!` to exclude. To keep a long-running queue off the API replicas, run one more instance with the same plugins and `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Every queue needs a consumer somewhere. See - [ADR-0044](../adr/0044-per-process-job-worker-selection.md). + [ADR-0045](../adr/0045-per-process-job-worker-selection.md). ## Seeding reference data (production) diff --git a/docs/platform/architecture.md b/docs/platform/architecture.md index 9059d7526..2527402a2 100644 --- a/docs/platform/architecture.md +++ b/docs/platform/architecture.md @@ -102,7 +102,7 @@ Solid arrows are runtime/build dependencies; dashed arrows are **adapter seams** **Vendor adapters** - concrete implementations of a module's adapter interfaces (PSP, KYC vendor, igaming aggregator, chat), shipped as separate packages. The interface is the seam; the implementation is swappable. For the pooling/sweeping/reconciliation shape a custody-style payment vendor adds, see [docs/adapters/custody.md](../adapters/custody.md#custody-topology). -**Background jobs** - long-running work runs off the request path on `JOB_QUEUE`: a module registers a worker with `ctx.jobs.worker` and enqueues to it. Every process consumes every queue by default; `JOB_WORKERS` narrows that, so a long-running queue can live on an extra instance that boots the same plugins while the API replicas skip it. See [ADR-0044](../adr/0044-per-process-job-worker-selection.md). +**Background jobs** - long-running work runs off the request path on `JOB_QUEUE`: a module registers a worker with `ctx.jobs.worker` and enqueues to it. Every process consumes every queue by default; `JOB_WORKERS` narrows that, so a long-running queue can live on an extra instance that boots the same plugins while the API replicas skip it. See [ADR-0045](../adr/0045-per-process-job-worker-selection.md). **SDK & consumption surface** diff --git a/docs/platform/system-design.md b/docs/platform/system-design.md index abc81f714..0761992a1 100644 --- a/docs/platform/system-design.md +++ b/docs/platform/system-design.md @@ -292,7 +292,7 @@ that adds it all. A split service needs a `SERVICE_NAME` of its own - its consumer group - while an instance that only takes a share of the queues keeps the replicas' `SERVICE_NAME`, so it competes for events rather than receiving a copy. `JOB_WORKERS` picks the queues a process consumes -([ADR-0044](../adr/0044-per-process-job-worker-selection.md)). +([ADR-0045](../adr/0045-per-process-job-worker-selection.md)). ## Reference - domain -> modules -> tables -> routes diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index fcc72e905..a6e654447 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -115,7 +115,7 @@ export type ModuleRegistry = { listen(listener: TriggerListener): void; getAll(): readonly RegisteredTriggerListener[]; }; - // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect), unless JOB_WORKERS leaves the queue to another process. See ADR-0014, ADR-0044. + // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect), unless JOB_WORKERS leaves the queue to another process. See ADR-0014, ADR-0045. jobs: { worker(registration: WorkerRegistration): void; getAll(): WorkerRegistration[]; diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index ff0348187..f0014c6f7 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -146,7 +146,7 @@ console.warn( ' - A module this service and another process both load handles each event in both, under each', ); console.warn( - ' SERVICE_NAME - audit writes every row twice. Until ADR-0045 lands, split modules no other process loads.', + ' SERVICE_NAME - audit writes every row twice. Until ADR-0046 lands, split modules no other process loads.', ); console.warn( ' - A manifest that leaves out a module behind a control (RG limits, country checks, audit', From 949973c971444ea421ec9fcd050ef56ea7028e41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Thu, 22 Oct 2026 09:49:17 +0200 Subject: [PATCH 14/16] docs(repo): renumber the service split adrs after the at-least-once delivery adr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev took ADR-0045 for at-least-once event delivery, so the job worker selection ADR becomes ADR-0046 and the dependency-only modules ADR becomes ADR-0047. References that mean the delivery ADR keep 0045. Co-Authored-By: Claude Opus 5.5 (1M context) Signed-off-by: Mateusz Paś --- .rulesync/commands/scaffold-plugin.md | 2 +- .rulesync/subagents/plugin-author.md | 2 +- docs/adapters/error-tracking.md | 2 +- docs/adr/0014-job-queue-and-realtime-transport-seams.md | 2 +- ...0017-extraction-readiness-manifest-outbox-command-ports.md | 2 +- docs/adr/0036-rg-limit-enforcement-and-cooldown.md | 2 +- ...-selection.md => 0046-per-process-job-worker-selection.md} | 2 +- ...047-dependency-only-modules-provide-but-do-not-consume.md} | 4 ++-- docs/guides/downstream-consumer.md | 4 ++-- docs/platform/architecture.md | 2 +- docs/platform/system-design.md | 2 +- packages/core/src/server/plugin-host/define-plugin.ts | 2 +- tools/create/create-service.ts | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) rename docs/adr/{0045-per-process-job-worker-selection.md => 0046-per-process-job-worker-selection.md} (99%) rename docs/adr/{0046-dependency-only-modules-provide-but-do-not-consume.md => 0047-dependency-only-modules-provide-but-do-not-consume.md} (96%) diff --git a/.rulesync/commands/scaffold-plugin.md b/.rulesync/commands/scaffold-plugin.md index 02cf61081..717fddbf6 100644 --- a/.rulesync/commands/scaffold-plugin.md +++ b/.rulesync/commands/scaffold-plugin.md @@ -6,7 +6,7 @@ description: 'Generate a new overlay extension plugin under extensions//. Run `pnpm gen plugin $ARGUMENTS` in the repo root. Variants: `pnpm gen adapter ` for a vendor-adapter swap; `pnpm gen job-worker ` for a background-job worker. -The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0045). +The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0046). - `ctx.provide(TOKEN, factory)` - bind/override an adapter or service. Last registration wins, so an overlay loaded after the default-binding module replaces the default - keep the order in `extensions.config.ts` intentional. Sealed compliance tokens (`@openora/core/compliance`) are rejected at compile time + runtime. - `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes. diff --git a/.rulesync/subagents/plugin-author.md b/.rulesync/subagents/plugin-author.md index e4f4648c1..af890931e 100644 --- a/.rulesync/subagents/plugin-author.md +++ b/.rulesync/subagents/plugin-author.md @@ -40,7 +40,7 @@ ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output); // agen ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })); // action type: a change a model may only propose ``` -`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0045). +`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0046). Agent tools and action types are served only through the `MCP_KERNEL` token, which checks the IAM grant of the admin behind the run, validates input, returns only the allow-listed output keys and declared error codes, and audits every tool call and action execution. A shape it cannot serve (eg a tool input that is not a top-level `z.object`, or an unbounded string) fails boot with the fix. `execute` must perform nothing on a replay while its effect still holds, and answer `already_applied`. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Contract types: `packages/core/src/contracts/adapters/mcp.ts`; why: ADR-0042. diff --git a/docs/adapters/error-tracking.md b/docs/adapters/error-tracking.md index f71c462fe..696d35739 100644 --- a/docs/adapters/error-tracking.md +++ b/docs/adapters/error-tracking.md @@ -81,7 +81,7 @@ export default { `Sentry.init` runs in the factory, which `createApp` resolves once for the container that serves, not in `register()`: a failed `SERVICE_MANIFEST` boot registers plugins into a container it then -drops (ADR-0045). Initialising in a file preloaded with `node --import` works as well. +drops (ADR-0046). Initialising in a file preloaded with `node --import` works as well. Register it last in `extensions.config.ts` (a `kind: 'infra'` overlay). A PostHog/Rollbar shop writes the equivalent overlay against the same port - core is unchanged. diff --git a/docs/adr/0014-job-queue-and-realtime-transport-seams.md b/docs/adr/0014-job-queue-and-realtime-transport-seams.md index 1b067b1ee..7c29d9bed 100644 --- a/docs/adr/0014-job-queue-and-realtime-transport-seams.md +++ b/docs/adr/0014-job-queue-and-realtime-transport-seams.md @@ -8,7 +8,7 @@ > > **Update (2026-08-31)**: [ADR-0031](./0031-realtime-transport-sse-and-http-over-web-sockets.md) shipped `RedisPubSubRealtimeTransport` and deleted `InProcessRealtimeTransport` too - `REALTIME_TRANSPORT` now auto-binds on `REDIS_URL` exactly like `JOB_QUEUE`, with no in-process fallback either. Both seams from this ADR are now fully superseded on the production-default question; the seam shapes and the client-push-is-not-inter-module decision are still unchanged. > -> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0045](./0045-per-process-job-worker-selection.md)). +> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0046](./0046-per-process-job-worker-selection.md)). ## Context diff --git a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md index 978f2ff7b..d4d981842 100644 --- a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md +++ b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md @@ -4,7 +4,7 @@ **Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default). **Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope). -> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0045](./0045-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0045](./0045-per-process-job-worker-selection.md). +> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0046](./0046-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0046](./0046-per-process-job-worker-selection.md). > **Update (2026-10-13)**: [ADR-0045](./0045-at-least-once-event-delivery.md) made the outbox always on - `OUTBOX_ENABLED` is gone, `createApp` always binds `OUTBOX` and starts the `OutboxRelay`, and money and compliance events are emitted with `emitInTransaction`. The "Opt-in" paragraph below no longer holds. The relay now claims rows atomically with a lease, and consumers dedup per handler through `processed_events` instead of on `eventId` alone. diff --git a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md index c26fb5f74..cf3f7d1ad 100644 --- a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md +++ b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md @@ -11,7 +11,7 @@ and the concurrency reasoning) is unchanged. **Update (2026-10-16)**: `RG_LIMITS` stays optional for its consumers in a full boot, but wallet and gaming list it in `controlPorts`, so a `SERVICE_MANIFEST` boot that loads either refuses to start without the compliance module while the plugin list has it: another process of the install -enforces the limits there ([ADR-0045](./0045-per-process-job-worker-selection.md)). +enforces the limits there ([ADR-0046](./0046-per-process-job-worker-selection.md)). ## Context diff --git a/docs/adr/0045-per-process-job-worker-selection.md b/docs/adr/0046-per-process-job-worker-selection.md similarity index 99% rename from docs/adr/0045-per-process-job-worker-selection.md rename to docs/adr/0046-per-process-job-worker-selection.md index 904d7389a..85d5ddd74 100644 --- a/docs/adr/0045-per-process-job-worker-selection.md +++ b/docs/adr/0046-per-process-job-worker-selection.md @@ -1,4 +1,4 @@ -# ADR-0045: Per-process job worker selection +# ADR-0046: Per-process job worker selection **Date**: 2026-10-13 **Status**: Proposed diff --git a/docs/adr/0046-dependency-only-modules-provide-but-do-not-consume.md b/docs/adr/0047-dependency-only-modules-provide-but-do-not-consume.md similarity index 96% rename from docs/adr/0046-dependency-only-modules-provide-but-do-not-consume.md rename to docs/adr/0047-dependency-only-modules-provide-but-do-not-consume.md index 994e11cf3..4c9dccd0a 100644 --- a/docs/adr/0046-dependency-only-modules-provide-but-do-not-consume.md +++ b/docs/adr/0047-dependency-only-modules-provide-but-do-not-consume.md @@ -1,8 +1,8 @@ -# ADR-0046: Dependency-only modules provide but do not consume +# ADR-0047: Dependency-only modules provide but do not consume **Date**: 2026-10-13 **Status**: Proposed -**Relates to**: ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0045 (job worker selection). +**Relates to**: ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0046 (job worker selection). ## Context diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index 68ae13f0a..e8931ded9 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -95,14 +95,14 @@ and each job reaches one of them. Identity's controls pull in compliance, and compliance depends on wallet, gaming, player-management and exchange-rate; player-management depends on chat. A split is those ten modules plus the ones you meant to move, and until - [ADR-0046](../adr/0046-dependency-only-modules-provide-but-do-not-consume.md) lands each of the + [ADR-0047](../adr/0047-dependency-only-modules-provide-but-do-not-consume.md) lands each of the ten handles every event once in the split and once in the rest of the install. - **Fewer queues in a process.** `JOB_WORKERS` picks the job queues a process consumes: names, `prefix*` patterns, `!` to exclude. To keep a long-running queue off the API replicas, run one more instance with the same plugins and `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Every queue needs a consumer somewhere. See - [ADR-0045](../adr/0045-per-process-job-worker-selection.md). + [ADR-0046](../adr/0046-per-process-job-worker-selection.md). ## Seeding reference data (production) diff --git a/docs/platform/architecture.md b/docs/platform/architecture.md index f4d641bf8..71711a8e8 100644 --- a/docs/platform/architecture.md +++ b/docs/platform/architecture.md @@ -102,7 +102,7 @@ Solid arrows are runtime/build dependencies; dashed arrows are **adapter seams** **Vendor adapters** - concrete implementations of a module's adapter interfaces (PSP, KYC vendor, igaming aggregator, chat), shipped as separate packages. The interface is the seam; the implementation is swappable. For the pooling/sweeping/reconciliation shape a custody-style payment vendor adds, see [docs/adapters/custody.md](../adapters/custody.md#custody-topology). -**Background jobs** - long-running work runs off the request path on `JOB_QUEUE`: a module registers a worker with `ctx.jobs.worker` and enqueues to it. Every process consumes every queue by default; `JOB_WORKERS` narrows that, so a long-running queue can live on an extra instance that boots the same plugins while the API replicas skip it. See [ADR-0045](../adr/0045-per-process-job-worker-selection.md). +**Background jobs** - long-running work runs off the request path on `JOB_QUEUE`: a module registers a worker with `ctx.jobs.worker` and enqueues to it. Every process consumes every queue by default; `JOB_WORKERS` narrows that, so a long-running queue can live on an extra instance that boots the same plugins while the API replicas skip it. See [ADR-0046](../adr/0046-per-process-job-worker-selection.md). **SDK & consumption surface** diff --git a/docs/platform/system-design.md b/docs/platform/system-design.md index 0761992a1..d3fb78010 100644 --- a/docs/platform/system-design.md +++ b/docs/platform/system-design.md @@ -292,7 +292,7 @@ that adds it all. A split service needs a `SERVICE_NAME` of its own - its consumer group - while an instance that only takes a share of the queues keeps the replicas' `SERVICE_NAME`, so it competes for events rather than receiving a copy. `JOB_WORKERS` picks the queues a process consumes -([ADR-0045](../adr/0045-per-process-job-worker-selection.md)). +([ADR-0046](../adr/0046-per-process-job-worker-selection.md)). ## Reference - domain -> modules -> tables -> routes diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index df6766d79..77b16fa56 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -121,7 +121,7 @@ export type ModuleRegistry = { listen(listener: TriggerListener): void; getAll(): readonly RegisteredTriggerListener[]; }; - // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect), unless JOB_WORKERS leaves the queue to another process. See ADR-0014, ADR-0045. + // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect), unless JOB_WORKERS leaves the queue to another process. See ADR-0014, ADR-0046. jobs: { worker(registration: WorkerRegistration): void; getAll(): WorkerRegistration[]; diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index f0014c6f7..166269c68 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -146,7 +146,7 @@ console.warn( ' - A module this service and another process both load handles each event in both, under each', ); console.warn( - ' SERVICE_NAME - audit writes every row twice. Until ADR-0046 lands, split modules no other process loads.', + ' SERVICE_NAME - audit writes every row twice. Until ADR-0047 lands, split modules no other process loads.', ); console.warn( ' - A manifest that leaves out a module behind a control (RG limits, country checks, audit', From 3a6cf01c166b39bc071f36d58616432824f59159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Mon, 2 Nov 2026 10:01:42 +0100 Subject: [PATCH 15/16] docs: renumber the job worker and dependency-only module ADRs past the sportsbook ones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev took ADR numbers 0046 and 0047 for the sportsbook ledger port and provider promotions; this branch's two ADRs become 0048 and 0049. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .rulesync/commands/scaffold-plugin.md | 2 +- .rulesync/subagents/plugin-author.md | 2 +- docs/adapters/error-tracking.md | 2 +- docs/adr/0014-job-queue-and-realtime-transport-seams.md | 2 +- ...0017-extraction-readiness-manifest-outbox-command-ports.md | 2 +- docs/adr/0036-rg-limit-enforcement-and-cooldown.md | 2 +- ...-selection.md => 0048-per-process-job-worker-selection.md} | 2 +- ...049-dependency-only-modules-provide-but-do-not-consume.md} | 4 ++-- docs/guides/downstream-consumer.md | 4 ++-- docs/platform/architecture.md | 2 +- docs/platform/system-design.md | 2 +- packages/core/src/server/plugin-host/define-plugin.ts | 2 +- tools/create/create-service.ts | 2 +- 13 files changed, 15 insertions(+), 15 deletions(-) rename docs/adr/{0046-per-process-job-worker-selection.md => 0048-per-process-job-worker-selection.md} (99%) rename docs/adr/{0047-dependency-only-modules-provide-but-do-not-consume.md => 0049-dependency-only-modules-provide-but-do-not-consume.md} (96%) diff --git a/.rulesync/commands/scaffold-plugin.md b/.rulesync/commands/scaffold-plugin.md index 717fddbf6..37c70b5e9 100644 --- a/.rulesync/commands/scaffold-plugin.md +++ b/.rulesync/commands/scaffold-plugin.md @@ -6,7 +6,7 @@ description: 'Generate a new overlay extension plugin under extensions//. Run `pnpm gen plugin $ARGUMENTS` in the repo root. Variants: `pnpm gen adapter ` for a vendor-adapter swap; `pnpm gen job-worker ` for a background-job worker. -The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0046). +The generator creates `extensions//plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0048). - `ctx.provide(TOKEN, factory)` - bind/override an adapter or service. Last registration wins, so an overlay loaded after the default-binding module replaces the default - keep the order in `extensions.config.ts` intentional. Sealed compliance tokens (`@openora/core/compliance`) are rejected at compile time + runtime. - `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes. diff --git a/.rulesync/subagents/plugin-author.md b/.rulesync/subagents/plugin-author.md index af890931e..eedceadc0 100644 --- a/.rulesync/subagents/plugin-author.md +++ b/.rulesync/subagents/plugin-author.md @@ -40,7 +40,7 @@ ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output); // agen ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })); // action type: a change a model may only propose ``` -`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0046). +`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0048). Agent tools and action types are served only through the `MCP_KERNEL` token, which checks the IAM grant of the admin behind the run, validates input, returns only the allow-listed output keys and declared error codes, and audits every tool call and action execution. A shape it cannot serve (eg a tool input that is not a top-level `z.object`, or an unbounded string) fails boot with the fix. `execute` must perform nothing on a replay while its effect still holds, and answer `already_applied`. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Contract types: `packages/core/src/contracts/adapters/mcp.ts`; why: ADR-0042. diff --git a/docs/adapters/error-tracking.md b/docs/adapters/error-tracking.md index 696d35739..14b3c6504 100644 --- a/docs/adapters/error-tracking.md +++ b/docs/adapters/error-tracking.md @@ -81,7 +81,7 @@ export default { `Sentry.init` runs in the factory, which `createApp` resolves once for the container that serves, not in `register()`: a failed `SERVICE_MANIFEST` boot registers plugins into a container it then -drops (ADR-0046). Initialising in a file preloaded with `node --import` works as well. +drops (ADR-0048). Initialising in a file preloaded with `node --import` works as well. Register it last in `extensions.config.ts` (a `kind: 'infra'` overlay). A PostHog/Rollbar shop writes the equivalent overlay against the same port - core is unchanged. diff --git a/docs/adr/0014-job-queue-and-realtime-transport-seams.md b/docs/adr/0014-job-queue-and-realtime-transport-seams.md index 7c29d9bed..658cf8ad5 100644 --- a/docs/adr/0014-job-queue-and-realtime-transport-seams.md +++ b/docs/adr/0014-job-queue-and-realtime-transport-seams.md @@ -8,7 +8,7 @@ > > **Update (2026-08-31)**: [ADR-0031](./0031-realtime-transport-sse-and-http-over-web-sockets.md) shipped `RedisPubSubRealtimeTransport` and deleted `InProcessRealtimeTransport` too - `REALTIME_TRANSPORT` now auto-binds on `REDIS_URL` exactly like `JOB_QUEUE`, with no in-process fallback either. Both seams from this ADR are now fully superseded on the production-default question; the seam shapes and the client-push-is-not-inter-module decision are still unchanged. > -> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0046](./0046-per-process-job-worker-selection.md)). +> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0048](./0048-per-process-job-worker-selection.md)). ## Context diff --git a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md index d4d981842..243904b12 100644 --- a/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md +++ b/docs/adr/0017-extraction-readiness-manifest-outbox-command-ports.md @@ -4,7 +4,7 @@ **Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default). **Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope). -> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0046](./0046-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0046](./0046-per-process-job-worker-selection.md). +> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0048](./0048-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0048](./0048-per-process-job-worker-selection.md). > **Update (2026-10-13)**: [ADR-0045](./0045-at-least-once-event-delivery.md) made the outbox always on - `OUTBOX_ENABLED` is gone, `createApp` always binds `OUTBOX` and starts the `OutboxRelay`, and money and compliance events are emitted with `emitInTransaction`. The "Opt-in" paragraph below no longer holds. The relay now claims rows atomically with a lease, and consumers dedup per handler through `processed_events` instead of on `eventId` alone. diff --git a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md index 2d94e514a..00f4d6807 100644 --- a/docs/adr/0036-rg-limit-enforcement-and-cooldown.md +++ b/docs/adr/0036-rg-limit-enforcement-and-cooldown.md @@ -11,7 +11,7 @@ and the concurrency reasoning) is unchanged. **Update (2026-10-16)**: `RG_LIMITS` stays optional for its consumers in a full boot, but wallet and gaming list it in `controlPorts`, so a `SERVICE_MANIFEST` boot that loads either refuses to start without the compliance module while the plugin list has it: another process of the install -enforces the limits there ([ADR-0046](./0046-per-process-job-worker-selection.md)). +enforces the limits there ([ADR-0048](./0048-per-process-job-worker-selection.md)). > **Update (2026-10-15)**: RG spend now reads the wallet ledger for every product, and `loss` > stays unenforced for the reason in [ADR-0046](./0046-sportsbook-ledger-port.md). diff --git a/docs/adr/0046-per-process-job-worker-selection.md b/docs/adr/0048-per-process-job-worker-selection.md similarity index 99% rename from docs/adr/0046-per-process-job-worker-selection.md rename to docs/adr/0048-per-process-job-worker-selection.md index 85d5ddd74..fb8f37782 100644 --- a/docs/adr/0046-per-process-job-worker-selection.md +++ b/docs/adr/0048-per-process-job-worker-selection.md @@ -1,4 +1,4 @@ -# ADR-0046: Per-process job worker selection +# ADR-0048: Per-process job worker selection **Date**: 2026-10-13 **Status**: Proposed diff --git a/docs/adr/0047-dependency-only-modules-provide-but-do-not-consume.md b/docs/adr/0049-dependency-only-modules-provide-but-do-not-consume.md similarity index 96% rename from docs/adr/0047-dependency-only-modules-provide-but-do-not-consume.md rename to docs/adr/0049-dependency-only-modules-provide-but-do-not-consume.md index 4c9dccd0a..b38a17685 100644 --- a/docs/adr/0047-dependency-only-modules-provide-but-do-not-consume.md +++ b/docs/adr/0049-dependency-only-modules-provide-but-do-not-consume.md @@ -1,8 +1,8 @@ -# ADR-0047: Dependency-only modules provide but do not consume +# ADR-0049: Dependency-only modules provide but do not consume **Date**: 2026-10-13 **Status**: Proposed -**Relates to**: ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0046 (job worker selection). +**Relates to**: ADR-0017 (service manifest), ADR-0030 (`SERVICE_NAME`), ADR-0048 (job worker selection). ## Context diff --git a/docs/guides/downstream-consumer.md b/docs/guides/downstream-consumer.md index e8931ded9..9f017ec0c 100644 --- a/docs/guides/downstream-consumer.md +++ b/docs/guides/downstream-consumer.md @@ -95,14 +95,14 @@ and each job reaches one of them. Identity's controls pull in compliance, and compliance depends on wallet, gaming, player-management and exchange-rate; player-management depends on chat. A split is those ten modules plus the ones you meant to move, and until - [ADR-0047](../adr/0047-dependency-only-modules-provide-but-do-not-consume.md) lands each of the + [ADR-0049](../adr/0049-dependency-only-modules-provide-but-do-not-consume.md) lands each of the ten handles every event once in the split and once in the rest of the install. - **Fewer queues in a process.** `JOB_WORKERS` picks the job queues a process consumes: names, `prefix*` patterns, `!` to exclude. To keep a long-running queue off the API replicas, run one more instance with the same plugins and `SERVICE_NAME` and `JOB_WORKERS=`, and give the replicas `JOB_WORKERS=!`. Every queue needs a consumer somewhere. See - [ADR-0046](../adr/0046-per-process-job-worker-selection.md). + [ADR-0048](../adr/0048-per-process-job-worker-selection.md). ## Seeding reference data (production) diff --git a/docs/platform/architecture.md b/docs/platform/architecture.md index 71711a8e8..b5816ecff 100644 --- a/docs/platform/architecture.md +++ b/docs/platform/architecture.md @@ -102,7 +102,7 @@ Solid arrows are runtime/build dependencies; dashed arrows are **adapter seams** **Vendor adapters** - concrete implementations of a module's adapter interfaces (PSP, KYC vendor, igaming aggregator, chat), shipped as separate packages. The interface is the seam; the implementation is swappable. For the pooling/sweeping/reconciliation shape a custody-style payment vendor adds, see [docs/adapters/custody.md](../adapters/custody.md#custody-topology). -**Background jobs** - long-running work runs off the request path on `JOB_QUEUE`: a module registers a worker with `ctx.jobs.worker` and enqueues to it. Every process consumes every queue by default; `JOB_WORKERS` narrows that, so a long-running queue can live on an extra instance that boots the same plugins while the API replicas skip it. See [ADR-0046](../adr/0046-per-process-job-worker-selection.md). +**Background jobs** - long-running work runs off the request path on `JOB_QUEUE`: a module registers a worker with `ctx.jobs.worker` and enqueues to it. Every process consumes every queue by default; `JOB_WORKERS` narrows that, so a long-running queue can live on an extra instance that boots the same plugins while the API replicas skip it. See [ADR-0048](../adr/0048-per-process-job-worker-selection.md). **SDK & consumption surface** diff --git a/docs/platform/system-design.md b/docs/platform/system-design.md index d3fb78010..bc102fb4c 100644 --- a/docs/platform/system-design.md +++ b/docs/platform/system-design.md @@ -292,7 +292,7 @@ that adds it all. A split service needs a `SERVICE_NAME` of its own - its consumer group - while an instance that only takes a share of the queues keeps the replicas' `SERVICE_NAME`, so it competes for events rather than receiving a copy. `JOB_WORKERS` picks the queues a process consumes -([ADR-0046](../adr/0046-per-process-job-worker-selection.md)). +([ADR-0048](../adr/0048-per-process-job-worker-selection.md)). ## Reference - domain -> modules -> tables -> routes diff --git a/packages/core/src/server/plugin-host/define-plugin.ts b/packages/core/src/server/plugin-host/define-plugin.ts index 4cba31845..1a8b37bbc 100644 --- a/packages/core/src/server/plugin-host/define-plugin.ts +++ b/packages/core/src/server/plugin-host/define-plugin.ts @@ -138,7 +138,7 @@ export type ModuleRegistry = { listen(listener: TriggerListener): void; getAll(): readonly RegisteredTriggerListener[]; }; - // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect), unless JOB_WORKERS leaves the queue to another process. See ADR-0014, ADR-0046. + // Started at boot against the resolved JOB_QUEUE (after all providers, so an overlay's durable driver is in effect), unless JOB_WORKERS leaves the queue to another process. See ADR-0014, ADR-0048. jobs: { worker(registration: WorkerRegistration): void; getAll(): WorkerRegistration[]; diff --git a/tools/create/create-service.ts b/tools/create/create-service.ts index 40c8369f9..39744aa4d 100644 --- a/tools/create/create-service.ts +++ b/tools/create/create-service.ts @@ -149,7 +149,7 @@ console.warn( ' - A module this service and another process both load handles each event in both, under each', ); console.warn( - ' SERVICE_NAME - audit writes every row twice. Until ADR-0047 lands, split modules no other process loads.', + ' SERVICE_NAME - audit writes every row twice. Until ADR-0049 lands, split modules no other process loads.', ); console.warn( ' - A manifest that leaves out a module behind a control (RG limits, country checks, audit', From 8bd19620a31878543a9e0f8ce1bcfca922ef00b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Mon, 2 Nov 2026 10:03:44 +0100 Subject: [PATCH 16/16] fix(core): read flagged manifest entries in the split boot check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev's SERVICE_MANIFEST entries carry a no-routes flag and a split now needs REALTIME_NAMESPACE: the boot check takes the entries, keeps the flags in the manifest line it suggests, and the tests set the namespace beside the service name. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../__tests__/register-side-effects.test.ts | 2 +- .../__tests__/service-manifest.test.ts | 2 +- .../server/plugin-host/service-manifest.ts | 7 +++++-- .../runtime/__tests__/create-app.test.ts | 20 ++++++++++++++++--- .../service-manifest-boot.e2e.test.ts | 3 ++- 5 files changed, 26 insertions(+), 8 deletions(-) diff --git a/packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts b/packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts index d3f0d6649..80979634b 100644 --- a/packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/register-side-effects.test.ts @@ -66,7 +66,7 @@ describe('the registration pass of a failed SERVICE_MANIFEST boot', () => { await expect( selectServiceEntries({ entries: loaded.map(({ entry }) => entry), - manifest: ['wallet'], + manifest: [{ id: 'wallet', noRoutes: false }], readPlugin: (entry) => importPlugin(entry), hostControls: [], isBound: () => false, diff --git a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts index 73530c751..f16eb1051 100644 --- a/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts +++ b/packages/core/src/server/plugin-host/__tests__/service-manifest.test.ts @@ -138,7 +138,7 @@ function checkOf( path: p.id, ...(infra.includes(p.id) ? { kind: 'infra' as const } : {}), })), - manifest, + manifest: manifest.map((id) => ({ id, noRoutes: false })), readPlugin, hostControls, isBound: (port) => bound.includes(port), diff --git a/packages/core/src/server/plugin-host/service-manifest.ts b/packages/core/src/server/plugin-host/service-manifest.ts index 8099b8b4b..1095503dd 100644 --- a/packages/core/src/server/plugin-host/service-manifest.ts +++ b/packages/core/src/server/plugin-host/service-manifest.ts @@ -83,7 +83,7 @@ export type HostControl = ControlPort & { export type ServiceManifestCheck = { /** The whole plugin list: the manifest's modules and every one it leaves out. */ entries: readonly PluginEntry[]; - manifest: readonly string[]; + manifest: readonly ServiceManifestEntry[]; readPlugin: (entry: PluginEntry) => Promise>; hostControls: readonly HostControl[]; /** Whether the process has bound the port so far. */ @@ -265,7 +265,10 @@ async function shortfallError(check: ServiceManifestChec const unregistered = [...plugins.values()].filter(({ id }) => !check.registered?.has(id)); const { binders, failed } = await portBinders(unregistered, ports); const { findings, added } = shortfall(check, plugins, binders); - const manifest = check.manifest.join(','); + // As the operator wrote it, flags included, so the suggested line keeps them. + const manifest = check.manifest + .map((m) => (m.noRoutes ? `${m.id}:${NO_ROUTES_FLAG}` : m.id)) + .join(','); const unknownBinder = findings.some((f) => f.kind === 'required' && f.boundBy === undefined); return new Error( `SERVICE_MANIFEST=${manifest} leaves out what this service needs:\n` + diff --git a/packages/core/src/server/runtime/__tests__/create-app.test.ts b/packages/core/src/server/runtime/__tests__/create-app.test.ts index fbaa75a2f..92dd37df6 100644 --- a/packages/core/src/server/runtime/__tests__/create-app.test.ts +++ b/packages/core/src/server/runtime/__tests__/create-app.test.ts @@ -415,7 +415,13 @@ describe('createApp - service name for the Redis Streams consumer group', () => }); describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is given', () => { - const keys = ['REDIS_URL', 'SERVICE_MANIFEST', 'SERVICE_NAME', 'JOB_WORKERS'] as const; + const keys = [ + 'REDIS_URL', + 'SERVICE_MANIFEST', + 'SERVICE_NAME', + 'REALTIME_NAMESPACE', + 'JOB_WORKERS', + ] as const; const saved = keys.map((key) => [key, process.env[key]] as const); afterEach(() => { @@ -441,12 +447,18 @@ describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is list: PluginEntry[] = plugins, ) { process.env['REDIS_URL'] = redisUrlForWorker(); - for (const key of ['SERVICE_MANIFEST', 'SERVICE_NAME', 'JOB_WORKERS'] as const) { + for (const key of [ + 'SERVICE_MANIFEST', + 'SERVICE_NAME', + 'REALTIME_NAMESPACE', + 'JOB_WORKERS', + ] as const) { delete process.env[key]; } if (env.SERVICE_MANIFEST) { process.env['SERVICE_MANIFEST'] = env.SERVICE_MANIFEST; process.env['SERVICE_NAME'] = 'fixture'; + process.env['REALTIME_NAMESPACE'] = 'fixture'; } if (env.JOB_WORKERS) { process.env['JOB_WORKERS'] = env.JOB_WORKERS; @@ -506,7 +518,7 @@ describe('createApp - SERVICE_MANIFEST and JOB_WORKERS on the plugin list it is }); describe('createApp - a split service fails closed on a control it would skip', () => { - const keys = ['REDIS_URL', 'SERVICE_MANIFEST', 'SERVICE_NAME'] as const; + const keys = ['REDIS_URL', 'SERVICE_MANIFEST', 'SERVICE_NAME', 'REALTIME_NAMESPACE'] as const; const saved = keys.map((key) => [key, process.env[key]] as const); afterEach(() => { @@ -536,9 +548,11 @@ describe('createApp - a split service fails closed on a control it would skip', process.env['REDIS_URL'] = redisUrlForWorker(); delete process.env['SERVICE_MANIFEST']; delete process.env['SERVICE_NAME']; + delete process.env['REALTIME_NAMESPACE']; if (manifest) { process.env['SERVICE_MANIFEST'] = manifest; process.env['SERVICE_NAME'] = 'fixture'; + process.env['REALTIME_NAMESPACE'] = 'fixture'; } const created = await createApp({ plugins, databaseUrl: DUMMY_DATABASE_URL }, (c) => { c.register(JOB_QUEUE, () => makeJobQueue()); diff --git a/packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts b/packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts index eb4bbfa33..d3c87e6e6 100644 --- a/packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts +++ b/packages/testing/src/__tests__/service-manifest-boot.e2e.test.ts @@ -11,7 +11,7 @@ const createServiceExample = ServiceExampleSchema.parse( ), ); const SUGGESTED_MANIFEST = /^Add them: SERVICE_MANIFEST=(\S+)$/m; -const SPLIT_ENV = ['SERVICE_MANIFEST', 'SERVICE_NAME'] as const; +const SPLIT_ENV = ['SERVICE_MANIFEST', 'SERVICE_NAME', 'REALTIME_NAMESPACE'] as const; let db: TestDb; let split: TestApp; @@ -21,6 +21,7 @@ async function bootSplit(manifest: string) { const saved = SPLIT_ENV.map((key) => [key, process.env[key]] as const); process.env['SERVICE_MANIFEST'] = manifest; process.env['SERVICE_NAME'] = 'split-e2e'; + process.env['REALTIME_NAMESPACE'] = 'split-e2e'; try { return await bootTestApp({ plugins: await loadExtensions(), databaseUrl: db.url }); } finally {