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
meta: an overlay-backed object read OMITS the injected system columns a registry-backed read includes — the same endpoint answers two different field sets #6562
Found while implementing #4513 (PR #6561). Same seam, different defect, deliberately left out of that PR's scope — filed separately and unassigned.
Summary
GET /api/v1/meta/object/:name answers a different set of fields depending on which link of its resolution chain produced the answer, for the same object:
Registry-backed answer → the schema afterapplySystemFields, so it carries the injected system columns (created_at, created_by, updated_at, updated_by, organization_id, owner_id, owning_business_unit_id) even when the author declared none of them.
Overlay-backed answer (a sys_metadata customization row, or a MetadataService body) → the stored document verbatim, so those columns are simply absent.
Whether an object has a customization overlay is invisible to the caller, so the same request can report fields with or without the platform's own columns and nothing in the response says which happened.
Evidence on origin/main
The two sources, and the asymmetry between them:
packages/objectql/src/registry.ts:1050 — registerObject runs schema = applySystemFields(schema, { multiTenant: this.multiTenant })before storing the contributor, so registry.getObject(name) always returns the post-injection schema.
Already pinned in-tree: packages/objectql/src/engine-audit-anchor-write.test.ts, the case 'the injected field metadata marks it read-only and system', asserts engine.registry.getObject('audit_task').fields.created_at is present and { readonly: true, system: true } — and taskObject declares only id / title / progress. So the registry answer carries a field the author never wrote.
packages/metadata-protocol/src/protocol.ts — getMetaItem step 1 returns this.convertStoredItem(...) of the sys_metadata body with no injection pass; step 2 (readItemFromMetadataService) likewise. Only step 3 reads the registry.
Consumers of the differing answers:
packages/runtime/src/domains/meta.ts:221 — the dispatcher's object branch registry fallback (qlService.registry.getObject(name)), and :417getAllObjects(packageId) for the list. Both post-injection.
packages/metadata-protocol/src/protocol.ts — getMetaItem / getMetaItems, whose overlay rows win over the registry entries they shadow.
#4513 is about a wrong value on a field that IS reported: created_at read readonly: false while writes to it were refused. PR #6561 closes exactly that by forcing the audit-family governance on declared audit fields at every read exit.
This issue is the absence case, and it was deliberately excluded there for reasons worth recording rather than re-deriving:
It also changes what the layered read (?layers=1) reports as overlay vs effective, i.e. what Studio shows an author as "what you customised" — injected columns nobody wrote would appear as a diff.
So the fix here is a scope and contract question, not a one-line change.
Why it still matters
The /meta surface is the machine-readable contract clients and AI authors code against. An author reading an overlay-backed object sees no created_at / owner_id / organization_id and reasonably concludes the columns do not exist — while every one of them is real in the database, filterable, orderable, and (for the audit family and organization_id) enforced read-only on write. The same reasoning that made #4513 worth fixing applies: the machine-readable face disagrees with the runtime, and only the client can see the disagreement.
It is filed as a finding rather than a defect because I did not measure a user hitting it today — the divergence is proved from the code paths and the existing registry pin above, not from a live repro. Triage should grade it.
Question for triage
Two readings, and they lead to different architectures — which is why this is not being guessed at inside #4513's PR:
A. GET /meta/object/:name serves the AUTHORED document. Then the registry-backed answer is the one that is wrong (it leaks materialized columns into what reads as authorship), and the fix is to strip injected-but-undeclared columns from the registry answer. resolveInjectedSystemColumns (@objectstack/spec/data) already computes exactly that set.
B. It serves the EFFECTIVE runtime schema. Then the overlay answer is the one that is wrong, and the fix is to run the served document through the injection pass — with the [P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326 round-trip invariant and the layered-read diff both needing an answer first (most likely: inject on the read exits but keep stripReadDecorations' counterpart on the write side so the round-trip stays byte-identical).
Related: #4513 / PR #6561 (the value half of this same seam), #4447 (the write-path enforcement), #5378 (resolveInjectedSystemColumns, the shared "which columns exist" derivation), #4326 (the round-trip invariant).
Found while implementing #4513 (PR #6561). Same seam, different defect, deliberately left out of that PR's scope — filed separately and unassigned.
Summary
GET /api/v1/meta/object/:nameanswers a different set of fields depending on which link of its resolution chain produced the answer, for the same object:applySystemFields, so it carries the injected system columns (created_at,created_by,updated_at,updated_by,organization_id,owner_id,owning_business_unit_id) even when the author declared none of them.sys_metadatacustomization row, or a MetadataService body) → the stored document verbatim, so those columns are simply absent.Whether an object has a customization overlay is invisible to the caller, so the same request can report
fieldswith or without the platform's own columns and nothing in the response says which happened.Evidence on
origin/mainThe two sources, and the asymmetry between them:
packages/objectql/src/registry.ts:1050—registerObjectrunsschema = applySystemFields(schema, { multiTenant: this.multiTenant })before storing the contributor, soregistry.getObject(name)always returns the post-injection schema.packages/objectql/src/engine-audit-anchor-write.test.ts, the case'the injected field metadata marks it read-only and system', assertsengine.registry.getObject('audit_task').fields.created_atis present and{ readonly: true, system: true }— andtaskObjectdeclares onlyid/title/progress. So the registry answer carries a field the author never wrote.packages/metadata-protocol/src/protocol.ts—getMetaItemstep 1 returnsthis.convertStoredItem(...)of thesys_metadatabody with no injection pass; step 2 (readItemFromMetadataService) likewise. Only step 3 reads the registry.Consumers of the differing answers:
packages/runtime/src/domains/meta.ts:221— the dispatcher's object branch registry fallback (qlService.registry.getObject(name)), and:417getAllObjects(packageId)for the list. Both post-injection.packages/metadata-protocol/src/protocol.ts—getMetaItem/getMetaItems, whose overlay rows win over the registry entries they shadow.Why this is NOT what #4513 fixed
#4513 is about a wrong value on a field that IS reported:
created_atreadreadonly: falsewhile writes to it were refused. PR #6561 closes exactly that by forcing the audit-family governance on declared audit fields at every read exit.This issue is the absence case, and it was deliberately excluded there for reasons worth recording rather than re-deriving:
readonly: false, so it is not the contradiction meta: /meta/objects reports field metadata from the build artifact, not the registry —created_atreadsreadonly: falsewhile the write path enforces read-only #4513 names — the acceptance bar of that card is met without touching this.GETthenPUTround-trip persists, which is a pinned invariant ([P3] Read decorations (_diagnostics, _draft) round-trip into persisted sys_metadata bodies #4326,packages/metadata-protocol/src/protocol.read-decorations.test.ts: "a GET → PUT round-trip persists a byte-identical body").?layers=1) reports asoverlayvseffective, i.e. what Studio shows an author as "what you customised" — injected columns nobody wrote would appear as a diff.So the fix here is a scope and contract question, not a one-line change.
Why it still matters
The
/metasurface is the machine-readable contract clients and AI authors code against. An author reading an overlay-backed object sees nocreated_at/owner_id/organization_idand reasonably concludes the columns do not exist — while every one of them is real in the database, filterable, orderable, and (for the audit family andorganization_id) enforced read-only on write. The same reasoning that made #4513 worth fixing applies: the machine-readable face disagrees with the runtime, and only the client can see the disagreement.It is filed as a
findingrather than a defect because I did not measure a user hitting it today — the divergence is proved from the code paths and the existing registry pin above, not from a live repro. Triage should grade it.Question for triage
Two readings, and they lead to different architectures — which is why this is not being guessed at inside #4513's PR:
GET /meta/object/:nameserves the AUTHORED document. Then the registry-backed answer is the one that is wrong (it leaks materialized columns into what reads as authorship), and the fix is to strip injected-but-undeclared columns from the registry answer.resolveInjectedSystemColumns(@objectstack/spec/data) already computes exactly that set.stripReadDecorations' counterpart on the write side so the round-trip stays byte-identical).Related: #4513 / PR #6561 (the value half of this same seam), #4447 (the write-path enforcement), #5378 (
resolveInjectedSystemColumns, the shared "which columns exist" derivation), #4326 (the round-trip invariant).