Skip to content

Commit 9e0ec86

Browse files
committed
fix(plugin-webhooks): round 2 — the boot report and docs only; the activation door's refusal and the reader verdict go
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 <noreply@anthropic.com>
1 parent 786143f commit 9e0ec86

7 files changed

Lines changed: 41 additions & 232 deletions

File tree

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,20 @@
11
---
22
'@objectstack/plugin-webhooks': patch
3-
'@objectstack/runtime': patch
43
---
54

6-
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
5+
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
76

87
Clause-②: no
98

109
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.
1110

1211
- **Before.**
1312
- 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.
14-
- `POST /api/v1/security/_activation/webhook/NAME` with `{ "enabled": true }` answered `200 { enabled: true }` and wrote the activation row.
15-
- Every record write still answered 200, and no delivery row was ever written.
16-
- **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.
17-
- **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.
13+
- Every record write still answered 200, the declaration still read active, and no delivery row was ever written.
14+
- **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.
1815
- **Unchanged.**
19-
- Every deployment whose auto-enqueuer runs: switching on and off and delivery behave as before.
20-
- 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.
21-
- `os validate` and `os build` are unchanged. They still accept a stack that declares webhooks without these tokens.
16+
- Every deployment whose auto-enqueuer runs.
17+
- A host that constructs `WebhookOutboxPlugin` with `autoEnqueue: false` gets no new log line. It turned the enqueuer off itself.
18+
- `os validate` and `os build`: they still accept a stack that declares webhooks without these tokens.
19+
- The activation door (`POST /api/v1/security/_activation/webhook/NAME`): it answers exactly as before.
2220
- **What to do.** A stack that declares webhooks needs `requires` to include both `'webhooks'` and `'realtime'`.

‎content/docs/automation/webhooks.mdx‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,15 +109,14 @@ requires: ['automation', 'triggers', 'webhooks', 'realtime'],
109109

110110
- `'webhooks'` mounts `plugin-webhooks`: the declared-webhook reader, the
111111
auto-enqueuer and the redeliver door. Without it nothing reads the
112-
declaration, and the activation door answers `503 SERVICE_UNAVAILABLE`
113-
because no declared-webhook reader is bound.
112+
declaration at all.
114113
- `'realtime'` mounts the realtime service, which is the auto-enqueuer's only
115114
source of record events: the engine publishes `data.record.*` through it, and
116115
nothing else feeds the enqueuer (§4.1). With `'webhooks'` and without
117-
`'realtime'` the declaration is read but never dispatched. The boot logs one
118-
`error` that names each declared webhook and the missing token. The
119-
activation door then refuses to switch a declared webhook on, answering
120-
`503 SERVICE_UNAVAILABLE` with the same reason. Switching one off still works.
116+
`'realtime'` the declaration is read but never dispatched. Record writes still
117+
succeed and no delivery is ever enqueued. The boot logs one `error` that names
118+
each declared webhook and the missing token. Switching a declared webhook on
119+
through the activation door delivers nothing on such a deployment either.
121120

122121
The outbox itself comes from `messaging`, which every boot mounts unless it runs
123122
with `--preset minimal`. A scaffolded app's `requires` is

‎packages/plugins/plugin-webhooks/src/auto-enqueuer-disabled-report.test.ts‎

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,7 @@
22

