Skip to content

fix(objectql): MetadataFacade object writes now reach the map its reads use (#6725) - #7211

Merged
os-zhuang merged 1 commit into
mainfrom
claude/issue-6725-facade-object-write-read
Aug 10, 2026
Merged

fix(objectql): MetadataFacade object writes now reach the map its reads use (#6725)#7211
os-zhuang merged 1 commit into
mainfrom
claude/issue-6725-facade-object-write-read

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Closes #6725.

The defect

MetadataFacade.register('object', …) wrote through SchemaRegistry.registerItem, which stores into the generic metadata map. Every one of the facade's object reads resolves from objectContributors, which only registerObject populates:

member route reads
getObject(name) registry.getObject objectContributors
get('object', name), exists('object', name) registry.getItem → special-cases the object type back to getObject objectContributors
list('object'), listNames('object'), listObjects() registry.listItems → special-cases to getAllObjects objectContributors

So an object written through the public facade was readable back through none of them — register resolved successfully and every subsequent read answered undefined / [].

IMetadataService (@objectstack/spec/contracts) declares getObject(name)get('object', name), and its own conformance test round-trips a register('object', …) through both members. This was a shipped contract that could not work. Dormant in-tree only because nothing on main installs a MetadataFacade into the metadata slot — but the class is exported from this package's root and core entrypoints, so a downstream host that installs it got the split, including ObjectQL's own bridgeObjectsToMetadataService, whose "already registered?" probe would never answer and so would re-register the full object set on every boot.

Shape chosen, and the blast radius measured for it

The card priced three dispositions and pre-ruled none. Measurement:

  • Who calls facade.register('object', …) today? In-tree: only this package's own test file. new MetadataFacade(...) appears nowhere on main outside the two objectql test files. As published API: any host occupying the metadata slot — and the two in-tree occupants (MetadataManager, createMemoryMetadata) both round-trip correctly, so the facade is the odd one out, not the reference.
  • What does registerObject do that registerItem does not? System-field injection, better-auth apiMethods reconciliation, ADR-0079 primary-title designation, __search companion provisioning, the ADR-0029 single-owner guard, contributor merge + priority sort, merge-cache invalidation, and the _objectRevision bump.
  • Is routing objects through registerItem itself the bug? No — and this is the part that decides the shape. registerItem's docblock does say "non-object metadata", but SchemaRegistry.unregisterObject's header (A deleted runtime object is still served by SchemaRegistry.getObject — the registry heal reaches the metadata map but never objectContributors #6808) states the actual invariant: "a runtime-authored object is written into TWO places (metadata['object'] via registerItem and objectContributors via registerObject)". The one in-tree precedent for this exact write, MetadataProtocol.applyObjectRegistryMutation, does both, with packageId || 'sys_metadata'. The facade was performing half of a documented two-place write.

So: the write now performs both halves, rather than moving to one of them.

Rejected, with reasons:

Details that are load-bearing

  • The contributor gets a copy. applyProtection stamps _packageId / _provenance in place, and applySystemFields returns its input unchanged on the no-injection path (systemFields: false, managedBy: 'better-auth', sys_*). A shared reference would therefore have written a synthetic package id onto the generic-map entry — exactly what the provenance pin forbids. Pinned by a new test that registers a systemFields: false object, i.e. the aliasing path.
  • Provenance. A package-less object registers under the 'sys_metadata' sentinel with _provenance: 'org' — both of which getArtifactItem / isArtifactBacked exclude, so it cannot read as code-shipped (the cloud#970 misclassification). Without the explicit 'org', applyProtection would default the copy to _provenance: 'package'. An object carrying a real _packageId registers under it and keeps 'package'.
  • Ordering. The contributor write runs first, because it is the half that can refuse (ADR-0029 single-owner). A refused registration now writes nothing at all instead of re-opening the split from the other side.
  • Both spellings. 'object' and 'objects' are both special-cased on the read side, so both are covered on the write side.

What happened to the provenance test

"never invents a synthetic package id for object registrations" is unchanged and still in place, still reading (registry as any).metadata.get('object') directly. That direct read remains the right instrument: the pin is about the stored document, and the object reads answer the contributor copy — which now exists and deliberately does carry the sentinel. Reading it through get('object', …) would have silently retargeted the assertion. A comment on the test now says so, and a new sibling test pins the aliasing hazard the copy exists to prevent.

Scope note: unregister

unregister('object', name) now removes both halves too. This is not scope creep — without it the fix would have introduced #6808's shape from the other side: a removal that empties only the generic map leaves getObject, which the data plane dispatches on, serving a deleted object for the life of the process. registry.unregisterObject(name) (the #6808 verb) is idempotent and refuses, per ADR-0029, an object still extended by another package.

Behaviour changes a caller can observe

  • The six read members now answer a facade-registered object, with the runtime-effective shape the contract promises.
  • register('object', …) can now throw where it previously succeeded and did nothing: claiming an object another package owns is refused (ADR-0029).
  • unregister('object', …) can throw for an object still extended by another package.

Tests

packages/objectql/src/metadata-facade.test.ts — 11 new cases, 14 total: round-trip through getObject and get (reference-identical, with an anti-vacuity toBeDefined); the enumeration members; the plural spelling; the runtime-effective shape on a multi-tenant registry; sentinel-not-artifact and real-package-id provenance; idempotent re-registration; the ADR-0029 refusal writing nothing; removal from both places; idempotent removal of an absent object.

Reverse-verified — pre-fix metadata-facade.ts restored under the new tests, red/green predicted per case before running. See the comment below.

Refs #6725, #6505 / PR #6723, #6853, #6808, ADR-0010, ADR-0029.


Generated by Claude Code

…ds use (#6725)

`MetadataFacade.register('object', …)` wrote through
`SchemaRegistry.registerItem`, into the generic `metadata` map. Every one of
the facade's object reads resolves from `objectContributors`, which only
`registerObject` populates: `getObject` goes straight there; `get('object', …)`
and `exists` go via `registry.getItem`, which special-cases the object type back
to `getObject`; `list`/`listNames` go via `registry.listItems`, which
special-cases to `getAllObjects`. So an object written through the public facade
was readable back through none of them — `register` resolved and every read
answered `undefined`.

`IMetadataService` declares `getObject(name)` ≡ `get('object', name)` and its
own conformance test round-trips a `register('object', …)` through both members,
so this was a shipped contract that could not work. Dormant in-tree only because
nothing on `main` installs a `MetadataFacade` into the `metadata` slot.

The write now performs both halves of the two-place object write the registry
documents (`SchemaRegistry.unregisterObject`'s header; the in-tree precedent is
`MetadataProtocol.applyObjectRegistryMutation`): `registerObject` for the
contributor entry the reads resolve, plus the existing `registerItem` for the
stored document. Both type spellings are covered, since both are special-cased
on the read side.

The contributor gets a COPY: `applyProtection` stamps in place and
`applySystemFields` returns its input unchanged when there is nothing to inject,
so a shared reference would have leaked a synthetic package id onto the stored
document — what the "never invents a synthetic package id" pin forbids. That pin
keeps its direct read of the generic map, because the stored document is what it
was written to guard. A package-less object registers under the `'sys_metadata'`
sentinel with `_provenance: 'org'`, so it cannot read as code-shipped.

`unregister('object', …)` removes both halves too. Without that the fix would
have re-opened #6808 from the other side: a removal that empties only the
generic map leaves `getObject` — what the data plane dispatches on — serving a
deleted object for the life of the process.

Refs #6725, #6505, PR #6723, #6808, ADR-0010, ADR-0029.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141cZum72My2vskaQSoQ1tZ
@vercel

vercel Bot commented Aug 10, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 10, 2026 2:49am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/objectql.

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

  • content/docs/concepts/metadata-lifecycle.mdx (via @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/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/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)

1 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/ 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-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Reverse-verification

Pre-fix packages/objectql/src/metadata-facade.ts restored from origin/main under the post-fix tests. Red/green predicted per case before running: 4 green — the three original provenance cases (they exercise the non-object path, or the stored document, neither of which the pre-fix source handles differently) and "unregistering an object nothing registered stays a no-op" (pre-fix unregisterItem warns and returns). 10 red — every round-trip case, the new aliasing pin (which dies on getObject(...) being undefined before it can compare anything), and the ADR-0029 case (pre-fix register resolves instead of throwing).

Measured:

 ❯ src/metadata-facade.test.ts (14 tests | 10 failed)
     × keeps the stored document unstamped even when the contributor copy is stamped
     × reads a registered object back through BOTH getObject and get
     × reads it back through the enumeration members too
     × closes the same split for the plural `objects` spelling
     × serves the runtime-effective object, as the contract says it does
     × registers a package-less object under the sentinel, not as an artifact
     × registers a package-stamped object under its own package id
     × re-registering the same object replaces it rather than accumulating owners
     × refuses to claim an object another package owns, and writes nothing
     × unregisters an object out of BOTH places it was written into
      Tests  10 failed | 4 passed (14)

Same 10, same 4. No deviations from the prediction. Fix restored → 14/14 pass.

Tests

  • packages/objectql full suite: 167 files, 2917 tests, all passing.
  • pnpm --filter @objectstack/objectql typecheck: clean.

Gates

Enumerated fresh from origin/main: 64 in lint.yml, 70 across all workflows. 48 of the 64 are root scripts; the other 16 run under pnpm --filter <pkg> (15 on @objectstack/spec, 1 on @objectstack/lint).

All 48 root gates: PASS. Three (check:app-nav-i18n, check:i18n, check:i18n-coverage) and check:type-check-debt first reported "PREREQUISITE NOT MET — the workspace packages are not built", which measures nothing; re-run after turbo run build --filter='./packages/*' --filter='./packages/*/*' (the same build lint.yml does before those steps) they pass. Notably green: check:meta-type-normalized, check:engine-double-contract, check:type-check-coverage, check:type-check-debt, check:empty-changeset, check:startup-registry-verdict, check:slot-lookup, check:service-providers, check:init-service-contract, check:tenant-chokepoint.

This diff does not reach packages/spec, so the spec-gate suite and the #6017 cross-seat declaration are not prerequisites here. Four spec gates were run anyway to rule out collateral and baseline drift — check:generated --reconcile-only, check:spec-changes, check:export-origins, check:api-surface — all PASS, so no generated artifact is stale against this base (origin/main @ 55da611).

One note on the enumeration: check:strictness-ledger and check:variant-docs appear in the lint.yml count only because they are named inside a comment there. Their real steps live in spec-liveness-check.yml, both @objectstack/spec-filtered. The count of 64 is right; two of its members are not lint.yml steps.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants