Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
90e023c
feat(plugin-webhooks)!: declared webhooks are held per namespace, eac…
claude Nov 12, 2026
1d1f7c6
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 12, 2026
10fd981
fix(runtime): keep the tracker id out of the webhook namespace door's…
claude Nov 12, 2026
457b3b4
fix(runtime): route-ledger census counts the webhook namespace door's…
claude Nov 12, 2026
c0f15d8
test(runtime): the activation door test pays @objectstack/metadata's …
claude Nov 12, 2026
dfbaaeb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 12, 2026
e8a47c4
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 12, 2026
a468e14
test(metadata): the #32458 boot-pass pins iterate the types the boot …
claude Nov 12, 2026
d1deac5
fix(plugin-webhooks, runtime): a disabled co-holder's webhook is neit…
claude Nov 12, 2026
9e02b5c
test(objectql): a stored webhook row is loaded at boot by loadMetaFro…
claude Nov 12, 2026
7a2debd
fix(service-messaging, plugin-webhooks): a declared webhook name that…
claude Nov 12, 2026
55efc3f
fix(plugin-security, runtime): the upgrade impact report reads webhoo…
claude Nov 12, 2026
938f948
test(objectql, plugin-webhooks): the boot-hydration double holds the …
claude Nov 12, 2026
6b72041
fix(plugin-webhooks): a redelivery naming no package counts every hol…
claude Nov 12, 2026
48b4cba
test(plugin-security): the upgrade impact report's suspected re-enabl…
claude Nov 12, 2026
77becda
docs(plugin-webhooks): the changeset says what the protocol read, a r…
claude Nov 12, 2026
9c5f335
Merge origin/main into claude/project-thread-3giljf
claude Nov 12, 2026
2200b8c
test: the not-narrowed example is the connector, now the flow and the…
claude Nov 12, 2026
a0131ec
Merge origin/main into claude/project-thread-3giljf
claude Nov 12, 2026
d9cea3d
test: pin what main's #32566, #32351 and the row-binding migration an…
claude Nov 12, 2026
dc6e8ed
docs(changeset): Clause-② arm (3) names the refused rollback to a rem…
claude Nov 12, 2026
281722e
Merge origin/main into claude/project-thread-3giljf
claude Nov 12, 2026
91acdcc
fix(plugin-webhooks): the durable reading asks the registry for every…
claude Nov 12, 2026
17cec0f
test: a webhook name beside a disabled co-holder reads as the enabled…
claude Nov 12, 2026
05499a3
test(metadata): the unscoped plural reads #32687 pins per slotted typ…
claude Nov 12, 2026
9322079
docs(changeset): the unscoped plural reads answer each package's webh…
claude Nov 12, 2026
491e70b
Merge origin/main into claude/project-thread-3giljf
claude Nov 12, 2026
c080016
docs(changeset): Clause-② arm (4), the library reads of a webhook onl…
claude Nov 12, 2026
d4f90f1
docs(changeset): the two widenings the disabled co-holder and the plu…
claude Nov 12, 2026
2173fae
Merge origin/main into claude/project-thread-3giljf
claude Nov 12, 2026
7f68e29
test(metadata): #32676's boot-priming cases run for the types the pas…
claude Nov 12, 2026
40c7981
docs(changeset): #31699 PR 2 declares the sealed same-namespace clash…
claude Nov 13, 2026
a5c37e9
Merge origin/main into claude/project-thread-3giljf
claude Nov 13, 2026
26a846f
test(metadata): #32791's by-package CONTROL runs for the types the bo…
claude Nov 13, 2026
c0500c6
test(objectql): the webhook's package-less item is declined at each h…
claude Nov 13, 2026
68145a9
docs(changeset): name the eighth moved answer, a declared:NAME redeli…
claude Nov 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/31699-webhook-names-per-namespace.md

Large diffs are not rendered by default.

15 changes: 10 additions & 5 deletions content/docs/automation/webhooks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -91,12 +91,16 @@ A webhook reaches the dispatcher one of two ways:
stored. A declared webhook is read-only: switch it on or off for the
deployment with
`POST /api/v1/security/_activation/webhook/:name` and body
`{ "enabled": true | false }`, which writes one `sys_metadata_activation` row
of type `webhook` and overrides the declared `isActive` in either direction
`{ "enabled": true | false }`, or, when packages of two namespaces each
declare the name, `POST /api/v1/security/_activation/webhook/:ns/:name`
(the bare address then answers `409` naming both). It writes that holder's
own `sys_metadata_activation` row of type `webhook` and overrides the
declared `isActive` in either direction
(ADR-0131 D6, regime C — the door, its authority tiers and its refusals are
the ones the declared-sharing-rule switch uses). To change one, author a copy
under a new name as a `sys_webhook` row and switch the declared one off.
Deliveries of a declared webhook carry `ref_id = declared:<name>`.
Deliveries of a declared webhook carry `ref_id = declared:<package>/<name>`,
or `declared:<name>` for one the environment declares (ADR-0150).
- **Authored by an organization** — a `sys_webhook` row (§3.1), switched by its
own `active` field.

Expand Down Expand Up @@ -437,8 +441,9 @@ For each incoming event the subscriber:
### 4.3 Stage 3 — Persist

A single enqueue per match. The `(source, dedup_key)` uniqueness
constraint — where `dedup_key` is `<webhookId>:<object>:<recordId>:<action>:<ts>`
— absorbs duplicate event deliveries from `at-least-once` semantics. After this
constraint — where `dedup_key` is `<webhookId>:<object>:<recordId>:<action>:<ts>`,
or, past the column's 191 characters, a fixed-length `sha256:` digest of that
string — absorbs duplicate event deliveries from `at-least-once` semantics. After this
stage the event producer is no longer involved; the rest is the dispatcher's
problem.

Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/package-scoped-resolution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,10 @@ describe('the types narrowed to the namespace (ADR-0150 D2/D9)', () => {
expect(isPackageScopedType(type), type).toBe(type !== ENGINE_FUNCTION_TYPE);
expect(isNamespaceScopedBareNameType(type), type).toBe(true);
}
// [#31696] The flow has narrowed (it is in the set above); a webhook has not.
// [#31696, #31699 PR 2] The flow and the webhook have narrowed (they are in the set above); a connector has not.
expect(isNamespaceScopedBareNameType('flow')).toBe(true);
expect(isNamespaceScopedBareNameType('webhook')).toBe(false);
expect(isNamespaceScopedBareNameType('webhook')).toBe(true);
expect(isNamespaceScopedBareNameType('connector')).toBe(false);
expect(isNamespaceScopedBareNameType(undefined)).toBe(false);
});

Expand Down
7 changes: 5 additions & 2 deletions packages/core/src/package-scoped-resolution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -136,8 +136,9 @@ export function isBareNameOneHolderType(type: unknown): type is BareNameOneHolde
* `email_template` (#31699), then `flow` (#31696: the engine holds a flow per
* `(package, name)`, and its doors, flow actions and flow endpoints resolve a
* name by package), then the picklist family (#31700): `picklist`,
* `mapping` and `dataset`, the `api` and the engine's `function` registry. A
* type not listed here keeps one package holder per deployment.
* `mapping` and `dataset`, the `api` and the engine's `function` registry,
* then `webhook` (#31699 PR 2). A type not listed here keeps one package
* holder per deployment.
*
* Every member but {@link ENGINE_FUNCTION_TYPE} is a metadata type ADR-0150
* addresses by package ({@link PACKAGE_SCOPED_TYPES}). The function registry
Expand All @@ -163,6 +164,8 @@ export const NAMESPACE_SCOPED_BARE_NAME_TYPES: readonly BareNameOneHolderType[]
'dataset',
'api',
'function',
// #31699 PR 2
'webhook',
]);

/** Has `type`'s one-holder rule narrowed to the namespace ({@link NAMESPACE_SCOPED_BARE_NAME_TYPES})? */
Expand Down
97 changes: 89 additions & 8 deletions packages/metadata/src/loaders/database-loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { MetadataManager } from '../metadata-manager.js';
import { MemoryLoader } from './memory-loader.js';
import { MetadataPlugin } from '../plugin.js';
import { PACKAGE_SLOTTED_STORE_TYPES, isBundledStoreType } from '../package-slotted-store.js';
import { DEFAULT_METADATA_TYPE_REGISTRY } from '@objectstack/spec/kernel';
import { FilesystemLoader } from './filesystem-loader.js';
import { JSONSerializer } from '../serializers/json-serializer.js';
// Paid at collection, not inside a case's window: `plugin.ts` imports it lazily (check:test-source-alias).
Expand Down Expand Up @@ -2082,6 +2083,8 @@ describe('[#32791] a stored package-less row the boot pass files under the host\
const BETA = 'com.beta.app';
const HOST = 'com.host.app';
const UNBUNDLED = [...PACKAGE_SLOTTED_STORE_TYPES].filter((type) => !isBundledStoreType(type));
/** [#31699 PR 2] The unbundled slotted types the boot pass reads: every one but `webhook`, which the registry the pass walks does not list. */
const BOOT_PASS_READS = UNBUNDLED.filter((type) => DEFAULT_METADATA_TYPE_REGISTRY.some((entry) => entry.type === type));
let driver: IDataDriver;

/** A row of `helper` bound to `packageId` by the column, or package-less for `null`. */
Expand Down Expand Up @@ -2400,7 +2403,7 @@ describe('[#32791] a stored package-less row the boot pass files under the host\
const seen: unknown[] = [];
const cells = (type: string, order: string) =>
[undefined, ALPHA, HOST].filter((hostPackage) => !(type === 'action' && order === 'package-less row saved last' && hostPackage === HOST));
for (const type of UNBUNDLED) {
for (const type of BOOT_PASS_READS) {
for (const order of ['package-less row saved last', 'package-less row saved first'] as const) {
for (const hostPackage of cells(type, order)) {
driver = createMockDriver();
Expand All @@ -2415,11 +2418,41 @@ describe('[#32791] a stored package-less row the boot pass files under the host\
}
}
}
expect(seen).toEqual(UNBUNDLED.flatMap((type) => [
expect(seen).toEqual(BOOT_PASS_READS.flatMap((type) => [
...cells(type, 'package-less row saved last').map((hostPackage) => [type, 'package-less row saved last', hostPackage ?? 'no host package', 'env']),
...cells(type, 'package-less row saved first').map((hostPackage) => [type, 'package-less row saved first', hostPackage ?? 'no host package', ALPHA]),
]));
expect(seen).toHaveLength(UNBUNDLED.length * 6 - 1);
expect(seen).toHaveLength(BOOT_PASS_READS.length * 6 - 1);
});

/**
* [#31699 PR 2] `webhook` is slotted, but the pass registers none of its
* rows (`DEFAULT_METADATA_TYPE_REGISTRY` lists no `webhook`), so the case
* above, which asks for the row the pass registered, does not hold for it.
* Its bare read is the loader's own: the package's row, in either save
* order, on every host. FROM (`webhook` out of `PACKAGE_SLOTTED_STORE_TYPES`,
* as on main): the row saved first, so `ALPHA` when the package-less row
* was saved last and the package-less row when it was saved first.
*/
it('CONTROL webhook, slotted but not a type the pass reads, one package\'s row and a package-less row: the pass registers neither, and the bare read answers the package\'s row in either save order on every host (FROM the row saved first)', async () => {
expect(BOOT_PASS_READS).toEqual(UNBUNDLED.filter((type) => type !== 'webhook'));
const seen: unknown[] = [];
for (const order of ['package-less row saved last', 'package-less row saved first'] as const) {
for (const hostPackage of [undefined, ALPHA, HOST]) {
driver = createMockDriver();
if (order === 'package-less row saved last') {
await seed('webhook', ALPHA, 1);
await seed('webhook', null, 2);
} else {
await seed('webhook', null, 1);
await seed('webhook', ALPHA, 2);
}
const m = await bootPass(hostPackage);
seen.push([order, hostPackage ?? 'no host package', registryKeys(m, 'webhook'), await answer(m, 'webhook')]);
}
}
expect(seen).toEqual(['package-less row saved last', 'package-less row saved first'].flatMap((order) =>
['no host package', ALPHA, HOST].map((host) => [order, host, [], ALPHA])));
});

it('CONTROL action, the host\'s own item registered in memory beside one package\'s stored row: the host is a holder — the bare read is refused naming both', async () => {
Expand Down Expand Up @@ -2452,6 +2485,12 @@ describe('[#32458] an unbundled slotted type\'s rows bound by the column only',
const BETA = 'com.beta.app';
const HOST = 'com.host.app';
const UNBUNDLED = [...PACKAGE_SLOTTED_STORE_TYPES].filter((type) => !isBundledStoreType(type));
/**
* The ones the boot pass reads: it walks `DEFAULT_METADATA_TYPE_REGISTRY`, and `webhook` (#31699 PR 2)
* is slotted without being listed there, so the pass registers none of its rows and its reads stay
* the loader's, as before it was slotted.
*/
const BOOT_PASS_READS = UNBUNDLED.filter((type) => DEFAULT_METADATA_TYPE_REGISTRY.some((entry) => entry.type === type));
let driver: IDataDriver;

// Bound by the `package_id` column; the body names no package (the protocol writes none).
Expand Down Expand Up @@ -2517,8 +2556,19 @@ describe('[#32458] an unbundled slotted type\'s rows bound by the column only',
});

it('the types are taken from the tree', () => {
expect(UNBUNDLED).toEqual(expect.arrayContaining(['api', 'agent', 'skill', 'action', 'permission']));
expect(UNBUNDLED).toEqual(expect.arrayContaining(['api', 'agent', 'skill', 'action', 'permission', 'webhook']));
expect(UNBUNDLED).not.toContain('email_template');
expect(BOOT_PASS_READS).toEqual(UNBUNDLED.filter((type) => type !== 'webhook'));
});

it('CONTROL — webhook: the boot pass registers none of its rows, so a read stays the loader\'s, as before it was slotted', async () => {
await seed('webhook', ALPHA, 1);
await seed('webhook', BETA, 2);
const before = await stored('webhook');
const m = await bootPass();
expect([...((m as any).registry.get('webhook')?.keys() ?? [])]).toEqual([]);
expect(await stored('webhook')).toEqual(before);
expect(driver.update).not.toHaveBeenCalled();
});

it('a body whose name is another row\'s key is never stamped with that row\'s package (contract review F3)', async () => {
Expand All @@ -2537,6 +2587,7 @@ describe('[#32458] an unbundled slotted type\'s rows bound by the column only',
});
// Main's answer for the name: the body `loadMany` keeps first, unstamped.
expect(await manager().loadMany(type)).toEqual([{ name: 'a', label: 'stray' }]);
if (!BOOT_PASS_READS.includes(type)) continue;
const m = await bootPass();
expect([...((m as any).registry.get(type)?.keys() ?? [])]).toEqual(['a']);
}
Expand Down Expand Up @@ -2574,7 +2625,7 @@ describe('[#32458] an unbundled slotted type\'s rows bound by the column only',
expect(driver.update).not.toHaveBeenCalled();
});

it(`${type}: the boot pass registers the row under its own package, never the host's or none (BEFORE: ALPHA's read served BETA's body)`, async () => {
if (BOOT_PASS_READS.includes(type)) it(`${type}: the boot pass registers the row under its own package, never the host's or none (BEFORE: ALPHA's read served BETA's body)`, async () => {
await seed(type, ALPHA, 1);
await seed(type, BETA, 2);
const before = await stored(type);
Expand All @@ -2601,7 +2652,7 @@ describe('[#32458] an unbundled slotted type\'s rows bound by the column only',
expect(driver.create).toHaveBeenCalledTimes(2);
});

it(`${type}: one holder — the boot pass writes nothing, and a bare read answers the unique holder, now stamped with its package (BEFORE: unstamped)`, async () => {
if (BOOT_PASS_READS.includes(type)) it(`${type}: one holder — the boot pass writes nothing, and a bare read answers the unique holder, now stamped with its package (BEFORE: unstamped)`, async () => {
await seed(type, ALPHA, 1);
const before = await stored(type);
const m = await bootPass();
Expand Down Expand Up @@ -2635,6 +2686,12 @@ describe('[#32521] the boot priming pass primes every holder of a name', () => {
const ALPHA = 'com.alpha.app';
const BETA = 'com.beta.app';
const UNBUNDLED = [...PACKAGE_SLOTTED_STORE_TYPES].filter((type) => !isBundledStoreType(type));
/**
* [#31699 PR 2] The ones the pass reads: it walks `DEFAULT_METADATA_TYPE_REGISTRY`, and `webhook`
* is slotted without being listed there, so the pass primes none of its rows (as for #32548's
* cases above). Its reads after the pass are pinned by the webhook CONTROL below.
*/
const BOOT_PASS_READS = UNBUNDLED.filter((type) => DEFAULT_METADATA_TYPE_REGISTRY.some((entry) => entry.type === type));
let driver: IDataDriver;

/** A row of `helper` bound to `packageId` by the column; `stamped` also names it in the body. */
Expand Down Expand Up @@ -2678,7 +2735,7 @@ describe('[#32521] the boot priming pass primes every holder of a name', () => {
});

for (const type of UNBUNDLED) {
for (const [shape, body] of [['bound by the column only', {}], ['stamped in the body too', { _packageId: true }]] as const) {
if (BOOT_PASS_READS.includes(type)) for (const [shape, body] of [['bound by the column only', {}], ['stamped in the body too', { _packageId: true }]] as const) {
it(`${type}, ${shape}: two holders — after the pass a bare read is refused 409 naming both, as without it, and list keeps both (BEFORE: BETA's body, list [BETA])`, async () => {
await seed(type, ALPHA, 1, body._packageId ? { _packageId: ALPHA } : {});
await seed(type, BETA, 2, body._packageId ? { _packageId: BETA } : {});
Expand Down Expand Up @@ -2775,7 +2832,7 @@ describe('[#32521] the boot priming pass primes every holder of a name', () => {
['package-less row saved first', HOST, [ALPHA], [`${ALPHA}\u0000helper`], ALPHA, ALPHA, undefined],
];

for (const type of UNBUNDLED) {
for (const type of BOOT_PASS_READS) {
it(`CONTROL ${type}, one package's row and a package-less row of the name (that holder's customization, D4): every read after the pass answers what it answered before — the row saved last — with no host package, a host package equal to the holder, or another`, async () => {
const seen: unknown[] = [];
for (const order of ['package-less row saved last', 'package-less row saved first'] as const) {
Expand Down Expand Up @@ -2903,6 +2960,30 @@ describe('[#32521] the boot priming pass primes every holder of a name', () => {
});
}

it('CONTROL — webhook, slotted but not a type the pass reads: it primes none of the rows, and after it every read answers what it answers without it — two holders refused naming both, each its own, the package-less row kept, nothing written — on a host declaring no package, one of the holders, or another', async () => {
expect(UNBUNDLED).toContain('webhook');
expect(BOOT_PASS_READS).not.toContain('webhook');
for (const order of ['none', 'first', 'between the two', 'last'] as const) {
for (const hostPackage of [undefined, ALPHA, BETA, HOST]) {
driver = createMockDriver();
const writes = [(version: number) => seed('webhook', ALPHA, version), (version: number) => seed('webhook', BETA, version)];
if (order !== 'none') writes.splice(order === 'first' ? 0 : order === 'last' ? 2 : 1, 0, (version: number) => seedPackageless('webhook', version));
for (const [at, write] of writes.entries()) await write(at + 1);
const m = await bootPass(hostPackage);
const at = [order, hostPackage];
expect([...at, registryKeys(m, 'webhook')]).toEqual([...at, []]);
for (const packageId of [undefined, ALPHA, BETA, HOST]) {
expect([...at, packageId, await answer(m, 'webhook', 'helper', packageId)]).toEqual([...at, packageId, await answer(cold(), 'webhook', 'helper', packageId)]);
}
expect([...at, await answer(m, 'webhook', 'helper')]).toEqual([...at, REFUSED]);
expect([...at, await answer(m, 'webhook', 'helper', ALPHA), await answer(m, 'webhook', 'helper', BETA), await answer(m, 'webhook', 'helper', HOST)])
.toEqual([...at, ALPHA, BETA, undefined]);
expect([...at, labels(await m.list('webhook'))]).toEqual([...at, order === 'none' ? [ALPHA, BETA] : [ALPHA, BETA, 'env']]);
expect(driver.update).not.toHaveBeenCalled();
}
}
});

it('agent, two holders of a name and another row\'s body that says the same name: no body of the name is stamped, so none takes a package its row is not bound to (contract review F3 of #32458)', async () => {
await seed('agent', ALPHA, 1);
await seed('agent', BETA, 2);
Expand Down
Loading
Loading