33
/**
44
* [#24910] A kernel with `webhooks` and without `realtime` delivers no declared
5-
* webhook — and says so ONCE, at `error`, and its declared-webhook reader tells
6-
* the activation door so.
5+
* webhook — and now says so ONCE, at `error`, with the consequence and the fix.
76
*
87
* ## The defect
98
*
@@ -25,14 +24,13 @@
2524
* the realtime service is a stand-in, and only on the legs that mount one.
2625
*
2726
* 1. Declared webhook, no realtime: one `error` naming the webhook, `realtime`
28-
* and the fix; no `disabled` warn; the reader's `dispatchUnavailable()`
29-
* names `realtime`; and a record write enqueues nothing (the loss itself).
30-
* 2. CONTROL, the same stack with realtime: no error, the reader answers
31-
* `null`, and the same write enqueues one delivery — so leg 1's zero is the
32-
* missing service and not a fixture that cannot deliver.
27+
* and the fix; no `disabled` warn; and a record write enqueues nothing (the
28+
* loss itself).
29+
* 2. CONTROL, the same stack with realtime: no error, and the same write
30+
* enqueues its deliveries — so leg 1's zero is the missing service and not a
31+
* fixture that cannot deliver.
3332
* 3. No declared webhook, no realtime: the `warn` it always was, no `error`.
34-
* 4. `autoEnqueue: false`: the host's own choice — nothing reported, and the
35-
* reader answers `null` (its redelivery reads the switch).
33+
* 4. `autoEnqueue: false`: the host's own choice — nothing reported.
3634
*/
3735

3836
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -205,20 +203,18 @@ describe('[#24910] the auto-enqueuer disabled on a stack that declares webhooks'
205203

206204
expect(enqueuerStarted).toBe(false);
207205
expect(reader).toBeInstanceOf(DeclaredWebhookSource);
208-
expect(reader.dispatchUnavailable()).toContain("add 'realtime' to `requires`");
209206
// The reader still answers the declaration — it is only dispatch that is missing.
210207
expect((await reader.declaredWebhookNamed(HOOK))?.active).toBe(true);
211208

212209
await writeRecord();
213210
expect(outbox.enqueued).toEqual([]);
214211
});
215212

216-
it('CONTROL — the same stack with realtime: no error, the reader answers null, and the same write is delivered', async () => {
217-
const { plugin, reader, enqueuerStarted, writeRecord, outbox } = await boot({ realtime: true });
213+
it('CONTROL — the same stack with realtime: no error, and the same write is delivered', async () => {
214+
const { plugin, enqueuerStarted, writeRecord, outbox } = await boot({ realtime: true });
218215

219216
expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]);
220217
expect(enqueuerStarted).toBe(true);
221-
expect(reader.dispatchUnavailable()).toBeNull();
222218

223219
await writeRecord();
224220
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'
228224
});
229225

230226
it('no declared webhook, no realtime: the warn it always was, never the error', async () => {
231-
const { plugin, reader } = await boot({ declare: false });
227+
const { plugin } = await boot({ declare: false });
232228

233229
expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]);
234230
const warns = plugin.lines('warn', 'auto-enqueuer] disabled');
235231
expect(warns).toHaveLength(1);
236232
expect(warns[0].meta).toMatchObject({ hasEngine: true, hasRealtime: false, hasMessaging: true });
237-
// Deployment-wide: a webhook declared later still could not be switched on here.
238-
expect(reader.dispatchUnavailable()).toContain("'realtime'");
239233
});
240234

241-
it('`autoEnqueue: false` is the host\'s own composition: nothing reported, and the reader answers null', async () => {
242-
const { plugin, reader, enqueuerStarted } = await boot({ plugin: { autoEnqueue: false } });
235+
it('`autoEnqueue: false` is the host\'s own composition: nothing reported', async () => {
236+
const { plugin, enqueuerStarted } = await boot({ plugin: { autoEnqueue: false } });
243237

244238
expect(plugin.lines('error', '[webhook-auto-enqueuer]')).toEqual([]);
245239
expect(plugin.lines('warn', '[webhook-auto-enqueuer]')).toEqual([]);
246240
expect(enqueuerStarted).toBe(false);
247-
expect(reader.dispatchUnavailable()).toBeNull();
248241
});
249242
});

‎packages/plugins/plugin-webhooks/src/declared-webhooks.ts‎

Lines changed: 6 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -136,25 +136,6 @@ export interface DeclaredWebhookReader {
136136
declaredWebhookNamed(name: string): Promise<DeclaredWebhook | null>;
137137
}
138138

