Uh oh!
There was an error while loading. Please reload this page.
fix(metadata-protocol): an object's overlay row is a base layer, not its resolved schema (#8027) - #8045
Conversation
…its resolved schema (#8027) A `sys_metadata` overlay row for an object — an admin renaming the object's label in Studio — was adopted as the object's RESOLVED schema. `getMetaItem` took the stored row as `item` and returned it; `getMetaItems` did the same through `mergePackageAwareOverlay`, which picks a per-slot winner WHOLESALE rather than merging fields; and `getMetaItemLayered`'s `effective` is `overlay ?? code`, so it inherited that body. ADR-0029 D9.2 defines the resolution as `overlay ?? own` with the `extend` contributors folded ON, which is what `SchemaRegistry.resolveObject` does for an overlay it knows about and what #7556 made the by-name read do for the MetadataService copy. The `sys_metadata` path was the one adopter that never folded. Measured with one `extend` contributor (three fields) and one env-wide row: `byName` and `listed` served the object with NO extension fields while `layers.code` served them and `layers.effective` did not — one `?layers=true` response reporting a `code` layer that has the fields and an `effective` layer that does not, with an `overlay` layer explaining none of the difference. Same user-visible shape as #7556: an admin who customises a label silently removes three extension-contributed fields from every writable form, while the data API keeps accepting and persisting them. THE FOLD IS NOT IDEMPOTENT, and that had to be cleared rather than assumed away. `mergeObjectDefinitions` CONCATENATES `validations` and `indexes` (`fields` is a key-keyed spread, the scalars are last-writer-wins). The precondition #7556 documented — "apply this only to a base that has not been through the fold" — is one no caller can honour, and two shipped call sites already violated it: 1. The MetadataService body on an in-process boot. `bridgeObjectsToMetadataService` seeds that service from `getAllObjects()`, so #7556's fold ran on a folded base and served every extender validation and index TWICE. Live on the parent commit; invisible to #7556's pin, which compares field names, and the field spread is idempotent. 2. A stored overlay row. The write path persists the body verbatim (ADR-0005 §Validation), so the Studio GET → edit → PUT round-trip stores whatever the read served — folded since #7556. D9.2 defines the row as the base layer but nothing enforces it, and seeded/imported/migrated rows are unconstrained besides. So `foldObjectExtendersOnto` is IDEMPOTENT instead of documented harder: an entry the extenders are about to add, already in the base, is removed first and re-added by the fold exactly once. Extenders still concatenate against each other (two contributors declaring an identical rule still yield two, matching `resolveObject`), and an unfolded base is returned by reference, unchanged. The `overlay` layer is deliberately left raw — it reports what a tenant customised, and a code-declared extension is not that (#7556's boundary). The draft read is deliberately left unfolded: a draft is a pending edit of the base layer and is PUT back verbatim. Pinned against the REGISTRY'S RESOLVED SCHEMA, not as agreement between the two routes: #7556's `byName === listed` pin is green throughout this defect, because both routes agree here — on a body that has already lost the fields. Eight cases over real handlers / real protocol / real registry, plus an anti-vacuity case pinning that the fixtures are discriminated. Byte-identity measured by dumping all three surfaces for nine hosts under this branch and under the pre-fix behaviour: 8 of 9 identical. The one that differs is the extended object on a bridged host, where the pre-fix payload carries `ext_rule`/`ext_idx` twice and this branch carries each once. Co-authored-by: Claude <noreply@anthropic.com>
…rlay-extension-fold
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
huangyiirene
commented
Aug 12, 2026
ACCEPT — flipped ready, auto-merge SQUASH armed @ |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#8027
domain:metadataPM seat on the dev's behalf — it pushedclaude/issue-8027-overlay-extension-foldbut no PR appeared, so the GitHub API is still unreachable from that container despite the App being connected. Body is the dev's own changeset; its report follows.Read this first — it is the more urgent half of the PR.
On an in-process (
bridged) boot, the by-name read and thecodelayer were serving every extender-contributedvalidationandindexTWICE. Introduced by #8015, live onmainnow.mergeObjectDefinitionsconcatenatesvalidationsandindexes(fieldsis a key-keyed spread and scalars are last-writer-wins, so those were always safe). #8015 documented a precondition — "callers must apply this only to a base that has not been through the fold" — and:⭐ And why #8015's own pin could not see it:"it compares FIELD NAMES, and the field spread is idempotent." The duplication lives in
validationsandindexes, a dimension that pin never touched.⇒ The fix makes
SchemaRegistry.foldObjectExtendersOntoidempotent rather than documenting the precondition harder: an entry theextendcontributors are about to add, already present in the base, is removed first and re-added exactly once. Extenders are still concatenated against each other — two contributors declaring an identical rule still yield two, matchingresolveObject— so nothing the fold did on an unfolded base is narrowed, and such a base is returned by reference, byte-identical.The defect this card was filed for
An overlay row for an object — an admin renaming its label in Studio — was adopted as the resolved schema.
getMetaItemreturned the stored row;getMetaItemsdid the same throughmergePackageAwareOverlay, which picks a per-slot winner wholesale rather than merging fields; andgetMetaItemLayered'seffectiveisoverlay ?? code, so it inherited the same body.ADR-0029 D9.2 defines the resolution as
overlay ?? ownwith theextendcontributors folded on — exactly whatSchemaRegistry.resolveObjectdoes for an overlay it knows about, and what #7556 made the by-name read do for the MetadataService copy. Thesys_metadatapath was the one adopter that never folded.Measured with one
extendcontributor (three fields) and one env-wide overlay row:byNameandlistedboth served the object with no extension fields, whilelayers.codeserved them andlayers.effectivedid not — so one?layers=trueresponse reported acodelayer that has the fields and aneffectivelayer that does not, with anoverlaylayer showing a customisation that explained none of the difference.Practical cost, the #7556 shape again: an admin who customises a label silently removes three extension-contributed fields from every writable form, while the data API keeps accepting and persisting them.
Pinned against the registry's resolved schema — ⛔ deliberately NOT as route agreement
packages/rest/src/meta-object-overlay-extension-fold.test.ts, eight cases over real handlers / real protocol / real registry:Cases: the overlay case on both routes and on
layers.effective;codeandeffectiveagreeing when the row customises nothing;layers.overlaystill reporting only what the tenant stored (an extension is not a tenant customisation — the boundary #7556 drew); an already-folded row and a bridged host holding the idempotency; the no-overlay and no-extension controls; and an anti-vacuity case pinning that the fixtures are discriminated.Byte-identity, measured rather than argued
All three surfaces dumped for nine hosts, under this branch and under the pre-fix behaviour: 8 of 9 identical. The one that differs is the extended object on a
bridgedhost, where the pre-fix payload carries['owner_rule','ext_rule','ext_rule']/['owner_idx','ext_idx','ext_idx']and this branch carries each once — the #7556 regression, repaired.Levels
metadata-protocolpatch — restores the contract these routes were already specified to answer, same reasoning #7556 used for the same routes.objectqlpatch — no new public API (foldObjectExtendersOntoexists since #7556); its documented contract moves from "not idempotent, callers must guarantee an unfolded base" to "idempotent", a defect fix rather than a capability, and no caller can be relying on duplicated validations.Generated by Claude Code