Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
8de88ee
feat(objectql, plugin-email): email template names held per namespace…
claude Nov 10, 2026
cef0ef9
test(objectql, plugin-email): pin the email template narrowing, the p…
claude Nov 10, 2026
c88476f
test(dogfood): the flow-family boot pins read the email template's na…
claude Nov 10, 2026
4312637
fix(metadata-protocol): the exact qualifier reads a bundle addition a…
claude Nov 10, 2026
8bbcc56
test(core, cli): the resolver and the stored-references report read t…
claude Nov 10, 2026
e37cebe
docs(changeset): a code package's bundle addition is still judged aga…
claude Nov 10, 2026
f1189c7
test(plugin-email): the platform-requested list pin loads core at mod…
claude Nov 10, 2026
3ccfb7a
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
3465aaa
fix(metadata-protocol): the exact qualifier keeps a pair's item on it…
claude Nov 10, 2026
a52f89e
docs(changeset): the email template change declares the ?package= nar…
claude Nov 10, 2026
a4326c9
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
50e1ef9
fix(plugin-email, service-automation, service-messaging): an email te…
claude Nov 10, 2026
a4ca293
fix(metadata-protocol, cli): a pair member row two holders could own …
claude Nov 10, 2026
1484ab5
test(plugin-email): read the last protocol request without Array.prot…
claude Nov 10, 2026
1fabc5d
feat(metadata): the in-memory store keeps one slot per (package, name…
claude Nov 10, 2026
f6bbafc
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
4c176c5
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
249a6a4
fix(metadata-protocol, cli)!: a package-less row of a name two packag…
claude Nov 10, 2026
9102aca
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
14bbef3
fix(service-automation, metadata-protocol, objectql, cli): contract r…
claude Nov 10, 2026
376b80e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
9ea252e
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
c79a4e6
test(metadata-protocol, rest, runtime): the getItem-reading doubles r…
claude Nov 10, 2026
c5d0e37
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 10, 2026
d808486
fix(cli): the stored-references hint names no tracker id in its print…
claude Nov 11, 2026
ee4bcab
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Nov 11, 2026
7136e33
fix(metadata-protocol): a row hydrated into the registry with no pack…
claude Nov 11, 2026
4668d9e
Merge origin/main into claude/project-thread-3giljf
claude Nov 11, 2026
460df4a
test(objectql): the package-less row is written as the store writes i…
claude Nov 11, 2026
248d471
fix(metadata): a bundled type's unscoped loader reads keep every memb…
claude Nov 11, 2026
26e2548
Merge origin/main into claude/project-thread-3giljf
claude Nov 11, 2026
61dfabf
docs(changeset): declare the loader half — rollback and delete by nam…
claude Nov 11, 2026
f9f589d
fix(metadata): MetadataManager's plural reads keep a bundled type's m…
claude Nov 11, 2026
f779531
fix(metadata): a bundled type's save writes its own member's row, and…
claude Nov 11, 2026
12ee9e5
docs(changeset): the manager's plural reads per member, and the membe…
claude Nov 11, 2026
cfb8f30
Merge origin/main into claude/project-thread-3giljf
claude Nov 11, 2026
0f93f8f
fix(metadata): the boot priming pass never writes an item back into t…
claude Nov 11, 2026
74a7e4c
Merge origin/main into claude/project-thread-3giljf
claude Nov 11, 2026
e8c7447
docs(metadata): MetadataUnregisterOptions keeps its own doc comment (…
claude Nov 11, 2026
fefaa51
test(metadata): the R5-A control seeds the database loader from a rea…
claude Nov 11, 2026
2990c4c
docs(metadata): the boot write-back note is its own changeset line; o…
claude Nov 11, 2026
74d2e76
Merge origin/main into claude/project-thread-3giljf
claude Nov 11, 2026
7594637
test(metadata): database-loader.test.ts pays the metadata-fs load at …
claude Nov 11, 2026
6ed3bea
docs(metadata): keep database-loader.ts out of round 7's diff
claude Nov 11, 2026
17cfbf8
fix(metadata): the boot pass binds a legacy row's empty package colum…
claude Nov 11, 2026
27abd45
Merge branch 'main' into claude/project-thread-3giljf
objectstack-fleet[bot] Nov 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/31699-email-template-names-per-namespace.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
'@objectstack/objectql': minor
'@objectstack/core': minor
'@objectstack/spec': minor
'@objectstack/metadata-protocol': minor
'@objectstack/metadata': minor
'@objectstack/plugin-email': minor
'@objectstack/service-automation': minor
'@objectstack/service-messaging': minor
'@objectstack/cli': minor
---

feat(objectql)!: two packages of two namespaces may each ship an email template of the same name, a send resolves the name from the package it is made from, and `?package=` reads an email template exactly (ADR-0150 child B)

Clause-②: yes (narrowing) — six refusals where an item was served or written before. (1) `GET /meta/email_template/NAME?package=X` (and `getMetaItem` with a `packageId`) for a name package X does not hold is refused `409 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 bare `GET /meta/email_template/NAME` of a name two packages already held before the one-holder rule (a clash the cold boot seals with a warning) is refused `409` naming both, where it served the first registered one (ADR-0150, covered types). (3) A flow's `notify` node 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 refused `409` naming 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`, `getMetaItem` with a `packageId`, `/meta/TYPE/NS/NAME`) that reaches 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 refused `409 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 refused `409 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 (its `locale`), 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 refused `409 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

<!-- adr-0087: not-required (no-migration-prescription) no key of any schema is added, removed, renamed or re-shaped and no stored row changes shape: what moves is which email template a by-name read or a send answers, and which second package an install admits; the stored metadata is read exactly as before, so there is nothing for `objectstack migrate meta` to rewrite -->

**BREAKING** for six callers. A caller of `GET /meta/email_template/NAME?package=X` (or `/meta/email_template/NS/NAME`) whose package X does not hold the template now answers `409 RESOURCE_CONFLICT` naming the holders instead of another package's template: name the package that holds it, or read with no package. A deployment that already held one template name under two packages reads `409` naming both on the bare address: read each by its package. A packaged flow whose `notify` template is held only outside its package's declared dependencies (or only in the environment) now fails its delivery with `409` naming the holders: declare a dependency on the package that ships the template (ADR-0141), or ship the template in the flow's package. A read naming a package that reached a package-less row of a name two packages hold now answers `409` naming both, and the list scoped to either leaves the row out (`os migrate stored-references` lists each). For an email template that 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. Where one of the holders ships the item, save the customization into that holder's pair declaring it (`overrides.package`) and delete the row; otherwise no door binds the row to one package today, and it stays refused and listed until it is deleted. A rollback by name of an email template whose name two packages hold, or a package holds beside the package-less row, now answers `409` naming the holders instead of overwriting one row: roll back the package's own item, or remove the extra row first; a rollback whose restored body names a locale with no stored row is refused as not found instead of overwriting another locale. A delete by name with no package of an email template two packages hold now answers `409` naming both instead of deleting the first row: delete naming the package (`packageId`), which takes that package's rows only. Nothing in the workspace is in any of the six positions.

The bare-name one-holder rule (`bare-name-one-holder.ts`) has narrowed for `email_template` from one package holder per deployment to one per namespace (ADR-0150 D2, D9). Its readers answer by `(package, name)`:

- `GET /meta/email_template/NAME` refuses `409 RESOURCE_CONFLICT` naming every holder when two packages hold the name, at every locale; `?package=ID` and `GET /meta/email_template/NS/NAME` serve that package's own.
- A send (`IEmailService.sendTemplate` / `renderTemplate`) resolves the name from `callerPackageId`, a new optional member of `SendTemplateInput` and `RenderTemplateInput` (`@objectstack/spec`): that package's own template, else the one holder in its declared dependency closure, else `409` naming every candidate. A name two packages hold is then read at the resolved holder's own address only (its members and its pair's added members; a package-less overlay of a name two packages hold is left out), never mixed with the other holder's. A send that names no package keeps the bare-name meaning: the environment's own template, else the one package holder, else `409` naming every holder. The eight platform-requested `auth.*` mails resolve environment-wide whatever is passed.
- The flow `notify` node records the package whose own flow body is armed (`AutomationEngine.flowCallerPackage`, now public) beside its template reference (`template:`, or a template named in the node's literal `payload.template`, which the channels read alike), as the delivery payload's `templateCallerPackageId`; the email and inbox channels hand it to the send. An environment-authored flow, or an environment override of a packaged one, records none; a `templateCallerPackageId` key in the node's literal `payload` is dropped, never forwarded. A delivery row queued before this release carries no such key and sends as before, with no package.

- `MetadataManager`'s in-memory store keeps one slot per `(package, name)` for `email_template` (`PACKAGE_SLOTTED_STORE_TYPES` in `@objectstack/metadata`, now `api`, `agent`, `skill`, `action`, `email_template` and `permission`): two packages' templates of one name both stay in it, `get(type, name, packageId)` answers that package's own (never another package's), and `get` naming no package of a two-holder name refuses `409` naming both (D3), where the second package's item overwrote the first's. The loaders follow the same rules (`DatabaseLoader`, `MemoryLoader`): a save fills `package_id`; a rollback and a delete by name are refused as (5) and (6) state; and their unscoped reads keep every member of every slot (one row per `(package, name, member)`, the canonical locale stored as `bundle_member` NULL), so no locale of any package's bundle is dropped. `MetadataManager.loadMany` and `loadManyKeyed` likewise answer one entry per `(package, name, member)` for an email template, where they answered one per name; `loadMany` reads a row's package from its `package_id` column when the body carries no stamp, and hands such a body on stamped with it (`_packageId`).
- The database loader's save of an email template (`DatabaseLoader.save`) writes the row of the member the body declares, FROM the slot's first row whatever its locale (so a save of `fr-FR` replaced the stored `en-US` body, and a boot's priming pass, which registers every stored member back, did so at every boot) TO that member's own row, inserting it with its `bundle_member` when the slot has none. It never writes over another member's row. A save whose body differs from its row only by a `_packageId` stamp naming the row's own package writes nothing. Every other type stored per `(package, name)` keeps one row per slot. Not a refusal: no write that succeeded before is refused.
- A boot no longer writes back rows it read from the same loader: the priming pass registers each stored item without saving it into the loader it was read from (`MetadataManager.register`'s `skipSourceLoader`), and a file-system item still seeds the database loader. So on a host that declares its package (`MetadataPlugin`'s `packageId`), a package-less `DatabaseLoader` row 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. The one write the pass still makes into the loader a row came from binds a legacy row's empty `package_id` column to the package that row's own stored body names, the column only (`DatabaseLoader.bindReadRowPackage`); a row whose body names no package stays package-less. `DatabaseLoader`'s `loadMany` and `loadManyKeyed` hand out a copy of each cached body, so the pass's stamp no longer reaches the loader's read cache.

So a package no longer has to rename an email template because a package of another namespace already ships one of that name, and each package's flows keep sending their own.

A customization pair's member row (a locale Studio added in an App's pair) that two or more holders of the name could own, because two packages of the App each ship the template and the row declares no `overrides` naming its base, is no package's own: each such holder's exact read refuses it `409` naming every one, the list scoped to a holder leaves it out (a send there takes the locale ladder's next rung), and `os migrate stored-references` lists it under `pairMembers`. With one such holder it is that holder's, as before (#28390 ruling A); the App's own address keeps serving it unless the row declares another base; a row whose one owner is another package is that package's, never served at a package sharing the pair. A row that declares the base it customizes (`overrides.package`) is served by that base only (`SchemaRegistry.getPackageScopedItem` included: the App's address skips it and answers the App's own item), but the save gate writes that declaration only where the base ships the member (`declared_package_ships_no_such_item`, #28777), so no door binds an added member to one base today: such a row stays refused and listed until it is deleted.

A package-less row of a name two or more packages hold (the environment's item, or a `sys_metadata` row bound to no package, written while one package held the name or before the one-holder rule) is the customization of one holder and cannot say which (ADR-0150 D4), so it is no holder's own (epic ruling on #31699): each holder's exact read refuses it `409` naming every holder, the list scoped to a holder leaves it out, and `os migrate stored-references` lists it under `packageLessRows`. With one holder it is that holder's customization, as before; the `_env` address is untouched; a name the platform requests by spelling (the `auth.*` mails) keeps its package-less row as the environment's. The rule sits in the exact qualifier (`packageLessRowOwners`, `@objectstack/metadata-protocol`), so it covers every type in `PACKAGE_SCOPED_READ_TYPES`, `agent` and `skill` (narrowed before this change) included.

Not changed, and declared: the holder set of the email template doors (the bare read, `?package=`, the send) is the registry's own, so a package an operator disabled still counts as a holder there 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 it is fixed there, once.

Unchanged, and still refused with the same `422 NAMESPACE_CONFLICT` envelope and sentence:

- a second package of the SAME namespace (a co-owner) holding the name (at `install-local`, a co-owner depends on the holder, so the ADR-0146 D2 override write gate refuses it first, `403 NOT_OVERRIDABLE`, as before);
- a package that declares no namespace, beside any other holder of the name, and any holder beside it;
- two packages of one shareable namespace (`base`, `system`, `sys`) holding the name;
- a built-in name;
- the eight platform-requested `auth.*` templates the authentication plugin sends with no package (`auth.password_reset`, `auth.verify_email`, `auth.magic_link`, `auth.invitation`, `auth.account_invitation`, `auth.two_factor_otp`, `auth.email_change_notice`, `auth.existing_account_sign_up`). They join core's `PLATFORM_REQUESTED_BARE_NAMES_BY_TYPE`, so they resolve environment-wide and keep one overriding package per deployment: an app may still override the built-in, and a second app's override is refused naming the first.

A package that ships only locales its base does not ship (an addition to the base's template) is still judged against the base whatever the namespaces, and refused naming it, as before; a send made from it resolves the name from its closure, as any other package of it would. A dependent's locale authored in Studio stays under the dependent: the exact qualifier (`?package=ID`) now reads it, and a customization pair's added locale on its base's address, as that package's own (`@objectstack/metadata-protocol`).

Every other covered type still on one package holder per deployment (flow, action, webhook, connector, and the rest) keeps it. The webhook and connector halves of this child follow in their own changes.
2 changes: 2 additions & 0 deletions content/docs/kernel/runtime-services/email-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ services.email.sendTemplate(input: SendTemplateInput): Promise<SendEmailResult>

`services.email.getDefaultFrom?.()` returns the configured sender, the `from` that `send()` uses when the input names none. It is `undefined` when no sender is configured.

`SendTemplateInput` and `RenderTemplateInput` take an optional `callerPackageId`: the package the send is made from. The template name is then resolved from that package: its own template, else the one holder in its declared dependency closure, else `409 RESOURCE_CONFLICT` naming every candidate. A send that names no package reads the environment's own template, else the one package holder, else `409` naming every holder. The eight platform-requested `auth.*` mails resolve environment-wide whatever is passed. A flow's `notify` node passes its flow's package for you.

## Over HTTP

`POST /api/v1/email/send` (`client.email.send`) hands a message to `send()`. Plugins and flows call `send()` directly and are not affected by what follows. The HTTP door checks four things first, and refuses a failure with `403 PERMISSION_DENIED` before anything is sent:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@
* - C (`m31693c`) declares the picklist `m31693_hue`, which A does not depend on.
*
* A and B also each declare the agent `m31693_helper`, the skill
* `m31693_triage` and the object-less action `m31693_digest`: two namespaces'
* holders of one agent, one skill and one object-less action name, which the
* one-holder rule no longer calls a clash (#31701, #31697, ADR-0150 D2/D9), so
* `m31693_triage`, the object-less action `m31693_digest` and the email
* template `m31693_notice`: two namespaces' holders of one agent, one skill,
* one object-less action and one email template name, which the one-holder
* rule no longer calls a clash (#31701, #31697, #31699; ADR-0150 D2/D9), so
* the sealed-clash section never lists them.
*
* Over a database an older release left: two managed `sys_sharing_rule` rows
Expand Down Expand Up @@ -91,11 +92,12 @@ const rule = (name: string) => ({
accessLevel: 'read', sharedWith: { type: 'user', value: 'u1' }, active: true,
});
const list = (name: string) => ({ name, label: name, options: [{ label: 'One', value: 'one' }] });
/** The agent, the skill (#31701) and the object-less action (#31697) A and B both declare, in their own namespaces. */
/** The agent, the skill (#31701), the object-less action (#31697) and the email template (#31699) A and B both declare, in their own namespaces. */
const ai = (tag: string) => ({
agents: [{ name: 'm31693_helper', label: `${tag} helper`, role: `${tag} role`, instructions: `${tag} helper instructions` }],
skills: [{ name: 'm31693_triage', label: `${tag} triage`, description: `${tag} triage`, instructions: `${tag} triage instructions`, tools: [] }],
actions: [{ name: 'm31693_digest', label: `${tag} digest`, type: 'script', body: { language: 'js', source: `return '${tag}';`, capabilities: [] } }],
emailTemplates: [{ name: 'm31693_notice', label: `${tag} notice`, locale: 'en-US', subject: `${tag} notice`, bodyHtml: `<p>${tag}</p>`, active: true }],
});
const pkgA = {
manifest: {
Expand Down Expand Up @@ -297,7 +299,7 @@ describe('os migrate stored-references (the report)', () => {
d8: 'R17', object: 'm31693a_task', field: 'hue', packageId: A, name: 'm31693_hue', reason: 'outside-closure',
candidates: [{ holder: 'package', packageId: C, address: 'm31693c/m31693_hue' }],
}]);
// [#31701, #31697] Only the picklist: never the agent, the skill and the object-less action A and B hold in two namespaces.
// [#31701, #31697, #31699] Only the picklist: never the agent, the skill, the object-less action and the email template A and B hold in two namespaces.
expect(payload.sealedClashes).toEqual([{ type: 'picklist', name: 'm31693_tone', candidates: [`package "${A}"`, `package "${B}"`] }]);
// The served platform carries no automation or approvals table here: named, not silently skipped.
expect(payload.tablesNotCarried).toEqual(expect.arrayContaining(['sys_automation_run', 'sys_flow_credential', 'sys_approval_request']));
Expand Down
Loading
Loading