Skip to content

Commit 038d4c9

Browse files
feat(metadata-protocol): one language of an email template is addressed by name — the by-name /meta doors accept a bundle member selector (#22877, stage 2) (#23070)
Fixes #22877 Clause-②: yes Widens: the accept set of the five by-name `/meta` doors (GET, DELETE, `/history`, `/publish`, `/rollback` on `/meta/:type/:name`) by one query parameter, `member`, and the four protocol requests behind them (`GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest`, `HistoryMetaItemRequest`) by one optional field, `member`; and three exported surfaces, each by one optional `member`: the last argument of `SchemaRegistry.getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry` (`@objectstack/objectql`), the `MetadataMutationEvent` field (`@objectstack/metadata-protocol`, also carried on the `ClusterMetadataMutationPayload` a peer accepts), and the header on each `SysMetadataRepository.listDrafts` entry. Stage 2 of 2 of ruling 6116165299 (letter A, Q2 i). Stage 1 (PR #22954, `5677add93f`) stores two locales of one `email_template` as two `sys_metadata` rows (`bundle_member`); every by-name door was still canonical-only. This PR lets each by-name door address one member, and carries the four items stage 1 left (seat answers 6118224899, landing record 6121513183). The ruling, quoted: > **Q2 → i:** `PUT` stays body-addressed. GET, DELETE, publish, history and revert by name accept one member selector in their closed query-parameter set (AGENTS.md route rule 5), named after the discriminator so it cannot be read as the UI translation locale (`?locale=` on `/meta` already means that); absent, it means the canonical member, which is what the registry's bare-name read answers; a wrong value is a located 400. This widens the doors' accept set: `Clause-②: yes`, a contract-tier review before landing. ## What changed ### 1. The selector (`metadata-protocol`, `rest`, `spec`) - **The name is `member`.** It is what the stored column holds (`sys_metadata.bundle_member`), and it is type-neutral. It is not `locale`, which on `/meta` already names the response translation locale (`extractLocale`). The seat suggested `member`; nothing measured argued for another name. - **One reading, shared by every door:** `resolveBundleMemberSelector` in `protocol.ts`, beside stage 1's member plumbing. Each of the five protocol doors calls it once, right after its type fold: `getMetaItem`, `deleteMetaItem`, `publishMetaItem`, `historyMetaItem`, `rollbackMetaItem`. - Absent means the canonical member. Naming the canonical value (`en-US`) is the same request. The value is trimmed. - Refused with a located 400 `VALIDATION_ERROR` (`param: 'member'`, the message names the selector): - a selector on a type with no discriminator (every type except `email_template`); - a value that is not a string; - a blank value (never read as absent); - a value longer than 100 characters, the declared `maxLength` of `bundle_member` (pinned equal to `SysMetadataObject`). - A well-formed member with nothing stored or shipped, measured per door: - GET answers it as absent (protocol `item: undefined`, REST 404); - DELETE answers `reset: false`; - publish answers `404 NO_DRAFT`; - history answers `{ events: [] }`; - revert answers `404 VERSION_NOT_FOUND`. - **The requests carry it.** The REST door builds each typed request literal against the spec request shapes (`TransportScopedMetaRequest`). So `GetMetaItemRequestSchema`, `DeleteMetaItemRequestSchema`, `PublishMetaItemRequestSchema` and `HistoryMetaItemRequestSchema` gain one optional `member` with one shared describe (cross-lane `domain:spec`). `rollbackMetaItem` has no declared request (the door calls it through a cast), so none was added. `authorable-surface/api.json` and `content/docs/references/api/protocol.mdx` are regenerated. ### 2. The five routes' closed query sets (`rest`, AGENTS.md route rule 5) None of the five routes declared a closed set before. Each set below is measured from the handler's own read points and from the helpers it calls, and each route now opens with `refuseUnknownQueryParams`, ahead of its arity gate: | route | closed set | read points | |:---|:---|:---| | `GET /meta/:type/:name` | `layers`, `state`, `preview`, `package`, `locale`, `member` | `wantsMetaItemLayers`; the two admitted draft switches; `metaItemPackageBinding` and `serveMetaItemLayered`; `locale` a frame down (`extractLocale` behind the cached validator and every translation) | | `DELETE /meta/:type/:name` | `state`, `dropStorage`, `member` | the draft discard and the opt-in teardown | | `GET …/history` | `sinceSeq`, `limit`, `member` | the declared paging pair | | `POST …/publish` | `package`, `member` | `metaItemPackageBinding` | | `POST …/rollback` | `toVersion`, `member` | the query fallback after the body | - `member` joins each route's `refuseRepeatedQueryParams` list too. - One reading of the parameter, `metaMemberSelector`, is shared by the five handlers. It passes a non-string value through, so the protocol refuses it; it is never dropped. - A GET naming a member bypasses the cached arm, because `getMetaItemCached` takes no member. - `?layers=` together with `?member=` is refused. The layered view reads no member, and answering with the canonical member's layers would be the silent wrong answer. - In-repo callers were measured, and none sends a parameter outside these sets: `@objectstack/client` and `objectui` at the pinned `9646e2ed2b` (`DraftChangesPanel`, `ResourceEditPage`, `client.meta.*`). ### 3. The registry's bare-name read (`objectql` `SchemaRegistry.getItem`) - A bare-name read of a discriminated type now answers the **canonical** member, wherever it sits in the tier order (overlay, then prefer-local, then the first composite). - Before, the first tier that held any member won, and canonical-first applied only inside that tier. So a `zh-CN`-only overlay answered a read that named no member. This was the half-false premise recorded in 6118224899. - A bundle with no canonical member still answers its first member, as before. - `getItem` and `getArtifactItem` take an optional `member` (a discriminator value), which narrows every tier to that member. ### 4. `removeRuntimeShadow` on a by-name reset (stage 1 finding 1) - `removeRuntimeShadow(type, name, member?)` and `removeOverlayEntry(type, name, member?)` address one member when one is named. With no member, they keep the group sweep, the shape `unregisterItem` takes for a withdrawal by name. - `restoreArtifactRegistryView` names the member on every tier. A MetadataService baseline of another member is not this member's baseline: that service keys a type's items by name alone. - **Found by the dogfood, fixed here:** a member's shadow is lifted when the bundle ships *any* artifact, not only an artifact of that member. - A hydrated overlay of a member the package does not ship wears the bundle's artifact envelope (`_packageId`, `_provenance` grafted by `mergeArtifactProtection`). So `removeOverlayEntry` refuses it as code. - On the showcase, a `zh-CN` reset therefore kept sending the reset wording until the next boot. - It is pinned in the objectql harness against a versioned package, so artifacts carry the envelope. ### 5. Package-batch doors and the cluster fan-out (stage 1 finding 2) - `SysMetadataRepository.listDrafts` headers carry the stored `member` when it is non-canonical. The protocol's public `listDrafts` serves its declared header without it. - **`publishPackageDrafts`:** - each draft is promoted as its member; - the pre-publish capture reads that member's active row; - the revert plan item records `member`; - a failed batch names the causal draft by member. - **`discardPackageDrafts`** (and `revertStoredPackage`, through `discardDraftInItsScope`) discards each draft as its member. - **`revertCommit`** reads, deletes, restores, re-binds and heals each plan item as its member. The revert's own plan carries the member too. `listCommits` serves the declared four keys. - **The cluster fan-out:** - `MetadataMutationEvent` gains an optional `member` (present only for a non-canonical member); - save, publish, delete, rollback and revert stamp it; - `applyRemoteMetadataMutation` re-reads, re-binds and heals that member's row. ## Pins (real code) `packages/objectql/src/environment-overlay-email-locale-restart.test.ts`, stage 1's harness (real `ObjectQL` and protocol; a restart is a new engine over the same stores). It is now a versioned package that also ships an `en-US`-only template: - GET: each member by name; absent and `en-US` both answer the canonical member; trimmed. - GET: a member nobody stored answers the code layer's member; one that no layer holds answers nothing. - DELETE by member: `zh-CN` reset → its row alone goes, and the `en-US` overlay stays the registry's entry, at once and after a restart. - DELETE of a member no artifact ships: its overlay leaves the registry and the layered list, and the canonical overlay stays. - DELETE with no member (the reverse): the canonical member is reset, and the `zh-CN` overlay stays registered. - publish by member: a `zh-CN` draft goes live. CONTROL: the bare publish answers `NO_DRAFT`. - history and revert by member: - one shared counter, three distinct versions; - each member sees its own events; - a revert to another member's version answers `404 VERSION_NOT_FOUND`. - CONTROL: a bare-name revert reverts the canonical member to its own version and leaves `zh-CN`. - A wrong selector is a located 400 on all five doors (a `view` type, blank, 101 characters), and the store bytes are unchanged. - `publishPackageDrafts`, `revertCommit` and `discardPackageDrafts` with a two-member bundle where `zh-CN` alone has a draft. The commit timeline and the draft list keep their declared keys. - The cluster fan-out (two control-plane replicas over one store, a delivering bus): the peer converges on the member that changed, and a member reset heals only that member. - The six stage 1 pins, unchanged. `packages/objectql/src/registry-i18n-bundle-key.test.ts`: - the bare-name read answers the canonical member with a `zh-CN`-only overlay; - named-member reads, artifact included; - the member-addressed heal, including the shipped-bundle and unshipped-bundle cases; naming no member keeps the sweep. `packages/metadata-protocol/src/protocol.bundle-member-selector.test.ts`: the one reading — every refusal, the trimmed pass, and the length bound pinned equal to the declared column. `packages/rest/src/rest-server-meta-member-selector.test.ts`, 30 cases. Per route: - **refusal:** a misspelled selector is answered with status, nested `error.code` and the exact located message, and the protocol is never called; a repeated selector is refused; - **preservation:** the exact request the protocol received, the member and every measured name included, `locale` too; GET bypasses the cache with a member and keeps it without one; - the layered refusal; - a CONTROL that no selector sends no `member` key; - the value refusals from a **real** protocol over an engine that is never reached. `packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts`, stage 1's showcase case extended, on a real SQLite file and real `sendTemplate`: - `?member=zh-CN` reads the `zh-CN` wording; - `DELETE ?member=zh-CN` resets `zh-CN` alone; - after a cold boot, `en-US` keeps the admin's wording and `zh-CN` falls to the `en-US` floor (the showcase ships `en-US` only); - `?member=zh-CN` is then 404; - a misspelled selector, and a selector on a type with no members, are located 400s. This case also measures that the showcase's `/meta` item read is served by `RestServer`'s route, which reads the selector. ## Evidence Every heavy run went through `scripts/pm/os-verify-lock.sh`, and each read its `VERDICT command-exit 0` line. Exit codes were captured before any pipe. The final head is `2b882e6774`, which merges `origin/main` `df7a137923`; the incoming commits touch none of this PR's files. **Gates.** - The derived union is `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `2b882e6774`: **118 commands, 118 exit 0**. - Reconciled with `--ran` (exit codes recorded): `118 derived, 118 run, 0 NOT-MEASURED, 0 UNRUN`. - The lead list of 86 commands at `0ede0f8ab3` is a subset of it. - An earlier round at `12b4b5047f` had `check:skill-examples` and `check:dual-build-cjs-loads` at exit 3 (PREREQUISITE NOT MET). After a full packages build (`turbo`, 72 tasks, exit 0) both pass, and the final round has them green. **Suites.** - `objectql`, full (`vitest run`): `Test Files 407 passed (407)`, `Tests 7955 passed`. This ran before the last registry commit (`2b882e6774`). - After that commit: - every `objectql` test file that names `removeRuntimeShadow`, `removeOverlayEntry` or `email_template`: 13 files, `246 passed`; - the restart harness and the registry bundle file: 2 files, `40 passed`. - `metadata-protocol`, full, at `12b4b5047f`: `Test Files 228 passed | 3 skipped (231)`, `Tests 28196 passed | 19 skipped`. - `rest`, full, at `00886e17d7` (the merge touched no `rest` file): `Test Files 279 passed (279)`, `Tests 5960 passed | 327 skipped`. After the merge, 6 targeted files (the new pins, the closed-set and multiplicity files, absent-404, history/diff door, publish package scope): `141 passed`. - `spec`: `protocol.test.ts` and `type-alias-convention.pin.test.ts`, `187 passed`. - Typecheck exit 0 for `spec`, `metadata-protocol`, `objectql` (with `check:test-typecheck`), `rest` (with `check:test-typecheck`) and `dogfood`. - `dogfood`: `email-template-two-locales-survive-boot.dogfood.test.ts`, `4 passed (4)`, after `objectql` was rebuilt at `2b882e6774`. - `pnpm --filter @objectstack/spec check:generated` named `check:docs` stale; `gen:docs` regenerated one page. `check:authorable-surface` wrote the 4 new request keys into `authorable-surface/api.json`. **ESLint, a proven narrowing.** - `pnpm exec eslint --no-inline-config --format json` over the 11 changed `.ts` files: 11 files linted, 0 errors, 0 warnings. - The population is `eslint.config.mjs`'s `files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']` minus `NEVER_LINTED`. No file was reported ignored. - That config never enables type-aware linting (no `parserOptions.project`, as its own header records), so this diff cannot move the verdict on any file it does not touch. - The repo-wide `pnpm lint` is CI's. ## Ablations Each ablation went through `scripts/ablation-replace.mjs` (wrap mode, on committed HEAD; anchor count 1 then 0, blob changed, restored blob equal to HEAD, `git diff HEAD` empty). A `metadata-protocol` mutation was rebuilt, because `objectql` resolves it through `dist`, a `KNOWN_UNALIASED_TEST_IMPORTS` pair. `ablation-dist-preflight` was run present on the mutation leg and `--absent` on the restore leg. 1. **`rollbackMetaItem` without the member** (the `restoreVersion` member option replaced by an expression that is `undefined` at runtime, with the marker `__ABLATION_22877_ROLLBACK_MEMBER_DROPPED`). The restart harness went `1 failed | 17 passed`. Only the member-revert pin was red (`No history row at version 2 … (its canonical member)`); the bare-name revert CONTROL stayed green. Restored: `18 passed`. 2. **The registry tier-order change** (`getItem`'s bare-name branch back to the first tier's first member, with the marker `__ABLATION_22877_REGISTRY_TIER_ORDER`; `objectql` source, so no build). The registry bundle file and the harness went `1 failed | 38 passed`. Only `a bare-name read answers the canonical member when only a second locale is overlaid` was red. The harness's bare-name pins stay green under this ablation, because `getMetaItem` answers the canonical artifact before it asks the registry, which is a second layer by design. Restored: `39 passed`. 3. **The registry heal without the member** (`removeRuntimeShadow(singular, name, memberKey)` with `memberKey` replaced). The harness went `3 failed | 15 passed`: DELETE by member, DELETE with no member (the reverse), and the cluster member reset were red, while stage 1's store-level reset pin stayed green. Restored: `18 passed`. 4. **The package-batch doors without the member.** - With the publish promotion's member dropped, the batch answered `success: false`, `outcome: refused`, `failed: [{ code: NO_DRAFT, error: No pending draft exists for email_template/acme.welcome — nothing to publish. }]`. That is the measured shape behind the changeset's sentence; both the publish and the revert pins were red. - With the discard's member dropped, the discard answered success while the `zh-CN` draft row stayed. - Both were restored, and the dist was proven absent. These four ablations ran at `fdf01eb9e9`, the head before `2b882e6774` (the versioned harness package, the unshipped-member pin and the bundle-level shadow test), when the restart harness had 18 cases. The code each one removed is unchanged since. At `2b882e6774` the harness has 19 cases, all green. ## Deviations, stated - **The value refusal's envelope.** - The selector's value is judged by the protocol, so the REST door renders that refusal through its one error door, `handleRouteError`. It comes out flat: `body.code` is `VALIDATION_ERROR`, and `body.error` is the sentence. - The route's own recognition refusal of a misspelled name is nested: `body.error.code`. - This is the existing split for every refusal the protocol raises on these routes (`check:route-envelope`, the repo-wide ratchet). The GET handler's own note says why converting part of it here would mint a new divergence. - The code is the same in both: `VALIDATION_ERROR`. - The `?layers=` plus `?member=` refusal is raised by the GET handler itself, not by the protocol. It is thrown into the same `handleRouteError`, so it is flat too; that is the handler's only pre-existing shape for a 400 of its own, so it mints no new dialect. - **Response contracts are unchanged.** - `GET /meta/_drafts` and `GET /packages/:id/commits` do not serve `member`. The ruling widens the doors' accept set, not these responses. - Two drafts or plan items of one name in two languages therefore read as two entries with the same `type` and `name` there. - **`removeRuntimeShadow` semantics, named member.** The artifact test stays at the level of the name: a packaged artifact of the bundle remains, as the method's contract always said, so it does not have to be an artifact of that member. Section 4 says why. - **File surface.** Every edit is inside the claim's listed files, plus: - the four test files and the dogfood case the claim names; - `protocol.lookup-artifact-item-call-sites.test.ts`, whose census records the re-scoped artifact-lookup site and the definition's new parameter; - two regenerated artifacts: `authorable-surface/api.json` and the protocol reference page. ## Acceptance notes - **The runtime dispatcher's `/meta` item read** (`packages/runtime/src/domains/meta.ts`) does not read `?member=`. A host served by the dispatcher alone would answer `GET …?member=zh-CN` with the canonical member, because that transport has no closed query set at all. The showcase is served by `RestServer` (measured by the dogfood case above). This is outside the claim's surface (`packages/runtime`), and is not measured on a dispatcher-only host. - **By-name doors not in the ruling's list** stay canonical-only: `/diff`, `/layers`, `/audit`, `/published` and `/references`. - **The MetadataService** (`MetadataManager`) keys a type's items by name alone, so for a bundle it holds one member. `getMetaItem` now takes its answer only when it is the addressed member; the service itself is unchanged. - **The environment kernel's write-through.** On an environment kernel, a save and a peer's convergence do not write a non-object overlay through to the registry (#6602, by design). The cluster pin therefore boots control-plane replicas. - **The untranslated `bundle_member` keys** stay owed to `area:i18n`, as the claim says. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 275ad42 commit 038d4c9

14 files changed

Lines changed: 1385 additions & 105 deletions
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/rest': minor
4+
'@objectstack/spec': minor
5+
'@objectstack/objectql': minor
6+
---
7+
8+
feat(metadata-protocol): one language of an email template is read, reset, published, viewed in history and reverted by name — the by-name `/meta` doors accept a bundle member selector (#22877)
9+
10+
Clause-②: yes
11+
12+
**What an administrator can now do.** An email template kept in several languages is one item per language in the store. Each language can now be addressed by name on every by-name door, with `?member=` naming the language:
13+
14+
- `GET /api/v1/meta/email_template/:name?member=zh-CN` reads the `zh-CN` wording: the stored customization when there is one, else the package's `zh-CN` template. A language that nothing stores or ships answers as an absent item (404).
15+
- `DELETE /api/v1/meta/email_template/:name?member=zh-CN` resets the `zh-CN` customization alone; the other languages keep theirs.
16+
- `POST /api/v1/meta/email_template/:name/publish?member=zh-CN` publishes the `zh-CN` draft.
17+
- `GET /api/v1/meta/email_template/:name/history?member=zh-CN` lists the `zh-CN` saves. All languages of one template share one version counter, so the version numbers of one language are not consecutive.
18+
- `POST /api/v1/meta/email_template/:name/rollback?member=zh-CN` reverts the `zh-CN` customization to one of its own versions. A version saved for another language answers `404 VERSION_NOT_FOUND`.
19+
20+
With no `?member=`, every door addresses the canonical member (`en-US`), exactly as before; naming `en-US` is the same request. This closes the window the previous release's changeset stated: a non-`en-US` language could not yet be reset, published, listed or reverted by name.
21+
22+
**The selector.** It is named `member`, after what the store keys a row by, and not `locale`: on `/meta`, `?locale=` already chooses the language the response is translated into. The protocol requests gain one optional field, `member`, on `GetMetaItemRequest`, `DeleteMetaItemRequest`, `PublishMetaItemRequest` and `HistoryMetaItemRequest`. A wrong value is refused with `400 VALIDATION_ERROR`, naming the parameter: the selector on a type whose items have no languages (every type except `email_template` today), a blank value, or a value longer than a stored member can hold (100 characters).
23+
24+
**Each of the five routes now declares its query parameters.** `GET`, `DELETE`, `/history`, `/publish` and `/rollback` on `/meta/:type/:name` refuse a query parameter they do not read with `400 VALIDATION_ERROR`, naming the parameter and listing the supported ones, instead of ignoring it. A misspelled `?memeber=zh-CN` is therefore refused, not answered with the `en-US` wording. The supported names are the ones each route has always read, plus `member`:
25+
26+
- `GET /meta/:type/:name`: `layers`, `state`, `preview`, `package`, `locale`, `member`.
27+
- `DELETE /meta/:type/:name`: `state`, `dropStorage`, `member`.
28+
- `GET /meta/:type/:name/history`: `sinceSeq`, `limit`, `member`.
29+
- `POST /meta/:type/:name/publish`: `package`, `member`.
30+
- `POST /meta/:type/:name/rollback`: `toVersion`, `member`.
31+
32+
A client that sent any other parameter to these routes was having it ignored; it now gets a 400 and can drop it. The deprecated `?layers=` view reads no language, so `?layers=` together with `?member=` is refused rather than answered with the `en-US` layers. A read naming a member skips the shared read cache.
33+
34+
**What else now follows the language** (`@objectstack/objectql`, `@objectstack/metadata-protocol`):
35+
36+
- A by-name read that names no language answers the `en-US` template even when only another language is customized. Before, the registry's read of the name could answer the other language's customization.
37+
- Resetting one language keeps the other languages' customizations served at once, not only after the next restart.
38+
- Publishing, discarding and reverting a package's changes (`POST /packages/:id/publish-drafts`, discard, and commit revert) address each language's draft or change as its own item. Before, a package whose only pending draft was a second language failed to publish (`NO_DRAFT`), and a discard reported that draft discarded while it stayed. The draft list and the commit timeline keep the fields they declare.
39+
- In a cluster, a save, reset or revert of one language converges the other servers on that language.
40+
41+
**For code that uses these packages directly.** Every addition below is optional; code that does not pass it behaves as before, except the bare-name read noted in the first item.
42+
43+
- `SchemaRegistry` (`@objectstack/objectql`) takes an optional last argument, `member`, on `getItem`, `getArtifactItem`, `removeRuntimeShadow` and `removeOverlayEntry`. It is the discriminator value of one bundle member, such as `'zh-CN'`.
44+
- `getItem(type, name, packageId, member)` answers that member, in the usual order (customization, then the requested package, then any package), or `undefined`. With no `member`, `getItem` now answers the canonical (`en-US`) member of a bundle. A bundle with no `en-US` member still answers its first member.
45+
- `getArtifactItem(type, name, packageId, member)` answers that member's packaged definition.
46+
- `removeRuntimeShadow(type, name, member)` and `removeOverlayEntry(type, name, member)` remove that member's customization entry only. With no `member`, they remove the whole bundle's customization entries, as before.
47+
- A type whose items have no languages ignores the argument.
48+
- `MetadataMutationEvent` (`@objectstack/metadata-protocol`) has an optional `member`: the language the mutated row belongs to. It is present only for a non-`en-US` language. Listeners registered with `onMetadataMutation` receive it. It also rides `ClusterMetadataMutationPayload`, and a server that receives it re-reads that language's row.
49+
- The entries that `SysMetadataRepository.listDrafts` returns have an optional `member`, present for a draft of a non-`en-US` language. The protocol's draft list (`GET /meta/_drafts`) does not include it.

‎content/docs/references/api/protocol.mdx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -728,6 +728,7 @@ A write-path strip event: caller-supplied fields legally dropped from the payloa
728728
| **actor** | `string` | optional | Identity recorded on the delete's history tombstone row. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. Absent, the event is recorded actor-less (null), deliberately not attributed to "system". |
729729
| **state** | `Enum<'active' \| 'draft'>` | optional | Which lifecycle row to discard: `draft` discards the pending draft overlay only (the still-active overlay, if any, keeps serving); `active` or absent resets the live row. Absent defaults to `active`. |
730730
| **dropStorage** | `boolean` | optional | Destructive opt-in, default false: also drop the object's physical table after the metadata row is removed (`object` type + `active` state only; never `sys_` tables). Used by the "discard a previewed object" flow so a publish-to-preview leaves no orphan table. |
731+
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
731732

732733

733734
---
@@ -1345,6 +1346,7 @@ Enable package response
13451346
| **type** | `string` | ✅ | Metadata type name |
13461347
| **name** | `string` | ✅ | Item name (snake_case identifier) |
13471348
| **packageId** | `string` | optional | Optional package ID to filter items by |
1349+
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
13481350
| **state** | `Enum<'active' \| 'draft'>` | optional | Draft-visibility switch — which lifecycle row to read (strict mode): `'draft'` opens the pending draft buffer (Studio's editor read) and fails when no draft exists; absent or `'active'` reads the live published row. Distinct from `previewDrafts`, which FALLS BACK to the active row when no draft exists. Declaration ≠ authorization: this member only selects which stored row is read — ADR-0106 masking is unaffected, and draft access is gated upstream, not by this schema. |
13491351
| **previewDrafts** | `boolean` | optional | Draft-visibility switch (ADR-0033 draft-overlay preview, non-strict): when true and `state` is not `'draft'`, a pending draft row is preferred if one exists, else the read falls back to the active row — the render path degrades to the published value instead of erroring. A served draft is tagged `_draft: true` so UIs can badge it. Declaration ≠ authorization: this member only switches which row is read, and ADR-0106 masking is unaffected — draft preview is admin-gated upstream, not by this schema. |
13501352

@@ -1875,6 +1877,7 @@ The published metadata item body, opaque by ruling (1C). Shape is the item's own
18751877
| **name** | `string` | ✅ | Item name |
18761878
| **sinceSeq** | `number` | optional | Exclusive lower bound on `seq` for pagination: only events with `seq > sinceSeq` are returned. Absent means "from the beginning". |
18771879
| **limit** | `number` | optional | Maximum events to return, oldest first. Forwarded to the repository unclamped and with NO default — absent means the full remaining change log. (Deliberately no declared bounds: unlike the audit twin's [1, 500] clamp, nothing on this path clamps or refuses, so declaring `.min()`/`.max()` here would refuse values the shipped verb accepts.) |
1880+
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
18781881

18791882

18801883
---
@@ -2319,6 +2322,7 @@ Installed package with runtime lifecycle state
23192322
| **actor** | `string` | optional | Identity recorded on the `op='publish'` history event. On the REST door this is the request's authenticated identity (one producer) — never a caller-supplied header. |
23202323
| **message** | `string` | optional | Optional human-readable note recorded with the publish history event. |
23212324
| **packageId** | `string \| null` | optional | ADR-0048 — the software package the draft being promoted was listed under, when the caller has one to state (`?package=<id>` on the REST door). ⚠️ `null` is NOT the same as absent, and the difference is load-bearing: the implementation branches on the KEY BEING PRESENT, so an ABSENT key keeps the historical "match any package" resolution while `null` pins the lookup to the package-UNBOUND row. Spread it in conditionally; a present-and-`undefined` key coerces to `null` downstream and makes a package-bound draft unfindable — a silent `no_draft` on the untouched path. |
2325+
| **member** | `string` | optional | Bundle member selector, for a metadata type whose identity is a pair (today only `email_template`, identified by `(name, locale)`): the member this request addresses, spelled as its discriminator value (`zh-CN`). Absent, the canonical member (`en-US` for `email_template`), which is what a bare-name read answers; naming the canonical value is the same request. It is not the response translation locale, which `?locale=` and `Accept-Language` choose. Refused with 400 `VALIDATION_ERROR` on a type that has no members, and for a blank value or one longer than a stored member can be (100 characters). A well-formed member with nothing stored or shipped answers as that door answers an absent item. |
23222326

23232327

23242328
---
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22877] THE one reading of the by-name doors' bundle member selector
5+
* (`resolveBundleMemberSelector`, ruling 6116165299 Q2 i): absent means the
6+
* canonical member, a well-formed member passes as named (trimmed), and a
7+
* wrong value is a located 400 `VALIDATION_ERROR` — the selector on a type
8+
* with no members, a value that is not a string, a blank one, and one longer
9+
* than a stored member can be. The doors that ask it are pinned on the real
10+
* engine in `@objectstack/objectql`'s `environment-overlay-email-locale-restart.test.ts`.
11+
*/
12+
13+
import { describe, expect, it } from 'vitest';
14+
import { SysMetadataObject } from '@objectstack/metadata-core';
15+
import { BUNDLE_MEMBER_MAX_LENGTH, BUNDLE_MEMBER_SELECTOR, resolveBundleMemberSelector } from './protocol.js';
16+
17+
function refusal(type: string, member: unknown): { status?: number; code?: string; param?: string; message: string } {
18+
try {
19+
resolveBundleMemberSelector(type, member);
20+
} catch (e: any) {
21+
return { status: e.status, code: e.code, param: e.param, message: String(e.message) };
22+
}
23+
throw new Error(`expected ${JSON.stringify(member)} on '${type}' to be refused`);
24+
}
25+
26+
describe('[#22877] resolveBundleMemberSelector', () => {
27+
it('is named `member`, never `locale`', () => {
28+
expect(BUNDLE_MEMBER_SELECTOR).toBe('member');
29+
});
30+
31+
it('absent means the canonical member, for every type', () => {
32+
expect(resolveBundleMemberSelector('email_template', undefined)).toBeUndefined();
33+
expect(resolveBundleMemberSelector('view', undefined)).toBeUndefined();
34+
});
35+
36+
it('a well-formed member passes as named, trimmed — the canonical value included', () => {
37+
expect(resolveBundleMemberSelector('email_template', 'zh-CN')).toBe('zh-CN');
38+
expect(resolveBundleMemberSelector('email_template', ' zh-CN ')).toBe('zh-CN');
39+
expect(resolveBundleMemberSelector('email_template', 'en-US')).toBe('en-US');
40+
// Either spelling of the type.
41+
expect(resolveBundleMemberSelector('email_templates', 'zh-CN')).toBe('zh-CN');
42+
expect(resolveBundleMemberSelector('email_template', 'x'.repeat(BUNDLE_MEMBER_MAX_LENGTH))).toHaveLength(100);
43+
});
44+
45+
it('a type with no members refuses the selector, located', () => {
46+
const r = refusal('view', 'zh-CN');
47+
expect(r).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'member' });
48+
expect(r.message).toBe(
49+
'The "member" selector names a bundle member, and a \'view\' item has none: its identity is its name alone. '
50+
+ 'Remove the selector.',
51+
);
52+
});
53+
54+
it('a value that cannot name a member is refused: not a string, blank, or overlong', () => {
55+
expect(refusal('email_template', ['zh-CN'])).toMatchObject({ status: 400, code: 'VALIDATION_ERROR', param: 'member' });
56+
expect(refusal('email_template', { zh: 'CN' }).message).toMatch(/^The "member" selector must be one string: the locale of one 'email_template' member/);
57+
const blank = refusal('email_template', ' ');
58+
expect(blank).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
59+
expect(blank.message).toMatch(/^The "member" selector is blank\. .*omit the selector to address the canonical member \('en-US'\)\.$/);
60+
const long = refusal('email_template', 'x'.repeat(BUNDLE_MEMBER_MAX_LENGTH + 1));
61+
expect(long).toMatchObject({ status: 400, code: 'VALIDATION_ERROR' });
62+
expect(long.message).toMatch(/^The "member" selector is 101 characters long/);
63+
});
64+
65+
it('the length bound IS the stored column\'s — a longer selector names no member that could be stored', () => {
66+
const column = (SysMetadataObject as any).fields.bundle_member;
67+
expect(column.maxLength).toBe(BUNDLE_MEMBER_MAX_LENGTH);
68+
});
69+
});

‎packages/metadata-protocol/src/protocol.lookup-artifact-item-call-sites.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,16 +68,16 @@ type Disposition = 'definition' | 'served-package envelope' | 'no';
6868
* envelope never reaches an answer whose body came from another package.
6969
*/
7070
const SITES: ReadonlyArray<Site & { readonly noPackage: boolean; readonly disposition: Disposition; readonly why: string }> = [
71-
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string', noPackage: false, disposition: 'definition',
71+
{ member: 'lookupArtifactItem', args: 'type: string, name: string, currentPackageId?: string, member?: string', noPackage: false, disposition: 'definition',
7272
why: 'the definition itself' },
7373
{ member: 'getMetaItem', args: 'request.type, request.name, envelopePackageId(request.packageId, item)', noPackage: true, disposition: 'served-package envelope',
7474
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule)' },
7575
{ member: 'readFlattenedMetaItems', args: 'request.type, itemName, itemPackageId', noPackage: true, disposition: 'served-package envelope',
7676
why: 'the list\'s envelope: each item\'s own package, the rule the by-name read shares' },
7777
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
7878
why: 'the shipped-flow arm: the served body IS this artifact, and the envelope is looked up at its own package' },
79-
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
80-
why: '[#22877] a bundled type\'s canonical member when no stored row of it exists: the served body IS this artifact, and the envelope is looked up at its own package' },
79+
{ member: 'getMetaItem', args: 'request.type, request.name, request.packageId, wantedMember', noPackage: true, disposition: 'no',
80+
why: '[#22877] a bundled type\'s addressed member (the selector\'s, else the canonical one) when no stored row of it exists: the served body IS this artifact, and the envelope is looked up at its own package' },
8181
{ member: 'getMetaItemLayered', args: 'request.type, request.name, request.packageId', noPackage: true, disposition: 'no',
8282
why: 'the layered read\'s code LAYER: body and envelope are one artifact, and its provenance fields are read off that layer' },
8383
{ member: 'packagedArtifactBase', args: 'type, name', noPackage: true, disposition: 'no',

0 commit comments

Comments
 (0)