139-
/**
140-
* [#24910] Whether a declared webhook switched ON is delivered on this
141-
* deployment at all — the question the activation door asks before it answers
142-
* `enabled: true`. Its own interface, beside {@link DeclaredWebhookReader}
143-
* rather than inside it: the dispatcher and the redelivery veto read
144-
* declarations, and only the door asks about dispatch.
145-
*/
146-
export interface DeclaredWebhookDispatch {
147-
/**
148-
* Why no declared webhook is delivered on this deployment — the
149-
* auto-enqueuer is disabled because a service it needs is missing — in one
150-
* sentence that names the composition fix; `null` when it runs. Also `null`
151-
* when the host turned auto-enqueue off itself (`autoEnqueue: false`): that
152-
* is a deliberate composition whose own code enqueues, and redelivery reads
153-
* the switch there.
154-
*/
155-
dispatchUnavailable(): string | null;
156-
}
157-
158139
/**
159140
* The raw declared `webhook` items, registry first, metadata service second.
160141
* Never throws: a reader that cannot answer yields nothing from that reader.
@@ -179,9 +160,10 @@ export function readDeclaredWebhookItems(engine: unknown, metadataService: unkno
179160
}
180161

181162
/**
182-
* [#24910] The distinct `name`s of the raw declared items — what a boot report
183-
* names when the declared webhooks cannot be dispatched. Read the way
184-
* {@link readDeclaredWebhookItems} reads them; never throws.
163+
* [#24910] The distinct `name`s of the raw declared items — what the boot
164+
* report names when the auto-enqueuer cannot run, so nothing declared would be
165+
* delivered. Read the way {@link readDeclaredWebhookItems} reads them; never
166+
* throws.
185167
*/
186168
export function readDeclaredWebhookNames(engine: unknown, metadataService: unknown): string[] {
187169
const names = new Set<string>();
@@ -194,30 +176,18 @@ export function readDeclaredWebhookNames(engine: unknown, metadataService: unkno
194176

195177
/**
196178
* The reader the plugin binds: the declarations from
197-
* {@link readDeclaredWebhookItems}, the switch from the activation ledger, and
198-
* (for the activation door) whether this deployment dispatches them at all.
179+
* {@link readDeclaredWebhookItems}, the switch from the activation ledger.
199180
*/
200-
export class DeclaredWebhookSource implements DeclaredWebhookReader, DeclaredWebhookDispatch {
181+
export class DeclaredWebhookSource implements DeclaredWebhookReader {
201182
/** Names already reported as failing validation — said once per process. */
202183
private readonly invalidReported = new Set<string>();
203184

204-
/**
205-
* @param dispatchGap - [#24910] Why the auto-enqueuer is not running on
206-
* this kernel, or `null` when it runs. The plugin decides it at
207-
* `kernel:ready`, BEFORE this reader is bound, so the door never reads a
208-
* verdict the boot has not reached yet.
209-
*/
210185
constructor(
211186
private readonly engine: unknown,
212187
private readonly metadataService: unknown,
213188
private readonly logger?: Logger,
214-
private readonly dispatchGap: string | null = null,
215189
) {}
216190

217-
dispatchUnavailable(): string | null {
218-
return this.dispatchGap;
219-
}
220-
221191
async list(): Promise<DeclaredWebhook[]> {
222192
const parsed = this.parseDeclared();
223193
if (parsed.length === 0) return [];

‎packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts‎

Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -149,8 +149,8 @@ type AutoEnqueueWiring =
149149

150150
/**
151151
* [#24910] Why the auto-enqueuer is not running, and the composition change
152-
* that starts it, as one sentence. The boot report and the activation door's
153-
* refusal both say exactly this, so the two can never name different fixes.
152+
* that starts it, as one sentence: the reason and the fix the boot report owes
153+
* (AGENTS.md "Degradation log levels").
154154
*/
155155
function autoEnqueueGapReason(gap: AutoEnqueueGap): string {
156156
const missing: string[] = [];
@@ -286,23 +286,18 @@ export class WebhookOutboxPlugin implements Plugin {
286286

287287
if (typeof (ctx as any).hook === 'function') {
288288
(ctx as any).hook('kernel:ready', async () => {
289-
// [#24910] Whether the auto-enqueuer can run is decided FIRST,
290-
// before the declared-webhook reader is bound below, so the
291-
// activation door reads the verdict from the reader's birth and
292-
// never a "dispatching" the boot has not established.
293-
const wiring = this.resolveAutoEnqueueWiring(ctx, autoEnqueueOpt);
294289
// The table's write doors, the legacy-secret sweep and the
295-
// declared-webhook reader — this only needs the data
290+
// declared-webhook reader FIRST — this only needs the data
296291
// engine, so it must not be gated behind the auto-enqueue
297292
// dispatch prerequisites (realtime + messaging).
298-
await this.bootWebhookTable(ctx, wiring);
293+
await this.bootWebhookTable(ctx);
299294
// [#22756] The redelivery veto, and beside it the `webhooks`
300295
// service — gated on the engine and messaging alone, like the
301296
// step above, and never on the auto-enqueue prerequisites. It
302297
// runs before the enqueuer writes its first row (#8069), and
303298
// before the mount below is mounted.
304299
this.bootRedeliverDoor(ctx, autoEnqueueOpt);
305-
await this.bootAutoEnqueue(ctx, autoEnqueueOpt, wiring);
300+
await this.bootAutoEnqueue(ctx, autoEnqueueOpt);
306301
this.registerAdminRoutes(ctx);
307302
});
308303
}
@@ -372,7 +367,7 @@ export class WebhookOutboxPlugin implements Plugin {
372367
* dispatches nothing. Runs before {@link bootAutoEnqueue}, which reads the
373368
* reader bound here.
374369
*/
375-
private async bootWebhookTable(ctx: PluginContext, wiring: AutoEnqueueWiring): Promise<void> {
370+
private async bootWebhookTable(ctx: PluginContext): Promise<void> {
376371
const engine = this.tryGetService<IDataEngine>(ctx, ['objectql', 'data']);
377372
if (!engine) {
378373
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 {
397392
bindWebhookLegacyCleartextGate(engine as any, ctx.logger as any);
398393
let metadataService: IMetadataService | undefined;
399394
try { metadataService = ctx.getService<IMetadataService>('metadata'); } catch { /* optional */ }
400-
// [#24910] Born knowing whether this kernel dispatches what it reads.
401-
this.declaredSource = new DeclaredWebhookSource(
402-
engine,
403-
metadataService,
404-
ctx.logger as any,
405-
wiring.kind === 'gap' ? autoEnqueueGapReason(wiring.gap) : null,
406-
);
395+
this.declaredSource = new DeclaredWebhookSource(engine, metadataService, ctx.logger as any);
407396
ctx.registerService(DECLARED_WEBHOOKS_SLOT, this.declaredSource);
408397
// And the declared-name guard: a row that took a declared name would
409398
// withhold the declaration for the whole deployment, so no non-system
@@ -462,8 +451,7 @@ export class WebhookOutboxPlugin implements Plugin {
462451
ctx.logger.error?.(
463452
`[webhook-auto-enqueuer] the ${declared.length} webhook${declared.length === 1 ? '' : 's'} this stack `
464453
+ `declares (${declared.join(', ')}) will NEVER be delivered: record writes still succeed and nothing `
465-
+ `else reports the loss, but no delivery is ever enqueued — ${autoEnqueueGapReason(gap)} Until then the `
466-
+ 'activation door refuses to switch a declared webhook on.',
454+
+ `else reports the loss, but no delivery is ever enqueued — ${autoEnqueueGapReason(gap)}`,
467455
undefined,
468456
{ ...gap, declared },
469457
);
@@ -478,8 +466,8 @@ export class WebhookOutboxPlugin implements Plugin {
478466
private async bootAutoEnqueue(
479467
ctx: PluginContext,
480468
opt: boolean | AutoEnqueuerOptions,
481-
wiring: AutoEnqueueWiring,
482469
): Promise<void> {
470+
const wiring = this.resolveAutoEnqueueWiring(ctx, opt);
483471
if (wiring.kind === 'off') return;
484472
if (wiring.kind === 'gap') {
485473
this.reportAutoEnqueueGap(ctx, wiring.gap);

0 commit comments

Comments
 (0)