From eeccde61ae0f74ed5739f1e214f5838735f8b6fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 06:22:54 +0000 Subject: [PATCH 1/4] fix(plugin-email): use the shared read-decoration strip, not a blanket `_` sweep MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `readEffectiveTemplate` stripped read decorations with a module-local copy of `stripReadDecorations` that dropped every key starting with `_`. The shared list it drifted from (`spec/kernel/metadata-read-decorations.ts`) carries exactly `['_diagnostics', '_draft']` and names the ADR-0010 protection envelope as "Deliberately NOT" a member — envelope state the write path legitimately carries, allowlisted by the closed schemas. The copy justified the sweep on the claim that `EmailTemplateDefinitionSchema` "declares no underscore key". `email-template.zod.ts` spreads `MetadataProtectionFields` into its `strictObject`, so every envelope key is declared and parses clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- ...mail-plugin.template-runtime-write.test.ts | 116 +++++++++++++++++- .../plugins/plugin-email/src/email-plugin.ts | 62 ++++++---- 2 files changed, 153 insertions(+), 25 deletions(-) diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index 66c6342d84..1ce419796c 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -263,8 +263,13 @@ describe('#7733 runtime email_template write materializes without a restart', () // `getMetaItem` returns a DECORATED item (`_diagnostics` from // `decorateMetadataItem`, `_packageId` / `_provenance` from the registry // and the overlay row). `EmailTemplateDefinitionSchema` is a strictObject - // that declares no underscore key, so an unstripped body would reject the - // very baseline the reset exists to restore. + // that declares neither `_diagnostics` nor `_draft`, so an unstripped body + // would reject the very baseline the reset exists to restore. + // + // [#16152] It DOES declare the ADR-0010 envelope (`_packageId` / + // `_provenance` and the `_lock*` family, via `MetadataProtectionFields`) — + // those parse clean and are not stripped. See the `#16152` describe block + // below for the pins that hold that apart. const protocol = withEffectiveRead(fakeProtocol(), async () => ({ type: 'email_template', name: 'auth.password_reset', @@ -378,3 +383,110 @@ describe('#7733 runtime email_template write materializes without a restart', () expect(engine.rows).toHaveLength(0); }); }); + +// ── #16152 ───────────────────────────────────────────────────────────────── +// +// `readEffectiveTemplate` used to strip read decorations with a MODULE-LOCAL +// copy of `stripReadDecorations` that dropped every key starting with `_`. +// The shared list it drifted from (`spec/kernel/metadata-read-decorations.ts`) +// carries exactly `['_diagnostics', '_draft']` and names the ADR-0010 +// protection envelope as "Deliberately NOT" a member — envelope state the +// write path legitimately carries, allowlisted by the closed schemas so a +// served document keeps its provenance on re-parse. +// +// The copy's docblock justified the blanket sweep on the claim that +// `EmailTemplateDefinitionSchema` "declares no underscore key". These pin why +// that claim is false and what the narrow strip must do instead. + +describe('#16152 read-decoration strip uses the shared list, not a blanket `_` sweep', () => { + /** The full ADR-0010 envelope, as `MetadataProtectionFields` declares it. */ + const ENVELOPE = { + _lock: 'no-delete', + _lockReason: 'Shipped by the auth package.', + _lockSource: 'artifact', + _provenance: 'package', + _packageId: 'com.objectstack.auth', + _packageVersion: '1.4.2', + _lockDocsUrl: 'https://example.invalid/locks', + } as const; + + it('the schema declares the ADR-0010 envelope and rejects the read decorations', async () => { + // The premise the deleted docblock got backwards, asserted directly + // against the schema rather than inferred from it. `email-template.zod.ts` + // spreads `MetadataProtectionFields` into its `strictObject`, so every + // envelope key is authorable surface here; `_diagnostics` / `_draft` are + // not declared anywhere in that shape, which is why they must be stripped. + const { EmailTemplateDefinitionSchema } = await import('@objectstack/spec/system'); + + const withEnvelope = EmailTemplateDefinitionSchema.safeParse({ ...template(), ...ENVELOPE }); + expect(withEnvelope.success).toBe(true); + // Guarding a KEY's reachability: no `unrecognized_keys` on any envelope key. + expect( + (withEnvelope as any).error?.issues?.filter((i: any) => i.code === 'unrecognized_keys') ?? [], + ).toEqual([]); + + for (const decoration of ['_diagnostics', '_draft']) { + const served = EmailTemplateDefinitionSchema.safeParse({ + ...template(), + [decoration]: decoration === '_draft' ? true : { valid: true }, + }); + expect(served.success).toBe(false); + const keys = (served as any).error.issues + .filter((i: any) => i.code === 'unrecognized_keys') + .flatMap((i: any) => i.keys); + expect(keys).toContain(decoration); + } + }); + + it('re-materializes a baseline served with the FULL protection envelope on it', async () => { + // The envelope rides along on the layered read (`_packageId` / + // `_provenance` from the registry, `_lock*` from the artifact layer). It + // parses clean, so the reset restores the packaged baseline with no + // projector failure — and `mapTemplateToRow`'s closed column list is what + // keeps it out of the row, not a strip. + const protocol = withEffectiveRead(fakeProtocol(), async () => ({ + item: { + ...template({ subject: 'The packaged subject' }), + ...ENVELOPE, + _diagnostics: { valid: true }, + _draft: false, + }, + })); + const { engine } = await boot({ protocol }); + + await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); + await protocol.remove('auth.password_reset'); + + expect(protocol.projectorFailures).toEqual([]); + const rows = rowsOf(engine, 'auth.password_reset'); + expect(rows).toHaveLength(1); + expect(rows[0].subject).toBe('The packaged subject'); + // `sys_email_template` declares no underscore column, and + // `mapTemplateToRow` projects a closed list — so the envelope cannot reach + // the row whatever the strip does. This is the measured reason there is no + // second, envelope-stripping pass beside the shared one. + for (const k of Object.keys(ENVELOPE)) expect(rows[0]).not.toHaveProperty(k); + }); + + it('does not silently swallow an underscore key the schema never declared', async () => { + // The blanket sweep dropped EVERY `_` key before the parse, so a key that + // is neither a decoration nor declared — a producer's typo, a decoration + // added upstream and never added to the shared list — vanished and the + // reset reported success. That is precisely the silent-strip failure the + // closed schemas (#4001) exist to end, and the shared list keeps loud: + // the projector surfaces it on the write's own response. + const protocol = withEffectiveRead(fakeProtocol(), async () => ({ + item: { ...template({ subject: 'The packaged subject' }), _notADeclaredKey: 'x' }, + })); + const { engine } = await boot({ protocol }); + + await protocol.save('auth.password_reset', template({ subject: 'An operator override' })); + await protocol.remove('auth.password_reset'); + + expect(protocol.projectorFailures).toHaveLength(1); + expect(protocol.projectorFailures[0]).toContain('_notADeclaredKey'); + // The override row is left exactly as it was — a body the schema refuses + // never becomes a write. + expect(rowsOf(engine, 'auth.password_reset')[0].subject).toBe('An operator override'); + }); +}); diff --git a/packages/plugins/plugin-email/src/email-plugin.ts b/packages/plugins/plugin-email/src/email-plugin.ts index f8042b2767..d529325534 100644 --- a/packages/plugins/plugin-email/src/email-plugin.ts +++ b/packages/plugins/plugin-email/src/email-plugin.ts @@ -2,6 +2,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { IDataEngine } from '@objectstack/spec/contracts'; +import { stripReadDecorations } from '@objectstack/spec/kernel'; import type { IEmailTransport, EmailAddress, @@ -66,23 +67,6 @@ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; */ const FAILED_READ = Symbol('email-template-read-failed'); -/** - * Drop the underscore-prefixed keys a SERVED metadata item carries - * (`_diagnostics`, `_packageId`, `_provenance`, `_draft`, …). Every one of - * them is a read-time verdict the protocol attaches, never an authored field: - * `EmailTemplateDefinitionSchema` is a `strictObject` and declares no - * underscore key, so leaving them on turns a perfectly good declaration into a - * validation failure. - */ -function stripReadDecorations(item: unknown): unknown { - if (!item || typeof item !== 'object' || Array.isArray(item)) return item; - const out: Record = {}; - for (const [k, v] of Object.entries(item as Record)) { - if (!k.startsWith('_')) out[k] = v; - } - return out; -} - /** * Plugin configuration. */ @@ -1282,13 +1266,45 @@ export class EmailServicePlugin implements Plugin { * registry, which for a delete is the same baseline. The three outcomes are * kept apart on purpose: only "nothing declares it" may deactivate a row. * - * The body is stripped of read decorations before it is returned. A served - * item carries the protocol's own underscore keys (`_diagnostics` from - * `decorateMetadataItem`, `_packageId` / `_provenance` from the registry and - * the overlay row), `EmailTemplateDefinitionSchema` is a `strictObject`, and - * it declares no underscore key — so handing the decorated body to the - * upsert would reject the very baseline this read exists to restore. This is + * The body is stripped of read decorations before it is returned, using the + * SHARED `stripReadDecorations` from `@objectstack/spec/kernel` — the same + * helper the other read-back-envelope consumers call (the dataset query in + * `rest-server.ts`, the cold-boot flow bind in `service-automation`, + * `saveMetaItem`'s verbatim persist, and the route-level seed apply). This is * the read-side twin of the strip `saveMetaItem` does on the write side. + * + * ## [#16152] Why this is the shared list and NOT a blanket `_`-sweep + * + * This call used to be a module-local copy that dropped **every** key + * starting with `_`, justified in its own docblock by the claim that + * `EmailTemplateDefinitionSchema` "declares no underscore key". That claim is + * false, and the schema disagrees in both directions: + * + * - `METADATA_READ_DECORATIONS` is `['_diagnostics', '_draft']`, and those + * two genuinely must go: the schema is a `strictObject`, neither key is + * declared, so an unstripped body rejects the very baseline this read + * exists to restore. + * - The ADR-0010 protection envelope (`_lock`, `_lockReason`, `_lockSource`, + * `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`) is + * **declared** — `email-template.zod.ts` spreads `MetadataProtectionFields` + * into the shape — and `metadata-read-decorations.ts` names it + * "Deliberately NOT" a member of the strip list, because it is envelope + * state the write path legitimately carries. Sweeping it here removed keys + * the schema was deliberately widened to accept. + * + * A blanket sweep is also a silent swallow: an underscore key that is neither + * a decoration nor declared is exactly what the closed schema exists to + * reject (#4001), and dropping it before the parse converts a loud + * `unrecognized_keys` into a quiet success. + * + * ⭐ There is deliberately **no** second, envelope-stripping pass beside this + * one. The envelope cannot reach the written row in the first place: + * `upsertDeclaredEmailTemplate` projects the parsed template through + * `mapTemplateToRow`, a closed column list, and `sys_email_template` declares + * no underscore column. Spelling an envelope strip here would encode a rule + * this path does not have, as a second copy of a truth that already lives in + * that projection — which is the drift this fix removes, re-introduced one + * layer up. */ private async readEffectiveTemplate( ctx: PluginContext, From bce0733e65ef258b096c2eebcfbb2c39ce98d079 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 06:37:36 +0000 Subject: [PATCH 2/4] test(plugin-email): correct a `_provenance` fixture the blanket sweep was hiding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_provenance: { source: 'code' }` is an object where `MetadataProvenanceSchema` is `z.enum(['package','org','env-forced'])`. The fixture was never spec-legal; the module-local blanket `_` sweep deleted it before the parse could say so. With the shared strip it reaches the schema and is rejected — the silent swallow, demonstrated on the repo's own fixture. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../src/email-plugin.template-runtime-write.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index 1ce419796c..0a693d3408 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -277,7 +277,12 @@ describe('#7733 runtime email_template write materializes without a restart', () ...template({ subject: 'The packaged subject' }), _diagnostics: { valid: true }, _packageId: 'com.objectstack.auth', - _provenance: { source: 'code' }, + // [#16152] Was `{ source: 'code' }` — an object, where + // `MetadataProvenanceSchema` is `z.enum(['package','org','env-forced'])`. + // It was never spec-legal; the blanket `_` sweep deleted it before the + // parse could say so, which is the silent swallow this card removes. + // Corrected to the spelling the rest of the repo's fixtures use. + _provenance: 'package', }, })); const { engine } = await boot({ protocol }); From 83c8e4cdd2b2cbc96a3e137a5416026ef5ff3d91 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 06:41:25 +0000 Subject: [PATCH 3/4] chore(changeset): plugin-email read-decoration strip Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../email-template-shared-read-decoration-strip.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/email-template-shared-read-decoration-strip.md diff --git a/.changeset/email-template-shared-read-decoration-strip.md b/.changeset/email-template-shared-read-decoration-strip.md new file mode 100644 index 0000000000..b94a340065 --- /dev/null +++ b/.changeset/email-template-shared-read-decoration-strip.md @@ -0,0 +1,14 @@ +--- +"@objectstack/plugin-email": patch +--- + +`plugin-email` strips read decorations with the shared list, not a blanket underscore sweep. + +`readEffectiveTemplate` — the layered read a `DELETE /meta/email_template/:name` runs to restore the packaged baseline an overlay was hiding — removed decorations with a module-local copy of `stripReadDecorations` that dropped **every** key beginning with `_`. The shared list it drifted from, `METADATA_READ_DECORATIONS` in `@objectstack/spec/kernel`, is exactly `['_diagnostics', '_draft']`, and its module header names the ADR-0010 protection envelope (`_lock`, `_lockReason`, `_lockSource`, `_provenance`, `_packageId`, `_packageVersion`, `_lockDocsUrl`) as deliberately **not** a member: it is envelope state the write path legitimately carries, and the closed metadata schemas allowlist it so a served document keeps its provenance on re-parse. + +The private copy justified its sweep on the claim that `EmailTemplateDefinitionSchema` "declares no underscore key". That is false — `email-template.zod.ts` spreads `MetadataProtectionFields` into its `strictObject`, so every envelope key is declared and parses clean. The copy was removing keys the schema was deliberately widened to accept, and the list lives in `spec` precisely so a producer and its consumers cannot drift like this. + +The path now calls the shared helper, matching the other read-back-envelope consumers (the dataset query in `rest-server.ts`, the cold-boot flow bind in `service-automation`, `saveMetaItem`'s verbatim persist, and the route-level seed apply). Two behavioural consequences: + +- An underscore key that is neither a decoration nor declared is no longer swallowed before validation. The closed schemas exist to reject exactly that (protocol 17), and the rejection is now reported on the write's own response through the mutation projector, instead of the reset quietly succeeding against a body the schema would have refused. +- The ADR-0010 envelope survives the strip. It still does not reach `sys_email_template`: `upsertDeclaredEmailTemplate` projects the parsed template through `mapTemplateToRow`, a closed column list, and the object declares no underscore column — so no stored row changes shape. There is deliberately no second, envelope-stripping pass beside the shared one; spelling one would re-create the drift this fixes, one layer up. From b454fc09d3decaa82fc6812f6e732045c41ba385 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 06:59:20 +0000 Subject: [PATCH 4/4] test(plugin-email): pay the spec load at module top, per check:test-source-alias `check:test-source-alias` flagged the dynamic `import('@objectstack/spec/system')` inside a test body: this package resolves that specifier through `dist/`, so the first call transforms the dependency's whole module graph inside a clocked window. Hoisted to a module-scope import, where vitest clocks nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y --- .../src/email-plugin.template-runtime-write.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts index 0a693d3408..5f0cd9242f 100644 --- a/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts +++ b/packages/plugins/plugin-email/src/email-plugin.template-runtime-write.test.ts @@ -27,6 +27,7 @@ import { describe, it, expect, vi } from 'vitest'; import { assertEngineUpdateDispatch } from '@objectstack/objectql'; +import { EmailTemplateDefinitionSchema } from '@objectstack/spec/system'; import { EmailServicePlugin } from './email-plugin.js'; const TABLE = 'sys_email_template'; @@ -415,14 +416,12 @@ describe('#16152 read-decoration strip uses the shared list, not a blanket `_` s _lockDocsUrl: 'https://example.invalid/locks', } as const; - it('the schema declares the ADR-0010 envelope and rejects the read decorations', async () => { + it('the schema declares the ADR-0010 envelope and rejects the read decorations', () => { // The premise the deleted docblock got backwards, asserted directly // against the schema rather than inferred from it. `email-template.zod.ts` // spreads `MetadataProtectionFields` into its `strictObject`, so every // envelope key is authorable surface here; `_diagnostics` / `_draft` are // not declared anywhere in that shape, which is why they must be stripped. - const { EmailTemplateDefinitionSchema } = await import('@objectstack/spec/system'); - const withEnvelope = EmailTemplateDefinitionSchema.safeParse({ ...template(), ...ENVELOPE }); expect(withEnvelope.success).toBe(true); // Guarding a KEY's reachability: no `unrecognized_keys` on any envelope key.