Skip to content

Commit bd6dab0

Browse files
refactor(plugin-webhooks,platform-objects)!: 18.x step 7c: retire the step 1–3 dual reads (declared-webhook mirror skip, sys_setting_audit global scope) (#24836)
Part of #24583 (step 7, part 7c: the step 1–3 dual reads). #24583 remains open: parts 7a and 7b land separately. Clause-②: yes (narrowing) **Base.** Branched from `origin/main` merged with `claude/issue-24581-ceremony-open` (#24681, steps 5+6), with merge commits only. #24681 has merged (`12d14765c`), and `main` was merged in again at `78f99c34c`. The one conflict was my edit of `declared-webhooks.ts` against the squash of the same lines; plugin-webhooks is byte-identical between #24681's head `c6f775cbd` and `main`. The deferred `migration.mdx` regeneration is discharged at `8f6bf26e6` and equals `main`. The diff against `main` is 26 files, +252 / −291, with no governed path. ## What changes **plugin-webhooks: step 1's dual read retires.** - `classifyDeclaredNameHolder` is deleted. So are the dispatcher's mirror skip (`AutoEnqueuer`'s `mirrorIds`) and the redeliver guard's mirror branch. - Any `sys_webhook` row that holds a declared webhook's name now answers as itself, and the declaration is withheld, reported once per name (regime C). The dispatcher's holder read asks for `id` and `name` only. - A delivery whose `ref_id` names a row is judged by that row. - Docblocks are rewritten in `declared-webhooks.ts`, `auto-enqueuer.ts`, `redeliver-guard.ts`, `sys-webhook.object.ts`, `webhook-outbox-plugin.ts` and `webhook-provenance.ts`. - `bindWebhookProvenanceStamp` is **kept**, not deleted (measured below: `customized` still has a reader). - `migrateLegacyWebhookSecrets`: only its docblock narrows (measured below: no code narrowing is possible). **Settings audit: step 2.** - `sys_setting_audit.scope` no longer declares `global`, so the engine refuses a global-scope row there (`VALIDATION_FAILED` on `scope`). - The four `platform-objects` translation bundles and the es-ES source hashes are regenerated (`node scripts/check-i18n-bundles.mjs --write`, one leaf each). - The "history reads both tables" docblocks are fixed in `settings-service-plugin.ts` and `sys-platform-setting-audit.object.ts`. **Identity mail: step 3, docblocks only.** `sys-identity-email.object.ts`, `sys-email.object.ts`, `email-log-objects.ts` and `email-plugin.ts` now say the ceremony moved the rows. Nothing is removable: - The body read gate on `sys_email` stays, because the move selects rows by shape. - A mail job with no object still reads `sys_email`, because a queued tenant mail row stays there. **Pins rewritten to the new rule, none weakened:** - `declared-webhooks.test.ts` §3 and its redelivery case. - `sys-setting.scope-options.test.ts`: the audit table's options are the spec enum minus `global`, and the sibling's are `['global']`. - `settings-deployment-level-expand.test.ts`: the dual-read case becomes a refusal pin plus a tenant-rung control. - The es-ES echo ledger: 45 → 44 echoes. - `settings-audit-global-rung-sibling.dogfood.test.ts`: part B now boots over the same file and pins the refusal, with its control. ## Regime C, recorded and pinned Step 5a leaves a seeded row whose name no installed package declares, and reports it. If a package declares that name **later**, the row now withholds the declaration and answers as itself. Before, it was skipped as a pristine mirror. The remedy is to rename or delete the row. Pinned by `a seeded row the ceremony left, its name declared later, withholds the declaration` (`declared-webhooks.test.ts`). ## Premise measurements ("after certification") - **Certified database, from the 18.0→HEAD harness after the ceremony** (green on 8f6bf26). - Every `sys_webhook` copy under a declared name is deleted, and its deliveries are re-sourced to `declared:NAME`. - `SELECT COUNT(*) FROM sys_setting_audit WHERE scope = 'global'` returns 0. - Both assertions sit in `upgrade-18x-harness.dogfood.test.ts`. - **Fresh birth-marked install.** R1 below, after a global setting write through the settings door: - `sys_setting_audit`: 1 row, 0 global. - `sys_platform_setting_audit`: 2 rows, 2 global. - `sys_webhook`: 0 rows. - The settings writer sends a global-scope entry to the sibling (`settingAuditObjectFor`). - **Walled boot.** The same reading under `isolated` with the real `@objectstack/organizations`: 1/0, 2/2, 0. - **`customized` still has a live reader: the ceremony.** `runDeclaredWebhooks` in `organization-ownership-apply.ts` settles a seeded row by it: a pristine copy is deleted, an edited one is kept as a clone. The ceremony re-runs from the start when a superseding reviewed plan replaces its checkpoint. A seeded row 5a kept can be edited and then declared later, so dropping the stamp would let that re-run delete an administrator's edit. "Once nothing reads `customized`" does not hold, so the stamp stays and its docblock names the ceremony as its reader. - **`migrateLegacyWebhookSecrets`.** What 5a leaves: - authored rows; - the ceremony's clones, which keep their `definition_json`; - seeded rows no package declares. Any of these can still carry a cleartext blob, and the sweep is keyed on the data. So the code stays, and the docblock now names exactly these rows. - **`no-organization-object`.** The one shipped composition with no organization object is `examples/embed-objectql`, a lean ObjectQL embedding. It uses neither webhooks nor settings, so none of 7c's readers is on its path. Every `objectstack.config.ts` app composes plugin-auth, which registers `sys_organization`. The `os init` output was not measured. ## Proof (head 8f6bf26 unless stated) - **R1.** `pnpm dev -- --fresh -p 38963`: health 200, seeded-admin sign-in 200, 0 error lines in the boot log. Readings above. - **R2 + R3.** - `upgrade-18x-harness.dogfood.test.ts`: 8/8. - `organization-ownership-boot-refusal-order.dogfood.test.ts` passes. - `identity-mail-read-gate.dogfood.test.ts` uses `ceremoniedFixture` and passes. - The six-file dogfood run totals 50/50, together with `settings-audit-global-rung-sibling`, `webhook-materialization` and `wall-declared-webhook-deployment-wide`. - **R4.** - `OS_TENANCY_POSTURE=isolated` `os dev --fresh --seed-admin`, run from a scratch app that re-exports the showcase config and declares the real `@objectstack/organizations`: health 200, sign-in 200, 0 ERROR lines. The banner lists the `Organizations` plugin, and the walled membership-policy gate was satisfied with `invite-only`. - Step 4's 7 walled e2e cases (`OS_TEST_TIERS=nightly`, `serve-organizations-host-resolution` + `serve-organizations-mount-failure`): 7/7. - **Suites.** - plugin-webhooks 222/222 (21 files). - service-settings 895/895. - platform-objects 1199/1199. - plugin-email 571/571. - `typecheck` exits 0 for all four. `check-i18n-bundles`: all bundles in sync. - **Gates.** - `node scripts/pm/dispatch-gates.mjs --commands` on 8f6bf26 derives 77 commands over the 26 paths. All 77 ran with exit codes captured before any pipe, and all 77 exit 0. `--ran` reports 77 run, 0 NOT-MEASURED, a derived zero. - Earlier, before #24681 landed, the derivation over the union diff gave 185 commands. 184 exited 0. `check-empty-changeset` flagged only #24681's own changeset edits. - `check-platform-checklist --changed-from origin/main`: exit 0, 0 anchors lost. - `check-governed-merges --branch claude/issue-24583-7c-dual-reads`: 0 of 26 paths on the register, 543 changed lines, under the human-merge line. - `pnpm --filter @objectstack/spec check:generated`: up to date after the merge's `gen:docs` discharge. - **Ablation of the main pin.** Through `scripts/ablation-replace.mjs`, the mirror skip was put back into `AutoEnqueuer.resolveDeclared`: a pristine `package` row is not counted as a holder. The anchor went 1 → 0 and the blob changed. `declared-webhooks.test.ts` went red, 2 failed / 20 passed: the regime-C pin and the switched-off-row pin. After the restore, `git diff HEAD` was empty. The test imports the source by relative path, so no `dist` rebuild was involved. ## NOT MEASURED - `os init` output reaching `no-organization-object`: out of 7c's readers' reach, not booted. ## Acceptance notes - Under `isolated`, the showcase's seed prints `[tenant-audit]` warnings ("bulkCreate on tenant-scoped object ... without options.tenantId"). They exist before this change and are not touched here. --- _Generated by [Claude Code](https://claude.ai/code/session_01KMpT5c9dcX9kj3rBBWaki8)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4be7281 commit bd6dab0

29 files changed

Lines changed: 259 additions & 297 deletions

‎.changeset/24529-declared-webhooks-from-registry.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,9 @@ What changes:
2222

2323
On a database an 18.0 install created (expand first — nothing is moved or deleted):
2424

25-
- 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.
25+
- 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.
2626
- 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.
2727
- A seeded row whose declaration was removed keeps delivering, as it did on 18.0.
28-
- 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.
28+
- Redelivering an old delivery of a pristine seeded copy is judged by the declaration, because the ceremony re-points that delivery at `declared:<name>`; a webhook switched off through the ledger is not re-sent.
2929

3030
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`).
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-webhooks': minor
3+
'@objectstack/platform-objects': minor
4+
---
5+
6+
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`.
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No key of any metadata schema is removed, renamed or re-shaped, and no public export, type or error code is removed or renamed. Two things narrow at run time. The dispatcher no longer skips a pristine seeded webhook row in favour of its declaration (an internal reader, never exported). A stored select value is refused on one platform object. Both apply only to rows the v18 ownership ceremony settles or moves before a database is certified, and the v18 boot guard refuses a database it has not certified. -->
11+
12+
**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.
13+
14+
- **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.
15+
- **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`.
16+
- **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.

‎packages/platform-objects/src/apps/translations/en.objects.generated.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3411,7 +3411,6 @@ export const enObjects: NonNullable<TranslationData['objects']> = {
34113411
label: "Scope",
34123412
help: "Cascade layer the row was written to.",
34133413
options: {
3414-
global: "Global",
34153414
tenant: "Tenant",
34163415
user: "User"
34173416
}

‎packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3411,7 +3411,6 @@ export const esESObjects: NonNullable<TranslationData['objects']> = {
34113411
label: "Ámbito",
34123412
help: "Capa de cascada en la que se escribió la fila.",
34133413
options: {
3414-
global: "Global",
34153414
tenant: "Inquilino",
34163415
user: "Usuario"
34173416
}

‎packages/platform-objects/src/apps/translations/es-ES.source-hashes.generated.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,6 @@ export const esESGeneratedSourceHashes: Readonly<Record<string, string>> = {
5757
"objects.sys_secret.fields.id.label": "00b0385c9c152888",
5858
"objects.sys_setting_audit.fields.actor_id.label": "b155813f8a7f06e3",
5959
"objects.sys_setting_audit.fields.id.label": "00b0385c9c152888",
60-
"objects.sys_setting_audit.fields.scope.options.global": "5e377106508d2ecd",
6160
"objects.sys_setting_audit.fields.source.options.api": "4baeeff968e192d1",
6261
"objects.sys_setting_audit.fields.source.options.ui": "95caad102a5c97ba",
6362
"objects.sys_sso_provider._actions.register_saml_provider.params.identifierFormat.placeholder": "5945ff9a6b644855",

‎packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3411,7 +3411,6 @@ export const jaJPObjects: NonNullable<TranslationData['objects']> = {
34113411
label: "スコープ",
34123412
help: "行が書き込まれたカスケードレイヤー。",
34133413
options: {
3414-
global: "グローバル",
34153414
tenant: "テナント",
34163415
user: "ユーザー"
34173416
}

‎packages/platform-objects/src/apps/translations/objects-es-es-echo-decisions.test.ts‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,8 +116,6 @@ const SAME_WORD_ERROR =
116116
'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.';
117117
const SAME_WORD_ACTOR =
118118
'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.';
119-
const SAME_WORD_GLOBAL =
120-
'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.';
121119
const SAME_WORD_VARIABLES =
122120
'Spanish spells the word exactly as English (las variables); the authored description of this same object already says (asunto + contenido + variables).';
123121
const BUNDLE_LOANWORD =
@@ -180,7 +178,6 @@ const DECISIONS: readonly Decision[] = [
180178
echo('sys_notification.fields.actor_id.label', 'Actor', SAME_WORD_ACTOR),
181179
echo('sys_metadata_audit.fields.actor.label', 'Actor', SAME_WORD_ACTOR),
182180
echo('sys_setting_audit.fields.actor_id.label', 'Actor', SAME_WORD_ACTOR),
183-
echo('sys_setting_audit.fields.scope.options.global', 'Global', SAME_WORD_GLOBAL),
184181
echo('sys_email_template.fields.variables_json.label', 'Variables (JSON)', SAME_WORD_VARIABLES),
185182

186183
echo('sys_oauth_access_token.fields.token.label', 'Token', BUNDLE_LOANWORD),
@@ -239,13 +236,16 @@ function undeclaredEchoes(rows: readonly Decision[]): string[] {
239236
describe('#20493 es-ES — the ledger itself (controls before verdicts)', () => {
240237
// [ADR-0131 D7] 47 → 46 echoes: `sys_setting.scope` no longer declares the
241238
// `global` option (the rung moved to `sys_platform_setting`), so its leaf left
242-
// the catalog with it. `sys_setting_audit.scope`'s `global` option stays.
239+
// the catalog with it.
243240
// [ADR-0131 D13] 46 → 45: `sys_view_definition` retired as inert, and its
244241
// bare `ID` leaf left the catalog with the object.
245-
it('is the size it claims: 3 pinned translations and 45 declared echoes, no path twice', () => {
246-
expect(DECISIONS.length).toBe(48);
242+
// [18.x step 7] 45 → 44: `sys_setting_audit.scope` no longer declares the
243+
// `global` option either (the ceremony moved those rows to
244+
// `sys_platform_setting_audit`), so its leaf left the catalog too.
245+
it('is the size it claims: 3 pinned translations and 44 declared echoes, no path twice', () => {
246+
expect(DECISIONS.length).toBe(47);
247247
expect(DECISIONS.filter((d) => d.verdict === 'translate').length).toBe(3);
248-
expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(45);
248+
expect(DECISIONS.filter((d) => d.verdict === 'echo').length).toBe(44);
249249
expect(new Set(DECISIONS.map((d) => d.path)).size).toBe(DECISIONS.length);
250250
});
251251

‎packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3411,7 +3411,6 @@ export const zhCNObjects: NonNullable<TranslationData['objects']> = {
34113411
label: "范围",
34123412
help: "该记录写入的级联层。",
34133413
options: {
3414-
global: "全局",
34153414
tenant: "租户",
34163415
user: "用户"
34173416
}

‎packages/platform-objects/src/audit/sys-email.object.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ export const SysEmail = ObjectSchema.create({
100100
// on read for a caller without it). An 18.0 install wrote its identity
101101
// mail — password reset, account invitation, verification, magic link,
102102
// email-change notice — into this log, stamped with the organization, and
103-
// those bodies carry sign-in and reset links. The rows stay here until the
104-
// 18.x ceremony moves them, and no column tells them apart from tenant mail
105-
// reliably: a magic link carries no `related_object`, and the log has no
106-
// template column. So the gate is on the columns, for every row: an
103+
// those bodies carry sign-in and reset links. The 18.x ceremony moves them
104+
// to `sys_identity_email` before the database is certified, selected by
105+
// shape — and no column tells them apart from tenant mail reliably (a
106+
// magic link carries no `related_object`, and the log has no template
107+
// column), so a row the shape misses stays here. So the gate is on the
108+
// columns, for every row: an
107109
// organization administrator keeps the row (envelope, subject, delivery
108110
// state, linkage) and loses only the body. The same field-level answer
109111
// closes every door that serves a row through the engine — the data API,

‎packages/platform-objects/src/audit/sys-identity-email.object.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@ import { ObjectSchema, Field } from '@objectstack/spec/data';
1919
* hook, the boot outbox sweep and attachment reclamation read it by id and
2020
* status exactly as they read `sys_email`.
2121
*
22-
* Expand-first: rows an 18.0 install wrote to `sys_email` for this mail stay
23-
* there; the mail pipeline keeps reading them, and moving them is the 18.x
24-
* upgrade ceremony's.
22+
* The rows an 18.0 install wrote to `sys_email` for this mail are moved here,
23+
* id for id, by the 18.x upgrade ceremony before the database is certified —
24+
* their queued jobs and activity timeline pointed at this log first — and the
25+
* v18 boot guard refuses a database it has not certified.
2526
*
2627
* `systemFields: { tenant: false }` removes the injected organization column.
2728
* With no column there is no tenant wall, so who may read is object

0 commit comments

Comments
 (0)