Repository navigation
feat(objectql)!: email template names held per namespace, a send resolves its template from its package, and ?package= reads an email template exactly (ADR-0150 child B, webhook family 1/3) - #32214
Conversation
…, not per deployment (ADR-0150 child B, part 1 of the webhook family) WIP: the switch, the platform-requested auth.* names, the bundle-addition rule, the package-scoped /meta read and the send's holder-filtered bundle. Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…latform-requested auth names and the bundle-addition rule Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…rrowing Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…nd a pair's member as the package's own A read naming a dependent whose only member of a bundled name is an addition (a locale its base does not ship) was refused not-held-by-qualifier once email_template joined the package-scoped readers, and a pair's added member on its base's address was replaced by the registry's item. The qualifier now asks the holders as the caller it names, and keeps an item of a pair of the admitted package. The co-owner dogfood case asserts the ADR-0146 D2 refusal a co-owner meets first at install-local. Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…he email template's narrowing; changeset Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…inst its base Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…ule top Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…s base's address for a bundled type only Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…rowing; pin the qualifier's holder ask for the nine Claude-Session: https://claude.ai/code/session_01ADHkmTMDJkEC5D92VHoU5v Co-authored-by: Claude <noreply@anthropic.com>
…-3giljf Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS Co-authored-by: Claude <noreply@anthropic.com>
…mplate send resolves its name from the caller's package (ADR-0150 D3) The flow notify node records the package whose own flow body is armed beside its template reference; the email and inbox channels hand it to sendTemplate/renderTemplate as callerPackageId; the registry loader resolves the name from it and, for a name two packages hold, reads the resolved holder's own address. A send that names no package keeps the bare-name meaning. Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS Co-authored-by: Claude <noreply@anthropic.com>
…is refused on both exact reads and listed by the upgrade report Two packages of one App share the App's customization pair, so a locale row there with no overrides declaration cannot say whose template it extends: each holder's exact read refuses it naming every holder, the list scoped to a holder leaves it out, and os migrate stored-references lists it under pairMembers. One holder, the App's own address and a row that declares its base keep being served. Pins for F1 (loader rungs, service ladder, notify node, channels, dogfood) and F2; changeset. Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS Co-authored-by: Claude <noreply@anthropic.com>
…otype.at, which the package's lib target does not declare Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 9 package(s): 37 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 191 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 4b8ed3604208abe8508f54a2a50516c728d5e079 && git checkout 4b8ed3604208abe8508f54a2a50516c728d5e079
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin c6230f4eeb0d099361dffba340f855a280a3334f 27abd458a8f948315e70a1636d2a83055d6bf3ea && git checkout -B drift-repro c6230f4eeb0d099361dffba340f855a280a3334f && git merge --no-ff 27abd458a8f948315e70a1636d2a83055d6bf3ea
node scripts/docs-audit/affected-docs.mjs --json c6230f4eeb0d099361dffba340f855a280a3334f
|
…) for email_template, beside api Two packages of two namespaces may now each hold one email template name, so the second package's item no longer overwrites the first's in MetadataManager's in-memory store: a read naming a package answers that package's own, and a read naming none of a two-holder name is the D3 refusal (#31527 B-shape item 1). Pins that email_template has no metadata redactor, so a save never takes the carry-forward's code-layer read for it (item 4). Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS Co-authored-by: Claude <noreply@anthropic.com>
…-3giljf # Conflicts: # packages/objectql/src/package-scoped-e8-readers.test.ts # packages/objectql/src/package-scoped-read.ts
…-3giljf # Conflicts: # packages/cli/src/utils/stored-package-references.ts # packages/metadata/src/package-slotted-store.ts
|
epic:#31527#1 · os-support-ai · session_01WGufzj7TEH65FDimZdn1H3 Epic red-line review of #32214 (#31699 PR 1/3, email_template) at head 4c176c5: no PASS yet. One item is owed before landing. I read the diff and body, but did not run them. Contract review and ACCEPT are seat 1's. Everything else holds against the three red lines. Owed: a legacy package-less row read under two holders. Ruling 6761802819 says it "must be refused for every covered type before that type narrows". This PR narrows
Holds:
Generated by Claude Code |
|
domain:server#1 · os-support-ai · session_01WGufzg5AUtn9pwa3g2bBqk Contract reviewServed-tier: Faces: schema (the published contract file is the one Inputs: card #31699 (body and its 8 comments), PR #32089 (its record 6761777785 and the epic rulings 6761802819), PR #32214 (body, 36 files, net diff against Gate verdicts on this head: none exist. The check-runs endpoint answers 0 runs for ① Derived judgments
② Semver levelChangeset
③ Boundary flags
④ Design judgmentEarlier findings (record 6761777785 on #32089), judged on this tree.
F-A — must-fix. A packaged flow still resolves a template name environment-wide when the reference is written in the node's literal
F-B — should-fix. A bound pair row is still served at the App's own address.
F-C — should-fix. The report's remedy for an ambiguous pair row has no door.
F-D — should-fix. The legacy package-less row now reaches the send, and the changeset contradicts its own change.
Implementing files, the rest.
Nits (prose faces, no effect on the contract).
Implemented-by: VERDICT: FAIL Generated by Claude Code |
…es hold is no holder's own A package-less row (the environment's item, or a sys_metadata row bound to no package) of a name two or more packages hold is the customization of one holder and cannot say which (ADR-0150 D4). Each holder's exact read now refuses it 409 ambiguous-in-closure naming every holder, the list scoped to a holder (which a send made from it reads) leaves it out, and os migrate stored-references lists it under packageLessRows. One holder, the _env address and the platform-requested auth mails are unchanged. The rule sits in the exact qualifier, so it covers every type read by package (epic ruling 6761802819 on #31699). Claude-Session: https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS Co-authored-by: Claude <noreply@anthropic.com>
…-3giljf # Conflicts: # packages/objectql/src/package-scoped-e8-readers.test.ts # packages/objectql/src/package-scoped-read.ts
|
epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT Epic red-line re-read at
|
|
epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT Contract review — delta (merge of main into reviewed head)Served-tier: ① Derived judgments
② Semver levelUnchanged from 6811135944. The merge adds no PR-authored change; ③ Boundary flagsNone new. There is no duplicate or removed export in ④ Design judgmentAdditive on both sides. The ADR-0150 claims the prior PASS relied on are neither weakened nor contradicted by main. Implemented-by: VERDICT: PASS |
|
epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT Landing — seat record re-pinned to the merge head
State change: ready for review + auto-merge (merge queue) in this stroke. On MERGED: #32422 closes via |
|
epic:#31527#1 · os-bill · session_01Utp6CYGYEUhpi29FpjLmxT Landed
Status: |
Requested by Yibo Wang · project thread
Part of #31699
Fixes #32422
Supersedes #32089
维护者速读
?package=精确读取邮件模板;配对包里一条可能属于两个包的 Studio 新增语言行在两个精确地址都返回 409,os migrate stored-references列出它;元数据内存存储对邮件模板按(包,名)分槽,两个包的同名模板不再互相覆盖;一条不带包的行(环境项或无包的 sys_metadata 行),若其名字被两个包持有,现在对所有按包读取的类型都在两个精确地址返回 409,并从两个包的列表中隐藏,os migrate stored-references在 packageLessRows 下列出它。?package=X读 X 不持有的名字、阶段 1 前已封存的同名冲突的裸读、打包流程的 notify 模板在其依赖闭包之外、两持有者名字的无包行、数据库加载器按名回滚(多于一个行槽)、按名删除两持有者的名字;加载器与 MetadataManager 的不限包列表读取保留每个包的每个语言成员;数据库加载器保存邮件模板时只写该语言自己的行(没有则插入),不再覆盖其他语言的行(metadata: DatabaseLoader is locale-blind for a bundled type, so saving one locale of an email template overwrites another locale's row in the same (package, name) slot #32422);启动预载不再把从某个加载器读到的行写回同一个加载器(R5-A),文件系统里的项照旧写入数据库加载器;宿主声明了 packageId 时,库里不属于任何包的行,启动时不再被打上宿主的戳写回(原来会多一个版本和一条历史,按包存放的类型还会绑定到宿主);遗留行的空 package_id 列仍按该行自己存储体里的包来绑定,只写这一列(R7-A);其余只放宽。回滚即 revert。packages/plugins/plugin-email/src/template-loader.ts(bundleAddressOf)、packages/services/service-automation/src/builtin/notify-node.ts、packages/metadata-protocol/src/protocol-exact-qualifier.ts(pairMemberOwners、packageLessRowOwners)、packages/cli/src/utils/stored-package-references.ts。pnpm --filter @objectstack/plugin-email exec vitest run src/template-loader.test.ts src/send-template.test.ts,以及pnpm --filter @objectstack/objectql exec vitest run src/package-scoped-exact-qualifier.test.ts,应全绿,其中 "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 F1]"、"[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 F2]" 与 "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699] a package-less row…" 三节逐级钉住。Clause-②: yes (narrowing) — six refusals where an item was served or written before. (1)
GET /meta/email_template/NAME?package=X(andgetMetaItemwith apackageId) for a name package X does not hold is refused409 RESOURCE_CONFLICT(not-held-by-qualifier, naming every holder), where it served the first registered package's template; first-party callers that name a package: 0. (2) The bareGET /meta/email_template/NAMEof a name two packages already held before the one-holder rule (a clash the cold boot seals with a warning) is refused409naming both, where it served the first registered one (ADR-0150, covered types). (3) A flow'snotifynode now sends its template as the flow's own package (SendTemplateInput.callerPackageId): from a package whose own flow body is armed, a template name no holder in that package's declared dependency closure holds (another package's template outside it, or a template only the environment authored) is refused409naming the holders, where the send served the one holder; first-party packaged flows that send a template: 0. (4) A read naming a package (?package=X,getMetaItemwith apackageId,/meta/TYPE/NS/NAME) that reaches a package-less row (the environment's item, or asys_metadatarow bound to no package) of a name two or more packages hold is refused409 RESOURCE_CONFLICT(ambiguous-in-closure, naming every holder), where each holder's address served that row as its own, and the list scoped to a holder (which a send made from it reads) leaves the row out; for every type read by package (PACKAGE_SCOPED_READ_TYPES:app,page,dashboard,report,book,analytics_cube,tool,skill,agent,api,email_template,picklist,mapping,dataset,permission); first-party: 0, the full dogfood battery green with it. Widened beside it: the engine's package door (SchemaRegistry.installPackage), its item seam (SchemaRegistry.registerItem) and the cold boot's holder seal (SchemaRegistry.sealBareNameHolders) accept, for an email template, a second package holder they refused before: one that declares a namespace other than the holder's. (5) A rollback by name of an email template in the database loader (DatabaseLoader.registerRollback) whose name has more than one row slot (two packages' rows, or a package's row beside the package-less row) is refused409 RESOURCE_CONFLICT(ambiguous-in-environment, naming every holder) before any write, where it overwrote the first row the driver returned with a body that may be another holder's; with one row slot it restores the row of the member the restored body declares (itslocale), and is refused as not found when that member has no row, where it overwrote whichever member's row came first. (6) A delete by name with no package of an email template two packages hold (DatabaseLoader.delete) is refused409 RESOURCE_CONFLICT(ambiguous-in-environment, naming both), where it deleted the first row the driver returned; a delete naming a package takes that package's rows, every member, and only those, where it took the first row when that row was the package's; with one holder a delete by name takes every row of the name, where it took the first. First-party callers that roll back or delete an email template two packages hold: 0. The loaders' unscoped reads (loadMany,loadManyKeyed) keep every member of every slot, one row per(package, name, member), as before this change they answered every row.Metadata-protocol: narrowed
Skills-docs: none —
content/docs/kernel/runtime-services/email-service.mdxgains thecallerPackageIdmember (how a send resolves its template name); no published skill teaches the scope of an email template name or the send's package;skills/objectstack-platform/SKILL.md's customization paragraph (a pair's added locale served on the base's address, a dependent's only under its own?package=) stays true for every one-holder name and is pinned by the #28802 dogfood, which stays green.Narrowing: yes — first-party callers: 0 (measured). For an email template, (4) changes the mail with no refusal: once a second package holds the name, the first holder's send moves from the operator's customized package-less wording to its own shipped wording, until the row is saved into one holder's pair. (1) No in-tree caller reads an email template with a package that does not hold it (Studio's bundle editor reads at the package its list names the item under). (2) No first-party package pair clashes on an email template name. (3) No first-party packaged flow's
notifynode names atemplate:or a literalpayload.template: of the files underexamples/andpackages/(tests, dist and the engine itself excluded) that carry atype: 'notify'node, none carries a stringtemplate:key (thenotifynodes ofapp-showcaseandapp-todosend inlinetitle/message). (5) and (6) No first-party package pair holds an email template name two packages share, so no first-party rollback or delete by name meets the refusal. (4) No first-party package pair holds one name of a package-read type beside a package-less row of it; the full dogfood battery (which boots the showcase, CRM and every example app) stays green with the refusal on. The full dogfood battery ran on the final head (Verification).This is the first of three PRs for #31699 (seat ruling Q1 = A): email_template here, then webhook, then connector (
Fixes #31699). Onlyemail_templatejoinsNAMESPACE_SCOPED_BARE_NAME_TYPEShere.Patch round 8
This round answers the epic ruling 6809488921 and the contract review 6809461650 (VERDICT FAIL at
6ed3bea2).DatabaseLoader.bindReadRowPackage(type, name, item), declared as an optionalMetadataLoadermember.MetadataManager.registercalls it in place of the write-through it skips underskipSourceLoader. It is the only call the pass makes into the loader an item came from. It binds the emptypackage_idcolumn of the legacy row that the loader's plural read handeditemout from. The package is the one that row's own stored body names: it is recorded from the stored row at the read, and checked again against a fresh read of the name's rows before the write. The write is the column only: no body, no version, no history row, no insert. It never readsitem's stamp as it stands now, so it never uses the host'sapplyProtectionstamp. It never uses aloadManykeying:withEntryPackagestamps only a column-bound row, and such a row is never a candidate. It never binds a row the read did not hand out. It never binds into a slot whose column another row of the name already holds (the same member, for a bundle). A row whose stored body names no package is never a candidate.database-loader.test.ts"[#31699 R5-A]", run through the realMetadataPlugin._loadFromFileSystem. (a) "[R7-A] an agent legacy row": on a host with a differentpackageId, the column is bound to the body's package by exactly oneupdate('sys_metadata', 'legacy', { package_id }), and the row is otherwise equal (body, version 1), with no history row. (b) "[R7-A, R7-B] a package-less row on a host with a packageId": the row is unchanged, with no update. R5-A pins (1), (2) and CONTROL (4) are unchanged and green. Ablated A25.DatabaseLoaderrow moves FROM saved back at boot with the host's stamp (_packageId,_provenance), a new version and a history row, and, for a type stored per(package, name), bound to the host, TO left as stored. Measured with the skip ablated: anagentrow went topackage_id= host, version 2, a stamped body and 1 history row; aviewrow went to version 2, a stamped body and 1 history row, with its column still empty. With the skip in place, both were unchanged.DatabaseLoader.loadManyandloadManyKeyedhand out a shallow copy of each cached body. The pass'sapplyProtectiontherefore stamps the copy, and the plural-read cache keeps the body as stored.applyProtectionwrites top-level keys only (_packageId,_packageVersion,_provenance,_lock, and the like), so a shallow copy is enough.loadSlotsandloadManySlotsanswer the cached rows as before; the pass does not read them.loadMany('agent'), read after the pass, answers the body unstamped. Ablated A26.loadMany's keying:loadManynever sees the host package, because the pass stamps it after the read.MetadataManager's in-memory registry keys anemail_templateby(package, name)with no member, and a write naming no package lands in the one holder's slot (storeWriteKey, D4). After the pass, pin (1)'s registry holds one entry: the alpha slot, holding the package-lessenv olderbody. Pin (2)'s registry holds the alpha slot with the package-lessfr-FRbody. Two members of one package collapse the same way. The rows themselves stay pinned as unchanged.MetadataRegisterOptionsis exported besideMetadataUnregisterOptionsfrom the package index.database-loader.tsholds the column-only path and the hand-out copy; ruling 6809488921 places both in the source loader.loader-interface.tsholds the optionalbindReadRowPackagemember, so the manager calls it typed.index.tsholds the N10 export.Patch round 7
This round answers the epic ruling 6801410166 on R5-A, which the red-line re-read 6801341801 and the contract review 6801348644 (VERDICT FAIL at
cfb8f300) raised. Its first commit,0f93f8f1, was pushed by the previous dev with no report and no verification. This round checks it against the ruling, adds what was missing, and measures it.MetadataManager.loadManyrecords which loader each item object came from, in aWeakMap.register(type, name, item, { skipSourceLoader: true })skips the write-through to that one loader, and every otherdatasource:loader still receives the item.MetadataPlugin._loadFromFileSystempasses the option. With the skip ablated (A23; the write-through loop is then byte-identical tocfb8f300's), a package'sen-USrow beside a package-lessen-USrow took the package-less body. Beside a package-lessfr-FRrow, a package-bound copy of it was inserted.database-loader.test.ts"[#31699 R5-A]", run through the realMetadataPlugin._loadFromFileSystemover aDatabaseLoader. (1) Both rows are unchanged, with no update and no insert. (2) The member-absent neighbour inserts nothing. (4) CONTROL: a file-system item still seeds the database loader. This uses the plugin's ownFilesystemLoaderover a temporaryrootDir, with the host'spackageIdstamped. Ablated A23.sys_metadatarow bound to the host's package, its one history row, and no update. That holds with the skip ablated (before) and in place (after).primehelper registersloadMany's items back without the skip, so they now pin a register made by any route other than the boot pass, and its doc says so.onlyBoundStampAddedloadMany's column-stamped bodies back without the skip (the column-bound two-member slot, and R4-B). The skip covers the boot pass only. The stamploadManyadds reaches every caller. Without the check, any other register of such a body, or the pass's write into a seconddatasource:loader, would write a new version that differs from its row only by the stamp.database-loader.tsis not touched this round: the claim's file surface does not name it, and the reason is recorded here and in the report.0f93f8f1putMetadataRegisterOptionsbetweenMetadataUnregisterOptions' doc comment and its declaration, which left that comment on the wrong type. It now sits above it.check:test-source-aliaswas red on0f93f8f1. Its R5-A pins importMetadataPlugin, whose source loads@objectstack/metadata-fslazily, so the load fell inside a case's clocked window.database-loader.test.tsnow imports it at module top, as the gate prescribes.check:test-source-alias✓origin/mainat14754c70(3 commits: #32415, #32454, #32453). The merge commit has no conflict and touches none of this PR's files.Patch round 6
This round answers the contract review 6799021376 on
f9f589da(VERDICT FAIL: must-fix R4-A, should-fix R4-B, nit N8) under the epic ruling 6799056319, which pulls #32422's fix into this PR.f9f589da, throughMetadataManagerover aDatabaseLoader. The priming pass thatMetadataPlugin._loadFromFileSystemruns (loadMany, thenregistereach) over a slot holdingen-USandfr-FRreplaced theen-USrow's body withfr-FR's: version 3, plus a history row. This happened whether the bodies were stamped or bound by the column only. A direct save offr-FRinto a slot holding onlyen-USoverwrote theen-USrow. The fix:DatabaseLoader.saveandslottedSaveTargetnow write, for a bundled type stored per(package, name), the row of the member the body declares. The canonical member isnull, asstoredBundleMemberOfreads it. A member with no row in the slot is inserted with itsbundle_member, which matches the store's unique key, so a save never writes over another member's row. The slot is still chosen from every row of the name, and every unbundled slotted type keeps one row per slot.MemoryLoaderwas checked the same way: a slot there holds one item, its save of a second locale replaces the first as onmain, and no new path reaches it, becauseregisterpersists only todatasource:loaders. It is unchanged; see Acceptance notes.database-loader.test.ts"[#31699 R4-A] the save of a bundled type writes its own member's row": the priming pass leaves a two-member slot's rows unchanged, with stamped bodies and with column-bound ones, with noupdateand no insert; a directfr-FRsave inserts its own row, leavesen-USuntouched, and a secondfr-FRsave updates its own row; CONTROL an unbundled slotted type (agent) saves to its one row. Ablated A22.MetadataManager.loadManymerged two packages' same member when the package was in the column onlyf9f589da: over two packages' rows bound bypackage_idalone,loadManyanswered alpha's two members only, and the priming pass inserted a package-less row. The fix:MetadataManager.loadManyreads a bundled slotted type through the loader's keyed entries where the loader has them, so each entry keeps its row's package. It hands such a body on stamped with that package (_packageId, as the protocol's own read binds a row), so a priming pass registers it into that package's slot and its member's row. A save whose body differs from its row only by that stamp naming the row's own package writes nothing (onlyBoundStampAdded).loadManyanswers all four(package, member)pairs, each stamped with its package, and the priming pass writes nothing (noupdate, no insert).MetadataManager.loadManyandloadManyKeyedanswer one entry per(package, name, member)for an email template, and adds a save bullet FROM → TO beside the slotted-store bullet. That bullet is not a refusal. The sentence that the save "still writes into its slot's first row" is removed. The six refusals are unchanged.Fixes #32422sits underPart of #31699.origin/mainat74216b60(3 commits): a merge commit with no conflict, touching none of this PR's files.Patch round 5
This round answers the epic ruling 6796068023 on round 4's R3-B reading: option A, fixed in this PR. Its answer 2 confirms
Clause-②: yes (narrowing).DatabaseLoader.loadMany/loadManyKeyedkeep one row per(package, name, member)for a bundled type stored per(package, name), with the canonical member spellednullas the store'sbundle_membercolumn spells it (storedBundleMemberOf: the column when it names a member, else the body'slocale). Every member of every slot is kept. Every other slotted type keeps one row per name, as #32246 has it. (Round 6: the save left out here as #32422 is member-aware too, andMetadataManager.loadManykeeps a column-bound package.)MemoryLoaderanswers every slot of a bundled type alike.MetadataManager.loadMany/loadManyKeyed, which merged the loaders' items one per name, merge a bundled type per(package, name, member)(bundleEntryKey), andDatabaseLoader's keyed entries of such a type carry the row's package. A rollback by name of a name with one row slot restores the row of the member the restored body declares, and is refused as not found when that member has no row. The rollback and delete refusals are unchanged from round 4's measurement. The save path's locale-blind overwrite was left out here as #32422 and is fixed in round 6.database-loader.test.ts"[#31699 R3-B] a bundled type keeps every member of every slot": one two-locale bundle keeps both locales (460df4a: onlyfr-FR); two packages' bundles keep all four rows (460df4a: 1 of 4); CONTROL a member's older row is no second member; a rollback by name with two holders is409naming both, with no write; a one-slot rollback restores its member's row; a delete by name with two holders is409, and a delete naming a package takes that package's rows only; a save fillspackage_id;MemoryLoaderanswers both packages' templates.metadata-manager-match-endpoint.test.ts: the #32246 one-item-per-name pins now run over the unbundled slotted types, and a bundled pin covers the manager'sloadMany/loadManyKeyedkeeping each package's member whileloadstill answers the one saved last. Ablated A21.Clause-②:paragraph is recounted to six refusals: (5) a rollback by name with more than one row slot, (6) a delete by name of a name two packages hold, each FROM → TO. The BREAKING paragraph covers both, and the slotted-store bullet states that the loaders' unscoped reads keep every member of every slot. The sameClause-②:line is here.check-adr-0087-registration, Check Changesetorigin/mainat7d82b2da(5 commits): a merge commit with no conflict, touching none of this PR's files.Patch round 4
This round answers the contract review 6792103054 on
7136e339(VERDICT PASS; should-fix R3-A and R3-B; nits N6 and N7) under the epic ruling 6792134290, which withdraws correction 6788403217 and lets answer 2 stand as first given.origin/mainatcbf40f85(9 commits, #32361 through #32404), a merge commit with no conflict. Of this PR's files it movesprotocol-exact-qualifier.ts,package-scoped-resolution.tsand its test,package-slotted-store.ts,registry.tsandengine.ts; both sides are kept.envRowwrites the canonical member's package-less row as the store writes it,bundle_member: null(overlayMemberValue,migrations/overlay-index.ts); any other member keeps its value. Measured on the merged tree before any assertion changed:?package=Aforen-US, a member A ships, beside that row answers409 RESOURCE_CONFLICTambiguous-in-closurenaming A and B, which is the ruled answer. The other nine cases of the describe stay green on the corrected fixture. No source change.409naming both; the holder's list and the send's read serve the holder's own shipped member, and the row is withheld. Ablated A20: 8 red, the R3-A pin among them.PACKAGE_SLOTTED_STORE_TYPESemail_templatein the setDatabaseLoader.loadManyandloadManyKeyedanswered one row per name: one package'sen-US+fr-FRbundle answered onlyfr-FR, and two packages' bundles answered 1 row of 4, where BASE (the type out of the set, same tree) answered every row. The rollback and delete refusals measured then are kept, and the save's locale-blind overwrite (onmaintoo) is #32422.Patch round 3
This round answers the delta contract review 6785448728 on
d8084865(VERDICT PASS, two should-fix and three nits) under the claim amendment 6788098985 and the epic answers 6788092691.origin/main(the PR wasdirty):permission(#32162) andemail_templatesit side by side inPACKAGE_SCOPED_READ_TYPES,PACKAGE_SLOTTED_STORE_TYPESand objectql'sWIRED_SINCE; #32162's permission describe and this PR's describes are both kept. Narrowing (4)'s type list now namespermissiontoo, since the package-less refusal covers every type inPACKAGE_SCOPED_READ_TYPES.package-scoped-e8-readers.test.ts,package-scoped-exact-qualifier.test.ts''or the provenance sentinelsys_metadata, skipped the package-less owner checkexactQualifierItemtreats''andsys_metadataas no package before the owner check, so such a row of a name two packages hold is refused409 ambiguous-in-closureat both exact addresses.sys_metadata, and one stamped'', under two holders → 409 at both naming both; CONTROL one holder → served. Ablated A19 (2 red).?package=Afor a member A ships, beside a package-less row of a name two packages hold, answers409 RESOURCE_CONFLICTambiguous-in-closurenaming both, while A's list and a send from A serve A's own. Corrected in round 4: this round's measurement used a fixture row the store never holds (the canonical member asbundle_member: 'en-US', where the store writesNULL), so the read missed the row and answered with the registry's item.api,agent,skill,action,email_template,permission. Body: "What changes" gives the namespace set asagent,skill,action,email_template.notifybullet says a template named in the literalpayload.templatenames the armed package too.Patch round 2
This round answers the contract review record 6772203835 on
4c176c5b(VERDICT FAIL), the epic seat's remaining red-line items (the package-less row; the App pair's registry slot), and the delta review of #32277 (6774012987: M1r, S2), whose owner-check order this branch shares, with the server seat's rulings on F-C and N1, in one push.exactQualifierItemaskspackageLessRowOwners(exported from metadata-protocol) before keeping a row that carries no package: when two or more packages hold the name, the read is refused409 ambiguous-in-closurenaming every holder. The package-scoped list leaves such a row out (packageLessRowWithheldin the stand-in rows), so a send made from either holder renders that holder's own. It is generic: every type inPACKAGE_SCOPED_READ_TYPES; a platform-requested name (auth.*) and the_envaddress are untouched.os migrate stored-referenceslists each suchsys_metadatarow underpackageLessRowswith its candidates. Declared as narrowing (4) for every such type,agent,skilland the E8 types included. Census: first-party 0; the full battery green.package-scoped-exact-qualifier.test.ts"[#31699] a package-less row of a name two packages hold": email_template two holders → 409 on both, each list and the send's read serve the holder's own; agent, skill, app, dashboard: env item + two holders → 409, + one holder → served; CONTROL_envunchanged. metadata-protocolprotocol-exact-qualifier.test.ts:packageLessRowOwnersnames every holder, CONTROLauth.*and a type not read by package → none. CLIstored-package-references.literal-namespace.test.ts: THE PIN lists an agent and an email_template row with both candidates; CONTROL one holder, or a bound row → not listed. Ablated A10–A13.payload.templateresolved environment-widenotifynode records the armed package (templateCallerPackageId) when the literalpayloadcarries a non-blanktemplatestring, as the channels read it, and the inline path forwards it; a literaltemplateCallerPackageIdkey is still dropped and replaced.notify-node.test.ts"a template reference written in the literal payload … names the armed package too". Ablated A14.pairRowOnAddress(exported), whichexactQualifierItemandpairMemberWithheldAtboth ask, so a generic pair line (#32277) can hoist it to the top of the function. It reads the declared base (overrides.package) first, before the App-pair keep, in both the exact read and the list.pairRowOnAddressanswersnot-memberwhen the row's one owner is not one of the address's bases, so the read falls through as for another package's item, and the list leaves it out. Through the doors today the qualifier refuses a non-holder first (not-held-by-qualifier), so the guard is pinned on the function.not-member; CONTROL the owner's own address →serve. objectql "[#31699 F2]": a package sharing the pair but holding no template of the name does not serve the member (refused409) and does not list it. Ablated A17.SchemaRegistry.getPackageScopedItem(type, name, APP)returned the App pair's plain-slot row whatever it declaredgetPackageScopedItemskips a paired<base>.customrow that declares another holder as its base (pairRowDeclaresAnotherHolder), so the App's address answers the App's own item. Generic for every type.package-scoped-resolver.test.ts"[#31699] a pair row that declares another holder as its base is skipped on the base's address":email_templateandpageanswer the base's own item, the other holder's unchanged; CONTROL a pair row declaring its own base, or none, is still the base's. Ablated A18.overrides.package" has no doordeclared_package_ships_no_such_item, #28777), so no door binds an added member to one base today and the row stays refused and listed until it is deleted. For a package-less row, where a holder ships the item, the remedy is saving the customization into that holder's pair declaring it and deleting the row. The bind door is raised with the epic seat as an open item.Metadata-protocol: narrowed(a template value refused at delivery; the level stays minor).check-adr-0087-registration✓api,agent,skill,email_template; narrowing (4) listsPACKAGE_SCOPED_READ_TYPESin full.email-service.mdxdocumentscallerPackageId; the Skills-docs line names the page.pnpm lint✓origin/mainatfb71296c:PACKAGE_SCOPED_READ_TYPESkeepsmappinganddatasetbesideemail_template, and objectql'sWIRED_SINCEcarriesapi,email_template,picklist,mapping,dataset. Then ate31ea52a(#32213, #32239, #32274), and at326453eb(#32310's caller arm:CallerPackageAddressexported beside this PR's owner helpers;bareNameHoldersMeetkeeps the platform-requestedauth.*exception ahead of spec'snamespacedHoldersMeet; #32310's dataset describe and this PR's describes both kept):actionandemail_templateboth narrowed (NAMESPACE_SCOPED_BARE_NAME_TYPES=agent,skill,action,email_template;PACKAGE_SLOTTED_STORE_TYPES=api,agent,skill,action,email_template);flowCallerPackagetakes main's slot-key body (#31696) and stays public for thenotifynode. Full battery and CI on the final head.9bff554e); the doublesexactQualifierItemnow asks this PR's exportedpairRowOnAddress(main's localpairMemberOwners,declaredOverrideBaseandisAppPairOfcopies dropped for the exported ones); the generic pair line covers every package-scoped type, as #32277 has it. The decision table is #32277's corrected order case by case: the App shortcut only when nothing or the App is declared; a sole owner served only when among the address's bases; no owner kept unless the row declares a base the address does not name.email_templatejoinsNAMESPACE_SCOPED_BARE_NAME_TYPESwhere #32277 moved it (@objectstack/core). The package-less refusal stays on the types whose by-name readers consult(package, name)(PACKAGE_SCOPED_READ_TYPES): the other package-scoped types #32277 newly judges at?package=are out of scope (below).readsByPackage('email_template') === true. Four getItem-reading doubles that bare-read an email template (metadata-protocolprotocol.reads-environment-only, restrest-server-meta-read-org-scopeandrest-server-meta-history-diff-org-scope, runtimemeta-write-org-scope) now say they read it by package, asSchemaRegistrydoes; without that #32277'snarrowedarm refuses them501.Patch round 1
This round answers the contract review record 6761777785 (VERDICT FAIL) under the epic seat's rulings 6761802819, from
claude/project-thread-x11fa6@a52f89e0merged withorigin/main.SendTemplateInput/RenderTemplateInput.callerPackageId(spec);EmailService.resolveAndRenderTemplatehands it to everyTemplateLoader.loadrung andbundleLocales; the registry loader resolves D3 from it (bundleAddressOf) and, for a name two packages hold, reads only the resolved holder's address (getMetaItemsForExecution({ type, packageId })). Thenotifynode recordsAutomationEngine.flowCallerPackage(context)(the flow's armed package, never the definition's_packageIdstamp) astemplateCallerPackageId; the email and inbox channels forward it. A literal payload key of that name is dropped.template-loader.test.ts"[#31699 F1]": own holder per namespace (alpha, beta), closure holder, outside-closure 409, no package: two holders → 409 (undefined and null), one holder → served, environment item → served, dependent addition reads its base, registry-only narrowing.send-template.test.ts: the caller reaches every rung (control: omitted / null / empty → none).notify-node.test.ts: armed package named, env body stamped_packageIdnames none, literal key dropped, inline path names none.email-channel.test.ts,inbox-channel.test.ts: forwarded (control: absent). Dogfoodbare-name-one-holder-flow-family:renderTemplateas alpha → alpha's subject, as beta → beta's.exactQualifierItemkeeps a pair row on a base only when the row has one owner among the name's holders (pairMemberOwners), else409 ambiguous-in-closurenaming every owner; the App's own address keeps ruling A; a row declaringoverrides.packageis served at that base only (round 2: read first, F-B). The package-scoped list (#28802 block) leaves such a row out (pairMemberWithheldAt).os migrate stored-referenceslists it underpairMemberswith its candidates.package-scoped-exact-qualifier.test.ts"[#31699 F2]": two holders → 409 on both exact reads naming both, both lists omit it; one holder → served (#28390 A); the App's own address → served; after a bind (overrides.package: X) → served at X only. CLIstored-package-references.literal-namespace.test.ts: listed with candidatessuitex/welcome,suitey/welcome(control: one holder or a declared row → not listed).check-adr-0087-registration✓bare-name-one-holder.tssaid the send "reads the resolved holder's bundle only"dogfood_batteryline contradicted the!and the arm1484ab59); the report states its numbers.B-shape additions (#31527 comment 6766935846, applied at the seat's call):
email_templatePACKAGE_SLOTTED_STORE_TYPES(@objectstack/metadata) is nowapi,agent,skill,email_template(main's #32151 entries kept), in the same change as theNAMESPACE_SCOPED_BARE_NAME_TYPESentry: two packages' same-named templates both stay inMetadataManager's in-memory store, a read naming a package answers that package's own (never another's), and a read naming none of the two-holder name is the D3409naming both.metadata-manager-email-template-slot.test.ts: both kept, by-package read (both faces), bare read409 ambiguous-in-environmentnaming both; CONTROL one holder answers the one item; CONTROLview(not slotted) keeps one item per name. Main's superset pin (objectqlregistry-bare-name-one-holder-ai-family.test.ts, #32151) covers it too. Ablated (A9).unregisterPackagealready drops only that package's slot, as forapi.)readCodeLayerForCarryForwardemail_templateregisters none, so a save never takes the carry-forward's code-layer read for it, with or without a package; there is no unhandled throw to reach.protocol.metadata-redaction.test.ts:hasMetadataRedactor('email_template')and('email_templates')are false.?package=, the send's D3 resolution) read the registry's holder set (packageScopedHoldersOf), so a disabled package still counts as a holder and is named in a refusal while the served lists withhold its items; the install gate counts it too, as it must. This is the class #32183 tracks for every package-scoped type and is fixed there once; it is declared in the changeset. Neither F2 nor the send path covers it.Round 1 declared the legacy package-less row under two holders as unchanged; round 2 refuses it (above).
What changes
NAMESPACE_SCOPED_BARE_NAME_TYPES(@objectstack/core) gains'email_template'(nowagent,skill,action,email_template);PACKAGE_SCOPED_READ_TYPESgains it after'api'.PLATFORM_REQUESTED_BARE_NAMES_BY_TYPEgains the eightauth.*mails; they resolve environment-wide whatever the caller and keep one overriding package per deployment.isBundleAdditionPair); a send made from it resolves from its closure.callerPackageIdon the send contract, throughEmailService,TemplateLoader(TemplateLoadOptions, exported) and the registry loader, which narrows to the resolved holder's address only when two or more packages hold the name, so every one-holder name reads byte-identically to before. Thenotifynode,templateCallerPackageOfin service-messaging, and both channels.?package=, the qualified path and_envname the exact holder for every package-scoped type, and a by-name read the registry cannot judge fails closed (ADR-0150 D3/D5) #32277).pairMemberOwners, and the one owner checkpairRowOnAddress(declared base first, the App's own pair, two owners refused, a sole owner elsewhere not served) that the exact read andpairMemberWithheldAtboth ask, in metadata-protocol;packageLessRowOwners/packageLessRowWithheldrefuse and withhold a package-less row of a name two packages hold, for every type read by package; both owner helpers exported for the CLI.SchemaRegistry.getPackageScopedItemskips a pair row that declares another holder as its base.DatabaseLoaderandMemoryLoaderkeep a bundled type's unscoped reads one entry per(package, name, member)(isBundledStoreType,storedBundleMemberOf,bundleEntryKeyinpackage-slotted-store.ts);MetadataManager.loadMany/loadManyKeyedanswer one entry per(package, name, member)for it,loadManyreading a row's package from itspackage_idcolumn when the body carries no stamp;DatabaseLoader.savewrites the row of the member the body declares and inserts a member with no row (round 6, metadata: DatabaseLoader is locale-blind for a bundled type, so saving one locale of an email template overwrites another locale's row in the same (package, name) slot #32422); a one-slot rollback by name restores its member's row; a rollback by name with more than one row slot and a delete by name of a two-holder name are refused409.payload.templatenames the armed package astemplate:does.email-service.mdx:callerPackageId.collectStoredPackageReferencesaddspairMembers(a keyset walk of activesys_metadatarows of package-read types stored under a customization package with two or more owners);os migrate stored-referencesandpackageLessRows(package-less active rows of package-read types whose name two or more packages hold);os migrate stored-referencesprints both, with hints that say no door binds an added member to one base today, and counts them in its total.Tests
All on this branch merged with
origin/mainat14754c70, afterpnpm install --frozen-lockfileand a build of the dogfood closure (--filter '@objectstack/dogfood^...') on17cfbf8b, the final head.typecheckat exit 0: metadata 1334/1334, objectql 12319/12319, metadata-protocol 22703 passed (29 skipped) and plugin-email 911/911.node scripts/pm/dispatch-gates.mjs --commandson17cfbf8b: all 127 commands run, each exit recorded before any pipe.--rangives 127 derived, 127 run, 0 NOT-MEASURED, 0 UNRUN, all exit 0. No scratch test or ablation ran during the sweep.pnpm linton17cfbf8b: exit 0.17cfbf8b: 68 check-runs, 58success, 10skipped, 0 failed.17cfbf8b:pnpm --filter @objectstack/dogfood testunderscripts/pm/os-verify-lock.shgivesVERDICT command-exit 0, with 451 files passed (3 skipped) and 3780 tests passed (42 skipped).Acceptance notes
Out of scope, named for the seat to card:
PACKAGE_SCOPED_READ_TYPESthat fix(metadata-protocol)!:?package=, the qualified path and_envname the exact holder for every package-scoped type, and a by-name read the registry cannot judge fails closed (ADR-0150 D3/D5) #32277 newly judges at?package=(flow,action,webhook,connectorand the rest of the covered set). Extending it there would refuse a request main accepts, so it needs its own narrowing entry, census and pins.MemoryLoader(the artifact door's store) keeps one item per(package, name)slot, so a package body's second locale of one template replaces its first there, as onmain(where the store was keyed by name). Nothing new reaches it:registerpersists only todatasource:loaders, so the priming pass never saves into it. Checked for R4-A and unchanged.agent,skill,action,api,permission), aDatabaseLoaderrow bound to its package by thepackage_idcolumn alone reachesregisterunstamped. With two holders, a register of it writes a new package-less row. This holds onmainby the same mechanism (metadata: the boot priming pass hands a column-bound slotted row to register unstamped, so with two holders of a name the DatabaseLoader save inserts a package-less row (unbundled slotted types) #32458). Round 7's source skip is per loader and covers every type, so the boot pass no longer does this. Measured with two packages' column-boundagentrows: with the skip ablated, the pass inserted a package-less copy; with it in place, nothing. Any other route that registers such a row still does it, which stays metadata: the boot priming pass hands a column-bound slotted row to register unstamped, so with two holders of a name the DatabaseLoader save inserts a package-less row (unbundled slotted types) #32458's.package_idcolumn. Round 8 (R7-A) gives that bind back, narrowly. A legacy row is a row of a type stored per(package, name)whose body carries the stamp. The pass binds the column of the row the loader handed out, to the package that row's own stored body names, and writes nothing else. This is the same column write as fix(metadata)!: the artifact door's in-memory loader and DatabaseLoader store api, agent, skill and action per package #32347's column-only update inDatabaseLoader.save, now reached without the save. A row whose stored body names no package stays package-less. A row whose slot another row of the name already holds by its column (the same member, for a bundle) is left as it is.loadManyanswers both, and the later registration wins the one(package, name)entry. Measured through the real pass with the file edited fromv1tov2: the next boot's registry heldv1while the row heldv2(version 2), and the boot after heldv2. The card is filed by the seat.email_template,MetadataManager's in-memory registry holds one entry per(package, name), with no member. A write naming no package lands in the one holder's slot (D4). So after the pass, a package's row and a package-less row of one name leave one registry entry: the package's slot, holding whichever body was registered last. Two members of one package leave one entry the same way. The stored rows are unchanged (pins (1) and (2)).SchemaRegistry.overlaySlotBindingstill binds a pair-stamped bare-slot row to the base without readingoverrides.package. This PR fixes the by-package read (getPackageScopedItem, pinned on the real registry). The binding's other readers arebundleMembersHeldBy(bundle-addition judgement) andgetEnvironmentScopedItem, and they are untouched.Ablations, each through
scripts/ablation-replace.mjs(mutation landed, restored to the HEAD blob with an emptygit diff HEAD); the metadata-protocol legs over a rebuilt dist withablation-dist-preflightfinding the marker, then--absentafter the restore build:holders.length < 2 || true): 3 red (each namespace's send reads its own address, a dependent's addition reads its base's address, registry-only narrowing).EmailServicedrops the caller (loadOptions→ none): 1 red (the caller reaches every rung).notifyrecords no caller: 1 red (armed package named).email_templateout ofPACKAGE_SLOTTED_STORE_TYPES(on4c176c5b): metadata 4 red (in the set, both kept, by-package read, bare-read 409), the two CONTROLs green; over the rebuilt metadata dist, objectql's superset pin 1 red (email_template: two packages' same-named items are both kept…).packageLessRowOwnersanswers none in the exact qualifier (dist; the marker comment is stripped by the build, so the preflight read ABSENT, and the red proves the mutation reached the built code): 5 red (email_template two holders; agent, skill, app, dashboard).auth.*→ none).payload.template: 1 red (F-A pin).pairMemberWithheldAt, dist): 1 red (F-B pin).pairRowOnAddress: A15b the App-pair keep ahead of a declared base (source, unit): 1 red (declared base read first); A15c the same mutation over the rebuilt dist (marker present, then--absent): 1 red (the F-B pin in objectql, which reads the exact read and the list).getPackageScopedItemkeeps a pair row declaring another holder (source): 1 red (the registry pin).''andsys_metadatano longer count as no package (dist; the marker comment is stripped by the build, the red proves the mutation reached it): 2 red (the hydrated-row pins).owners.length > 99, metadata-protocol dist;ablation-dist-preflightfound the marker in 2 built files, then--absentafter the restore build): 8 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699] a package-less row…", the R3-A pin among them; restored, 10/10 green.unscopedRowKey(row.name, row.packageId, null), metadata source; the suite readssrc/): 5 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R3-B]" (one bundle, two bundles, the member CONTROL, the one-slot rollback, the delete); restored to the HEAD blob with an emptygit diff HEAD, 8/8 green.&& truein place of the member match inslottedSaveTarget, metadata source; a first attempt replaced it with);, whichablation-replacerefused as unmeasurable, so it never ran): 4 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R4-A]" (both priming pins, the R4-B priming pin, the directfr-FRsave), the unbundled CONTROL green; restored to the HEAD blob with an emptygit diff HEAD.false &&ahead ofoptions?.skipSourceLoaderinMetadataManager.register, metadata source): 2 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R5-A]". In (1) the package'sen-USrow took the package-less body. In (2) a package-boundfr-FRcopy was inserted. The file-system CONTROL stayed green with the same writes, and round 6's pins stayed green. Restored to the HEAD blob with an emptygit diff HEAD.onlyBoundStampAddedoff (false &&ahead of it inDatabaseLoader.save), with the skip in place, over the whole metadata suite: 2 red of 1332. These are round 6's column-bound two-member slot and R4-B, the two that registerloadMany's bodies back without the skip. Restored to the HEAD blob with an emptygit diff HEAD.await loader.bindReadRowPackage?.(type, name, data);→void 0;inMetadataManager.register): 1 red in "[objectql: narrow the phase-1 one-holder refusal from environment to namespace for webhook, connector and email_template (ADR-0150 child B, webhook family) #31699 R5-A]", the R7-A agent legacy row (its column stayed empty). R5-A pins (1) and (2), CONTROL, and the package-less pin stayed green. Restored to the HEAD blob with an emptygit diff HEAD.const copy = entry.data;inDatabaseLoader.handOut), over the whole loader test file: 1 red of 112, the package-less pin. The loader'sloadManyanswered the body carrying the host's_packageIdstamp. Restored to the HEAD blob with an emptygit diff HEAD.Changed lines: 49 files, +2763 / −167 = 2930 against
origin/mainat14754c70(under 3000).feat(objectql)!: email template names held per namespace, and ?package= reads an email template exactly (ADR-0150 child B, webhook family 1/3) #32089 is closed as superseded.
Verification
pnpm --filter @objectstack/plugin-email exec vitest run src/template-loader.test.ts src/send-template.test.tspnpm --filter @objectstack/objectql exec vitest run src/package-scoped-exact-qualifier.test.ts(needs metadata-protocol built)pnpm --filter @objectstack/service-automation exec vitest run src/builtin/notify-node.test.tspnpm --filter @objectstack/dogfood exec vitest run test/bare-name-one-holder-flow-family.dogfood.test.ts(over a built dist)🤖 Generated with Claude Code
https://claude.ai/code/session_01WGufzgX7CHbaN1f2ZtiAiS