Skip to content

MetadataFacade.register('object', …) writes where neither of its own object reads look #6725

Description

@qq9340100

Observation-class finding, filed per Prime Directive #10 while implementing #6505 (PR #6723). Unassigned, finding, deliberately not queued — dormant in-tree (see "Reachability" below), but it is exported public API.

The fact (read on origin/main)

MetadataFacade (packages/objectql/src/metadata-facade.ts) writes objects through SchemaRegistry.registerItem:

asyncregister(type,name,data){
...
if(type==='object'){this.registry.registerItem(type,definition,'name',packageId);}else{ ... }}

registerItem is documented as the "Universal Register Method for non-object metadata" and stores into registry.metadata.get('object'). But both of the facade's object reads bypass that map:

  • getObject(name) calls registry.getObject(name), which resolves from objectContributors;
  • get('object', name) calls registry.getItem('object', name), which special-cases the object type straight back to registry.getObject(name) (registry.ts:1536).

So an object written through facade.register('object', …) is not readable back through facade.getObject or facade.get('object', …). Only registry.registerObject populates objectContributors.

The asymmetry is already visible in the package's own test, which reaches into the private map to observe the write (packages/objectql/src/metadata-facade.test.ts, "never invents a synthetic package id for object registrations"):

// getItem('object', …) routes to the merged-object path, so read the// generic collection directly to inspect what register() stored.conststored=(registryasany).metadata.get('object')?.get('task');

Reachability

objectql/src/plugin.ts (bridgeObjectsToMetadataService) does exactly the write-then-read this breaks: it copies registry objects into the metadata slot with register('object', …) and skips ones where await metadataService.getObject(obj.name) already answers. On a facade-backed metadata slot that read would never see the bridged object, so every boot would re-register the full set.

Today that path is not exercised in-tree: new MetadataFacade(...) appears nowhere on main outside its own test file — the metadata slot is filled by MetadataManager (packages/metadata) or createMemoryMetadata (packages/core/src/fallbacks/memory-metadata.ts), and both round-trip correctly. But MetadataFacade is exported from @objectstack/objectql's root and core entrypoints, so a downstream host (cloud, a third-party kernel) can install it into that slot and get the silent write/read split.

Dispositions worth pricing (no recommendation forced)

  1. Route register('object', …) to registry.registerObject so writes land where the reads look — behaviour change for anything relying on the current storage location, and registerObject runs the materialization seam (system columns, primary-title, protection) that registerItem does not.
  2. Refuse type === 'object' at MetadataFacade.register with a named error pointing at registerObject — declared-and-enforced, no silent half-write.
  3. Document the split as intentional and leave it — cheapest, but it leaves an exported class whose write and read disagree.

Note the interaction with #6505 / PR #6723: that PR documents getObject(name) as equivalent to get('object', name) and as answering the runtime-effective object. Both remain true under any disposition here — this finding is about the write side, which the contract does not currently speak to.

Refs #6505, PR #6723.

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions