From 96a99eb731b07b3060f2e1bbba2c5a0fa2fb519d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:47:44 +0000 Subject: [PATCH 1/3] wip(plugin-webhooks,runtime): a declared webhook the deployment cannot dispatch is reported at error and refused at the activation door Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../src/auto-enqueuer-disabled-report.test.ts | 249 ++++++++++++++++++ .../plugin-webhooks/src/declared-webhooks.ts | 49 +++- .../src/webhook-outbox-plugin.ts | 135 +++++++++- .../domains/catalog-activation-door.test.ts | 80 +++++- .../runtime/src/domains/catalog-activation.ts | 63 ++++- 5 files changed, 560 insertions(+), 16 deletions(-) create mode 100644 packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts new file mode 100644 index 00000000000..c4db97275d5 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts @@ -0,0 +1,249 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#24910] A kernel with `webhooks` and without `realtime` delivers no declared + * webhook — and says so ONCE, at `error`, and its declared-webhook reader tells + * the activation door so. + * + * ## The defect + * + * The auto-enqueuer's only source of record events is the realtime service: the + * engine publishes `data.record.*` through it and nothing else feeds the + * enqueuer. Without it the plugin disabled the enqueuer with one boot `warn`, + * while every record write still answered 200, the declaration still said the + * webhook was active, and the activation door answered `enabled: true` for a + * switch that switched nothing. By AGENTS.md "Degradation log levels" that is + * the durability class: the system looks normal and a delivery it claims never + * lands. + * + * ## What is pinned, on a real kernel boot + * + * A real `LiteKernel` runs the plugin's real `kernel:ready`; the engine is a + * real `ObjectQL` over a real in-memory `SqlDriver`, the stack's webhook comes + * from `defineWebhook` through the engine's real manifest decomposition + * (`registerApp`), and the outbox is messaging's real `MessagingService`. Only + * the realtime service is a stand-in, and only on the legs that mount one. + * + * 1. Declared webhook, no realtime: one `error` naming the webhook, `realtime` + * and the fix; no `disabled` warn; the reader's `dispatchUnavailable()` + * names `realtime`; and a record write enqueues nothing (the loss itself). + * 2. CONTROL, the same stack with realtime: no error, the reader answers + * `null`, and the same write enqueues one delivery — so leg 1's zero is the + * missing service and not a fixture that cannot deliver. + * 3. No declared webhook, no realtime: the `warn` it always was, no `error`. + * 4. `autoEnqueue: false`: the host's own choice — nothing reported, and the + * reader answers `null` (its redelivery reads the switch). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import type { Plugin, PluginContext } from '@objectstack/core'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { defineWebhook } from '@objectstack/spec/automation'; +import type { IRealtimeService, RealtimeEventHandler, RealtimeEventPayload } from '@objectstack/spec/contracts'; +import { MemoryHttpOutbox, MessagingService, type EnqueueHttpInput } from '@objectstack/service-messaging'; +import { DeclaredWebhookSource, declaredWebhookRefId } from './declared-webhooks.js'; +import { SysWebhook } from './sys-webhook.object.js'; +import { WebhookOutboxPlugin, type WebhookOutboxPluginOptions } from './webhook-outbox-plugin.js'; + +const OBJECT = 'my_app_note'; +const HOOK = 'my_app_note_changed'; +const silentLogger = { info() {}, warn() {}, error() {}, debug() {} } as any; + +/** The webhook the card's reproduction declares, as `defineWebhook` authors it. */ +const DECLARED = defineWebhook({ + name: HOOK, + object: OBJECT, + triggers: ['create', 'update', 'delete'], + url: 'http://127.0.0.1:3999/hook', + method: 'POST', + isActive: true, +}); + +class FakeRealtime implements IRealtimeService { + private subs = new Map(); + private n = 0; + async publish(event: RealtimeEventPayload): Promise { + for (const sub of this.subs.values()) { + if (sub.opts?.object && event.object !== sub.opts.object) continue; + await sub.handler(event); + } + } + async subscribe(_channel: string, handler: any, opts?: any): Promise { + const id = `s-${++this.n}`; + this.subs.set(id, { handler, opts }); + return id; + } + async unsubscribe(id: string): Promise { this.subs.delete(id); } +} + +/** messaging's real outbox, with every enqueue it receives recorded. */ +class RecordingOutbox extends MemoryHttpOutbox { + readonly enqueued: EnqueueHttpInput[] = []; + override async enqueue(input: EnqueueHttpInput): Promise { + this.enqueued.push(input); + return super.enqueue(input); + } +} + +interface BootOptions { + realtime?: boolean; + declare?: boolean; + plugin?: WebhookOutboxPluginOptions; +} + +/** + * Stands in for the plugin's declared dependency + * (`dependencies = ['com.objectstack.service.messaging']`) and registers what a + * served boot holds for `requires: ['webhooks']`: the engine, messaging, and + * realtime only where the case asks. + */ +class FixturePlugin implements Plugin { + name = 'com.objectstack.service.messaging'; + type = 'standard' as const; + version = '1.0.0'; + readonly outbox = new RecordingOutbox(); + readonly messaging = new MessagingService({ logger: silentLogger }); + + constructor( + private readonly engine: ObjectQL, + private readonly realtime: FakeRealtime | undefined, + ) { + this.messaging.setHttpOutbox(this.outbox); + } + + init(ctx: PluginContext): void { + ctx.registerService('manifest', { register: () => {}, list: () => [] }); + ctx.registerService('objectql', this.engine); + ctx.registerService('messaging', this.messaging); + if (this.realtime) ctx.registerService('realtime', this.realtime); + } +} + +/** The real plugin, with what its boot logs observed. */ +class ObservedWebhookPlugin extends WebhookOutboxPlugin { + readonly logs: Array<{ level: string; message: string; meta: unknown }> = []; + + override async init(ctx: PluginContext): Promise { + for (const level of ['debug', 'info', 'warn', 'error'] as const) { + vi.spyOn(ctx.logger, level).mockImplementation((message: unknown, ...rest: unknown[]) => { + this.logs.push({ level, message: String(message), meta: level === 'error' ? rest[1] : rest[0] }); + }); + } + await super.init(ctx); + } + + lines(level: string, fragment: string) { + return this.logs.filter((l) => l.level === level && l.message.includes(fragment)); + } +} + +const open: Array<() => Promise> = []; + +afterEach(async () => { + while (open.length) { + try { await open.pop()?.(); } catch { /* already stopped */ } + } + vi.restoreAllMocks(); +}); + +async function boot(options: BootOptions) { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + const engine = new ObjectQL(); + engine.registerDriver(driver, true); + await engine.init(); + engine.registerApp({ + id: 'my_app', + namespace: 'my_app', + version: '0.0.0', + objects: [{ + name: OBJECT, + label: 'Note', + // Owner sharing is not under test here — keep RLS out of the way. + sharingModel: 'public_read_write', + fields: { title: { type: 'text', label: 'Title' } }, + }], + ...(options.declare === false ? {} : { webhooks: [DECLARED] }), + }); + engine.registry.registerObject(SysWebhook as any, 'test'); + await engine.syncSchemas(); + const realtime = options.realtime ? new FakeRealtime() : undefined; + if (realtime) engine.setRealtimeService(realtime); + + const fixture = new FixturePlugin(engine, realtime); + const plugin = new ObservedWebhookPlugin(options.plugin); + const kernel = new LiteKernel({ logger: { level: 'silent' } as any }); + kernel.use(fixture); + kernel.use(plugin); + await kernel.bootstrap(); + open.push(async () => { await kernel.shutdown(); await driver.disconnect(); }); + + const reader = kernel.getService('declaredWebhooks'); + const enqueuerStarted = (() => { + try { return Boolean(kernel.getService('webhook.autoEnqueuer')); } catch { return false; } + })(); + /** Insert then update one record, and give fire-and-forget enqueues a moment to land. */ + const writeRecord = async () => { + const created: any = await engine.insert(OBJECT, { title: 'seed' }); + await engine.update(OBJECT, { title: 'edited' }, { where: { id: created.id } }); + for (let i = 0; i < 20 && fixture.outbox.enqueued.length < 2; i++) await new Promise((r) => setTimeout(r, 25)); + }; + return { plugin, reader, enqueuerStarted, writeRecord, outbox: fixture.outbox }; +} + +describe('[#24910] the auto-enqueuer disabled on a stack that declares webhooks', () => { + it('no realtime: ONE error naming the webhook, the missing service and the fix — and a record write enqueues nothing', async () => { + const { plugin, reader, enqueuerStarted, writeRecord, outbox } = await boot({}); + + const errors = plugin.lines('error', '[webhook-auto-enqueuer]'); + expect(errors).toHaveLength(1); + expect(errors[0].message).toContain(HOOK); + expect(errors[0].message).toContain("add 'realtime' to `requires`"); + expect(errors[0].meta).toMatchObject({ hasEngine: true, hasRealtime: false, hasMessaging: true, declared: [HOOK] }); + expect(plugin.lines('warn', 'auto-enqueuer] disabled')).toEqual([]); + + expect(enqueuerStarted).toBe(false); + expect(reader).toBeInstanceOf(DeclaredWebhookSource); + expect(reader.dispatchUnavailable()).toContain("add 'realtime' to `requires`"); + // The reader still answers the declaration — it is only dispatch that is missing. + expect((await reader.declaredWebhookNamed(HOOK))?.active).toBe(true); + + await writeRecord(); + expect(outbox.enqueued).toEqual([]); + }); + + it('CONTROL — the same stack with realtime: no error, the reader answers null, and the same write is delivered', async () => { + const { plugin, reader, enqueuerStarted, writeRecord, outbox } = await boot({ realtime: true }); + + expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]); + expect(enqueuerStarted).toBe(true); + expect(reader.dispatchUnavailable()).toBeNull(); + + await writeRecord(); + expect(outbox.enqueued.map((d) => [d.refId, d.label])).toEqual([ + [declaredWebhookRefId(HOOK), 'data.record.created'], + [declaredWebhookRefId(HOOK), 'data.record.updated'], + ]); + }); + + it('no declared webhook, no realtime: the warn it always was, never the error', async () => { + const { plugin, reader } = await boot({ declare: false }); + + expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]); + const warns = plugin.lines('warn', 'auto-enqueuer] disabled'); + expect(warns).toHaveLength(1); + expect(warns[0].meta).toMatchObject({ hasEngine: true, hasRealtime: false, hasMessaging: true }); + // Deployment-wide: a webhook declared later still could not be switched on here. + expect(reader.dispatchUnavailable()).toContain("'realtime'"); + }); + + it('`autoEnqueue: false` is the host\'s own composition: nothing reported, and the reader answers null', async () => { + const { plugin, reader, enqueuerStarted } = await boot({ plugin: { autoEnqueue: false } }); + + expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]); + expect(plugin.lines('warn', '[webhook-auto-enqueuer]')).toEqual([]); + expect(enqueuerStarted).toBe(false); + expect(reader.dispatchUnavailable()).toBeNull(); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts index ec1e3a3ec0a..4b7fc4d8c0a 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts @@ -136,6 +136,25 @@ export interface DeclaredWebhookReader { declaredWebhookNamed(name: string): Promise; } +/** + * [#24910] Whether a declared webhook switched ON is delivered on this + * deployment at all — the question the activation door asks before it answers + * `enabled: true`. Its own interface, beside {@link DeclaredWebhookReader} + * rather than inside it: the dispatcher and the redelivery veto read + * declarations, and only the door asks about dispatch. + */ +export interface DeclaredWebhookDispatch { + /** + * Why no declared webhook is delivered on this deployment — the + * auto-enqueuer is disabled because a service it needs is missing — in one + * sentence that names the composition fix; `null` when it runs. Also `null` + * when the host turned auto-enqueue off itself (`autoEnqueue: false`): that + * is a deliberate composition whose own code enqueues, and redelivery reads + * the switch there. + */ + dispatchUnavailable(): string | null; +} + /** * The raw declared `webhook` items, registry first, metadata service second. * Never throws: a reader that cannot answer yields nothing from that reader. @@ -159,20 +178,46 @@ export function readDeclaredWebhookItems(engine: unknown, metadataService: unkno } } +/** + * [#24910] The distinct `name`s of the raw declared items — what a boot report + * names when the declared webhooks cannot be dispatched. Read the way + * {@link readDeclaredWebhookItems} reads them; never throws. + */ +export function readDeclaredWebhookNames(engine: unknown, metadataService: unknown): string[] { + const names = new Set(); + for (const raw of readDeclaredWebhookItems(engine, metadataService)) { + const name = (raw as { name?: unknown })?.name; + if (typeof name === 'string' && name) names.add(name); + } + return [...names]; +} + /** * The reader the plugin binds: the declarations from - * {@link readDeclaredWebhookItems}, the switch from the activation ledger. + * {@link readDeclaredWebhookItems}, the switch from the activation ledger, and + * (for the activation door) whether this deployment dispatches them at all. */ -export class DeclaredWebhookSource implements DeclaredWebhookReader { +export class DeclaredWebhookSource implements DeclaredWebhookReader, DeclaredWebhookDispatch { /** Names already reported as failing validation — said once per process. */ private readonly invalidReported = new Set(); + /** + * @param dispatchGap - [#24910] Why the auto-enqueuer is not running on + * this kernel, or `null` when it runs. The plugin decides it at + * `kernel:ready`, BEFORE this reader is bound, so the door never reads a + * verdict the boot has not reached yet. + */ constructor( private readonly engine: unknown, private readonly metadataService: unknown, private readonly logger?: Logger, + private readonly dispatchGap: string | null = null, ) {} + dispatchUnavailable(): string | null { + return this.dispatchGap; + } + async list(): Promise { const parsed = this.parseDeclared(); if (parsed.length === 0) return []; diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 737ab9aa911..dc502e55478 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -23,6 +23,7 @@ import { DeclaredWebhookSource, DECLARED_WEBHOOK_NAME_GUARD_PACKAGE, bindDeclaredWebhookNameGuard, + readDeclaredWebhookNames, } from './declared-webhooks.js'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; import { createWebhookRedeliverGuard } from './redeliver-guard.js'; @@ -121,6 +122,55 @@ function requestDoorContext(request: Request): RedeliverDoorContext { }; } +/** + * [#24910] Which of the auto-enqueuer's three prerequisites this kernel holds: + * the data engine (it reads the subscriptions), the realtime service (its ONLY + * source of record events — nothing else feeds it) and messaging (the outbox + * it enqueues onto). Read once, at `kernel:ready`, when every plugin's + * `init()` has registered its services, so an absence is final. + */ +interface AutoEnqueueGap { + readonly hasEngine: boolean; + readonly hasRealtime: boolean; + readonly hasMessaging: boolean; +} + +/** What the auto-enqueuer runs with on this kernel, decided once at `kernel:ready`. */ +type AutoEnqueueWiring = + /** The host turned it off itself (`autoEnqueue: false`) — deliberate, nothing to report. */ + | { readonly kind: 'off' } + | { + readonly kind: 'ready'; + readonly engine: IDataEngine; + readonly realtime: IRealtimeService; + readonly messaging: MessagingHttpSurface; + } + | { readonly kind: 'gap'; readonly gap: AutoEnqueueGap }; + +/** + * [#24910] Why the auto-enqueuer is not running, and the composition change + * that starts it, as one sentence. The boot report and the activation door's + * refusal both say exactly this, so the two can never name different fixes. + */ +function autoEnqueueGapReason(gap: AutoEnqueueGap): string { + const missing: string[] = []; + const fixes: string[] = []; + if (!gap.hasRealtime) { + missing.push('the realtime service (the only source of the record events it dispatches)'); + fixes.push("add 'realtime' to `requires`"); + } + if (!gap.hasMessaging) { + missing.push('the messaging service (the outbox it enqueues onto)'); + fixes.push("add 'messaging' to `requires` (a `--preset minimal` boot mounts none of the always-on capabilities)"); + } + if (!gap.hasEngine) { + missing.push('the ObjectQL data engine (it reads the subscriptions)'); + fixes.push('mount the ObjectQL data engine'); + } + return `the webhook auto-enqueuer is disabled because ${missing.join(' and ')} ` + + `${missing.length === 1 ? 'is' : 'are'} not available. Fix: ${fixes.join('; ')}, then restart.`; +} + export interface WebhookOutboxPluginOptions { /** * Auto-enqueue config. When enabled (default `true` if the realtime + data @@ -236,18 +286,23 @@ export class WebhookOutboxPlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { + // [#24910] Whether the auto-enqueuer can run is decided FIRST, + // before the declared-webhook reader is bound below, so the + // activation door reads the verdict from the reader's birth and + // never a "dispatching" the boot has not established. + const wiring = this.resolveAutoEnqueueWiring(ctx, autoEnqueueOpt); // The table's write doors, the legacy-secret sweep and the - // declared-webhook reader FIRST — this only needs the data + // declared-webhook reader — this only needs the data // engine, so it must not be gated behind the auto-enqueue // dispatch prerequisites (realtime + messaging). - await this.bootWebhookTable(ctx); + await this.bootWebhookTable(ctx, wiring); // [#22756] The redelivery veto, and beside it the `webhooks` // service — gated on the engine and messaging alone, like the // step above, and never on the auto-enqueue prerequisites. It // runs before the enqueuer writes its first row (#8069), and // before the mount below is mounted. this.bootRedeliverDoor(ctx, autoEnqueueOpt); - await this.bootAutoEnqueue(ctx, autoEnqueueOpt); + await this.bootAutoEnqueue(ctx, autoEnqueueOpt, wiring); this.registerAdminRoutes(ctx); }); } @@ -317,7 +372,7 @@ export class WebhookOutboxPlugin implements Plugin { * dispatches nothing. Runs before {@link bootAutoEnqueue}, which reads the * reader bound here. */ - private async bootWebhookTable(ctx: PluginContext): Promise { + private async bootWebhookTable(ctx: PluginContext, wiring: AutoEnqueueWiring): Promise { const engine = this.tryGetService(ctx, ['objectql', 'data']); if (!engine) { ctx.logger.warn?.('[webhook] sys_webhook write doors and the declared-webhook reader not bound — no data engine available'); @@ -342,7 +397,13 @@ export class WebhookOutboxPlugin implements Plugin { bindWebhookLegacyCleartextGate(engine as any, ctx.logger as any); let metadataService: IMetadataService | undefined; try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } - this.declaredSource = new DeclaredWebhookSource(engine, metadataService, ctx.logger as any); + // [#24910] Born knowing whether this kernel dispatches what it reads. + this.declaredSource = new DeclaredWebhookSource( + engine, + metadataService, + ctx.logger as any, + wiring.kind === 'gap' ? autoEnqueueGapReason(wiring.gap) : null, + ); ctx.registerService(DECLARED_WEBHOOKS_SLOT, this.declaredSource); // And the declared-name guard: a row that took a declared name would // withhold the declaration for the whole deployment, so no non-system @@ -360,21 +421,71 @@ export class WebhookOutboxPlugin implements Plugin { } } - private async bootAutoEnqueue( + /** + * [#24910] What the auto-enqueuer runs with on this kernel: off by the + * host's own choice, ready with its three prerequisites, or missing some of + * them. Read at `kernel:ready` — every `init()` has registered its services + * by then, so a missing one is a fact of this composition, not a boot that + * has not finished. + */ + private resolveAutoEnqueueWiring( ctx: PluginContext, opt: boolean | AutoEnqueuerOptions, - ): Promise { - if (opt === false) return; + ): AutoEnqueueWiring { + if (opt === false) return { kind: 'off' }; const engine = this.tryGetService(ctx, ['objectql', 'data']); const realtime = this.tryGetService(ctx, ['realtime']); const messaging = this.getMessaging(ctx); - if (!engine || !realtime || !messaging) { - ctx.logger.warn?.( - '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available', - { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging }, + if (engine && realtime && messaging) return { kind: 'ready', engine, realtime, messaging }; + return { kind: 'gap', gap: { hasEngine: !!engine, hasRealtime: !!realtime, hasMessaging: !!messaging } }; + } + + /** + * [#24910] Say once that the auto-enqueuer is not running — at `error` when + * the stack DECLARES webhooks, `warn` when it declares none. + * + * By AGENTS.md's one question (Degradation log levels): with declarations + * present the system looks normal from outside — every record write answers + * 200, the declaration says the webhook is active — while not one delivery + * it claims is ever enqueued. That is the durability class, so the line + * owes the consequence and the fix, and says it here, once, before any + * write rather than once per lost event. With nothing declared the kernel + * is visibly smaller than it could be (no webhook to lose yet), which is the + * functional class: `warn`, as before. + */ + private reportAutoEnqueueGap(ctx: PluginContext, gap: AutoEnqueueGap): void { + const declared = readDeclaredWebhookNames( + this.tryGetService(ctx, ['objectql', 'data']), + this.tryGetService(ctx, ['metadata']), + ); + if (declared.length > 0) { + ctx.logger.error?.( + `[webhook-auto-enqueuer] the ${declared.length} webhook${declared.length === 1 ? '' : 's'} this stack ` + + `declares (${declared.join(', ')}) will NEVER be delivered: record writes still succeed and nothing ` + + `else reports the loss, but no delivery is ever enqueued — ${autoEnqueueGapReason(gap)} Until then the ` + + 'activation door refuses to switch a declared webhook on.', + undefined, + { ...gap, declared }, ); return; } + ctx.logger.warn?.( + '[webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available', + { ...gap }, + ); + } + + private async bootAutoEnqueue( + ctx: PluginContext, + opt: boolean | AutoEnqueuerOptions, + wiring: AutoEnqueueWiring, + ): Promise { + if (wiring.kind === 'off') return; + if (wiring.kind === 'gap') { + this.reportAutoEnqueueGap(ctx, wiring.gap); + return; + } + const { engine, realtime, messaging } = wiring; if (!messaging.isHttpDeliveryReady()) { ctx.logger.warn?.( '[webhook-auto-enqueuer] messaging HTTP outbox not ready (no data engine / reliableDelivery off) — webhook deliveries will not be durable', diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index 04bf5653bfe..4c8ce4ffc71 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -664,13 +664,18 @@ describe('the catalog activation door — a declared sharing rule', () => { // that reader (`declared-webhooks.test.ts`). describe('the catalog activation door — a declared webhook', () => { const HOOK = 'crm_contact_changed'; - const declaredWebhooks = (declared: Record = { [HOOK]: { packageId: 'crm' } }) => ({ + const declaredWebhooks = ( + declared: Record = { [HOOK]: { packageId: 'crm' } }, + // [#24910] What the reader says about dispatch: `null`, the dispatcher runs. + dispatchUnavailable: () => string | null = () => null, + ) => ({ declaredWebhooks: { async declaredWebhookNamed(name: string) { return declared[name] ? { webhook: { name }, active: true, ...declared[name] } : null; }, + dispatchUnavailable, }, }); @@ -728,4 +733,77 @@ describe('the catalog activation door — a declared webhook', () => { expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`))).toBe(200); expect(h.tables[LEDGER][0].package_id ?? null).toBeNull(); }); + + // [#24910] A deployment whose webhook dispatcher is not running (the + // auto-enqueuer reads record events only from the realtime service) delivers + // no declared webhook, so `enabled: true` there would report a switch that + // switches nothing. The reader says so; the door refuses ON and keeps OFF. + describe('a deployment that delivers no declared webhook', () => { + const REASON = "the webhook auto-enqueuer is disabled because the realtime service is not available. Fix: add 'realtime' to `requires`, then restart."; + const undelivered = () => declaredWebhooks(undefined, () => REASON); + + it('switching ON is refused 503 SERVICE_UNAVAILABLE naming the webhook and the reader\'s reason, and nothing is written', async () => { + const h = boot('single', {}, undelivered()); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(messageOf(res)).toContain(`'${HOOK}'`); + expect(messageOf(res)).toContain(REASON); + expect(h.writes).toEqual([]); + }); + + it('an empty body (the default, ON) is refused the same way', async () => { + const h = boot('single', {}, undelivered()); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, {}); + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(h.writes).toEqual([]); + }); + + it('CONTROL: switching OFF on the same deployment is written — the refusal is about ON only', async () => { + const h = boot('single', {}, undelivered()); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: false }); + expect(statusOf(res)).toBe(200); + expect(h.tables[LEDGER]).toEqual([ + expect.objectContaining({ metadata_type: 'webhook', name: HOOK, active: false }), + ]); + }); + + it('an undeclared name is still 404, not the dispatch refusal: the declaration is confirmed first', async () => { + const h = boot('single', {}, undelivered()); + const res = await flip(h, TENANT_ADMIN(), '/_activation/webhook/our_own_hook', { enabled: true }); + expect(statusOf(res)).toBe(404); + expect(h.writes).toEqual([]); + }); + + it('the authority gates still answer first: a walled tenant admin gets 403, not the 503', async () => { + const h = boot('group', {}, undelivered()); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(h.writes).toEqual([]); + }); + }); + + it('a reader that does not say whether its dispatcher runs: ON is refused 503 and nothing is written; OFF is written', async () => { + const h = boot('single', {}, { + declaredWebhooks: { + async declaredWebhookNamed(name: string) { return name === HOOK ? { webhook: { name } } : null; }, + }, + }); + const on = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); + expect(statusOf(on)).toBe(503); + expect(codeOf(on)).toBe('SERVICE_UNAVAILABLE'); + expect(h.writes).toEqual([]); + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: false }))).toBe(200); + expect(h.writes).toHaveLength(1); + }); + + it('a reader whose dispatch answer throws: ON is refused 503, never a write', async () => { + const h = boot('single', {}, declaredWebhooks(undefined, () => { throw new Error('state unreadable'); })); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(h.writes).toEqual([]); + }); }); diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts index 6a816ce10cd..3f12fcb6122 100644 --- a/packages/runtime/src/domains/catalog-activation.ts +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -137,6 +137,26 @@ function declaredWebhookReaderOf(service: unknown): DeclaredWebhookReader | unde return typeof candidate?.declaredWebhookNamed === 'function' ? (candidate as DeclaredWebhookReader) : undefined; } +/** + * [#24910] Whether this deployment DELIVERS a declared webhook switched on, as + * the same reader answers it: the reason the dispatcher is not running, or + * `null` when it is. plugin-webhooks decides it once its kernel is ready (the + * auto-enqueuer reads record events only from the realtime service), and binds + * the reader already knowing it. + * + * Declared REQUIRED for switching ON: a reader that cannot say leaves the door + * unable to confirm that `enabled: true` is true, and the door refuses rather + * than reporting a switch that may switch nothing. Switching OFF never asks. + */ +interface DeclaredWebhookDispatch { + dispatchUnavailable(): string | null; +} + +function declaredWebhookDispatchOf(service: unknown): DeclaredWebhookDispatch | undefined { + const candidate = service as Partial | null | undefined; + return typeof candidate?.dispatchUnavailable === 'function' ? (candidate as DeclaredWebhookDispatch) : undefined; +} + /** * The service-backed types — declarations a plugin owns, not catalog entries — * each with how its declaration is looked up and how it is named in a refusal. @@ -147,6 +167,12 @@ const DECLARATION_LOOKUPS: Readonly | undefined; + /** + * [#24910] Why switching `name` ON would switch nothing on this deployment, + * or `undefined` when it takes effect. Asked only for `enabled: true`, after + * the declaration is confirmed. + */ + switchOnRefusal?(service: unknown, name: string): string | undefined; }>> = { sharing_rule: { service: 'sharingRules', @@ -168,6 +194,26 @@ const DECLARATION_LOOKUPS: Readonly { + const cannotConfirm = (why: string) => + `Cannot confirm that declared webhook '${name}' would be delivered — ${why}. ` + + 'Refusing rather than answering enabled: true for a webhook that may never be sent.'; + const dispatch = declaredWebhookDispatchOf(service); + if (!dispatch) { + return cannotConfirm('the declared-webhook reader bound to this deployment does not say whether its dispatcher runs'); + } + let reason: string | null; + try { + reason = dispatch.dispatchUnavailable(); + } catch (err: any) { + return cannotConfirm(`the declared-webhook reader could not say whether its dispatcher runs (${err?.message ?? String(err)})`); + } + return reason === null + ? undefined + : `Declared webhook '${name}' cannot be switched on — this deployment delivers no declared webhook: ` + + `${reason} Refusing rather than answering enabled: true for a webhook that would never be sent; ` + + 'switching it off is unaffected.'; + }, }, }; @@ -214,7 +260,9 @@ function ledgerUnregistered(ql: any): boolean { * the engine — the one `resolveUserAuthzGrants` reads, so this door and * the resolver cannot disagree about which names exist. No reader bound, * or a reader that cannot answer ⇒ 503 (an outage is not a verdict); - * unknown ⇒ 404. + * unknown ⇒ 404. A declared webhook switched ON is also refused 503 where + * the deployment delivers no declared webhook at all — the reader says why + * — since that 200 would report a switch that switches nothing. * 5. **The durable write**, through the engine. A packaged item's row names * its package; an environment-authored item's row names none. */ @@ -331,6 +379,19 @@ export async function handleCatalogActivationWrite( ), }; } + // [#24910] A switch ON is a claim that the declaration now takes + // effect. Where this deployment delivers no declared webhook at all (the + // dispatcher is not running), a 200 `enabled: true` would be the claim + // with nothing behind it, so the door refuses in the same 503 shape as + // a reader it cannot consult. Switching OFF is always true and is + // written as before. + const switchOnRefusal = enabled ? declaration.switchOnRefusal?.(service, entry.name) : undefined; + if (switchOnRefusal !== undefined) { + return { + handled: true, + response: deps.error(switchOnRefusal, 503, { code: 'SERVICE_UNAVAILABLE' }), + }; + } } const reader = isDeclarationType(type) ? undefined : securityCatalogReaderOf(ql); if (!isDeclarationType(type) && !reader) { From 02d22e1eec575d85060332d618a68b3deb9960bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:55:06 +0000 Subject: [PATCH 2/3] docs(webhooks): name the two tokens a declared webhook needs; changeset Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../24910-declared-webhook-undeliverable.md | 22 ++++++++++++++++++ content/docs/automation/webhooks.mdx | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 .changeset/24910-declared-webhook-undeliverable.md diff --git a/.changeset/24910-declared-webhook-undeliverable.md b/.changeset/24910-declared-webhook-undeliverable.md new file mode 100644 index 00000000000..386cdbf921e --- /dev/null +++ b/.changeset/24910-declared-webhook-undeliverable.md @@ -0,0 +1,22 @@ +--- +'@objectstack/plugin-webhooks': patch +'@objectstack/runtime': patch +--- + +fix(plugin-webhooks,runtime): a declared webhook that the deployment can never deliver is reported at `error` once at boot, and the activation door refuses to switch it on + +Clause-②: no + +The webhook auto-enqueuer reads record events only from the realtime service. A stack with `requires: ['webhooks']` and no `'realtime'` therefore never delivers a declared webhook. + +- **Before.** + - The boot logged one `WARN [webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available`. The line named no webhook, gave no consequence and gave no fix. + - `POST /api/v1/security/_activation/webhook/NAME` with `{ "enabled": true }` answered `200 { enabled: true }` and wrote the activation row. + - Every record write still answered 200, and no delivery row was ever written. +- **After** (`@objectstack/plugin-webhooks`). When the stack declares at least one webhook, the boot logs one `error` line instead of the warning. The line names each declared webhook and says that none of them will be delivered while record writes keep succeeding. It names the missing service and the fix, for example "add 'realtime' to `requires`". A stack that declares no webhook gets the same `warn` as before. +- **After** (`@objectstack/runtime`). On such a deployment, switching a declared webhook ON through the activation door answers `503` with `error.code: 'SERVICE_UNAVAILABLE'` and writes nothing. The message gives the same reason and fix as the boot line. Switching one OFF still answers `200` and writes the row. A name nothing declares is still `404`. The authority checks still answer first. +- **Unchanged.** + - Every deployment whose auto-enqueuer runs: switching on and off and delivery behave as before. + - A host that constructs `WebhookOutboxPlugin` with `autoEnqueue: false` gets no new log line and no new refusal. It has turned the enqueuer off itself, and redelivery still reads the switch. + - `os validate` and `os build` are unchanged. They still accept a stack that declares webhooks without these tokens. +- **What to do.** A stack that declares webhooks needs `requires` to include both `'webhooks'` and `'realtime'`. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 5b642798588..944893cc752 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -100,6 +100,29 @@ A webhook reaches the dispatcher one of two ways: - **Authored by an organization** — a `sys_webhook` row (§3.1), switched by its own `active` field. +**The capabilities a declared webhook needs.** A stack that declares a webhook +must name two tokens in `requires`, beside whatever else it declares: + +```ts +requires: ['automation', 'triggers', 'webhooks', 'realtime'], +``` + +- `'webhooks'` mounts `plugin-webhooks`: the declared-webhook reader, the + auto-enqueuer and the redeliver door. Without it nothing reads the + declaration, and the activation door answers `503 SERVICE_UNAVAILABLE` + because no declared-webhook reader is bound. +- `'realtime'` mounts the realtime service, which is the auto-enqueuer's only + source of record events: the engine publishes `data.record.*` through it, and + nothing else feeds the enqueuer (§4.1). With `'webhooks'` and without + `'realtime'` the declaration is read but never dispatched. The boot logs one + `error` that names each declared webhook and the missing token. The + activation door then refuses to switch a declared webhook on, answering + `503 SERVICE_UNAVAILABLE` with the same reason. Switching one off still works. + +The outbox itself comes from `messaging`, which every boot mounts unless it runs +with `--preset minimal`. A scaffolded app's `requires` is +`['automation', 'triggers']`, so it carries neither of the two tokens above. + On a database an 18.0 pre-release (`18.0.0-next.*`) created, the rows that release's boot seeder wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the ownership migration removes them, a pristine one is ignored in favour of its From 9e0ec86422df88182665719fc18acaf44495360d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 17:31:23 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(plugin-webhooks):=20round=202=20?= =?UTF-8?q?=E2=80=94=20the=20boot=20report=20and=20docs=20only;=20the=20ac?= =?UTF-8?q?tivation=20door's=20refusal=20and=20the=20reader=20verdict=20go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activation door's 200 -> 503 narrowing is the maintainer's to rule, so packages/runtime is back to main's bytes, and the declared-webhook reader's dispatch verdict (its only consumer was that door) is removed. Claude-Session: https://claude.ai/code/session_011TdZ8GjixNZFLz1xJD9zzK Co-authored-by: Claude --- .../24910-declared-webhook-undeliverable.md | 16 ++-- content/docs/automation/webhooks.mdx | 11 ++- .../src/auto-enqueuer-disabled-report.test.ts | 31 +++---- .../plugin-webhooks/src/declared-webhooks.ts | 42 ++-------- .../src/webhook-outbox-plugin.ts | 30 +++---- .../domains/catalog-activation-door.test.ts | 80 +------------------ .../runtime/src/domains/catalog-activation.ts | 63 +-------------- 7 files changed, 41 insertions(+), 232 deletions(-) diff --git a/.changeset/24910-declared-webhook-undeliverable.md b/.changeset/24910-declared-webhook-undeliverable.md index 386cdbf921e..8061a18c6e1 100644 --- a/.changeset/24910-declared-webhook-undeliverable.md +++ b/.changeset/24910-declared-webhook-undeliverable.md @@ -1,9 +1,8 @@ --- '@objectstack/plugin-webhooks': patch -'@objectstack/runtime': patch --- -fix(plugin-webhooks,runtime): a declared webhook that the deployment can never deliver is reported at `error` once at boot, and the activation door refuses to switch it on +fix(plugin-webhooks): a declared webhook that the deployment can never deliver is reported once at boot, at `error`, with the consequence and the fix Clause-②: no @@ -11,12 +10,11 @@ The webhook auto-enqueuer reads record events only from the realtime service. A - **Before.** - The boot logged one `WARN [webhook-auto-enqueuer] disabled — ObjectQL, Realtime, or Messaging service not available`. The line named no webhook, gave no consequence and gave no fix. - - `POST /api/v1/security/_activation/webhook/NAME` with `{ "enabled": true }` answered `200 { enabled: true }` and wrote the activation row. - - Every record write still answered 200, and no delivery row was ever written. -- **After** (`@objectstack/plugin-webhooks`). When the stack declares at least one webhook, the boot logs one `error` line instead of the warning. The line names each declared webhook and says that none of them will be delivered while record writes keep succeeding. It names the missing service and the fix, for example "add 'realtime' to `requires`". A stack that declares no webhook gets the same `warn` as before. -- **After** (`@objectstack/runtime`). On such a deployment, switching a declared webhook ON through the activation door answers `503` with `error.code: 'SERVICE_UNAVAILABLE'` and writes nothing. The message gives the same reason and fix as the boot line. Switching one OFF still answers `200` and writes the row. A name nothing declares is still `404`. The authority checks still answer first. + - Every record write still answered 200, the declaration still read active, and no delivery row was ever written. +- **After.** When the stack declares at least one webhook, the boot logs one `error` line instead of the warning. The line names each declared webhook and says none of them will be delivered while record writes keep succeeding. It names the missing service and the fix, for example "add 'realtime' to `requires`". A stack that declares no webhook gets the same `warn` as before. - **Unchanged.** - - Every deployment whose auto-enqueuer runs: switching on and off and delivery behave as before. - - A host that constructs `WebhookOutboxPlugin` with `autoEnqueue: false` gets no new log line and no new refusal. It has turned the enqueuer off itself, and redelivery still reads the switch. - - `os validate` and `os build` are unchanged. They still accept a stack that declares webhooks without these tokens. + - Every deployment whose auto-enqueuer runs. + - A host that constructs `WebhookOutboxPlugin` with `autoEnqueue: false` gets no new log line. It turned the enqueuer off itself. + - `os validate` and `os build`: they still accept a stack that declares webhooks without these tokens. + - The activation door (`POST /api/v1/security/_activation/webhook/NAME`): it answers exactly as before. - **What to do.** A stack that declares webhooks needs `requires` to include both `'webhooks'` and `'realtime'`. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 944893cc752..095ef028770 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -109,15 +109,14 @@ requires: ['automation', 'triggers', 'webhooks', 'realtime'], - `'webhooks'` mounts `plugin-webhooks`: the declared-webhook reader, the auto-enqueuer and the redeliver door. Without it nothing reads the - declaration, and the activation door answers `503 SERVICE_UNAVAILABLE` - because no declared-webhook reader is bound. + declaration at all. - `'realtime'` mounts the realtime service, which is the auto-enqueuer's only source of record events: the engine publishes `data.record.*` through it, and nothing else feeds the enqueuer (§4.1). With `'webhooks'` and without - `'realtime'` the declaration is read but never dispatched. The boot logs one - `error` that names each declared webhook and the missing token. The - activation door then refuses to switch a declared webhook on, answering - `503 SERVICE_UNAVAILABLE` with the same reason. Switching one off still works. + `'realtime'` the declaration is read but never dispatched. Record writes still + succeed and no delivery is ever enqueued. The boot logs one `error` that names + each declared webhook and the missing token. Switching a declared webhook on + through the activation door delivers nothing on such a deployment either. The outbox itself comes from `messaging`, which every boot mounts unless it runs with `--preset minimal`. A scaffolded app's `requires` is diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts index c4db97275d5..1b87f57ac62 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts @@ -2,8 +2,7 @@ /** * [#24910] A kernel with `webhooks` and without `realtime` delivers no declared - * webhook — and says so ONCE, at `error`, and its declared-webhook reader tells - * the activation door so. + * webhook — and now says so ONCE, at `error`, with the consequence and the fix. * * ## The defect * @@ -25,14 +24,13 @@ * the realtime service is a stand-in, and only on the legs that mount one. * * 1. Declared webhook, no realtime: one `error` naming the webhook, `realtime` - * and the fix; no `disabled` warn; the reader's `dispatchUnavailable()` - * names `realtime`; and a record write enqueues nothing (the loss itself). - * 2. CONTROL, the same stack with realtime: no error, the reader answers - * `null`, and the same write enqueues one delivery — so leg 1's zero is the - * missing service and not a fixture that cannot deliver. + * and the fix; no `disabled` warn; and a record write enqueues nothing (the + * loss itself). + * 2. CONTROL, the same stack with realtime: no error, and the same write + * enqueues its deliveries — so leg 1's zero is the missing service and not a + * fixture that cannot deliver. * 3. No declared webhook, no realtime: the `warn` it always was, no `error`. - * 4. `autoEnqueue: false`: the host's own choice — nothing reported, and the - * reader answers `null` (its redelivery reads the switch). + * 4. `autoEnqueue: false`: the host's own choice — nothing reported. */ import { afterEach, describe, expect, it, vi } from 'vitest'; @@ -205,7 +203,6 @@ describe('[#24910] the auto-enqueuer disabled on a stack that declares webhooks' expect(enqueuerStarted).toBe(false); expect(reader).toBeInstanceOf(DeclaredWebhookSource); - expect(reader.dispatchUnavailable()).toContain("add 'realtime' to `requires`"); // The reader still answers the declaration — it is only dispatch that is missing. expect((await reader.declaredWebhookNamed(HOOK))?.active).toBe(true); @@ -213,12 +210,11 @@ describe('[#24910] the auto-enqueuer disabled on a stack that declares webhooks' expect(outbox.enqueued).toEqual([]); }); - it('CONTROL — the same stack with realtime: no error, the reader answers null, and the same write is delivered', async () => { - const { plugin, reader, enqueuerStarted, writeRecord, outbox } = await boot({ realtime: true }); + it('CONTROL — the same stack with realtime: no error, and the same write is delivered', async () => { + const { plugin, enqueuerStarted, writeRecord, outbox } = await boot({ realtime: true }); expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]); expect(enqueuerStarted).toBe(true); - expect(reader.dispatchUnavailable()).toBeNull(); await writeRecord(); expect(outbox.enqueued.map((d) => [d.refId, d.label])).toEqual([ @@ -228,22 +224,19 @@ describe('[#24910] the auto-enqueuer disabled on a stack that declares webhooks' }); it('no declared webhook, no realtime: the warn it always was, never the error', async () => { - const { plugin, reader } = await boot({ declare: false }); + const { plugin } = await boot({ declare: false }); expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]); const warns = plugin.lines('warn', 'auto-enqueuer] disabled'); expect(warns).toHaveLength(1); expect(warns[0].meta).toMatchObject({ hasEngine: true, hasRealtime: false, hasMessaging: true }); - // Deployment-wide: a webhook declared later still could not be switched on here. - expect(reader.dispatchUnavailable()).toContain("'realtime'"); }); - it('`autoEnqueue: false` is the host\'s own composition: nothing reported, and the reader answers null', async () => { - const { plugin, reader, enqueuerStarted } = await boot({ plugin: { autoEnqueue: false } }); + it('`autoEnqueue: false` is the host\'s own composition: nothing reported', async () => { + const { plugin, enqueuerStarted } = await boot({ plugin: { autoEnqueue: false } }); expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]); expect(plugin.lines('warn', '[webhook-auto-enqueuer]')).toEqual([]); expect(enqueuerStarted).toBe(false); - expect(reader.dispatchUnavailable()).toBeNull(); }); }); diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts index 4b7fc4d8c0a..0b503f4a85f 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts @@ -136,25 +136,6 @@ export interface DeclaredWebhookReader { declaredWebhookNamed(name: string): Promise; } -/** - * [#24910] Whether a declared webhook switched ON is delivered on this - * deployment at all — the question the activation door asks before it answers - * `enabled: true`. Its own interface, beside {@link DeclaredWebhookReader} - * rather than inside it: the dispatcher and the redelivery veto read - * declarations, and only the door asks about dispatch. - */ -export interface DeclaredWebhookDispatch { - /** - * Why no declared webhook is delivered on this deployment — the - * auto-enqueuer is disabled because a service it needs is missing — in one - * sentence that names the composition fix; `null` when it runs. Also `null` - * when the host turned auto-enqueue off itself (`autoEnqueue: false`): that - * is a deliberate composition whose own code enqueues, and redelivery reads - * the switch there. - */ - dispatchUnavailable(): string | null; -} - /** * The raw declared `webhook` items, registry first, metadata service second. * Never throws: a reader that cannot answer yields nothing from that reader. @@ -179,9 +160,10 @@ export function readDeclaredWebhookItems(engine: unknown, metadataService: unkno } /** - * [#24910] The distinct `name`s of the raw declared items — what a boot report - * names when the declared webhooks cannot be dispatched. Read the way - * {@link readDeclaredWebhookItems} reads them; never throws. + * [#24910] The distinct `name`s of the raw declared items — what the boot + * report names when the auto-enqueuer cannot run, so nothing declared would be + * delivered. Read the way {@link readDeclaredWebhookItems} reads them; never + * throws. */ export function readDeclaredWebhookNames(engine: unknown, metadataService: unknown): string[] { const names = new Set(); @@ -194,30 +176,18 @@ export function readDeclaredWebhookNames(engine: unknown, metadataService: unkno /** * The reader the plugin binds: the declarations from - * {@link readDeclaredWebhookItems}, the switch from the activation ledger, and - * (for the activation door) whether this deployment dispatches them at all. + * {@link readDeclaredWebhookItems}, the switch from the activation ledger. */ -export class DeclaredWebhookSource implements DeclaredWebhookReader, DeclaredWebhookDispatch { +export class DeclaredWebhookSource implements DeclaredWebhookReader { /** Names already reported as failing validation — said once per process. */ private readonly invalidReported = new Set(); - /** - * @param dispatchGap - [#24910] Why the auto-enqueuer is not running on - * this kernel, or `null` when it runs. The plugin decides it at - * `kernel:ready`, BEFORE this reader is bound, so the door never reads a - * verdict the boot has not reached yet. - */ constructor( private readonly engine: unknown, private readonly metadataService: unknown, private readonly logger?: Logger, - private readonly dispatchGap: string | null = null, ) {} - dispatchUnavailable(): string | null { - return this.dispatchGap; - } - async list(): Promise { const parsed = this.parseDeclared(); if (parsed.length === 0) return []; diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index dc502e55478..1ca4e826143 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -149,8 +149,8 @@ type AutoEnqueueWiring = /** * [#24910] Why the auto-enqueuer is not running, and the composition change - * that starts it, as one sentence. The boot report and the activation door's - * refusal both say exactly this, so the two can never name different fixes. + * that starts it, as one sentence: the reason and the fix the boot report owes + * (AGENTS.md "Degradation log levels"). */ function autoEnqueueGapReason(gap: AutoEnqueueGap): string { const missing: string[] = []; @@ -286,23 +286,18 @@ export class WebhookOutboxPlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { - // [#24910] Whether the auto-enqueuer can run is decided FIRST, - // before the declared-webhook reader is bound below, so the - // activation door reads the verdict from the reader's birth and - // never a "dispatching" the boot has not established. - const wiring = this.resolveAutoEnqueueWiring(ctx, autoEnqueueOpt); // The table's write doors, the legacy-secret sweep and the - // declared-webhook reader — this only needs the data + // declared-webhook reader FIRST — this only needs the data // engine, so it must not be gated behind the auto-enqueue // dispatch prerequisites (realtime + messaging). - await this.bootWebhookTable(ctx, wiring); + await this.bootWebhookTable(ctx); // [#22756] The redelivery veto, and beside it the `webhooks` // service — gated on the engine and messaging alone, like the // step above, and never on the auto-enqueue prerequisites. It // runs before the enqueuer writes its first row (#8069), and // before the mount below is mounted. this.bootRedeliverDoor(ctx, autoEnqueueOpt); - await this.bootAutoEnqueue(ctx, autoEnqueueOpt, wiring); + await this.bootAutoEnqueue(ctx, autoEnqueueOpt); this.registerAdminRoutes(ctx); }); } @@ -372,7 +367,7 @@ export class WebhookOutboxPlugin implements Plugin { * dispatches nothing. Runs before {@link bootAutoEnqueue}, which reads the * reader bound here. */ - private async bootWebhookTable(ctx: PluginContext, wiring: AutoEnqueueWiring): Promise { + private async bootWebhookTable(ctx: PluginContext): Promise { const engine = this.tryGetService(ctx, ['objectql', 'data']); if (!engine) { ctx.logger.warn?.('[webhook] sys_webhook write doors and the declared-webhook reader not bound — no data engine available'); @@ -397,13 +392,7 @@ export class WebhookOutboxPlugin implements Plugin { bindWebhookLegacyCleartextGate(engine as any, ctx.logger as any); let metadataService: IMetadataService | undefined; try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } - // [#24910] Born knowing whether this kernel dispatches what it reads. - this.declaredSource = new DeclaredWebhookSource( - engine, - metadataService, - ctx.logger as any, - wiring.kind === 'gap' ? autoEnqueueGapReason(wiring.gap) : null, - ); + this.declaredSource = new DeclaredWebhookSource(engine, metadataService, ctx.logger as any); ctx.registerService(DECLARED_WEBHOOKS_SLOT, this.declaredSource); // And the declared-name guard: a row that took a declared name would // withhold the declaration for the whole deployment, so no non-system @@ -462,8 +451,7 @@ export class WebhookOutboxPlugin implements Plugin { ctx.logger.error?.( `[webhook-auto-enqueuer] the ${declared.length} webhook${declared.length === 1 ? '' : 's'} this stack ` + `declares (${declared.join(', ')}) will NEVER be delivered: record writes still succeed and nothing ` - + `else reports the loss, but no delivery is ever enqueued — ${autoEnqueueGapReason(gap)} Until then the ` - + 'activation door refuses to switch a declared webhook on.', + + `else reports the loss, but no delivery is ever enqueued — ${autoEnqueueGapReason(gap)}`, undefined, { ...gap, declared }, ); @@ -478,8 +466,8 @@ export class WebhookOutboxPlugin implements Plugin { private async bootAutoEnqueue( ctx: PluginContext, opt: boolean | AutoEnqueuerOptions, - wiring: AutoEnqueueWiring, ): Promise { + const wiring = this.resolveAutoEnqueueWiring(ctx, opt); if (wiring.kind === 'off') return; if (wiring.kind === 'gap') { this.reportAutoEnqueueGap(ctx, wiring.gap); diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index 4c8ce4ffc71..04bf5653bfe 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -664,18 +664,13 @@ describe('the catalog activation door — a declared sharing rule', () => { // that reader (`declared-webhooks.test.ts`). describe('the catalog activation door — a declared webhook', () => { const HOOK = 'crm_contact_changed'; - const declaredWebhooks = ( - declared: Record = { [HOOK]: { packageId: 'crm' } }, - // [#24910] What the reader says about dispatch: `null`, the dispatcher runs. - dispatchUnavailable: () => string | null = () => null, - ) => ({ + const declaredWebhooks = (declared: Record = { [HOOK]: { packageId: 'crm' } }) => ({ declaredWebhooks: { async declaredWebhookNamed(name: string) { return declared[name] ? { webhook: { name }, active: true, ...declared[name] } : null; }, - dispatchUnavailable, }, }); @@ -733,77 +728,4 @@ describe('the catalog activation door — a declared webhook', () => { expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`))).toBe(200); expect(h.tables[LEDGER][0].package_id ?? null).toBeNull(); }); - - // [#24910] A deployment whose webhook dispatcher is not running (the - // auto-enqueuer reads record events only from the realtime service) delivers - // no declared webhook, so `enabled: true` there would report a switch that - // switches nothing. The reader says so; the door refuses ON and keeps OFF. - describe('a deployment that delivers no declared webhook', () => { - const REASON = "the webhook auto-enqueuer is disabled because the realtime service is not available. Fix: add 'realtime' to `requires`, then restart."; - const undelivered = () => declaredWebhooks(undefined, () => REASON); - - it('switching ON is refused 503 SERVICE_UNAVAILABLE naming the webhook and the reader\'s reason, and nothing is written', async () => { - const h = boot('single', {}, undelivered()); - const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); - expect(statusOf(res)).toBe(503); - expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); - expect(messageOf(res)).toContain(`'${HOOK}'`); - expect(messageOf(res)).toContain(REASON); - expect(h.writes).toEqual([]); - }); - - it('an empty body (the default, ON) is refused the same way', async () => { - const h = boot('single', {}, undelivered()); - const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, {}); - expect(statusOf(res)).toBe(503); - expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); - expect(h.writes).toEqual([]); - }); - - it('CONTROL: switching OFF on the same deployment is written — the refusal is about ON only', async () => { - const h = boot('single', {}, undelivered()); - const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: false }); - expect(statusOf(res)).toBe(200); - expect(h.tables[LEDGER]).toEqual([ - expect.objectContaining({ metadata_type: 'webhook', name: HOOK, active: false }), - ]); - }); - - it('an undeclared name is still 404, not the dispatch refusal: the declaration is confirmed first', async () => { - const h = boot('single', {}, undelivered()); - const res = await flip(h, TENANT_ADMIN(), '/_activation/webhook/our_own_hook', { enabled: true }); - expect(statusOf(res)).toBe(404); - expect(h.writes).toEqual([]); - }); - - it('the authority gates still answer first: a walled tenant admin gets 403, not the 503', async () => { - const h = boot('group', {}, undelivered()); - const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); - expect(statusOf(res)).toBe(403); - expect(codeOf(res)).toBe('PERMISSION_DENIED'); - expect(h.writes).toEqual([]); - }); - }); - - it('a reader that does not say whether its dispatcher runs: ON is refused 503 and nothing is written; OFF is written', async () => { - const h = boot('single', {}, { - declaredWebhooks: { - async declaredWebhookNamed(name: string) { return name === HOOK ? { webhook: { name } } : null; }, - }, - }); - const on = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); - expect(statusOf(on)).toBe(503); - expect(codeOf(on)).toBe('SERVICE_UNAVAILABLE'); - expect(h.writes).toEqual([]); - expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: false }))).toBe(200); - expect(h.writes).toHaveLength(1); - }); - - it('a reader whose dispatch answer throws: ON is refused 503, never a write', async () => { - const h = boot('single', {}, declaredWebhooks(undefined, () => { throw new Error('state unreadable'); })); - const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); - expect(statusOf(res)).toBe(503); - expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); - expect(h.writes).toEqual([]); - }); }); diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts index 3f12fcb6122..6a816ce10cd 100644 --- a/packages/runtime/src/domains/catalog-activation.ts +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -137,26 +137,6 @@ function declaredWebhookReaderOf(service: unknown): DeclaredWebhookReader | unde return typeof candidate?.declaredWebhookNamed === 'function' ? (candidate as DeclaredWebhookReader) : undefined; } -/** - * [#24910] Whether this deployment DELIVERS a declared webhook switched on, as - * the same reader answers it: the reason the dispatcher is not running, or - * `null` when it is. plugin-webhooks decides it once its kernel is ready (the - * auto-enqueuer reads record events only from the realtime service), and binds - * the reader already knowing it. - * - * Declared REQUIRED for switching ON: a reader that cannot say leaves the door - * unable to confirm that `enabled: true` is true, and the door refuses rather - * than reporting a switch that may switch nothing. Switching OFF never asks. - */ -interface DeclaredWebhookDispatch { - dispatchUnavailable(): string | null; -} - -function declaredWebhookDispatchOf(service: unknown): DeclaredWebhookDispatch | undefined { - const candidate = service as Partial | null | undefined; - return typeof candidate?.dispatchUnavailable === 'function' ? (candidate as DeclaredWebhookDispatch) : undefined; -} - /** * The service-backed types — declarations a plugin owns, not catalog entries — * each with how its declaration is looked up and how it is named in a refusal. @@ -167,12 +147,6 @@ const DECLARATION_LOOKUPS: Readonly | undefined; - /** - * [#24910] Why switching `name` ON would switch nothing on this deployment, - * or `undefined` when it takes effect. Asked only for `enabled: true`, after - * the declaration is confirmed. - */ - switchOnRefusal?(service: unknown, name: string): string | undefined; }>> = { sharing_rule: { service: 'sharingRules', @@ -194,26 +168,6 @@ const DECLARATION_LOOKUPS: Readonly { - const cannotConfirm = (why: string) => - `Cannot confirm that declared webhook '${name}' would be delivered — ${why}. ` + - 'Refusing rather than answering enabled: true for a webhook that may never be sent.'; - const dispatch = declaredWebhookDispatchOf(service); - if (!dispatch) { - return cannotConfirm('the declared-webhook reader bound to this deployment does not say whether its dispatcher runs'); - } - let reason: string | null; - try { - reason = dispatch.dispatchUnavailable(); - } catch (err: any) { - return cannotConfirm(`the declared-webhook reader could not say whether its dispatcher runs (${err?.message ?? String(err)})`); - } - return reason === null - ? undefined - : `Declared webhook '${name}' cannot be switched on — this deployment delivers no declared webhook: ` + - `${reason} Refusing rather than answering enabled: true for a webhook that would never be sent; ` + - 'switching it off is unaffected.'; - }, }, }; @@ -260,9 +214,7 @@ function ledgerUnregistered(ql: any): boolean { * the engine — the one `resolveUserAuthzGrants` reads, so this door and * the resolver cannot disagree about which names exist. No reader bound, * or a reader that cannot answer ⇒ 503 (an outage is not a verdict); - * unknown ⇒ 404. A declared webhook switched ON is also refused 503 where - * the deployment delivers no declared webhook at all — the reader says why - * — since that 200 would report a switch that switches nothing. + * unknown ⇒ 404. * 5. **The durable write**, through the engine. A packaged item's row names * its package; an environment-authored item's row names none. */ @@ -379,19 +331,6 @@ export async function handleCatalogActivationWrite( ), }; } - // [#24910] A switch ON is a claim that the declaration now takes - // effect. Where this deployment delivers no declared webhook at all (the - // dispatcher is not running), a 200 `enabled: true` would be the claim - // with nothing behind it, so the door refuses in the same 503 shape as - // a reader it cannot consult. Switching OFF is always true and is - // written as before. - const switchOnRefusal = enabled ? declaration.switchOnRefusal?.(service, entry.name) : undefined; - if (switchOnRefusal !== undefined) { - return { - handled: true, - response: deps.error(switchOnRefusal, 503, { code: 'SERVICE_UNAVAILABLE' }), - }; - } } const reader = isDeclarationType(type) ? undefined : securityCatalogReaderOf(ql); if (!isDeclarationType(type) && !reader) {