diff --git a/.changeset/24529-declared-webhooks-from-registry.md b/.changeset/24529-declared-webhooks-from-registry.md index 0162a7c1e4b..49db9de93e5 100644 --- a/.changeset/24529-declared-webhooks-from-registry.md +++ b/.changeset/24529-declared-webhooks-from-registry.md @@ -22,9 +22,9 @@ What changes: On a database an 18.0 install created (expand first — nothing is moved or deleted): -- The rows the 18.0 seeder wrote stay where they are. A pristine one (`managed_by: 'package'`, not `customized`) is ignored in favour of its declaration, so a declared webhook is never delivered twice; it follows the declaration's URL and switch, as the seeder's rewrite on every boot made it do. +- The rows the 18.0 seeder wrote stay where they are until the ownership ceremony settles them, and the v18 boot guard refuses the database until the ceremony has certified it. A pristine copy (`managed_by: 'package'`, not `customized`) is deleted by the ceremony, its deliveries re-pointed at the declaration, so a declared webhook is never delivered twice. - A seeded row an administrator edited (`customized: true`), or a row an administrator authored under a declared name, answers as itself, and the declaration is not dispatched beside it — an administrator's `active: false` on a noisy webhook keeps holding. This is logged at `warn` once per name per process, with the remedy. - A seeded row whose declaration was removed keeps delivering, as it did on 18.0. -- Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent. +- Redelivering an old delivery of a pristine seeded copy is judged by the declaration, because the ceremony re-points that delivery at `declared:`; a webhook switched off through the ledger is not re-sent. Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is the ownership ceremony's `declared-webhooks` step (`os migrate organization-ownership`). diff --git a/.changeset/24583-7c-dual-reads-retired.md b/.changeset/24583-7c-dual-reads-retired.md new file mode 100644 index 00000000000..a5974131abc --- /dev/null +++ b/.changeset/24583-7c-dual-reads-retired.md @@ -0,0 +1,16 @@ +--- +'@objectstack/plugin-webhooks': minor +'@objectstack/platform-objects': minor +--- + +refactor(plugin-webhooks,platform-objects)!: 18.x step 7c retires the dual reads steps 1–2 added for 18.0 databases. Any `sys_webhook` row that holds a declared webhook's name now answers as itself. `sys_setting_audit.scope` no longer declares `global`. + +Clause-②: yes (narrowing) + + + +**BREAKING** narrowing, shipped as `minor` under the launch-window convention for breaking changes (ADR-0087, the ratified pre-launch launch-window section; the decisions retired here are ADR-0131 D2, D6 and D7). The 18.x upgrade ceremony settles every row these readers existed for before a database is certified, so a certified database and a fresh install read the same either way. + +- **Declared webhooks (plugin-webhooks).** Before: a `sys_webhook` row seeded by the 18.0 seeder under a declared name, and never edited (`managed_by` `package`/`platform`, not `customized`), was skipped as a "mirror". The declaration answered in its place, for dispatch and for redelivery. Now: any row holding a declared name answers as itself and the declaration is withheld, reported once per name (regime C). The ceremony (step 5a) deletes or settles every such copy, so on a certified database this changes one case only. A seeded row the ceremony kept, because no package declared its name then, now withholds a declaration of that name added later, where before it was skipped. **Remedy:** rename or delete that row, and the declaration answers. The provenance stamp (`customized`) stays: the ceremony still reads it when a superseding reviewed plan re-runs the ceremony from the start. +- **Settings audit (platform-objects).** Before: `sys_setting_audit.scope` declared `global`, `tenant` and `user`, and a reader of the global rung's history read both `sys_setting_audit` and `sys_platform_setting_audit`. Now: `sys_setting_audit.scope` declares `tenant` and `user` only, and an insert with `scope: 'global'` is refused (`VALIDATION_FAILED` on `scope`). The global rung's history is `sys_platform_setting_audit` alone. The settings service already wrote global-rung rows there, and the ceremony (step 5b) moves every 18.0 row. **Remedy:** none for a certified database. A script that wrote audit rows by hand writes global-scope rows to `sys_platform_setting_audit`. +- **Unchanged.** Identity mail: only docblocks change. The tenant mail log keeps its body read gate, and a mail job with no object still reads `sys_email`, because a tenant mail row an 18.0 process queued stays there. diff --git a/packages/platform-objects/src/apps/translations/en.objects.generated.ts b/packages/platform-objects/src/apps/translations/en.objects.generated.ts index c3fea6d5892..9ac19b36828 100644 --- a/packages/platform-objects/src/apps/translations/en.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.objects.generated.ts @@ -3411,7 +3411,6 @@ export const enObjects: NonNullable = { label: "Scope", help: "Cascade layer the row was written to.", options: { - global: "Global", tenant: "Tenant", user: "User" } diff --git a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts index 38f92251b46..ea6be3bb174 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts @@ -3411,7 +3411,6 @@ export const esESObjects: NonNullable = { label: "Ámbito", help: "Capa de cascada en la que se escribió la fila.", options: { - global: "Global", tenant: "Inquilino", user: "Usuario" } diff --git a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts index b7a742d87c7..e3712999715 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts @@ -57,7 +57,6 @@ export const esESGeneratedSourceHashes: Readonly> = { "objects.sys_secret.fields.id.label": "00b0385c9c152888", "objects.sys_setting_audit.fields.actor_id.label": "b155813f8a7f06e3", "objects.sys_setting_audit.fields.id.label": "00b0385c9c152888", - "objects.sys_setting_audit.fields.scope.options.global": "5e377106508d2ecd", "objects.sys_setting_audit.fields.source.options.api": "4baeeff968e192d1", "objects.sys_setting_audit.fields.source.options.ui": "95caad102a5c97ba", "objects.sys_sso_provider._actions.register_saml_provider.params.identifierFormat.placeholder": "5945ff9a6b644855", diff --git a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts index b19afd78a6c..f188da5d8b5 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts @@ -3411,7 +3411,6 @@ export const jaJPObjects: NonNullable = { label: "スコープ", help: "行が書き込まれたカスケードレイヤー。", options: { - global: "グローバル", tenant: "テナント", user: "ユーザー" } diff --git a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts index f8586562ae3..5525f5c9eda 100644 --- a/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts @@ -116,8 +116,6 @@ const SAME_WORD_ERROR = 'Spanish spells the word exactly as English (el error); this bundle already writes it so in the authored label Último error on sys_job_queue.'; const SAME_WORD_ACTOR = 'Spanish spells the word exactly as English (el actor); this bundle already writes it so in authored help (Último actor que escribió esta fila), and the messaging bundle authors its actor label as Actor.'; -const SAME_WORD_GLOBAL = - 'Spanish spells the word exactly as English (global); the security bundle already writes it so in authored help (nulo = global (entre inquilinos)), beside the authored sibling scope options Inquilino and Usuario.'; const SAME_WORD_VARIABLES = 'Spanish spells the word exactly as English (las variables); the authored description of this same object already says (asunto + contenido + variables).'; const BUNDLE_LOANWORD = @@ -180,7 +178,6 @@ const DECISIONS: readonly Decision[] = [ echo('sys_notification.fields.actor_id.label', 'Actor', SAME_WORD_ACTOR), echo('sys_metadata_audit.fields.actor.label', 'Actor', SAME_WORD_ACTOR), echo('sys_setting_audit.fields.actor_id.label', 'Actor', SAME_WORD_ACTOR), - echo('sys_setting_audit.fields.scope.options.global', 'Global', SAME_WORD_GLOBAL), echo('sys_email_template.fields.variables_json.label', 'Variables (JSON)', SAME_WORD_VARIABLES), echo('sys_oauth_access_token.fields.token.label', 'Token', BUNDLE_LOANWORD), @@ -239,13 +236,16 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] { describe('#20493 es-ES — the ledger itself (controls before verdicts)', () => { // [ADR-0131 D7] 47 → 46 echoes: `sys_setting.scope` no longer declares the // `global` option (the rung moved to `sys_platform_setting`), so its leaf left - // the catalog with it. `sys_setting_audit.scope`'s `global` option stays. + // the catalog with it. // [ADR-0131 D13] 46 → 45: `sys_view_definition` retired as inert, and its // bare `ID` leaf left the catalog with the object. - it('is the size it claims: 3 pinned translations and 45 declared echoes, no path twice', () => { - expect(DECISIONS.length).toBe(48); + // [18.x step 7] 45 → 44: `sys_setting_audit.scope` no longer declares the + // `global` option either (the ceremony moved those rows to + // `sys_platform_setting_audit`), so its leaf left the catalog too. + it('is the size it claims: 3 pinned translations and 44 declared echoes, no path twice', () => { + expect(DECISIONS.length).toBe(47); expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3); - expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(45); + expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(44); expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length); }); diff --git a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts index 3d3a1139873..0b3f3e58446 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts @@ -3411,7 +3411,6 @@ export const zhCNObjects: NonNullable = { label: "范围", help: "该记录写入的级联层。", options: { - global: "全局", tenant: "租户", user: "用户" } diff --git a/packages/platform-objects/src/audit/sys-email.object.ts b/packages/platform-objects/src/audit/sys-email.object.ts index 4095336aad8..5a3beae402b 100644 --- a/packages/platform-objects/src/audit/sys-email.object.ts +++ b/packages/platform-objects/src/audit/sys-email.object.ts @@ -100,10 +100,12 @@ export const SysEmail = ObjectSchema.create({ // on read for a caller without it). An 18.0 install wrote its identity // mail — password reset, account invitation, verification, magic link, // email-change notice — into this log, stamped with the organization, and - // those bodies carry sign-in and reset links. The rows stay here until the - // 18.x ceremony moves them, and no column tells them apart from tenant mail - // reliably: a magic link carries no `related_object`, and the log has no - // template column. So the gate is on the columns, for every row: an + // those bodies carry sign-in and reset links. The 18.x ceremony moves them + // to `sys_identity_email` before the database is certified, selected by + // shape — and no column tells them apart from tenant mail reliably (a + // magic link carries no `related_object`, and the log has no template + // column), so a row the shape misses stays here. So the gate is on the + // columns, for every row: an // organization administrator keeps the row (envelope, subject, delivery // state, linkage) and loses only the body. The same field-level answer // closes every door that serves a row through the engine — the data API, diff --git a/packages/platform-objects/src/audit/sys-identity-email.object.ts b/packages/platform-objects/src/audit/sys-identity-email.object.ts index 2051dd21cb6..ccb913bfefd 100644 --- a/packages/platform-objects/src/audit/sys-identity-email.object.ts +++ b/packages/platform-objects/src/audit/sys-identity-email.object.ts @@ -19,9 +19,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * hook, the boot outbox sweep and attachment reclamation read it by id and * status exactly as they read `sys_email`. * - * Expand-first: rows an 18.0 install wrote to `sys_email` for this mail stay - * there; the mail pipeline keeps reading them, and moving them is the 18.x - * upgrade ceremony's. + * The rows an 18.0 install wrote to `sys_email` for this mail are moved here, + * id for id, by the 18.x upgrade ceremony before the database is certified — + * their queued jobs and activity timeline pointed at this log first — and the + * v18 boot guard refuses a database it has not certified. * * `systemFields: { tenant: false }` removes the injected organization column. * With no column there is no tenant wall, so who may read is object diff --git a/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts b/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts index 65cbd7e9c48..20192f9dc6a 100644 --- a/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts +++ b/packages/platform-objects/src/system/sys-platform-setting-audit.object.ts @@ -12,8 +12,8 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Tenant and user rows stay in `sys_setting_audit`, each stamped with the * organization whose value changed. * - * Same row shape as `sys_setting_audit`, so one reader reads both tables the - * same way; `scope` is always `global` here. + * Same row shape as `sys_setting_audit`, so one reader reads either table the + * same way; `scope` is always `global` here, and never `global` there. * * ## No organization column, governed by object permission * @@ -27,8 +27,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * ## The 18.0 rows * * An 18.0 install wrote its global-rung audit rows into `sys_setting_audit`, - * stamped with the Default Organization. They stay there until the upgrade - * ceremony moves them here; a reader of the global rung's history reads both. + * stamped with the Default Organization. The upgrade ceremony moves them here, + * id for id, before the database is certified (18.x step 5b), and the v18 boot + * guard refuses a database it has not certified — so the global rung's history + * is this table alone. * * Append-only: the settings audit writer is the only writer, and the generic * data API offers reads only. diff --git a/packages/platform-objects/src/system/sys-setting-audit.object.ts b/packages/platform-objects/src/system/sys-setting-audit.object.ts index c2f98367612..c638fb5d986 100644 --- a/packages/platform-objects/src/system/sys-setting-audit.object.ts +++ b/packages/platform-objects/src/system/sys-setting-audit.object.ts @@ -20,6 +20,12 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * - The schema here carries settings-specific fields (`scope`, * `cascade_source`) that don't make sense on a generic row. * + * Tenant and user rungs only (ADR-0131 D7, ruling record 6257510973 · Q4 A): + * a global-rung change is deployment-level, and its row lives in + * `sys_platform_setting_audit`. The upgrade ceremony moved every global-rung + * row an 18.0 install wrote here, so `scope` declares no `global` option and a + * row claiming it is refused. + * * Append-only contract: enforced at the application layer (the only * writer is SettingsService). Operators MUST NOT delete rows; instead * use lifecycle policies to archive cold rows to a separate bucket. @@ -71,7 +77,6 @@ export const SysSettingAudit = ObjectSchema.create({ scope: Field.select( [ - { label: 'Global', value: 'global' }, { label: 'Tenant', value: 'tenant' }, { label: 'User', value: 'user' }, ], diff --git a/packages/platform-objects/src/system/sys-setting.object.ts b/packages/platform-objects/src/system/sys-setting.object.ts index dccd56b65c1..98b328f7bd8 100644 --- a/packages/platform-objects/src/system/sys-setting.object.ts +++ b/packages/platform-objects/src/system/sys-setting.object.ts @@ -130,9 +130,10 @@ export const SysSetting = ObjectSchema.create({ // The option list is the storage-side mirror of the cascade layers THIS // table stores: `SpecifierScopeSchema` // (`packages/spec/src/system/settings-manifest.zod.ts`) minus `global`. - // `sys-setting.scope-options.test.ts` pins the parity. The sibling audit - // object (`sys_setting_audit.scope`) keeps all three, because the audit - // writer records a global-scope change too. + // `sys-setting.scope-options.test.ts` pins the parity. The audit trail + // splits the same way: `sys_setting_audit.scope` keeps `tenant` and + // `user`, and a global-scope change is recorded in + // `sys_platform_setting_audit`. // `global` left with the rung itself (ADR-0131 D7): a global value is // written to `sys_platform_setting`, no writer produces a `global` row // here, and the resolver excludes one from its reads — so the option was a diff --git a/packages/platform-objects/src/system/sys-setting.scope-options.test.ts b/packages/platform-objects/src/system/sys-setting.scope-options.test.ts index 7930152bcd0..ead2f4a268a 100644 --- a/packages/platform-objects/src/system/sys-setting.scope-options.test.ts +++ b/packages/platform-objects/src/system/sys-setting.scope-options.test.ts @@ -17,11 +17,13 @@ // [ADR-0131 D7] Since the global rung moved to the tenant-less // `sys_platform_setting`, this table stores the cascade's OTHER layers: its // option list is the spec enum minus `global`, and that difference is pinned -// as such. The audit trail still records every layer, `global` included. +// as such. The audit trail follows the same split: `sys_setting_audit` keeps +// the tenant and user rungs, `sys_platform_setting_audit` the global one. import { describe, expect, it } from 'vitest'; import { SpecifierScopeSchema } from '@objectstack/spec/system'; import { SysSetting } from './sys-setting.object.js'; import { SysSettingAudit } from './sys-setting-audit.object.js'; +import { SysPlatformSettingAudit } from './sys-platform-setting-audit.object.js'; /** Declared option values of a select field, in declaration order. */ function optionValues(object: unknown, field: string): string[] { @@ -59,13 +61,16 @@ describe('sys_setting.scope — value domain (#6036)', () => { ); }); - it('the audit trail object records EVERY layer — the global rung\'s changes included', () => { - // `buildSettingAuditWriter` writes the changed key's scope, and a - // global-scope change is still a change: the audit option list is the - // full spec enum, not this table's subset. + it('[18.x step 7] the audit trail splits the layers as the values do: global in the sibling, the rest here', () => { + // `buildSettingAuditWriter` routes a global-scope change to + // `sys_platform_setting_audit`, and the upgrade ceremony moved every + // global-rung row an 18.0 install wrote to `sys_setting_audit`. So the + // tenant table's option list is this table's subset, and the sibling's is + // the rung it holds; together they are the full spec enum. expect([...optionValues(SysSettingAudit, 'scope')].sort()).toEqual( - [...SpecifierScopeSchema.options].sort(), + SpecifierScopeSchema.options.filter((s) => s !== 'global').sort(), ); + expect(optionValues(SysPlatformSettingAudit, 'scope')).toEqual(['global']); }); it('keeps `tenant` as the default, and the default is a declared option', () => { diff --git a/packages/plugins/plugin-email/src/email-log-objects.ts b/packages/plugins/plugin-email/src/email-log-objects.ts index 51bcbb5f66c..1e5dafceea8 100644 --- a/packages/plugins/plugin-email/src/email-log-objects.ts +++ b/packages/plugins/plugin-email/src/email-log-objects.ts @@ -15,7 +15,9 @@ * reader cannot recover from the id is WHICH object holds it, so a durable * job carries the object beside the id. A job with no object is one an 18.0 * process published, when `sys_email` was the only log — it reads - * `sys_email` (expand-first: those rows and jobs stay where they are). + * `sys_email`. The upgrade ceremony points the jobs of the identity mail it + * moves at `sys_identity_email` first, so what still reads this way names a + * tenant mail row, which stays in `sys_email`. */ /** The tenant mail log. */ diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index e7545d5d86e..95a4798133f 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -997,9 +997,9 @@ export class EmailServicePlugin implements Plugin { // not grounds to refuse an otherwise healthy boot; the gate above is // where this plugin refuses. // - // Both logs, each on its own: `sys_email` (which keeps every identity - // row an 18.0 install wrote there — expand-first) and its sibling - // `sys_identity_email`. One log failing to sweep does not stop the + // Both logs, each on its own: `sys_email` and its sibling + // `sys_identity_email` (where the upgrade ceremony moved the identity + // rows an 18.0 install wrote to `sys_email`). One log failing to sweep does not stop the // other, and the settled result is their sum. if (persistence) { const svc = this.service; diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index a84360b4471..b6ab043272f 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -24,7 +24,6 @@ import { type LegacyDefinitionCredentialKey, } from './webhook-legacy-cleartext.js'; import { - classifyDeclaredNameHolder, declaredWebhookRefId, type DeclaredWebhook, type DeclaredWebhookReader, @@ -457,9 +456,9 @@ export class AutoEnqueuer { return; } - // [ADR-0131 D2/D6] The declared webhooks, and the 18.0 mirror rows they - // replace. A failed read keeps the previous cache, exactly as a failed - // row read above does: neither a double fire nor a silent loss. + // [ADR-0131 D2/D6] The declared webhooks no row withholds. A failed read + // keeps the previous cache, exactly as a failed row read above does: + // neither a double fire nor a silent loss. const declared = await this.resolveDeclared(); if (!declared) return; @@ -472,7 +471,6 @@ export class AutoEnqueuer { next.set(key, arr); }; for (const row of rows) { - if (declared.mirrorIds.has(String(row?.id))) continue; const sub = this.parseRow(row); if (!sub) continue; // [#7799, #7986] Neither credential is in the row we just read — @@ -517,22 +515,19 @@ export class AutoEnqueuer { } /** - * [ADR-0131 D2/D6] The declared webhooks switched on, as subscriptions, - * and the ids of the 18.0 mirror rows they replace. `undefined` when a read - * failed, so the caller keeps the cache it has. + * [ADR-0131 D2/D6] The declared webhooks switched on, as subscriptions. + * `undefined` when a read failed, so the caller keeps the cache it has. * - * A `sys_webhook` row holding a declared name — any row, active or not — is - * read the way the 18.0 seeder would have treated it on this boot - * (`classifyDeclaredNameHolder`): a pristine mirror is skipped and the - * declaration answers; a customized or administrator-authored row answers - * as itself and the declaration is withheld, said once per name. That is - * what keeps a declared webhook from firing twice on an upgraded database, - * and an administrator's `active: false` on a seeded row in force. + * A `sys_webhook` row holding a declared name — any row, active or not, + * whatever its provenance — answers as itself and the declaration is + * withheld, said once per name (regime C; `declared-webhooks.ts`). That is + * what keeps a declared webhook from firing twice beside a row of its name, + * and a row's `active: false` in force. */ private async resolveDeclared(): Promise< - { subscriptions: CachedSubscription[]; mirrorIds: Set } | undefined + { subscriptions: CachedSubscription[] } | undefined > { - const empty = { subscriptions: [], mirrorIds: new Set() }; + const empty = { subscriptions: [] }; if (!this.declared) return empty; let declared: DeclaredWebhook[]; try { @@ -549,7 +544,7 @@ export class AutoEnqueuer { this.subscriptionsObject, { where: { name: { $in: declared.map((d) => d.webhook.name) } }, - fields: ['id', 'name', 'managed_by', 'customized'], + fields: ['id', 'name'], }, { context: SYSTEM_CTX }, ); @@ -561,12 +556,8 @@ export class AutoEnqueuer { return undefined; } - const mirrorIds = new Set(); const heldByRow = new Set(); - for (const row of Array.isArray(holders) ? holders : []) { - if (classifyDeclaredNameHolder(row) === 'mirror') mirrorIds.add(String(row.id)); - else heldByRow.add(String(row.name)); - } + for (const row of Array.isArray(holders) ? holders : []) heldByRow.add(String(row.name)); const subscriptions: CachedSubscription[] = []; for (const d of declared) { @@ -583,7 +574,7 @@ export class AutoEnqueuer { sub.deploymentWide = true; subscriptions.push(sub); } - return { subscriptions, mirrorIds }; + return { subscriptions }; } /** @@ -613,9 +604,9 @@ export class AutoEnqueuer { } /** - * A declared webhook withheld because a `sys_webhook` row holds its name: - * a seeded row an administrator edited before this release, or a row an - * administrator authored under the name. The row answers; the declaration + * A declared webhook withheld because a `sys_webhook` row holds its name — + * a row that held the name before a package declared it, seeded or + * authored (no write may take a declared name). The row answers; the declaration * does not also fire beside it. Said once per name per process at `warn` — * the row is in force and dispatching (or switched off on purpose), so * nothing claimed is being lost. @@ -625,8 +616,8 @@ export class AutoEnqueuer { this.declarationWithheldReported.add(name); this.logger?.warn?.( `[webhook-auto-enqueuer] declared webhook '${name}' is not dispatched: a ${this.subscriptionsObject} row ` + - 'of the same name answers for it (a seeded copy edited before declared webhooks were read from the ' + - 'registry, or a row authored under the name). The row stays in force as it is. To return to the ' + + 'of the same name answers for it (a row that held the name before a package declared it). The row ' + + 'stays in force as it is. To return to the ' + 'declaration, delete the row; to change the declared webhook, author a copy under a new name and ' + `switch the declared one off (POST /api/v1/security/_activation/webhook/${name} with ` + '{ "enabled": false }).', diff --git a/packages/plugins/plugin-webhooks/src/declared-webhook-name-guard.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhook-name-guard.test.ts index 0b288b7f8a9..bca2a1ebf24 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhook-name-guard.test.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhook-name-guard.test.ts @@ -13,8 +13,8 @@ * declared sharing rules. * * Each refusal below carries its discriminating control: an undeclared name, - * an update that keeps its name (the 18.0 mirror rows an upgraded database - * holds), a system write — so a guard that refused everything goes red too. + * an update that keeps its name (a row that held the name before a package + * declared it), a system write — so a guard that refused everything goes red too. */ import { describe, expect, it } from 'vitest'; diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts index 79ccb30887f..d50867e133e 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts @@ -12,14 +12,16 @@ * 2. **The switch (regime C).** A `sys_metadata_activation` row of type * `webhook` turns a declared webhook off, or on when it shipped inactive, * and a ledger write refreshes the cache in the request that made it. - * 3. **The dual read, on an 18.0-shaped database.** The rows the 18.0 seeder - * wrote stay. A pristine one is ignored in favour of its declaration — one - * delivery, never two; an edited one (`customized: true`) or an admin row - * of the same name answers as itself and the declaration is withheld, so - * an administrator's `active: false` survives the upgrade. The fixture + * 3. **A row holding a declared name, on a certified database.** The + * ownership ceremony settles every seeded copy of a declaration before + * certification; what it leaves is a seeded row no package declares, and + * authored rows. Any row holding a declared name answers as itself and the + * declaration is withheld — one delivery, never two — whatever the row's + * provenance: a seeded row the ceremony left whose name a package declares + * LATER is withheld-by, not skipped (regime C). The fixture * {@link seeded18_0Row} is byte-for-byte the row shape 18.0's seeder wrote - * (its `mapWebhookToRow` plus provenance), which is the shape an upgraded - * database holds. + * (its `mapWebhookToRow` plus provenance), which is the shape such a row + * keeps. */ import { describe, expect, it, vi } from 'vitest'; @@ -461,34 +463,40 @@ describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 }); // --------------------------------------------------------------------------- -// 3. The dual read, on an 18.0-shaped database +// 3. A row holding a declared name, on a certified database // --------------------------------------------------------------------------- -describe('declared webhooks — an 18.0 database keeps its seeded rows, and nothing fires twice', () => { - it('a pristine seeded row is ignored in favour of its declaration: one delivery, from the declaration', async () => { - const wh = declaredWebhook({ secret: 'shh' }); +describe('declared webhooks — a row holding a declared name answers as itself (regime C)', () => { + it('a seeded row the ceremony left, its name declared later, withholds the declaration: one delivery, from the row', async () => { + // The 18.x step 5a leftover: a pristine seeded row no package declared + // at the ceremony, so it stayed. A package now declares its name with + // another URL. The row answers; the declaration is withheld, said once. + const warn = vi.fn(); const engine = newEngine({ - declared: { webhook: [wh] }, - rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)] }, + declared: { webhook: [declaredWebhook({ url: 'https://hooks.example/task-v2', secret: 'shh' })] }, + rows: { sys_webhook: [seeded18_0Row('whk_left', declaredWebhook())] }, }); - const { enqueuer, realtime, calls } = await startEnqueuer(engine); + const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); + await enqueuer.refresh(); await realtime.publish(taskCreated()); await flush(); expect(calls).toHaveLength(1); - expect(calls[0].refId).toBe(declaredWebhookRefId('task_changed')); - expect(calls[0].signingSecret).toBe('shh'); - // Expand first: the row is left exactly where 18.0 put it. - expect(engine.rows['sys_webhook']).toHaveLength(1); + expect(calls[0]).toMatchObject({ refId: 'whk_left', url: 'https://hooks.example/task' }); + const withheld = warn.mock.calls.filter(([m]) => String(m).includes("declared webhook 'task_changed' is not dispatched")); + expect(withheld).toHaveLength(1); expect(engine.writes).toEqual([]); await enqueuer.stop(); }); - it('a pristine seeded row follows its declaration\'s switch: switched off, neither the row nor the declaration fires', async () => { + it('a pristine seeded row switched off keeps the declaration withheld: neither fires, whatever the ledger says', async () => { const wh = declaredWebhook(); const engine = newEngine({ declared: { webhook: [wh] }, - rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)], [LEDGER]: [ledgerRow('task_changed', false)] }, + rows: { + sys_webhook: [seeded18_0Row('whk_left', wh, { active: false })], + [LEDGER]: [ledgerRow('task_changed', true)], + }, }); const { enqueuer, realtime, calls } = await startEnqueuer(engine); await realtime.publish(taskCreated()); @@ -497,37 +505,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth await enqueuer.stop(); }); - it('a pristine seeded row follows a changed declaration — the new URL, as the 18.0 seeder would have rewritten it', async () => { - const engine = newEngine({ - declared: { webhook: [declaredWebhook({ url: 'https://hooks.example/task-v2' })] }, - rows: { sys_webhook: [seeded18_0Row('whk_seeded', declaredWebhook())] }, - }); - const { enqueuer, realtime, calls } = await startEnqueuer(engine); - await realtime.publish(taskCreated()); - await flush(); - expect(calls.map((c) => c.url)).toEqual(['https://hooks.example/task-v2']); - await enqueuer.stop(); - }); - - it('a seeded row an administrator switched off (customized) stays off: the declaration is withheld, said once', async () => { - const warn = vi.fn(); - const wh = declaredWebhook(); - const engine = newEngine({ - declared: { webhook: [wh] }, - rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh, { active: false, customized: true })] }, - }); - const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); - await enqueuer.refresh(); - await realtime.publish(taskCreated()); - await flush(); - - expect(calls).toEqual([]); - const withheld = warn.mock.calls.filter(([m]) => String(m).includes("declared webhook 'task_changed' is not dispatched")); - expect(withheld).toHaveLength(1); - await enqueuer.stop(); - }); - - it('a seeded row an administrator pointed elsewhere (customized) answers as itself — one delivery, to the row\'s URL', async () => { + it('a customized seeded row answers as itself — one delivery, to the row\'s URL', async () => { const wh = declaredWebhook({ isActive: false }); const engine = newEngine({ declared: { webhook: [wh] }, @@ -545,24 +523,12 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth await enqueuer.stop(); }); - it('an admin edit through the provenance stamp turns a pristine seeded row into one that answers as itself', async () => { - const wh = declaredWebhook(); - const engine = newEngine({ - declared: { webhook: [wh] }, - rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)] }, - }); + it('the provenance stamp still records an admin edit of a seeded row — the ceremony\'s reader of `customized`', async () => { + const wh = declaredWebhook({ name: 'retired_hook' }); + const engine = newEngine({ rows: { sys_webhook: [seeded18_0Row('whk_left', wh)] } }); bindWebhookProvenanceStamp(engine as any); - const { enqueuer, realtime, calls } = await startEnqueuer(engine); - - await engine.update('sys_webhook', { id: 'whk_seeded', active: false }, { context: ADMIN_CTX }); + await engine.update('sys_webhook', { id: 'whk_left', active: false }, { context: ADMIN_CTX }); expect(engine.rows['sys_webhook'][0].customized).toBe(true); - await realtime.publish({ type: 'data.record.updated', object: 'sys_webhook', payload: { recordId: 'whk_seeded' } }); - await flush(); - await realtime.publish(taskCreated()); - await flush(); - - expect(calls).toEqual([]); - await enqueuer.stop(); }); it('an administrator-authored row under a declared name answers as itself, and the declaration is withheld', async () => { @@ -583,7 +549,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth await enqueuer.stop(); }); - it('a pristine seeded row whose declaration is gone keeps dispatching, as it did on 18.0', async () => { + it('a seeded row whose name no package declares keeps dispatching, as it did on 18.0', async () => { const engine = newEngine({ rows: { sys_webhook: [seeded18_0Row('whk_orphan', declaredWebhook({ name: 'retired_hook' }))] }, }); @@ -621,14 +587,14 @@ describe('declared webhooks — the redelivery veto', () => { .toMatch(/switched off/); }); - it('judges an 18.0 pristine seeded row\'s old delivery by the declaration that now answers for it', async () => { + it('judges a pristine seeded row holding a declared name by the row, not by the declaration\'s switch', async () => { const wh = declaredWebhook(); const engine = newEngine({ declared: { webhook: [wh] }, - rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)], [LEDGER]: [ledgerRow('task_changed', false)] }, + rows: { sys_webhook: [seeded18_0Row('whk_left', wh)], [LEDGER]: [ledgerRow('task_changed', false)] }, }); - // The row itself still reads active:true; the declaration's switch governs. - expect(await guardFor(engine)({ source: 'webhook', refId: 'whk_seeded' })).toMatch(/switched off/); + // The row reads active:true and the dispatcher reads it as itself, so a replay is allowed. + expect(await guardFor(engine)({ source: 'webhook', refId: 'whk_left' })).toBeUndefined(); }); it('judges a customized seeded row by the row, as before', async () => { diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts index f878e821d4e..ec1e3a3ec0a 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts @@ -11,7 +11,7 @@ * for a declared webhook, and the dispatcher ({@link AutoEnqueuer}) reads the * declaration itself, here, on every cache refresh. `sys_webhook` holds only the * webhooks an organization authors — plus, on a database an 18.0 install - * created, the mirror rows that release's seeder left behind (below). + * created, the seeded rows the ownership ceremony leaves (below). * * ## Where the declaration is read * @@ -56,25 +56,24 @@ * activation door's ADR-0126 §5 posture rule); an organization administrator * is refused there. * - * ## An 18.0 database: the dual read (expand first) + * ## A row holding a declared name: the row answers (regime C) * - * The 18.0 seeder's rows stay where they are; deleting them, and re-sourcing - * their `sys_http_delivery` tails, is the ownership ceremony's step - * (ADR-0131 D10 fate 2). Until then the reader must not fire a declared webhook - * twice, and must not undo what an administrator did to a seeded row. So a - * `sys_webhook` row holding a declared name is judged by what the 18.0 seeder - * would have done with it on this boot ({@link classifyDeclaredNameHolder}): + * A `sys_webhook` row that holds a declared webhook's name — any row, whatever + * its provenance — answers as itself, and the declaration is withheld for the + * whole deployment, reported once per name per process. No write may take a + * declared name ({@link bindDeclaredWebhookNameGuard}), so such a row is one + * that held the name before a package declared it. * - * - **A pristine mirror** (`managed_by` `package` or `platform`, not `customized`): the - * seeder would have overwritten it from the declaration. The row is ignored - * and the declaration answers. - * - **Any other row** (`customized: true`, or `managed_by: 'admin'`): the - * seeder never touched it, and an administrator's `active: false` on a noisy - * webhook survived every redeploy. The row answers as itself and the - * declaration is withheld, reported once per name per process. - * - * A pristine mirror whose declaration is gone is an ordinary row: 18.0 left it - * in place and dispatching, and so does this release. + * The 18.0 seeder's copies are not read apart from that any more. The + * ownership ceremony settles every one of them before a database is certified + * (18.x step 5a, ADR-0131 D10 fate 2): a pristine copy is deleted with its + * deliveries re-sourced to the declaration, a switch-only edit becomes the + * declaration's activation row, any other edit becomes a clone under a new + * name; and the v18 boot guard refuses a database the ceremony has not + * certified. What 5a leaves is a seeded row whose name no installed package + * declares — an ordinary webhook, dispatching as itself, as it did on 18.0. If + * a package declares that name later, the row withholds the declaration like + * any other row: rename or delete the row to let the declaration answer. */ import { WebhookSchema, type WebhookParsed } from '@objectstack/spec/automation'; @@ -254,23 +253,10 @@ export async function readWebhookActivation(engine: unknown, logger?: Logger): P return out; } -/** - * How a `sys_webhook` row that holds a declared webhook's name is read — what - * the 18.0 seeder would have done with it on this boot (see the module doc): - * `'mirror'` — a pristine seeded copy, ignored in favour of the declaration; - * `'row'` — customized or administrator-authored, answering as itself while the - * declaration is withheld. - */ -export function classifyDeclaredNameHolder(row: { managed_by?: unknown; customized?: unknown }): 'mirror' | 'row' { - return isSeededProvenance(row.managed_by) && row.customized !== true ? 'mirror' : 'row'; -} - /** * The provenance values a seeded copy carries: `package` and `platform`, the * same pair the provenance hook stamps `customized` on - * (`webhook-provenance.ts`) and the upgrade ceremony settles. One predicate, - * so the mirror skip, the redeliver guard and the ceremony never disagree on - * which rows are copies of a declaration. + * (`webhook-provenance.ts`) and the upgrade ceremony settles. */ export function isSeededProvenance(managedBy: unknown): boolean { return managedBy === 'package' || managedBy === 'platform'; @@ -319,9 +305,8 @@ export const DECLARED_WEBHOOK_NAME_GUARD_PACKAGE = 'plugin-webhooks:declared-nam /** * [ADR-0131 D6 regime C] Bind the guard above on `sys_webhook` writes: an * insert of a declared name is refused, and so is an update that CHANGES - * `name` onto one; an update that leaves the name alone passes — so an 18.0 - * database's seeded mirror rows, and the rows an administrator customised - * there, stay editable exactly as they were. + * `name` onto one; an update that leaves the name alone passes — so a row that + * held a name before a package declared it stays editable exactly as it was. * * System writes pass: they are the platform's own machinery (the boot sweep, * the ownership ceremony, migrations), never an author taking a name. diff --git a/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts b/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts index 78976c4ab85..b7b3ac7bd25 100644 --- a/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts +++ b/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts @@ -5,26 +5,29 @@ * secrets out of `sys_webhook.definition_json` and into the encrypted * `signing_secret` column. * - * ## Why a sweep and not just the seeder + * ## Which rows it can still find * The 18.0 seeder `bootstrapDeclaredWebhooks` (retired since: declared webhooks * are read from the registry, ADR-0131 D2) re-seeded package-declared rows on - * every boot, so those healed themselves the moment the new mapping landed. The - * rows that did NOT heal are exactly the ones most likely to hold a real - * production key: + * every boot, so those healed themselves the moment the new mapping landed, and + * the ownership ceremony settles every copy of a declaration before a database + * is certified (18.x step 5a). The rows it leaves are the ones this sweep is + * for, and the ones most likely to hold a real production key: * - * - `managed_by: 'admin'` — authored in Setup, never touched by the seeder; - * - `customized: true` — a package row an admin edited, deliberately frozen - * against re-seeding (seed-not-clobber, #3461 / #2909). + * - `managed_by: 'admin'` — authored in Setup, and the ceremony's clones of an + * edited copy (renamed in place, their `definition_json` kept); + * - a seeded row whose name no installed package declares — the ceremony + * leaves it, an ordinary webhook, customized or not. * * Leaving those behind would make this a half-migration: the code path that * created the exposure would be fixed while the exposed values stayed in the * table. So the sweep is keyed off the DATA (does this blob contain a secret?), - * not off provenance. + * not off provenance — which is why the ceremony's settlement narrows nothing + * here: each kind of row it leaves can still carry a blob. * * ## What it is careful about * - **System context.** The provenance hook exempts `isSystem` writes, so - * migrating a package row does not stamp `customized: true` and freeze it - * against future seeding. + * migrating a seeded row does not stamp `customized: true` on it — the sweep + * is the platform's write, not an administrator's edit. * - **Idempotent.** A row whose blob no longer carries a `secret` is skipped, * so the sweep is free on every boot after the first. * - **Fail-closed, per row.** With no CryptoProvider the encrypted write throws diff --git a/packages/plugins/plugin-webhooks/src/redeliver-guard.ts b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts index cfbc9451c59..3f20f367a67 100644 --- a/packages/plugins/plugin-webhooks/src/redeliver-guard.ts +++ b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts @@ -68,16 +68,15 @@ * same questions of the declaration, through the reader the dispatcher uses. * Not declared any more is case 1, switched off (the activation ledger, or a * declared `isActive: false` no ledger row overrides) is case 2. Case 3 cannot - * arise: the signing key is code, held with the declaration. A pristine 18.0 - * mirror row (`classifyDeclaredNameHolder`) whose name is still declared is - * judged by its declaration too, because the dispatcher reads the declaration - * in its place: replaying one of its old deliveries must answer to the switch - * that now governs the webhook, not to a row nothing reads. + * arise: the signing key is code, held with the declaration. A delivery whose + * `ref_id` names a `sys_webhook` row is judged by that row, whatever name it + * holds: the dispatcher reads such a row as itself (regime C), and the + * ownership ceremony re-sourced every pristine 18.0 copy's deliveries to + * `declared:NAME` before it deleted the copy. */ import type { IDataEngine } from '@objectstack/spec/contracts'; import { - classifyDeclaredNameHolder, declaredWebhookNameOfRefId, type DeclaredWebhookReader, } from './declared-webhooks.js'; @@ -140,11 +139,6 @@ export function createWebhookRedeliverGuard( ); } - if (declared && classifyDeclaredNameHolder(subscription) === 'mirror' && typeof subscription.name === 'string') { - const declaration = await declared.declaredWebhookNamed(subscription.name); - if (declaration) return judgeDeclared(declared, subscription.name, declaration); - } - // [#22804] Case 2: the off switch. `=== true` is the dispatcher's own // predicate (`AutoEnqueuer` loads `{ active: true }`), so a replay is // refused for exactly the rows automatic dispatch skips — `false`, and @@ -193,16 +187,15 @@ export function createWebhookRedeliverGuard( } /** - * The guard's answer for a delivery a declared webhook produced, or a pristine - * 18.0 mirror the declaration now answers for (see the module doc). A lookup - * that throws propagates: "we could not check" never reads as "allowed". + * The guard's answer for a delivery a declared webhook produced (see the module + * doc). A lookup that throws propagates: "we could not check" never reads as + * "allowed". */ async function judgeDeclared( declared: DeclaredWebhookReader | undefined, name: string, - known?: Awaited>, ): Promise { - const declaration = known ?? (declared ? await declared.declaredWebhookNamed(name) : null); + const declaration = declared ? await declared.declaredWebhookNamed(name) : null; if (!declaration) { return ( `the declared webhook '${name}' this delivery belongs to is no longer declared by the application, so ` diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 1e3bf6397b9..0cd912c9a1c 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -17,10 +17,11 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * webhooks })` / `defineWebhook()`), written by the 18.0 boot seeder * `bootstrapDeclaredWebhooks`. That seeder is retired: a declared webhook * is read from the registry (`declared-webhooks.ts`) and no boot writes - * such a row any more. The rows an 18.0 install left stay until the - * ownership ceremony deletes them; meanwhile a pristine one is ignored in - * favour of its declaration, and one an administrator edited (`customized: - * true`) answers as itself. + * such a row any more. The ownership ceremony settles the copies an 18.0 + * install left under a declared name before the database is certified; + * what stays is a row whose name no package declares, which dispatches as + * itself. A row holding a declared name answers as itself, and the + * declaration is withheld (regime C). * * One row per `name`. This plugin's {@link AutoEnqueuer} loads active rows, * and the declared webhooks, on boot + on `sys_webhook:changed` events, and turns matching `data.record.*` @@ -271,8 +272,9 @@ export const SysWebhook = ObjectSchema.create({ // engine strips them from non-system payloads (forge/clear-proof), while // the provenance stamp hook writes with isSystem (as the retired 18.0 seeder // did). Deliberately NOT a write gate: webhooks are a first-class admin - // authoring/tuning surface — admins may edit or deactivate a package row, - // which then answers as itself rather than for its declaration. + // authoring/tuning surface — admins may edit or deactivate a package row; + // `customized` records that for the ownership ceremony, which settles an + // edited copy of a declaration as a clone rather than deleting it. managed_by: Field.select( ['platform', 'package', 'admin'], { diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 1cd063f8b8b..737ab9aa911 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -307,8 +307,9 @@ export class WebhookOutboxPlugin implements Plugin { * `sys_webhook` row for one, and the dispatcher, the redelivery veto and the * activation door read the declaration through one * {@link DeclaredWebhookSource}, bound here to the `declaredWebhooks` slot. - * The 18.0 seeder's rows stay until the ownership ceremony deletes them; - * `declared-webhooks.ts` says how they are read meanwhile. + * The ownership ceremony settles the 18.0 seeder's rows before a database + * is certified; `declared-webhooks.ts` says how a row holding a declared + * name is read. * * Gated on the DATA ENGINE alone — deliberately independent of the * auto-enqueue dispatch prerequisites (realtime + messaging): the write @@ -322,9 +323,9 @@ export class WebhookOutboxPlugin implements Plugin { ctx.logger.warn?.('[webhook] sys_webhook write doors and the declared-webhook reader not bound — no data engine available'); return; } - // Bind the provenance stamp so an admin edit freezes a seeded row: an - // 18.0 mirror an administrator edits stops being one, and answers as - // itself rather than for its declaration (`classifyDeclaredNameHolder`). + // Bind the provenance stamp so an admin edit of a seeded row is + // recorded: the ownership ceremony keeps an edited copy as a clone + // rather than deleting it (`webhook-provenance.ts`). this.boundEngine = engine; bindWebhookProvenanceStamp(engine as any, ctx.logger as any); // [#8566] And the headers_secret shape gate, BEFORE the sweep below @@ -347,9 +348,9 @@ export class WebhookOutboxPlugin implements Plugin { // withhold the declaration for the whole deployment, so no non-system // write may take one (409 `RESOURCE_CONFLICT`). bindDeclaredWebhookNameGuard(engine as any, this.declaredSource); - // [#7799] Then heal the rows that still hold a key in cleartext: the - // ones an administrator authored or edited, and any 18.0 seeded row the - // seeder had not yet rewritten. + // [#7799] Then heal the rows that still hold a key in cleartext — the + // rows the ownership ceremony leaves: authored ones, clones, and seeded + // rows no package declares. try { await migrateLegacyWebhookSecrets(engine, ctx.logger as any); } catch (err: any) { diff --git a/packages/plugins/plugin-webhooks/src/webhook-provenance.ts b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts index 61166dc41c7..d45aa447389 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-provenance.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts @@ -6,10 +6,15 @@ * `sys_webhook` is RECORD-AUTHORITATIVE: a row — including any admin tuning such * as flipping a noisy webhook to `active: false` — is the authority. A * `package` row is a copy of a declared webhook the 18.0 seeder - * (`bootstrapDeclaredWebhooks`, retired: ADR-0131 D2) wrote; the dispatcher - * ignores a pristine one in favour of its declaration and lets one marked - * `customized` answer as itself (`classifyDeclaredNameHolder`), so this hook is - * the half that DETECTS the admin edit: any non-system update touching a + * (`bootstrapDeclaredWebhooks`, retired: ADR-0131 D2) wrote. The dispatcher no + * longer reads `customized`: any row holding a declared name answers as itself + * (regime C, `declared-webhooks.ts`). Its reader is the ownership ceremony + * (18.x step 5a), which settles a seeded row under a declared name by this + * column — a pristine copy is deleted, an edited one kept as a clone — and + * which runs again from the start when a reviewed plan supersedes its + * checkpoint. A seeded row the ceremony leaves (its name declared by no + * package) can be edited and then declared later, so this hook stays the half + * that DETECTS the admin edit: any non-system update touching a * `package`/`platform`-seeded row stamps `customized: true` onto the payload. * * Why a data hook (and not a write gate or the REST layer), verbatim to the @@ -43,9 +48,9 @@ * stamp to the rest - which is what makes a row-conditioned rewrite safe to * leave here. * - * Declining on a predicate write was weighed and REJECTED (#15302): the seeder - * skips only rows marked `customized`, so the rows left unstamped would be - * exactly the ones the next boot clobbers - discarding the operator edit this + * Declining on a predicate write was weighed and REJECTED (#15302): the + * ceremony keeps only rows marked `customized`, so the rows left unstamped + * would be exactly the ones it deletes - discarding the operator edit this * stamp exists to remember. */ diff --git a/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts b/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts index 312ab07145c..5fd964c62e4 100644 --- a/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts +++ b/packages/qa/dogfood/test/settings-audit-global-rung-sibling.dogfood.test.ts @@ -2,7 +2,8 @@ // // [ADR-0131 D7 · 18.x step 2] The settings audit trail's global rung is the // deployment-level `sys_platform_setting_audit` (ruling record 6257510973, -// Q4 A) — the EXPAND step, on the booted showcase, composed the way `os serve` +// Q4 A) — the expand step, and step 7's retirement of the old home, on the +// booted showcase, composed the way `os serve` // composes it: the audit plugin and the ownership boot guard. // // Two boots over one SQLite file: @@ -12,17 +13,12 @@ // the global setting's history row is in the sibling, with no // organization column; the tenant setting's stays in `sys_setting_audit`, // stamped. -// B. The same database with an 18.0-shaped row added — what an 18.0 install -// wrote: the global setting's history row in `sys_setting_audit`, stamped -// with the Default Organization. HEAD boots over it (the boot guard does -// not refuse); the global rung's history is served from both tables, -// selected by SCOPE in `sys_setting_audit` (the 18.0 row is stamped, so a -// NULL-organization selector would miss it); and once the stack stops the -// 18.0 row is still there, stamped (expand-first: only the upgrade -// ceremony moves it). -// -// This file builds its own 18.0-shaped row; the 18.x line's upgrade harness -// boots a whole 18.0 database, and this step runs it once it lands. +// B. One home (18.x step 7). The upgrade ceremony moves every global-rung +// row an 18.0 install wrote to `sys_setting_audit` before the database is +// certified, so that table declares no `global` scope any more. Booted +// again over the same file, the engine refuses a global-scope row there +// — through the system context, the writer's own — and admits the same +// row at the tenant rung (the control). // // NOT eligible for the shared showcase project: it writes org-wide settings and // boots twice over its own file. @@ -58,7 +54,7 @@ function tableRows(file: string, table: string): Array> { } } -describe('[18.x step 2] the settings audit trail\'s global rung is deployment-level — expand-first', () => { +describe('[18.x step 2] the settings audit trail\'s global rung is deployment-level', () => { const dir = mkdtempSync(join(tmpdir(), 'dogfood-expand-18x-')); const file = join(dir, 'expand.db'); let stack: VerifyStack | undefined; @@ -108,22 +104,14 @@ describe('[18.x step 2] the settings audit trail\'s global rung is deployment-le ]); }); - describe('B. the same database with an 18.0-shaped global audit row', () => { - const LEGACY_AUDIT = 'expand18x_legacy_global_audit'; + describe('B. one home: sys_setting_audit refuses a global-scope row', () => { let bootError: unknown; + const auditRow = (scope: string) => ({ + namespace: 'ai', key: 'gateway_api_key', scope, action: 'set', source: 'api', + new_hash: 'hmac-sha256:18.0', encrypted: true, created_at: '2026-10-19T00:00:00.000Z', + }); beforeAll(async () => { - // What an 18.0 install wrote: the global rung's history in - // sys_setting_audit, stamped with the Default Organization. - const db = new DatabaseSync(file); - try { - db.prepare( - 'INSERT INTO "sys_setting_audit" ("id", "organization_id", "namespace", "key", "scope", "action", "source", "new_hash", "encrypted", "created_at") ' - + 'VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - ).run(LEGACY_AUDIT, organizationId, 'ai', 'gateway_api_key', 'global', 'set', 'api', 'hmac-sha256:18.0', 1, '2026-10-19T00:00:00.000Z'); - } finally { - db.close(); - } try { stack = await boot(file); } catch (error) { @@ -131,35 +119,27 @@ describe('[18.x step 2] the settings audit trail\'s global rung is deployment-le } }, 240_000); - it('boots: nothing about the sibling reads as the v17 shape', () => { + it('boots again over the same file', () => { expect(bootError, String((bootError as Error | undefined)?.stack ?? bootError)).toBeUndefined(); expect(stack).toBeDefined(); }); - it('the global rung\'s history is served from both tables: the 18.0 row selected by scope, and the new one', async () => { + it('control: the same row at the tenant rung lands', async () => { const ql = await stack!.kernel.getServiceAsync('objectql'); - const where = { namespace: 'ai', key: 'gateway_api_key', scope: 'global' }; - const legacy = rowsOf(await ql.find('sys_setting_audit', { where }, SYSTEM)); - const current = rowsOf(await ql.find('sys_platform_setting_audit', { where }, SYSTEM)); - expect(legacy.map((r) => r.id)).toEqual([LEGACY_AUDIT]); - expect(legacy[0].organization_id).toBe(organizationId); - expect(current).toHaveLength(1); - - // And through the data API, as the platform admin reads each grid. - const tok = await stack!.signIn(); - for (const [object, id] of [['sys_setting_audit', LEGACY_AUDIT], ['sys_platform_setting_audit', String(current[0].id)]] as const) { - const res = await stack!.apiAs(tok, 'GET', `/data/${object}`); - expect(res.status, object).toBe(200); - expect(rowsOf(await res.json()).map((r: any) => r.id), object).toContain(id); - } + await ql.insert('sys_setting_audit', auditRow('tenant'), SYSTEM); + const where = { namespace: 'ai', key: 'gateway_api_key', scope: 'tenant' }; + expect(rowsOf(await ql.find('sys_setting_audit', { where }, SYSTEM))).toHaveLength(1); }); - it('the 18.0 row keeps its place and its organization once the stack stops — nothing moved or re-stamped', async () => { + it('a global-scope row is refused on scope, and the table still holds none', async () => { + const ql = await stack!.kernel.getServiceAsync('objectql'); + await expect(ql.insert('sys_setting_audit', auditRow('global'), SYSTEM)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: [expect.objectContaining({ field: 'scope' })], + }); await stack?.stop(); stack = undefined; - expect(tableRows(file, 'sys_setting_audit').find((r) => r.id === LEGACY_AUDIT)).toEqual( - expect.objectContaining({ organization_id: organizationId, scope: 'global' }), - ); + expect(tableRows(file, 'sys_setting_audit').filter((r) => r.scope === 'global')).toEqual([]); }); }); }); diff --git a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts index 93a4f35712a..8bb069a3c34 100644 --- a/packages/services/service-settings/src/settings-deployment-level-expand.test.ts +++ b/packages/services/service-settings/src/settings-deployment-level-expand.test.ts @@ -16,12 +16,12 @@ * - **The new write shape.** A global-rung audit row goes to * `sys_platform_setting_audit`, with no organization; a tenant-rung row * stays in `sys_setting_audit`, stamped. - * - **The dual read, on 18.0-shaped rows.** An 18.0 install wrote its - * global-rung audit rows into `sys_setting_audit`, stamped with the Default - * Organization. The global rung's history is selected by SCOPE in that - * table (never by a NULL organization, which those rows do not have) beside - * the sibling; nothing re-stamps or moves them — that is the upgrade - * ceremony's. + * - **No second home (18.x step 7).** An 18.0 install wrote its global-rung + * audit rows into `sys_setting_audit`, stamped with the Default + * Organization; the upgrade ceremony moves them to the sibling before the + * database is certified. So `sys_setting_audit` declares no `global` scope, + * and the engine refuses a row that claims it: the global rung's history is + * the sibling alone. */ import { describe, it, expect } from 'vitest'; @@ -202,24 +202,30 @@ describe('[18.x expand · Q4 A] the settings audit trail\'s new write shape, und }); }); -describe('[18.x expand · Q4 A] the dual read, on 18.0-shaped rows', () => { - it('the global rung\'s history reads both tables: the 18.0 row in sys_setting_audit and the new one in the sibling', async () => { - const { engine, svc, seed } = await boot(); - // The 18.0 shape: a global-rung audit row in sys_setting_audit, stamped - // with the Default Organization by the 18.0 engine. - seed('sys_setting_audit', { - id: 'aud_18', organization_id: DEFAULT_ORG, namespace: 'relay_probe', key: 'api_key', scope: 'global', - action: 'set', source: 'api', actor_id: null, encrypted: true, new_hash: 'hmac-sha256:18', - created_at: '2026-10-19T00:00:00.000Z', +describe('[18.x step 7] the global rung\'s history has one home: sys_setting_audit refuses a global-scope row', () => { + const auditRow = (scope: string) => ({ + namespace: 'relay_probe', key: 'api_key', scope, action: 'set', source: 'api', encrypted: true, + new_hash: 'hmac-sha256:18', created_at: '2026-10-19T00:00:00.000Z', + }); + + it('control: the same row at the tenant rung lands', async () => { + const { engine, rows } = await boot(); + await engine.insert('sys_setting_audit', auditRow('tenant'), SYS); + expect(rows('sys_setting_audit')).toEqual([expect.objectContaining({ scope: 'tenant' })]); + }); + + it('a global-scope row is refused before the driver sees it, and the writer\'s global row lands in the sibling', async () => { + const { engine, svc, rows, writesTo } = await boot(); + await expect(engine.insert('sys_setting_audit', auditRow('global'), SYS)).rejects.toMatchObject({ + code: 'VALIDATION_FAILED', + fields: [expect.objectContaining({ field: 'scope' })], }); - await svc.setMany('relay_probe', { api_key: 're_456' }, { tenantId: DEFAULT_ORG, userId: undefined }); + expect(writesTo('sys_setting_audit')).toEqual([]); - const where = { namespace: 'relay_probe', key: 'api_key', scope: 'global' }; - const legacy = (await engine.find('sys_setting_audit', { where }, SYS)) as any[]; - const current = (await engine.find('sys_platform_setting_audit', { where }, SYS)) as any[]; - expect(legacy).toEqual([expect.objectContaining({ id: 'aud_18', organization_id: DEFAULT_ORG, new_hash: 'hmac-sha256:18' })]); - expect(current).toHaveLength(1); - expect(current[0]!.organization_id ?? null).toBeNull(); - expect(current[0]!.new_hash).not.toBe('hmac-sha256:18'); + await svc.setMany('relay_probe', { api_key: 're_456' }, { tenantId: DEFAULT_ORG, userId: undefined }); + expect(rows('sys_setting_audit')).toEqual([]); + expect(rows('sys_platform_setting_audit')).toEqual([ + expect.objectContaining({ namespace: 'relay_probe', key: 'api_key', scope: 'global' }), + ]); }); }); diff --git a/packages/services/service-settings/src/settings-service-plugin.ts b/packages/services/service-settings/src/settings-service-plugin.ts index 958f95af684..f42777f06d1 100644 --- a/packages/services/service-settings/src/settings-service-plugin.ts +++ b/packages/services/service-settings/src/settings-service-plugin.ts @@ -564,9 +564,10 @@ export const PLATFORM_SETTING_AUDIT_OBJECT = 'sys_platform_setting_audit'; * `sys_setting_audit` — the split `sys_setting` / `sys_platform_setting` makes * for the values themselves. * - * An 18.0 install wrote global-rung rows into `sys_setting_audit`; they stay - * there until the upgrade ceremony moves them, so a reader of the global rung's - * history reads both tables. + * An 18.0 install wrote global-rung rows into `sys_setting_audit`; the upgrade + * ceremony moves them to the sibling before the database is certified, so the + * global rung's history is the sibling alone, and `sys_setting_audit` declares + * no `global` scope. */ export function settingAuditObjectFor(scope: string): string { return scope === 'global' ? PLATFORM_SETTING_AUDIT_OBJECT : SETTING_AUDIT_OBJECT; diff --git a/packages/spec/liveness/webhook.json b/packages/spec/liveness/webhook.json index 44531971175..0ffb012b813 100644 --- a/packages/spec/liveness/webhook.json +++ b/packages/spec/liveness/webhook.json @@ -4,7 +4,7 @@ "props": { "name": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource (`WebhookSchema.parse` of each registry item; the first registration of a name holds it); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`name: wh.name`, and `declaredWebhookRefId(wh.name)` as the subscription id and every delivery's `ref_id`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#resolveDeclared (the name finds the `sys_webhook` row holding it, which an 18.0 database may still carry, and `classifyDeclaredNameHolder` decides which answers); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`name: (row.name as string) ?? row.id` — the dispatcher's dedup/log key)", + "evidence": "packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource (`WebhookSchema.parse` of each registry item; the first registration of a name holds it); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`name: wh.name`, and `declaredWebhookRefId(wh.name)` as the subscription id and every delivery's `ref_id`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#resolveDeclared (the name finds any `sys_webhook` row holding it; such a row answers as itself and the declaration is withheld); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`name: (row.name as string) ?? row.id` — the dispatcher's dedup/log key)", "note": "Was dead pre-bridge (admin-CRUD-only). Now fed by authoring. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", "verifiedAt": "2026-10-19" },