Skip to content

Commit 21d95f9

Browse files
committed
feat(spec): key emailTemplates by (name, locale); D3 entry, changeset, export-origins (#22934)
An email-template i18n bundle is one name across several locales (the registry's ITEM_KEY_DISCRIMINATORS entry), so the identity is the pair, an untagged entry keyed as the en-US floor. Measured by the spec's own stack-email-template-locale-floor pins, which author such bundles. Adds the D3 semantic entry stack-collection-duplicate-entry-refused, the changeset (Clause-②: no (narrowing)), and regenerates export-origins for the new deriveViewContainerObject export. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude <noreply@anthropic.com>
1 parent c1c13cb commit 21d95f9

6 files changed

Lines changed: 161 additions & 12 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata': patch
4+
---
5+
6+
feat(spec)!: `defineStack` refuses two entries of one named collection under one identity, naming the collection, the identity and every index — for 27 collections (#22934)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered stack-collection-duplicate-entry-refused -->
11+
12+
**BREAKING**: an accept-set narrowing on a published authoring surface, shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
The runtime registers the entries of these collections under one identity each, and a stack could author two entries under one identity with nothing refusing it: `defineStack` and `objectstack validate` accepted the pair (exit 0, `valid: true`, no finding naming it). Measured on a source boot (`objectstack serve`, no build) and on `objectstack build` followed by an artifact-backed boot:
15+
16+
- `objects`, `pages`, `dashboards`, `reports`, `datasets`, `flows`, `jobs`, `emailTemplates`, `books`, `positions`, `permissions`, `capabilities`, `sharingRules`, `webhooks`, `mappings`, `analyticsCubes`, `connectors`, `picklists`, `tools`, `skills`, `views`, `apis`, `apps` and `objectExtensions` kept only the LAST entry on both paths, with no notice at the default log level (for `apis`, the first entry's path answered 404);
17+
- `datasources`: the metadata described the last entry while the driver connected with the first one's configuration;
18+
- `hooks`: the metadata listed one hook while both handlers were bound and ran;
19+
- `docs`: `objectstack validate` and `objectstack build` refused the pair (`docs/duplicate-name`), while a source boot kept the last.
20+
21+
**What is refused.** `defineStack`'s cross-reference pass refuses the stack with its existing envelope (`STACK_CROSS_REFERENCE_INVALID`, status 422, `defineStack cross-reference validation failed (N issues):`), one finding per collision, naming the collection, the identity and every index:
22+
23+
```text
24+
✗ Datasource 'crm_external' is declared twice in `datasources`: stack.datasources[0] and stack.datasources[1]. …
25+
```
26+
27+
The identity is what the runtime keys an entry by: its `name`; the `(name, locale)` pair for `emailTemplates` (one name across several locales is an i18n bundle, and an entry with no `locale` counts as `en-US`); the extended object (`extend`) for `objectExtensions`; and for `views` the object a container binds to (`object`, else `list.data.object`, else `form.data.object`, else its `name`). The check runs in an object-less stack too, and every entry counts, byte-identical twins included. `objectstack validate`, `objectstack build` and a source boot all load the config through `defineStack`, so all three refuse it at load. Like every cross-reference check it is skipped under `defineStack(…, { strict: false })`. On an `app` package, two apps under one name now get this refusal, which runs before the single-app count.
28+
29+
**Unchanged.** Distinct identities; an email-template i18n bundle (one name, one entry per locale); the map form under distinct keys (two keys whose entries spell one explicit `name` are one identity, and are refused); the same name reused by another package of a multi-package project (`composeStacks(…, { manifest: 'preserve' })` keeps each package's collections in its own `packages[i]` body, which this check does not read); `plugins` (last-one-wins with a warning is the kernel's declared contract), `agents` (withdrawn for app packages) and `picklistExtensions` (merged by option value); and the existing `actions` and `functions` refusals.
30+
31+
## FROM → TO
32+
33+
| you wrote | write instead |
34+
|:--|:--|
35+
| `pages: [{ name: 'home', … }, { name: 'home', … }]` (any of the 24 name-keyed collections) | one entry per name: rename one (`home_admin`) and every reference that meant it, or merge the two into one entry |
36+
| `emailTemplates: [{ name: 'acme.welcome', … }, { name: 'acme.welcome', locale: 'en-US', … }]` | one entry per name and locale: give the second its own `locale` (`zh-CN`), or merge the two |
37+
| `datasources: [{ name: 'ext', config: a }, { name: 'ext', config: b }]` | one datasource per name — the driver used to connect `a` while the metadata described `b` |
38+
| `hooks: [{ name: 'audit', object: 'a', … }, { name: 'audit', object: 'b', … }]` | one hook with `object: ['a', 'b']`, or two hooks with two names |
39+
| `apis: [{ name: 'feed', path: '/api/v1/apps/x/a' }, { name: 'feed', path: '/api/v1/apps/x/b' }]` | give each endpoint its own name |
40+
| `views: [defineView({ object: 'task', list }), defineView({ object: 'task', form })]` | one container per object: `defineView({ object: 'task', list, form })` (named views go in `listViews` / `formViews`) |
41+
| `objectExtensions: [{ extend: 'task', fields: { a } }, { extend: 'task', fields: { b } }]` | one extension per target: `{ extend: 'task', fields: { a, b } }` |
42+
43+
**The one-line fix: give each entry of a collection its own identity, or merge the duplicates into one entry.**
44+
45+
No D2 conversion rewrites it: which entry keeps the identity, and what the other one becomes, is the author's call. Its D3 record is the semantic entry `stack-collection-duplicate-entry-refused` (protocol 18).
46+
47+
**`@objectstack/metadata`.** `deriveViewContainerObject` — the "which object does a `views` container bind to" derivation the registrars file containers by — moved into `@objectstack/spec` (exported from its root and `./ui`), because the stack contract now judges the same slot. `@objectstack/metadata` re-exports that one declaration under the same name, from its root and from `@objectstack/metadata/view-container`; behaviour is unchanged. That subpath used to import nothing and now loads the `@objectstack/spec` root entry, which every in-repo importer of it already loads.
48+
49+
**Who is affected, measured.** The four example apps (`examples/app-crm`, `app-todo`, `app-showcase`, `app-multi-package`) carry no duplicate under these identities. Deployed metadata and other repositories were not measured.

‎packages/spec/export-origins/root.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@
173173
"defineView": "src/ui/view.zod.ts#defineView (function)",
174174
"defineViewItem": "src/ui/view.zod.ts#defineViewItem (function)",
175175
"defineWebhook": "src/automation/webhook.zod.ts#defineWebhook (function)",
176+
"deriveViewContainerObject": "src/ui/view-container-object.ts#deriveViewContainerObject (function)",
176177
"expandViewContainer": "src/ui/view.zod.ts#expandViewContainer (function)",
177178
"expandViewContainerWithDiagnostics": "src/ui/view.zod.ts#expandViewContainerWithDiagnostics (function)",
178179
"expression": "src/shared/expression.zod.ts#expression (function)",

‎packages/spec/export-origins/ui.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,7 @@
496496
"defineView": "src/ui/view.zod.ts#defineView (function)",
497497
"defineViewItem": "src/ui/view.zod.ts#defineViewItem (function)",
498498
"deriveColumnSummary": "src/ui/view-grouping-query.ts#deriveColumnSummary (function)",
499+
"deriveViewContainerObject": "src/ui/view-container-object.ts#deriveViewContainerObject (function)",
499500
"diagnoseViewMetadata": "src/ui/view.zod.ts#diagnoseViewMetadata (function)",
500501
"expandViewContainer": "src/ui/view.zod.ts#expandViewContainer (function)",
501502
"expandViewContainerWithDiagnostics": "src/ui/view.zod.ts#expandViewContainerWithDiagnostics (function)",
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { SemanticMigration } from '../../types.js';
4+
5+
// #22934. An accept-set narrowing of the stack aggregate: two entries of one
6+
// of 27 named collections that the runtime registers under one identity are
7+
// refused by the cross-reference pass — the batch sibling of the duplicate
8+
// action-key refusal and of the `functions` array's duplicate-name refusal. No
9+
// D2 conversion: which entry keeps the identity, and what the other one
10+
// becomes, is the author's call.
11+
export const entry: SemanticMigration = {
12+
id: 'stack-collection-duplicate-entry-refused',
13+
// No backticks in `surface` — build-upgrade-guide renders it inside a code
14+
// span already, and a nested backtick would close it.
15+
surface:
16+
'stack.objects, datasources, pages, dashboards, reports, datasets, flows, jobs, emailTemplates, books, '
17+
+ 'positions, permissions, capabilities, sharingRules, webhooks, hooks, mappings, analyticsCubes, '
18+
+ 'connectors, picklists, docs, tools, skills, views, apis, apps and objectExtensions — two or more '
19+
+ "entries of one collection under one identity, e.g. pages: [{ name: 'home', ... }, { name: 'home', ... }]",
20+
replacement:
21+
'One entry per identity in each collection. The identity is the entry name, the name and locale '
22+
+ 'for emailTemplates (one name across several locales is an i18n bundle; an entry with no locale '
23+
+ 'counts as en-US), the extended object (extend) for objectExtensions, and for views the object a '
24+
+ 'container binds to (object, else '
25+
+ 'list.data.object, else form.data.object, else its name). Rename one of the colliding entries (and '
26+
+ 'every reference that meant it), or merge them into a single entry: one container per object for '
27+
+ 'views, one extension per target object for objectExtensions, and a name of its own for each apis '
28+
+ 'endpoint. Distinct identities, an email-template i18n bundle, the map form under distinct keys, '
29+
+ 'and the same name reused by another package are unchanged.',
30+
reason:
31+
'The runtime registers each of these collections under one identity per entry, and a stack could '
32+
+ 'author two entries under one identity with nothing refusing it: defineStack and objectstack '
33+
+ 'validate accepted the pair (exit 0, valid true), and the boot then lost or split it silently. '
34+
+ 'Measured on a source boot and on objectstack build followed by an artifact-backed boot: 23 '
35+
+ 'collections and objectExtensions kept only the LAST entry on both paths, with no notice at the '
36+
+ 'default log level; for datasources the metadata described the last entry while the driver '
37+
+ 'connected with the first one\'s configuration; for hooks the metadata listed one hook while both '
38+
+ 'handlers were bound and ran; and for docs objectstack validate and objectstack build refused the '
39+
+ 'pair while a source boot kept the last. The stack aggregate now refuses the pair, in the same '
40+
+ 'cross-reference envelope as a duplicate action key, with one finding per collision naming the '
41+
+ 'collection, the identity and every index. Not convertible: which entry keeps the identity is a '
42+
+ 'fact only the author holds.',
43+
acceptanceCriteria:
44+
'os validate and os build load the stack without a STACK_CROSS_REFERENCE_INVALID finding of the form '
45+
+ '"X is declared twice in KEY: stack.KEY[i] and stack.KEY[j]". After renaming or merging, check that '
46+
+ 'what the runtime now serves is the entry you meant: the boot used to keep the last entry (and, for '
47+
+ 'datasources, connect the first), so the behaviour you observed may have come from a different entry '
48+
+ 'than the one you keep.',
49+
};

‎packages/spec/src/stack-duplicate-entry.test.ts‎

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,10 @@
1616
* docs : os validate / os build refuse (docs/duplicate-name), a source boot keeps the last
1717
* ```
1818
*
19-
* The identity is what the registrars key an entry by: `name` (25 keys),
20-
* `extend` (`objectExtensions`), and for `views` the object an aggregated
21-
* container binds to (`deriveViewContainerObject`). Out of the table, each
19+
* The identity is what the registrars key an entry by: `name` (24 keys),
20+
* `(name, locale)` (`emailTemplates`, whose i18n bundle is one name across
21+
* several locales), `extend` (`objectExtensions`), and for `views` the object
22+
* an aggregated container binds to (`deriveViewContainerObject`). Out of the table, each
2223
* for its own reason: `plugins`, `agents`, `picklistExtensions`, and `actions`
2324
* / `functions`, which have their own checks (`stack-duplicate-action-key`,
2425
* `stack-duplicate-function-name`).
@@ -119,7 +120,14 @@ const BATCH: readonly Row[] = [
119120
byName('datasets', 'Dataset', dataset),
120121
byName('flows', 'Flow', flow),
121122
byName('jobs', 'Job', { name: 'dupe_job', label: 'Job', schedule: { type: 'interval', intervalMs: 60000 }, body: { language: 'js', source: 'return 1;', capabilities: [] } }),
122-
byName('emailTemplates', 'Email template', { name: 'dupe_mail', label: 'Mail', subject: 'Hi', bodyHtml: '<p>Hello</p>' }),
123+
{
124+
// An i18n bundle is one name across several locales: the identity is the
125+
// pair, an untagged entry keyed as the floor tag `en-US`.
126+
...byName('emailTemplates', 'Email template', { name: 'dupe_mail', label: 'Mail', subject: 'Hi', bodyHtml: '<p>Hello</p>' }),
127+
identity: 'dupe_mail',
128+
sameIdentity: (e) => ({ ...e, label: 'Second', locale: 'en-US' }),
129+
subject: "Email template 'dupe_mail' for locale 'en-US'",
130+
},
123131
byName('books', 'Book', { name: 'dupe_book', label: 'Book', groups: [] }),
124132
byName('positions', 'Position', { name: 'dupe_pos', label: 'Pos' }),
125133
byName('permissions', 'Permission set', { name: 'dupe_perm', label: 'Perm', objects: {} }),
@@ -372,6 +380,20 @@ describe('defineStack - CONTROL: distinct identities and the out-of-batch collec
372380
},
373381
);
374382

383+
it('emailTemplates: one name across several locales is an i18n bundle, not a duplicate', () => {
384+
const mail = (locale?: string) => ({
385+
name: 'dupe_mail',
386+
label: `Mail ${locale ?? 'floor'}`,
387+
...(locale === undefined ? {} : { locale }),
388+
subject: 'Hi',
389+
bodyHtml: '<p>Hello</p>',
390+
});
391+
expect(refusal({ manifest, objects: [thing], emailTemplates: [mail(), mail('zh-CN'), mail('ja-JP')] })).toBeNull();
392+
const r = refusal({ manifest, objects: [thing], emailTemplates: [mail(), mail('zh-CN'), mail('zh-CN')] });
393+
expectCrossReferenceEnvelope(r, 1);
394+
findingFor(r, 'emailTemplates', "Email template 'dupe_mail' for locale 'zh-CN'", [1, 2]);
395+
});
396+
375397
it('the cross-scope action pair — one global, one object-bound, one name — stays accepted', () => {
376398
const act = { name: 'dupe_go', label: 'Go', type: 'script' as const, target: 'noop' };
377399
expect(refusal({ manifest, objects: [thing], actions: [act, { ...act, objectName: 'dupe_thing' }] })).toBeNull();

‎packages/spec/src/stack.zod.ts‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2306,11 +2306,13 @@ function collectDuplicateFunctionNameErrors(config: ObjectStackDefinition): stri
23062306
* - `docs`: `os validate` and `os build` refused the pair (`docs/duplicate-name`)
23072307
* while a source boot kept the last.
23082308
*
2309-
* The identity is the string the registrars key an entry by: `name` for 25
2310-
* collections, the extended object (`extend`) for `objectExtensions`, and for
2311-
* `views` the slot every registrar files the entry under — an aggregated
2312-
* container under the object it binds to ({@link deriveViewContainerObject}),
2313-
* any other entry under its own `name`, falling back to the same derivation.
2309+
* The identity is the string the registrars key an entry by: `name` for 24
2310+
* collections, the `(name, locale)` pair for `emailTemplates` (an i18n bundle
2311+
* is one name across several locales), the extended object (`extend`) for
2312+
* `objectExtensions`, and for `views` the slot every registrar files the entry
2313+
* under — an aggregated container under the object it binds to
2314+
* ({@link deriveViewContainerObject}), any other entry under its own `name`,
2315+
* falling back to the same derivation.
23142316
* An entry with no readable identity claims no slot and is not counted.
23152317
*
23162318
* Deliberately NOT in the table, each for a reason that is not "nobody looked":
@@ -2325,8 +2327,11 @@ function collectDuplicateFunctionNameErrors(config: ObjectStackDefinition): stri
23252327
* map-supported collection reaches this check already normalized (key → `name`,
23262328
* an explicit `name` winning), so two map keys whose entries spell one `name`
23272329
* collide here too. Cross-package reuse of a name is untouched (ADR-0048 §3.4):
2328-
* a composed project carries each package's collections in its own
2329-
* `packages[i]` body, never at the top level this check reads.
2330+
* a multi-package project (`composeStacks(…, { manifest: 'preserve' })`)
2331+
* carries each package's collections in its own `packages[i]` body, which this
2332+
* check does not read. The same-package case is the one §3.4 leaves to
2333+
* authoring-time hygiene (`os lint`'s duplicate-name advisory); it is refused
2334+
* here at the contract, as the action-key check already does for actions.
23302335
*/
23312336
interface SameStackEntryIdentity {
23322337
/** The identity the registrars key this collection's entries by, or `undefined` when an entry has none. */
@@ -2376,7 +2381,29 @@ const SAME_STACK_ENTRY_IDENTITY = Object.freeze({
23762381
datasets: lastWinsByName('Dataset', 'datasets'),
23772382
flows: lastWinsByName('Flow', 'flows'),
23782383
jobs: lastWinsByName('Job', 'jobs'),
2379-
emailTemplates: lastWinsByName('Email template', 'email templates'),
2384+
// An i18n bundle is one `name` across several `locale` tags, so the identity
2385+
// is the pair — the registry's `ITEM_KEY_DISCRIMINATORS` entry, keyed as
2386+
// `name@locale` with an untagged entry keyed as the floor tag (the schema's
2387+
// own default). Two entries collide only on one name AND one locale.
2388+
emailTemplates: {
2389+
identity: (entry) => {
2390+
const name = readEntryName(entry);
2391+
if (name === undefined) return undefined;
2392+
const locale = (entry as { locale?: unknown }).locale;
2393+
const tag = typeof locale === 'string' && locale.trim() ? locale.trim() : EMAIL_TEMPLATE_FLOOR_LOCALE;
2394+
return `${name}@${tag}`;
2395+
},
2396+
subject: (key) => {
2397+
const at = key.lastIndexOf('@');
2398+
return `Email template '${key.slice(0, at)}' for locale '${key.slice(at + 1)}'`;
2399+
},
2400+
consequence: () =>
2401+
'The runtime keys email templates by name and locale, so only the last of these entries is kept ' +
2402+
'for that locale and the others are silently dropped.',
2403+
remedy: () =>
2404+
'Rename one of them, give each its own `locale` (one entry per locale is an i18n bundle), or merge ' +
2405+
'them into a single entry.',
2406+
},
23802407
books: lastWinsByName('Book', 'books'),
23812408
positions: lastWinsByName('Position', 'positions'),
23822409
permissions: lastWinsByName('Permission set', 'permission sets'),

0 commit comments

Comments
 (0)