fix(plugin-email): use the shared read-decoration strip, not a blanket underscore sweep - #16239
Conversation
…t `_` sweep `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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
… was hiding
`_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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
…ource-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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): 1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 4 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b6b6c5c0bc4d40bb0f61092d0661c17b84a21545 && git checkout b6b6c5c0bc4d40bb0f61092d0661c17b84a21545
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1ebde49536b431dc891b4bbe1a144b638c6205ae b454fc09d3decaa82fc6812f6e732045c41ba385 && git checkout -B drift-repro 1ebde49536b431dc891b4bbe1a144b638c6205ae && git merge --no-ff b454fc09d3decaa82fc6812f6e732045c41ba385
node scripts/docs-audit/affected-docs.mjs --json 1ebde49536b431dc891b4bbe1a144b638c6205ae
|
PM verification — PASSMeasured at head Fence — heldLocal merge-base diff: 3 files — The card's premise — falsified justification confirmed false, at the sourceThe deleted private copy justified its blanket
⇒ The schema disagrees with the old docblock in both directions at once, exactly as the card filed. The blanket sweep was removing keys the schema had been deliberately widened to accept, under a reason that was never true. ⭐ The measurement that decided the repair, re-takenThe brief made step 1 a measurement, not a code change, because it decides which of two repairs is correct. I re-took it:
⇒ The dropped provenance could not have been read by anything, rather than merely not being read today. That is the stronger statement, and it is what makes branch 2 (a second named envelope strip) the wrong repair: it would encode the rule "this path needs the envelope gone", which is false — and a second copy of a truth Pin population — reported honestly, and the honesty is the right callOnly one of three pins is defect-discriminating (red under ablation with
⭐ A seat that reports two of its three pins as non-populating is doing the thing that makes the third one trustworthy. The side finding is an exposure, not a regressionSwitching to the narrow list turned a pre-existing test red because its fixture carried ChangesetJudged, not defaulted: PM half-state — mine, and now fixedThe seat reported the card carried assignee Carried forward, not droppedThe seat could not file its out-of-scope finding — Verdict: PASS. Generated by Claude Code |
Contract review (clause ②) — PASS · landing held on the pair declaration — PR #16239 at head
|
Fixes #16152
plugin-emailkept a module-localstripReadDecorationsthat dropped every key starting with_, drifting from the shared list inpackages/spec/src/kernel/metadata-read-decorations.ts— the module whose header says the list lives inspecprecisely so producer and consumers cannot drift. This PR deletes the private copy and calls the shared helper.Located by content, not by name: the same identifier is exported from
@objectstack/spec/kernel, so a grep finds both. ⛔metadata-read-decorations.tsis not touched — the shared list is correct as it stands.The schema fact, re-verified on this branch's merge base
The private copy justified its blanket sweep in its own docblock by claiming
EmailTemplateDefinitionSchema"declares no underscore key". Re-measured rather than inherited from the card:packages/spec/src/system/email-template.zod.ts:5importsMetadataProtectionFields, and line 141 spreads it into the shape:...MetadataProtectionFields,.packages/spec/src/shared/strict-object.ts—strictObjectisz.object(shape, { error }).strict(), so the shape really is closed.MetadataProtectionFields(packages/spec/src/kernel/metadata-protection.zod.ts:81) declares exactly seven keys:_lock,_lockReason,_lockSource,_provenance,_packageId,_packageVersion,_lockDocsUrl._diagnosticsnor_draftappears anywhere in that chain (grep count 0 in both files).⇒ The claim is false. Every ADR-0010 envelope key is declared and parses clean; the two read decorations are undeclared and genuinely must go. The copy was removing keys the schema was deliberately widened to accept, and keeping the two it had to remove — disagreeing with the shared list in both directions, exactly as the card states.
What the dropped provenance actually costs: measured
The card and the triage comment both left this open, and the triage seat's census stopped at the package boundary. Measured through to the write, and the answer is structural rather than a census result:
readEffectiveTemplatehas exactly one consumer in the repo —email-plugin.ts:1240, feedingupsertDeclaredEmailTemplate. Nothing else sees its return value.upsertDeclaredEmailTemplate(bootstrap-declared-email-templates.ts:187) parses the body and then projects it throughmapTemplateToRow(:76), a closed column list:name, label, category, locale, subject, body_html, body_text, from_address, from_name, reply_to, active, is_system, description, variables_json. No underscore key is projected.sys_email_template(packages/platform-objects/src/audit/sys-email-template.object.ts) declares 19 columns and zero underscore columns.⇒ The dropped provenance costs nothing on this path, and could not have cost anything. The stronger reading is not "nothing reads it" but "nothing can read it": the envelope cannot reach the written row whatever the strip does. This also answers the triage seat's pre-registered escalation clause — trigger (a) does not fire, and neither does (b): the
_lock*family cannot make a written row lose protection, becausesys_email_templatenever carried lock state; ADR-0010 protection lives onsys_metadata. p3 stands on both triggers.Which repair, and why the other one is wrong here
Took the first branch — call the shared helper — matching the four consumers that already do (the dataset query in
rest-server.ts:10502, the cold-boot flow bind inservice-automation/plugin.ts:1906,saveMetaItem's verbatim persist, and the route-level seed apply inruntime/domains/packages.ts:1572).The second branch — a separately named envelope strip beside it — would have been wrong for this path, and the measurement above is why. A second strip would encode the rule "this path needs the envelope gone". That rule is not true here: the envelope's absence from the row is already guaranteed by
mapTemplateToRow's closed projection. Spelling it as a strip would put a second copy of that truth one layer up — which is the very drift this PR removes, re-created in a new place. There is deliberately no second pass, and the docblock says so.Safety of the narrower list was checked rather than assumed: the only producers of underscore keys on a served item are
_diagnostics(decorateMetadataItem,metadata-diagnostics.ts:159),_draft(draft/preview reads), and the ADR-0010 envelope (registry / overlay row). That is exactlyMETADATA_READ_DECORATIONSplus the declared envelope, with nothing left over.A second, unplanned finding: the sweep was hiding a malformed fixture
Switching to the narrow list turned the pre-existing test
restores a baseline served WITH read decorations on itred. The cause is not the repair — the fixture carried_provenance: { source: 'code' }, an object, whereMetadataProvenanceSchemaisz.enum(['package','org','env-forced']). It was never spec-legal; the blanket sweep deleted it before the parse could say so. Corrected to_provenance: 'package', the spelling every other fixture in the repo uses.This is the silent-swallow failure demonstrated on the repo's own test data, and it is the concrete reason the blanket form is worse than a wrong list: it also swallows malformed values of the keys it sweeps.
Each pin's population
Ablation restored the pre-fix blanket copy. Mutation proved on disk —
git hash-objectdeltad5293255…to91191c53…, injected-marker count 1, surviving shared-import count 0 — and the restore proved by blob equality back tod5293255…plus an emptygit diff HEAD, under atrap … EXIT INT TERMwith an absolute path.does not silently swallow an underscore key the schema never declaredexpected [] to have a length of 1: under the blanket sweep the undeclared key vanishes before the parse and the reset reports success.the schema declares the ADR-0010 envelope and rejects the read decorationsre-materializes a baseline served with the FULL protection envelope on itmapTemplateToRowdrops it. It stands as a regression guard that the envelope does not break the parse.Reported plainly because only one of the three is defect-discriminating: the other two are premise and regression guards, and calling them pins of the fix would overstate them.
Verification
Gate family derived mechanically on the final head
b454fc09dvianode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack; the Reconciliation line reads 56 families, and--commandson the final head is byte-identical to the list that was run. All 56 measured green, exit codes captured immediately after a single redirected command, never through a pipe.Two came back exit 3 — PREREQUISITE NOT MET, read as unmeasured rather than as passes:
check:dual-build-cjs-loadsandcheck:type-check-debtboth need the built closure. Ranturbo run build --filter='./packages/*' --filter='./packages/*/*'and re-ran both — green (103/66/619/1against floors90/58/520/1; 12 ledger entries re-measured, none above its recorded number).One came back a genuine exit 1:
check:test-source-aliasrejected a dynamicimport('@objectstack/spec/system')inside a test body — this package resolves that specifier throughdist/, so the first call would transform the dependency's module graph inside a clocked window. Hoisted to a module-scope import, as the gate prescribes; re-run green.Also ran the 4 artifact-roster gates the derivation flags as keeping their roster in a directory one of these paths is in, whose silence is not evidence either way:
check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity— all exit 0.pnpm --filter @objectstack/plugin-email test— 30 files / 468 tests passedpnpm --filter @objectstack/plugin-email typecheck— exit 0, and itscheck:test-typecheckleg confirms the test layer really is compiled, so the new test file is measured rather than merely excludedtype-check-coverage,type-check-debt,test-source-alias,cross-package-test-inputs,dts-closure,nul-bytes— all exit 0Heavy runs were serialized through
scripts/pm/os-verify-lock.sh; the wall-clock figures in its VERDICT lines are shared-box readings, not idle-machine numbers.Changeset
Included,
patch. Judged rather than defaulted:@objectstack/plugin-emailisprivate: falseat17.3.0, and the diff changes observable runtime behaviour of that published package — an undeclared underscore key now surfaces on the write's response instead of being swallowed. Not askip-changesetcase, which is for diffs that publish nothing.Out of scope
packages/plugins/plugin-security/src/permission-set-projection.test.ts:1231pinsmergeRowPatchIntoBodystripping_packageIdand_provenancefrom a body — the same drift as this card, in a third package, and its fixture carries the same malformed_provenance: { a: 1 }object shape. Deliberately not fixed here: different package, and it would pull in a separate test and gate surface. It could not be filed as an issue during this run — the MCPsearch_issueschannel needed for the duplicate check returnedAPI rate limit already exceeded(repo-scoped REST is 403 for this session), and filing without a duplicate check is not an option this seat takes. Handed to the PM in the structured report for filing.Generated by Claude Code