Skip to content

fix(metadata): register()/unregister() announce to subscribe() watchers (#3112)#3131

Merged
os-zhuang merged 2 commits into
mainfrom
fix/metadata-register-notify
Jul 17, 2026
Merged

fix(metadata): register()/unregister() announce to subscribe() watchers (#3112)#3131
os-zhuang merged 2 commits into
mainfrom
fix/metadata-register-notify

Conversation

@os-zhuang

@os-zhuang os-zhuang commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #3112.

The trap

MetadataManager.register() updated the in-memory registry, persisted to writable loaders and published to the realtime service — but never called notifyWatchers(). unregister() had the same gap. subscribe() looked like it covered every write, and covered almost none of them.

One correction to the issue's framing, verified while investigating: watchers never fired "from the saveMetaItem path" directly — saveMetaItem never touches the MetadataManager at all. It writes sys_metadata, the repository's in-memory broadcaster emits, and applyRepoEvent (metadata-manager.ts:1590) translates that into the watch event. Same conclusion, different mechanism.

The consumer that pays for this is ObjectQL's SchemaRegistry bridge — the component that decides what is queryable. #3108 worked around it for the metadata:reloaded seam; the asymmetry remained everywhere else.

Decision: option 1, with an explicit opt-out

Announcing is now the default, so a new call site (e.g. a cloud artifact-api mid-run refresh) is correct without knowing this contract exists. The five bulk/boot sites opt out explicitly via the new MetadataWriteOptions{ notify: false } — so the silent path is deliberate and visible at the call site instead of a global trap.

Naive "just call notifyWatchers()" was not viable, for a reason beyond the flood the issue anticipated:

  • Boot ordering. ObjectQL subscribes at plugin.ts:367; the bulk bridgeObjectsToMetadataService runs at :492. The subscriber is already attached, so the flood is real.
  • Provenance corruption (not in the issue). The bridge copies objects out of the SchemaRegistry. Announcing feeds them straight back into ObjectQL's handler, which re-registers under _packageId ?? 'metadata-service' — silently overwriting the true package provenance of every bridged object whose body carries no _packageId. That site must stay silent on correctness grounds, not just cost.

What actually changes at runtime

This is a contract fix, not a bug-fix bundle — being precise after auditing every consumer:

  • Live: runtime datasource writes (datasource-admin-plugin.ts:254/383) now reach the HMR SSE stream, which subscribes to every registered type. The "future mid-run register() call site" the issue predicted already exists today, and was silent.
  • Latent: unregisterPackage() / bulkUnregister() now announce their deletes. Correct, and regression-tested — but neither has a production caller in framework or cloud today, so nothing was observably broken. Called out so the changeset doesn't oversell it.
  • Unchanged: the object type sees no new events at runtime — both of its register() sites are deliberately silenced.

Call-site posture

Site Posture Why
objectql boot bridge silent copies out of the registry; announcing corrupts provenance
metadata artifact ingest (×3) silent reload announces the whole batch once via metadata:reloaded
metadata FS boot priming silent pre-consumer; later edits flow through FileSystemRepository
datasource-admin register/unregister announces genuine mid-run write (was the silent bug)
unregisterPackage / bulkUnregister announces genuine runtime delete (was the silent bug)

Verification

  • New register-notifies-watchers.test.ts (15 tests) pins both halves: announce by default, silence only on request. Verified non-vacuous — 8 fail against the pre-change manager, and the 6 asserting silence correctly still pass.
  • New no-flood guard on the reload path. Verified non-vacuous — fails with expected [{ type: 'added', … }] to deeply equal [] when the { notify: false } opt-out is dropped.
  • Green: spec 6890, objectql 905 (the sole registry subscriber), metadata 274, service-datasource 93, downstream-contract 14 + tsc, plus plugin-dev/plugin-security/metadata-protocol/rest/runtime.
  • api-surface ratchet: 0 breaking, 1 added (MetadataWriteOptions); snapshot regenerated. The 3-arg forms keep working — implementors with fewer params stay structurally compatible.

Downstream (cloud) — audited, follow-up filed

cloud consumes framework via link: to this checkout, so this lands there on merge with no version bump. Audit result: 5 call sites, all boot-time bulk (service-ai agent bridge, ai-studio tools/agents/skills, legacy-agent cleanup), no agent/tool/skill subscriber, and no IMetadataService implementor — so no correctness break. They should still take { notify: false } for the same reason this PR's bridge does: notifyWatchers() publishes to the cluster unconditionally whenever pubsub is attached, and apps/objectos-ee runs service-cluster + redis — so each node boot would otherwise emit ~50 needless metadata.changed broadcasts carrying full definition bodies. Sequenced after this PR, since MetadataWriteOptions does not exist until it merges.

Reviewer notes

  • notifyWatchers() also does cluster fan-out, so runtime register() now broadcasts on metadata.changed. That is the intended peer-invalidation design (see the field comment at metadata-manager.ts:132). No new secret egress: StoredDatasource carries external.credentialsRef (a reference — "cleartext secret is transient, never stored"), and register() already published the same body to the realtime service.
  • registerInMemory() stays silent by design (GitOps-owned artefacts) — now documented on the method rather than left as folklore.

🤖 Generated with Claude Code

…rs (#3112)

`MetadataManager.register()` updated the in-memory registry, persisted to
writable loaders and published to the realtime service — but never called
`notifyWatchers()`. `unregister()` had the same gap. Watchers only ever fired
from the `saveMetaItem` path (indirectly, via the sys_metadata repository watch
stream) and the filesystem watcher, so `subscribe()` looked like it covered
every write while silently missing all of them.

The consumer that pays for this is ObjectQL's SchemaRegistry bridge — the
component that decides what is queryable. #3108 worked around it for the
`metadata:reloaded` seam by carrying the parsed artifact on the event payload,
but the asymmetry remained for every other `register()` call site and every
other `subscribe()` consumer.

Announce by default, so a new call site is correct without knowing the contract
exists. Two concrete fixes fall out: `unregisterPackage()` now tells cached
consumers to drop uninstalled objects instead of serving them until restart,
and runtime datasource writes reach watchers.

Bulk ingest opts out explicitly via the new `MetadataWriteOptions`
(`{ notify: false }`) at the five boot/batch sites, each of which either runs
before consumers cache anything or announces the whole batch once. ObjectQL's
registry bridge MUST stay silent for a second reason: it copies objects OUT of
the SchemaRegistry, and announcing would feed them back through a handler that
re-registers under `_packageId ?? 'metadata-service'` — overwriting the true
package provenance of every object whose body carries no `_packageId`.

Additive only: the 3-arg forms keep working, and the api-surface ratchet reports
0 breaking / 1 added.

Tests pin both halves of the contract (announce by default, silence only when
asked) and the no-flood invariant on the reload path; both were verified to fail
against the pre-change behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Jul 17, 2026 1:34pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 17, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata, @objectstack/objectql, @objectstack/spec.

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

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata, @objectstack/objectql, packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via packages/objectql, @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/migration-from-objectql.mdx (via @objectstack/objectql)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/vercel.mdx (via @objectstack/objectql)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/cli.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via packages/metadata, @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/objectql)
  • content/docs/permissions/authentication.mdx (via @objectstack/objectql)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/metadata, @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/objectql)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/metadata-service.mdx (via @objectstack/metadata)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via packages/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/metadata, @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/objectql, @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

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.

…, not a bug fix

unregisterPackage()/bulkUnregister() have no production caller in framework or
cloud today, so their announce is correct-but-latent. The one live behavior
change is runtime datasource writes reaching the HMR SSE stream. Say so instead
of advertising two 'concrete fixes'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@os-zhuang
os-zhuang merged commit d2723e2 into main Jul 17, 2026
16 of 17 checks passed
@os-zhuang
os-zhuang deleted the fix/metadata-register-notify branch July 17, 2026 13:05
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.

MetadataManager.register() never notifies subscribe() watchers — runtime consumers go stale on every non-saveMetaItem write

1 participant