Skip to content

fix(metadata-protocol): an object's overlay row is a base layer, not its resolved schema (#8027) - #8045

Merged
huangyiirene merged 2 commits into
mainfrom
claude/issue-8027-overlay-extension-fold
Aug 12, 2026
Merged

fix(metadata-protocol): an object's overlay row is a base layer, not its resolved schema (#8027)#8045
huangyiirene merged 2 commits into
mainfrom
claude/issue-8027-overlay-extension-fold

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#8027

⚠️PR opened by the domain:metadata PM seat on the dev's behalf — it pushed claude/issue-8027-overlay-extension-fold but 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.

⚠️⚠️ This also repairs a LIVE REGRESSION shipped by #7556 (PR #8015) one hour ago

Read this first — it is the more urgent half of the PR.

On an in-process (bridged) boot, the by-name read and the code layer were serving every extender-contributed validation and index TWICE. Introduced by #8015, live on main now.

mergeObjectDefinitionsconcatenatesvalidations and indexes (fields is 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:

that precondition turned out to be 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 registry.getAllObjects() — bodies that are already resolved — so Object-extension overlay fields are missing from GET /meta/object/:name (present in the list route) — the overlay's fields can never be set through the UI #7556's fold ran on a folded base.
  2. A stored overlay row. The write path persists the request body verbatim (ADR-0005 §Validation), so the ordinary Studio GET → edit → PUT round-trip stores whatever the read served — and since Object-extension overlay fields are missing from GET /meta/object/:name (present in the list route) — the overlay's fields can never be set through the UI #7556 that read is folded. Seeded / imported / migrated rows are unconstrained besides.

And why #8015's own pin could not see it:"it compares FIELD NAMES, and the field spread is idempotent." The duplication lives in validations and indexes, a dimension that pin never touched.

⚠️That is a gap in my review, not just in the pin. I accepted #8015 and called its four-host agreement pin excellent — it was, for the dimension it tested. I did not ask whether the pin covered the other fields of a merged object. A duplicated index does not fail a test; it fails a deployment.

⇒ The fix makes SchemaRegistry.foldObjectExtendersOntoidempotent rather than documenting the precondition harder: an entry the extend contributors 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, matching resolveObject — 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. getMetaItem returned the stored row; 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 the same body.

ADR-0029 D9.2 defines the resolution as overlay ?? ownwith the extend contributors folded on — exactly 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 overlay row: byName and listed both served the object with no extension fields, while layers.code served them and layers.effective did not — so one ?layers=true response reported a code layer that has the fields and an effective layer that does not, with an overlay layer 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:

#7556's byName === listed pin is green throughout this defect, because here both routes agree — on a body that has already lost the fields.

Cases: the overlay case on both routes and on layers.effective; code and effective agreeing when the row customises nothing; layers.overlay still 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 bridged host, 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 (foldObjectExtendersOnto exists 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

…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>
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 1:28pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/metadata-protocol, @objectstack/objectql.

16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/data-modeling/formulas.mdx(via packages/objectql)
  • content/docs/deployment/migration-from-objectql.mdx(via @objectstack/objectql)
  • content/docs/deployment/vercel.mdx(via @objectstack/objectql)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/objectql)
  • content/docs/kernel/runtime-services/examples.mdx(via packages/objectql)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol, @objectstack/objectql)
  • content/docs/kernel/services.mdx(via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx(via @objectstack/objectql)
  • content/docs/permissions/system-context.mdx(via packages/objectql)
  • content/docs/plugins/index.mdx(via @objectstack/objectql)
  • content/docs/plugins/packages.mdx(via @objectstack/objectql)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/objectql)
  • content/docs/protocol/objectql/query-syntax.mdx(via packages/objectql)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/objectql)

2 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/implementation-status.mdx(via @objectstack/objectql)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol)

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review August 12, 2026 13:48
@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — flipped ready, auto-merge SQUASH armed @ bf2028a

All 25 jobs concluded before arming: 23 success, 2 skipped (Build Docs, Console Pin Gate), zero failures.TypeScript Type Check green at 13:44:30Z, Check Changeset green. Read per-job, not off an aggregate.

⚠️Prioritised because it repairs a regression that is live on main right now — every in-process boot has been serving duplicated extender validation and index entries since #8015 landed at ~12:00Z.

Review criteria, verified

⚠️ The review gap was mine, and the criterion is now recorded

I accepted #8015 and called its four-host agreement pin excellent. It was excellent for the dimension it tested — field names — and I never asked whether it covered the other fields of a merged object. mergeObjectDefinitions concatenates validations and indexes; a name-based pin is structurally blind to both.

Standing review criterion from now on: when a merge CONCATENATES, a name-based pin proves nothing about the concatenated arrays. A duplicated index does not fail a test — it fails a deployment.

This is the second time today a landed PR's pin turned out narrower than its apparent coverage, and both times the next card found it. That is the system working, but it works one card late.


Generated by Claude Code

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A tenant object overlay drops every objectExtensions field from the resolved schema, and effective contradicts code in the same response

2 participants

@huangyiirene@claude