From b64b6cab5e6fe3b5ef69cd5fc59569de6e65b9fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:54:20 +0000 Subject: [PATCH 1/3] refactor(plugins): retire the `i?.content ?? i` unwrap family from plugin read paths (#8378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `{ name, content }` storage envelope these ten reads presumed has no producer: `registerMetadataCollections` registers each stack-collection element as-is, `loadMetaFromDb` registers the parsed body rather than the `sys_metadata` row, and the facade's own interim boxing was removed by #8349. #7519 shed the same unwrap from MetadataFacade after that measurement; this retires it at the remaining plugin seams. Removal is a fix, not a tidy-up. None of the types read here declares a stored `content` key, so wherever the key did appear the unwrap replaced a whole authoring document with one of its values — and `''`, falsy but non-nullish, passed `??` and then died at `filter(Boolean)`, dropping the item silently. On email-template the harm is sharper: `content` is a REJECTION alias (`strictObject({ aliases: { content: 'bodyHtml' } })`), not a conversion — the ADR-0087 registry has zero `email_template` entries. The unwrap replaced the document with the HTML string, so the parse answered `expected object, received string` and the boot warning's `name` came back `undefined`, instead of the schema's own "Did you mean `content` → `bodyHtml`?". The four plugin test fakes that boxed items as `{ content: }` were the only producers of that envelope in the tree; they now register documents the way the real engine does. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk --- .../src/engine-capability-provenance.test.ts | 16 ++++-- .../engine-nested-plugin-collections.test.ts | 6 ++- ...ngine-nested-plugin-view-expansion.test.ts | 3 +- ...bootstrap-declared-email-templates.test.ts | 12 ++++- .../src/bootstrap-declared-email-templates.ts | 51 +++++++++++++++++-- .../plugins/plugin-email/src/email-plugin.ts | 12 ++++- .../bootstrap-declared-capabilities.test.ts | 7 ++- .../src/bootstrap-declared-permissions.ts | 17 ++++++- .../src/bootstrap-declared-positions.test.ts | 4 +- .../src/bootstrap-declared-positions.ts | 7 ++- .../src/permission-set-projection.ts | 6 ++- .../src/bootstrap-declared-sharing-rules.ts | 11 +++- .../src/bootstrap-declared-webhooks.test.ts | 7 ++- .../src/bootstrap-declared-webhooks.ts | 16 ++++-- 14 files changed, 147 insertions(+), 28 deletions(-) diff --git a/packages/objectql/src/engine-capability-provenance.test.ts b/packages/objectql/src/engine-capability-provenance.test.ts index 6631087a80..b60120cd52 100644 --- a/packages/objectql/src/engine-capability-provenance.test.ts +++ b/packages/objectql/src/engine-capability-provenance.test.ts @@ -24,8 +24,10 @@ * Consequence before the fix: `plugin-security`'s `bootstrapDeclaredCapabilities` * resolves the owner as `cap._packageId ?? cap.packageId` and reads its input * with `readDeclared(ql, 'capability')`, which is - * `engine.registry.listItems('capability')` mapped through `i?.content ?? i` - * (`packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts:61-69`). + * `engine.registry.listItems('capability')`, filtered + * (`packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts`). + * That read used to map through `i?.content ?? i`; #8378 retired the unwrap + * across this family after measuring the envelope has no producer. * With `capabilities` outside the seam that list was ALWAYS empty, so the * `_packageId` half of that `??` could never be satisfied and the author-side * `packageId` — documented in `CapabilityDeclarationSchema` as the *fallback* @@ -48,11 +50,15 @@ import { ObjectQL } from './engine'; /** * The exact read `bootstrapDeclaredCapabilities` performs on the engine, kept * in one place so the shape this suite depends on is stated once: - * `readDeclared(ql, type)` → `registry.listItems(type)` unwrapped by - * `content ?? item`. + * `readDeclared(ql, type)` → `registry.listItems(type)`, filtered. + * + * [#8378] The `content ?? item` unwrap this helper used to mirror is gone from + * the production read. It was a no-op here — these tests drive a REAL + * `ObjectQL`, whose `registerMetadataCollections` registers each item as-is — + * which is exactly the measurement that retired it. */ function readDeclaredShape(engine: ObjectQL, type: string): any[] { - return (engine.registry.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + return (engine.registry.listItems(type) ?? []).filter(Boolean); } const PKG = 'com.acme.exporter'; diff --git a/packages/objectql/src/engine-nested-plugin-collections.test.ts b/packages/objectql/src/engine-nested-plugin-collections.test.ts index edec624773..6b0ab169a7 100644 --- a/packages/objectql/src/engine-nested-plugin-collections.test.ts +++ b/packages/objectql/src/engine-nested-plugin-collections.test.ts @@ -81,15 +81,17 @@ function manifestDirect(collections: readonly string[]) { return manifest; } +// [#8378] Both helpers mirrored the plugin readers' `content ?? item` unwrap, +// which has been retired: `registerMetadataCollections` registers each +// collection element as-is, so the unwrap was a no-op on this real engine. function registeredNames(engine: ObjectQL, type: string): string[] { return (engine.registry.listItems(type) ?? []) - .map((i: any) => i?.content ?? i) .filter(Boolean) .map((i: any) => i.name); } function registeredItem(engine: ObjectQL, type: string): any { - return (engine.registry.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean)[0]; + return (engine.registry.listItems(type) ?? []).filter(Boolean)[0]; } describe('registerPlugin — the four collections a nested plugin used to drop (#7049)', () => { diff --git a/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts index 5b9b87fbd7..dcacbb4b7e 100644 --- a/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts +++ b/packages/objectql/src/engine-nested-plugin-view-expansion.test.ts @@ -98,9 +98,10 @@ function viaNestedPlugin() { return { id: PKG, name: 'sales', plugins: [{ name: 'sales-nested', views: [accountContainer()] }] }; } +// [#8378] The `content ?? item` unwrap this mirrored is retired — a no-op on a +// real engine, whose `registerMetadataCollections` registers items as-is. function viewItems(engine: ObjectQL): any[] { return (engine.registry.listItems('view') ?? []) - .map((i: any) => i?.content ?? i) .filter(Boolean); } diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts index fd75060ec1..d52549808a 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts @@ -39,9 +39,15 @@ class FakeEngine { if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); } + // [#8378] Registers items EXACTLY as the real engine does — the document + // itself. This fake used to box each one as `{ content: }`, which made + // it the only producer of that envelope anywhere in the tree: a fiction that + // kept the production `i?.content ?? i` looking load-bearing while nothing + // real ever wrote the shape (`registerMetadataCollections` registers items + // as-is; `loadMetaFromDb` registers the parsed body, not the row). get _registry() { return { - listItems: (type: string) => (this.declared[type] ?? []).map((content) => ({ content })), + listItems: (type: string) => [...(this.declared[type] ?? [])], }; } @@ -261,7 +267,9 @@ describe('bootstrapDeclaredEmailTemplates', () => { it('falls back to the metadata service when the registry is empty', async () => { const engine = new FakeEngine(); - const metadataService = { list: () => [{ content: declaredTemplate() }] }; + // [#8378] `IMetadataService.list` answers the documents themselves; the + // `{ content: … }` box this fixture used to build had no producer. + const metadataService = { list: () => [declaredTemplate()] }; const result = await bootstrapDeclaredEmailTemplates(engine as any, metadataService); diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts index abe362b2cf..278eaa66ec 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.ts @@ -104,14 +104,57 @@ function uid(prefix: string): string { /** * Read declared `email_template` items from the ObjectQL registry (where the * manifest decomposition parks `stack.emailTemplates`), falling back to the - * metadata service. Items may be wrapped as `{ content }` — unwrap to the raw - * authoring object. + * metadata service. Both reads hand back the authoring document itself. + * + * ## [#8378] Why there is no `i?.content ?? i` here any more + * + * The sentence this docblock used to end with — "Items may be wrapped as + * `{ content }` — unwrap to the raw authoring object" — described an envelope + * with **no producer**. Re-measured at this seam rather than inherited from + * #7519's measurement of `MetadataFacade`: + * + * - `registerMetadataCollections` (objectql `engine.ts`) registers each + * `stack.emailTemplates` element as-is — `registerItem(type, item, 'name')`, + * no boxing; + * - `loadMetaFromDb` (metadata-protocol) registers + * `convertStoredItem(JSON.parse(record.metadata))` — the parsed body, never + * the `sys_metadata` row (whose body column is `metadata`, not `content`); + * - `MetadataFacade`'s own interim boxing of non-object values, the one writer + * that ever produced the shape, was removed by #8349. + * + * ## …and why removing it is a FIX rather than a tidy-up + * + * `content` IS a spelling an author can write on an email template — but as a + * **rejection alias**, not a conversion. `EmailTemplateDefinitionSchema`'s + * `strictObject({ aliases: { content: 'bodyHtml', … } })` table feeds + * `strictUnknownKeyError`, which runs only on the `unrecognized_keys` path and + * only builds a *message*; it never rewrites the key. Nor does the ADR-0087 + * conversion layer — `packages/spec/src/conversions/registry.ts` has zero + * `email_template` entries, so `normalizeStackInput` emits no notice and leaves + * `content` exactly where the author wrote it. + * + * So the key survives to this read only through a door that skips validation + * (`defineStack(…, { strict: false })`, a hand-built manifest, a direct + * registry write) — and on precisely that path the unwrap was actively harmful, + * in two ways: + * + * 1. **It destroyed the author's own prescription.** With the unwrap, the + * string reached `EmailTemplateDefinitionSchema.parse()` and the template + * was refused with `Invalid input: expected object, received string`, and + * the boot warning's `name` field came back `undefined` — the operator + * could not even tell which template failed. Without it the document + * reaches the parse intact and the schema answers what it was built to + * answer: *Unrecognized key(s) on this email template: `content`. Did you + * mean `content` → `bodyHtml`?* + * 2. **`content: ''` vanished.** Falsy but non-nullish, so it passed `??` and + * then died at the `filter(Boolean)` below — the template was dropped with + * no warning, no count, nothing (the ADR-0078 silent-loss shape). */ function readDeclared(engine: any, metadataService: any, type: string): any[] { try { const reg = engine?._registry; if (reg?.listItems) { - const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + const items = (reg.listItems(type) ?? []).filter(Boolean); if (items.length > 0) return items; } } catch { @@ -120,7 +163,7 @@ function readDeclared(engine: any, metadataService: any, type: string): any[] { try { const listed = metadataService?.list?.(type); const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []); - return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : []; + return Array.isArray(arr) ? arr.filter(Boolean) : []; } catch { return []; } diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index 600c32e246..1ee123a695 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -1168,7 +1168,13 @@ export class EmailServicePlugin implements Plugin { ? await evt.metadataService?.get?.('email_template', String(evt.name)) : undefined); if (!raw) return; - await upsertDeclaredEmailTemplate(engine, (raw as any)?.content ?? raw, undefined, ctx.logger as any); + // [#8378] `evt.body` / `metadataService.get` hand back the authoring + // document itself — there is no `{ name, content }` envelope. Unwrapping + // could only have replaced the template with one of its values, and on + // this door `content` cannot even arrive: `saveMetaItem` validates every + // `email_template` write against `EmailTemplateDefinitionSchema` and + // refuses the key with 422 `INVALID_METADATA` before persisting. + await upsertDeclaredEmailTemplate(engine, raw, undefined, ctx.logger as any); ctx.logger.info(`EmailServicePlugin: email template '${evt.name}' materialized from a runtime write`); } catch (err: any) { ctx.logger.warn( @@ -1228,7 +1234,9 @@ export class EmailServicePlugin implements Plugin { } if (!answered) return FAILED_READ; if (!item) return undefined; - return stripReadDecorations((item as any)?.content ?? item); + // [#8378] The effective read answers the template document itself; no + // `{ name, content }` envelope exists to unwrap on this path either. + return stripReadDecorations(item); } async dispose(): Promise { diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts index bd187205dd..6102e60e5a 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts @@ -11,9 +11,12 @@ function makeQl(declared: any[] = []) { rows, // readDeclared() reads engine.registry.listItems(type); stub it so // capabilities are surfaced without a metadata service. + // + // [#8378] Items are surfaced as the real engine surfaces them — the + // document itself, not a `{ content: }` box that nothing produces. registry: { listItems(type: string) { - return type === 'capability' ? declared.map((c) => ({ content: c })) : []; + return type === 'capability' ? [...declared] : []; }, }, async find(object: string, q: any) { @@ -59,7 +62,7 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () = await bootstrapDeclaredCapabilities(ql, null); // Ship a new label on the next boot. (ql as any).registry.listItems = (t: string) => - t === 'capability' ? [{ content: { name: 'billing.refund', label: 'Issue Refund', _packageId: 'com.acme.billing' } }] : []; + t === 'capability' ? [{ name: 'billing.refund', label: 'Issue Refund', _packageId: 'com.acme.billing' }] : []; const out2 = await bootstrapDeclaredCapabilities(ql, null); expect(out2.seeded).toBe(0); expect(out2.updated).toBe(1); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts index 109b7ec545..aa861131ca 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts @@ -57,12 +57,27 @@ interface SeedOptions { * items provenance-stamped with `_packageId`) is the reliable source in every * boot path; the metadata-service facade only surfaces these once the * compiled-artifact loader runs (serve.ts). + * + * [#8378] The registered item IS the authoring document — there is no + * `{ name, content }` envelope to unwrap, so the `i?.content ?? i` this read + * used to carry is gone. Same measurement #7519 made for `MetadataFacade`, + * re-taken at this seam: `registerMetadataCollections` (objectql `engine.ts`) + * registers each stack-collection element as-is, and `loadMetaFromDb` registers + * `convertStoredItem(JSON.parse(record.metadata))` — the parsed body, never the + * `sys_metadata` row. Nothing in the tree produces the envelope. + * + * Removal is a FIX, not a tidy-up. The types read through here (`permission`, + * `capability`, `object`) all reject `content` as an unrecognized key, so + * whenever the key did appear the unwrap replaced a whole authoring document + * with one of its values — and `''`, falsy but non-nullish, passed `??` and + * then died at the `filter(Boolean)` below, dropping the item with no + * diagnostic at all. */ export function readDeclared(engine: any, type: string): any[] { try { const reg = engine?.registry; if (reg?.listItems) { - return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + return (reg.listItems(type) ?? []).filter(Boolean); } } catch { /* fall through */ } return []; diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts index df0ce82d2b..d5c39469eb 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts @@ -20,7 +20,9 @@ function makeQl(declared: any[] = []) { const rows: any[] = []; return { rows, - registry: { listItems: (type: string) => (type === 'position' ? declared.map((c) => ({ content: c })) : []) }, + // [#8378] Items are surfaced as the real engine surfaces them — the + // document itself, not a `{ content: }` box that nothing produces. + registry: { listItems: (type: string) => (type === 'position' ? [...declared] : []) }, async find(object: string, q: any) { if (object !== 'sys_position') return []; const where = q?.where ?? {}; diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index 998865078e..f056b50c34 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -47,12 +47,17 @@ interface SeedOptions { * (populated by `manifest.register` from the stack's `positions`/`sharingRules` * arrays) is the reliable source in every boot path; the metadata-service * facade only surfaces these once the compiled-artifact loader runs (serve.ts). + * + * [#8378] No `{ name, content }` unwrap: the registered item IS the authoring + * document. `PositionSchema` declares no `content` key and rejects one as + * unrecognized, so the unwrap could only ever have destroyed a document — + * see `bootstrap-declared-permissions.ts` for the full measurement. */ function readDeclared(engine: any, type: string): any[] { try { const reg = engine?.registry; if (reg?.listItems) { - return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + return (reg.listItems(type) ?? []).filter(Boolean); } } catch { /* fall through */ } return []; diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index 1cf282840a..3ce8c32639 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -303,8 +303,10 @@ const isProjectionEcho = (v: any): boolean => function readDeclaredBody(ql: any, name: string): any { try { const items = ql?.registry?.listItems?.('permission') ?? []; - for (const i of items) { - const body = i?.content ?? i; + for (const body of items) { + // [#8378] The registered item IS the body — no `{ name, content }` + // envelope to unwrap (`PermissionSetSchema` rejects `content` as an + // unrecognized key, and nothing in the tree produces the envelope). // Skip projection echoes too: deleteMetaItem's registry heal // (restoreArtifactRegistryView) can re-register the metadata manager's // view — which may be OUR marked copy — into the engine registry as a diff --git a/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts b/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts index 51511ce5d9..5f7a0dad71 100644 --- a/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts +++ b/packages/plugins/plugin-sharing/src/bootstrap-declared-sharing-rules.ts @@ -74,11 +74,20 @@ export function celToFilter(cel: unknown): Record | null { return result.ok ? (result.filter as Record) : null; } +/** + * Read declared items straight off the engine's SchemaRegistry. + * + * [#8378] No `{ name, content }` unwrap: the registered item IS the authoring + * document. `SharingRuleSchema` declares no `content` key and rejects one as + * unrecognized, so the `i?.content ?? i` this read used to carry could only + * ever have replaced a rule document with one of its values — see + * `plugin-security/src/bootstrap-declared-permissions.ts` for the measurement. + */ function readDeclared(engine: any, type: string): any[] { try { const reg = engine?._registry; if (reg?.listItems) { - return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + return (reg.listItems(type) ?? []).filter(Boolean); } } catch { /* fall through */ } return []; diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts index 4838930f0e..e1fb59a76c 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts @@ -42,9 +42,14 @@ class FakeEngine { } // Declared-metadata registry (where manifest decomposition parks stack.webhooks). + // + // [#8378] Items are registered EXACTLY as the real engine registers them — + // the document itself. Boxing each as `{ content: }` made this fake + // the only producer of that envelope in the tree, which is what kept the + // production `i?.content ?? i` looking load-bearing. get _registry() { return { - listItems: (type: string) => (this.declared[type] ?? []).map((content) => ({ content })), + listItems: (type: string) => [...(this.declared[type] ?? [])], }; } diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts index 637172d734..a4669d930a 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts @@ -92,13 +92,23 @@ function uid(prefix: string): string { /** * Read declared `webhook` items from the ObjectQL registry (where the manifest * decomposition parks `stack.webhooks`), falling back to the metadata service. - * Items may be wrapped as `{ content }` — unwrap to the raw authoring object. + * + * [#8378] Both reads hand back the authoring document itself. The sentence that + * used to stand here — "Items may be wrapped as `{ content }` — unwrap to the + * raw authoring object" — described an envelope with **no producer**: + * `registerMetadataCollections` (objectql `engine.ts`) registers each + * `stack.webhooks` element as-is, `loadMetaFromDb` registers the parsed body + * rather than the `sys_metadata` row, and `MetadataFacade` shed its own copy of + * this unwrap in #7519. `WebhookSchema` declares no `content` key and rejects + * one as unrecognized, so wherever the key did appear the unwrap replaced the + * whole webhook with one of its values — and `''` (falsy, non-nullish) passed + * `??` and then died at `filter(Boolean)`, dropping the webhook silently. */ function readDeclared(engine: any, metadataService: any, type: string): any[] { try { const reg = engine?._registry; if (reg?.listItems) { - const items = (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); + const items = (reg.listItems(type) ?? []).filter(Boolean); if (items.length > 0) return items; } } catch { @@ -107,7 +117,7 @@ function readDeclared(engine: any, metadataService: any, type: string): any[] { try { const listed = metadataService?.list?.(type); const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []); - return Array.isArray(arr) ? arr.map((i: any) => i?.content ?? i).filter(Boolean) : []; + return Array.isArray(arr) ? arr.filter(Boolean) : []; } catch { return []; } From 19db2899cef7c93e4bf2745ce9b1bba92b65651f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:03:00 +0000 Subject: [PATCH 2/3] test(plugin-email): pin that a `content`-carrying template reaches the schema intact (#8378) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cases, both putting a `content` key genuinely in play — a template that never spells `content` exercises only the unwrap's `?? i` arm and would pass against a completely unfixed tree. - `content: '

…'` — the rejection must carry the schema's own `content` → `bodyHtml` prescription and name the template. With the unwrap the parse received a bare string, so it answered `expected object, received string` and the warning's `name` was `undefined`. - `content: ''` — falsy but non-nullish, so it passed `??` and was then dropped by `filter(Boolean)`: no row, no warning, no count. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk --- ...bootstrap-declared-email-templates.test.ts | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts index d52549808a..9ce7d7f087 100644 --- a/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts +++ b/packages/plugins/plugin-email/src/bootstrap-declared-email-templates.test.ts @@ -356,3 +356,77 @@ describe('mapTemplateToRow', () => { expect(row).not.toHaveProperty('variables_json'); }); }); + +// --------------------------------------------------------------------------- +// [#8378] The retired `i?.content ?? i` unwrap +// --------------------------------------------------------------------------- + +/** + * `content` is a spelling an author really can write on an email template — + * `EmailTemplateDefinitionSchema` lists it in its `strictObject` **aliases** + * table (`content: 'bodyHtml'`). That table is a REJECTION facility, not a + * conversion: it feeds `strictUnknownKeyError`, which runs only on the + * `unrecognized_keys` path and only builds a *message*. Nothing rewrites the + * key — the ADR-0087 conversion registry has zero `email_template` entries, so + * `normalizeStackInput` emits no notice and leaves `content` where it was + * written. + * + * So when the key reaches this bridge (a `defineStack(…, { strict: false })` + * load, a hand-built manifest, a direct registry write — every validating door + * refuses it first), the schema is ready with the author's fix. The unwrap was + * the one thing standing between the author and their own prescription: it + * replaced the document with the HTML string, and a string cannot carry a + * key-level rejection. + * + * Both cases below put a `content` key genuinely IN PLAY — the point of the + * fixture. A template that never spells `content` exercises the unwrap's + * `?? i` arm only, and would pass against a completely unfixed tree. + */ +describe('declared email templates carrying the `content` alias spelling (#8378)', () => { + it('reaches the schema as a DOCUMENT, so the rejection carries the `content` → `bodyHtml` fix', async () => { + const engine = new FakeEngine({ + declared: { + email_template: [ + // `bodyHtml` deliberately absent — `content` is the author's attempt + // at it, which is exactly what the alias table exists to answer. + { name: 'auth.welcome', label: 'Welcome', subject: 'Hi', content: '

Welcome

' }, + ], + }, + }); + const warn = vi.fn(); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); + + expect(result).toEqual({ seeded: 0, skipped: 1 }); + expect(rowsOf(engine)).toHaveLength(0); + expect(warn).toHaveBeenCalledTimes(1); + + const [, meta] = warn.mock.calls[0]; + // The unwrap used to hand `.parse()` a bare string, so the diagnostic could + // name neither the offending key nor the template it came from. + expect(meta.name).toBe('auth.welcome'); + expect(meta.error).toContain('content'); + expect(meta.error).toContain('bodyHtml'); + expect(meta.error).not.toContain('expected object, received string'); + }); + + it('does not silently vanish when `content` is the empty string', async () => { + const engine = new FakeEngine({ + declared: { + email_template: [ + // `''` is falsy but NON-nullish, so it passed `??` and was then + // dropped by the reader's own `filter(Boolean)` — the template + // disappeared with no warning, no count and no row. + { name: 'ops.digest', label: 'Digest', subject: 'Daily', content: '' }, + ], + }, + }); + const warn = vi.fn(); + + const result = await bootstrapDeclaredEmailTemplates(engine as any, undefined, { warn }); + + expect(result).toEqual({ seeded: 0, skipped: 1 }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][1].name).toBe('ops.digest'); + }); +}); From 9f92e4fefc16cb8400e855f85b9fd023de4bbf9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 16:23:17 +0000 Subject: [PATCH 3/3] chore(changeset): retire the `i?.content ?? i` unwrap family (#8378) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01ARidKDYSCD56LaygrvDPnk --- .changeset/retire-content-unwrap-family.md | 53 ++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 .changeset/retire-content-unwrap-family.md diff --git a/.changeset/retire-content-unwrap-family.md b/.changeset/retire-content-unwrap-family.md new file mode 100644 index 0000000000..8be4c49fa9 --- /dev/null +++ b/.changeset/retire-content-unwrap-family.md @@ -0,0 +1,53 @@ +--- +"@objectstack/plugin-email": patch +"@objectstack/plugin-security": patch +"@objectstack/plugin-sharing": patch +"@objectstack/plugin-webhooks": patch +--- + +fix(plugins): a declared item reaches its schema intact — retire the `i?.content ?? i` unwrap from plugin read paths (#8378) + +Ten production reads over `SchemaRegistry.listItems` unwrapped every declared +item as `i?.content ?? i`, presuming a `{ name, content }` storage envelope. +That envelope has **no producer**. Re-measured at these seams rather than +inherited from #7519's measurement of `MetadataFacade`: + +- `registerMetadataCollections` (objectql) registers each stack-collection + element as-is — `registerItem(type, item, 'name')`, no boxing; +- `loadMetaFromDb` registers `convertStoredItem(JSON.parse(record.metadata))` — + the parsed body, never the `sys_metadata` row (whose body column is + `metadata`, not `content`); +- the facade's own interim boxing of non-object values, the one writer that ever + produced the shape, was removed by #8349. + +**Removal is a fix, not a cleanup.** None of the types read through these seams +— `permission`, `position`, `capability`, `object`, `sharingRule`, `webhook`, +`emailTemplate` — declares a stored `content` key; every one of them rejects it +as an unrecognized key. So wherever the key did appear the unwrap replaced a +whole authoring document with one of its values, and `''` — falsy but +non-nullish — passed `??` and then died at the reader's own `filter(Boolean)`, +dropping the item with no warning, no count and no row. + +**On email templates the harm was sharpest, and it is the one users will +notice.** `content` really is a spelling an author can write there: +`EmailTemplateDefinitionSchema` lists it in its `strictObject` **aliases** table +(`content: 'bodyHtml'`). That table is a *rejection* facility, not a conversion — +it feeds `strictUnknownKeyError`, which runs only on the `unrecognized_keys` +path and only builds a message; nothing rewrites the key, and the ADR-0087 +conversion layer has no `email_template` entry either. The schema was therefore +always ready with the author's fix, and the unwrap was the one thing standing +between the author and it: the HTML string reached +`EmailTemplateDefinitionSchema.parse()`, which answered `Invalid input: expected +object, received string`, and the boot warning's `name` field came back +`undefined` — so an operator could not even tell **which** template had failed. + +A template authored with `content` now yields what it was always meant to: + +> Unrecognized key(s) on this email template: `content`. Did you mean +> `content` → `bodyHtml`? + +…named against the template it came from, and counted as `skipped` rather than +vanishing. + +No behaviour changes for spec-valid metadata: the reads hand back exactly the +documents they always did.