You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit abf8655
Browse filesBrowse the repository at this point in the historyBrowse files
fix(metadata-protocol): the artifact envelope is stamped only on the bundle member the package ships (#23074) (#23094)
Fixes#23074
Clause-②: no
## What was wrong
An `email_template` is identified by `(name, locale)`, so its locales
form a bundle. When the environment saves an overlay of a member the
package does **not** ship (a `zh-CN` version of a template the package
ships in `en-US` only), that overlay is the environment's own item.
`mergeArtifactProtection` grafted the bundle's artifact envelope onto it
anyway: every caller looked the artifact up by name, canonical-first, so
the `zh-CN` overlay was registered and served wearing the `en-US`
artifact's `_packageId`, `_packageVersion` and `_provenance: 'package'`,
and `isCodeArtifactBody` read it as packaged code.
PR #23070 (stage 2 of #22877, `038d4c9588`) routed around one reader: it
lifted the member's runtime shadow on ANY member's artifact in
`removeRuntimeShadow`. This PR changes the graft itself.
## Measured on `origin/main` `62ce45d140` (before) and on this branch
(after)
The fixture is the stage 1/2 harness
`packages/objectql/src/environment-overlay-email-locale-restart.test.ts`:
a real `ObjectQL` engine, the real protocol, and a versioned package
that ships `acme.digest` in `en-US` only. Each reading comes after
`en-US` and `zh-CN` overlays are saved and the kernel restarts, so both
overlays are hydrated. `isCodeArtifactBody` appears as "code".
| reader of the unshipped `zh-CN` overlay | before | after |
|:---|:---|:---|
| registry entry (`getItem(type, name, undefined, 'zh-CN')`) |
`_packageId: com.acme.mail`, `_packageVersion: 1.0.0`, `_provenance:
package`; code **true** | `_provenance: org`, no package stamps; code
false |
| registry artifact lookup (`getArtifactItem(..., 'zh-CN')`) | answers
**the overlay itself** as the package's `zh-CN` artifact | `undefined` |
| by-name read, `GET ?member=zh-CN`: body and envelope | body wears the
envelope; `provenance: package`, `packageId`, `packageVersion: 1.0.0` |
no envelope; `provenance` / `packageId` / `packageVersion` absent |
| by-name read: `lock` / `editable` / `deletable` | `none` / true / true
| `none` / true / true (unchanged, see below) |
| list, served and for execution | the `zh-CN` item wears the envelope |
no envelope |
| overlay-entry retirement (`removeOverlayEntry(..., 'zh-CN')`) |
**refused** as code | retires it |
| shadow heal (`removeRuntimeShadow(..., 'zh-CN')`) | lifts it (the
#23070 name-level test) | declines: no `zh-CN` artifact (member level,
below) |
| `DELETE ?member=zh-CN`, end state | entry gone, receipt `reset: true`
| same |
CONTROL, measured at the same readers: the overlay of a shipped member
(`acme.welcome` in `zh-CN`, and the canonical `en-US`) wears its
artifact's envelope, is answered by its artifact, is served under its
package, is refused by the retirement and un-shadowed by the heal.
Nothing about it changes.
The other `isCodeArtifactBody` readers never see an `email_template`
member, because each is gated on its own type:
`runtime/src/domains/actions.ts` (actions), `runtime/src/flow-clone.ts`,
`service-automation`'s flow precedence and the protocol's flow write
refusal (flows), `plugin-security/src/packaged-permission-set-lock.ts`
(permission sets), the protocol's declined-row predicates (shipped
flows, code datasources, sealed types; `email_template` is
overlay-enabled, so never sealed), and the registry's object branch and
collision warning (objects, and composite artifacts only).
## The change
- **`mergeArtifactProtection` (`metadata-protocol`)** takes the type and
grafts an artifact only onto a body of the **same bundle member**, read
by `itemDiscriminator` (`bundleMemberOf`), the identity's one
declaration in `ITEM_KEY_DISCRIMINATORS`. An artifact of another member
counts as no artifact. Every type without members reads `undefined` on
both sides, so its merge is byte-for-byte what it was.
- **Its four call sites** pass the type, and the three that can serve a
bundle member look the artifact up at the body's own member
(`lookupArtifactItem`'s `member`): the boot and write-through hydration
(`hydrateOverlayIntoRegistry`), the by-name read (`getMetaItem`) and the
list (`readFlattenedMetaItems`). Without this second half, a shipped
non-canonical member would lose its envelope, because the
canonical-first lookup answers the `en-US` artifact for a `zh-CN` body
(ablation A2 below). The view expansion (`expandRuntimeViewContainer`)
only passes the type: a view has no members.
- **`removeRuntimeShadow` (`objectql`) returns to member level** for a
named member: a shadow is lifted only where an artifact **of that
member** takes its place. An unshipped member's overlay now declines
there, and the heal's last tier (`removeOverlayEntry`) retires it,
because it is no longer grafted. Naming no member keeps the group sweep,
as before.
- No exported signature changes: `mergeArtifactProtection` and
`bundleMemberOf` are module-private, and `removeRuntimeShadow` keeps its
signature.
### Deliberately unchanged
- **The lock binds the name.** The read's `lock` and the `_lock` gate
both come from the one item-lock resolution over every package that
ships the NAME (`artifactLockLayerAt`). The merge applies the read's
resolved lock before its member check, so a template the package locks
stays locked in every locale, at the read and at the gate. Making the
lock per member would loosen protection, and that is a contract decision
this card does not carry.
- **`resettable` and the delete receipt** come from
`isArtifactBacked(type, name)`, which is name-level and not the graft.
An unshipped member still reads `resettable: true` and its reset answers
`reset: true`. See the acceptance notes.
## Pins (real doors)
All in
`packages/objectql/src/environment-overlay-email-locale-restart.test.ts`,
new block `[#23074]`, with each CONTROL as its own case:
- the boot hydration: the unshipped member registers as the tenant row
it is (no stamps, not code, no `zh-CN` artifact); CONTROL: the shipped
members' overlays wear their artifacts' envelopes;
- the by-name read `?member=zh-CN`: served as the environment's item (no
envelope, `lock: none`, editable, deletable); CONTROL: the shipped
member is served under its package;
- the list, served and for execution: the same split;
- the reset `?member=zh-CN`: the unshipped member is declined by the
shadow heal and retired by the overlay-entry tier, at once and after a
restart; CONTROL: the shipped member is un-shadowed onto its artifact
and refused by the retirement;
- a registry that ignores `member` (answers the canonical artifact): the
graft's own check keeps that artifact off the `zh-CN` body; the
canonical member still wears it;
- CONTROL: a type with no members (`translation`, which carries a
`locale` but is identified by its name) keeps its artifact's envelope
when the overlay's `locale` differs from the artifact's.
Also updated: the `registry-i18n-bundle-key.test.ts` case that pinned
the name-level lift for an unshipped member now pins the member-level
decline and the retirement. The two call-site ledgers
(`protocol.lookup-artifact-item-call-sites.test.ts`,
`protocol.register-item-call-sites.test.ts`) record the new argument
text.
## Ablation
Run from committed state with `scripts/ablation-replace.mjs`, under a
`trap` restore. Each `metadata-protocol` leg was rebuilt and checked
with `scripts/ablation-dist-preflight.mjs`, because objectql's suite
reads that package's `dist/`. The two legs that delete the guard fail
the DTS step (TS6133: the new `type` parameter is unread), but ESM and
CJS are emitted, and the preflight proves the ESM the suite consumes
carries the mutation. Every restore was proven by blob hash equal to
HEAD and an empty `git diff HEAD`, then rebuilt and preflighted present
again. The final run was 29/29 green.
| leg | mutation | red | green |
|:---|:---|:---|:---|
| A3 | the unconditional graft back: guard removed, canonical-first
lookups | 6: all five unshipped cases, plus #22877's door case for a
member no artifact ships | every CONTROL (23) |
| A1 | the guard alone removed | only "a registry that answers another
member's artifact" | 28 |
| A2 | the member-addressed lookups alone removed | exactly the four
shipped-member CONTROLs | 25 |
| A4 | `removeRuntimeShadow` back to the name-level test (registry
source, no build) | only the unshipped reset case | 28 |
A3 also shows the decision about `removeRuntimeShadow`: with the graft
back, the member-level heal leaves the grafted overlay registered (the
#22877 door case turns red). So the member-level test is sound
**because** of the graft fix, and not without it.
One leg is declared void: a first A3 run coincided with an unlocked
`check:type-check-debt`, whose full build rewrote
`metadata-protocol/dist/index.js` mid-leg. The preflight caught it
(`marker still present`), and the run was redone with nothing in
parallel (the readings above).
## Evidence
- Real door:
`packages/qa/dogfood/test/email-template-two-locales-survive-boot.dogfood.test.ts`,
the showcase file that found the defect (real SQLite, real
`sendTemplate`, real MetadataService), passes 4/4 on the fixed `dist/`:
`DELETE ?member=zh-CN`, then the next `zh-CN` send uses the admin's
`en-US` wording at once, and `?member=zh-CN` is 404 after a cold boot.
- `pnpm --filter @objectstack/metadata-protocol typecheck` exit 0; `pnpm
--filter @objectstack/objectql typecheck` exit 0 (its
`check:test-typecheck` held the ledger).
- `@objectstack/metadata-protocol` `vitest run`: 228 files passed, 3
skipped; 28203 tests passed. `@objectstack/objectql` `--project local`:
408/408 files, 7984 tests; `--project repo`: 1/1 file, 5 tests. All at
`5d156f7044`.
- Lint, a proven narrowing: `eslint --no-inline-config --format json`
over the six changed TS files (`eslint.config.mjs` applies to all six,
and none is reported as ignored) gives 6 files, 0 errors, 0 warnings.
This repo's config enables no type-aware linting (no
`parserOptions.project`, no typed rules), so this diff cannot change the
verdict on any untouched file.
- Gates: the derived union `node scripts/pm/dispatch-gates.mjs
--commands --repo objectstack-ai/objectstack` at `5d156f7044` (the
dispatch's 58 commands plus 11 changeset families this diff adds): **69
commands, 69 exit 0**, each exit code captured before any pipe.
Reconciled with `--ran`: `69 derived, 69 run, 0 NOT-MEASURED, 0 UNRUN`.
`check:dual-build-cjs-loads` and `check:type-check-debt` were measured
after a full `turbo run build` of `./packages/*` (72 tasks) at that
head. Every heavy run went through `scripts/pm/os-verify-lock.sh`, and
each result is its `VERDICT command-exit 0` line.
## Acceptance notes
- `resettable: true` on the by-name read, and `reset: true` on `DELETE
?member=`, for a member the package does not ship: both come from
`isArtifactBacked(type, name)`, by name. After that reset the `zh-CN`
sends fall back to the package's `en-US` wording (the send ladder),
while `GET ?member=zh-CN` answers 404. So "reset to the package default"
is true at send time and not at the by-name read. #22877 pinned `reset:
true` for this case on purpose. Recorded, not changed here. Carrier:
none.
- `getMetaItemLayered` takes no member, so its code layer is always the
canonical artifact. That is unchanged and outside the graft.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
A language the environment added to an email template, which the package does not ship, is no longer treated as packaged code
7
+
8
+
Clause-②: no
9
+
10
+
An `email_template` is identified by its name and its `locale`, so the languages of one template form a bundle. When an administrator customizes a template in a language its package does not ship (for example a `zh-CN` version of a template the package ships in `en-US` only), that version is the environment's own item. Until now it was registered and served wearing the package's protection envelope, taken from the shipped `en-US` version:
11
+
12
+
- the registry's artifact lookup answered the environment's `zh-CN` version as the package's own `zh-CN` artifact;
13
+
-`GET /meta/email_template/:name?member=zh-CN` and the `/meta/email_template` list served it with `provenance: 'package'`, the package's id and its version;
14
+
- the registry heal that runs after `DELETE ?member=zh-CN` refused to retire it as packaged code.
15
+
16
+
-`@objectstack/metadata-protocol`: the artifact envelope (`_packageId`, `_packageVersion`, `_provenance`, and an artifact lock) is copied only onto a version of the same language as the shipped artifact. Each place that copies it looks up the artifact of the body's own language first. A language the package does not ship is registered and served as the environment's item. A language the package does ship keeps its envelope, its lock and its reset exactly as before. Types without languages are unchanged.
17
+
-`@objectstack/objectql`: `SchemaRegistry.removeRuntimeShadow(type, name, member)` lifts a named language's shadow only when the package ships that language. For any other language it declines, and the heal's last step (`removeOverlayEntry`) retires the entry. Naming no language still sweeps the whole bundle, as before.
18
+
19
+
A lock still binds the template name, at the read and at the write gate alike: a template the package locks stays locked in every language.
why: 'the by-name read\'s envelope: naming no package, the package of the item it serves (the list\'s rule); [#23074] at the served item\'s own bundle member'},
why: 'scoped to the row\'s own package; undefined only for a package-less row, whose body names no package; [#23074] at the row\'s own bundle member'},
0 commit comments