fix(objectql): deleting an object really unregisters it — a name-addressed SchemaRegistry.unregisterObject (#6808) - #6818
Merged
Conversation
…sed SchemaRegistry.unregisterObject (#6808) `deleteMetaItem`'s registry heal (`restoreArtifactRegistryView`, the #6687 three-tier walk) addresses only `SchemaRegistry`'s generic `metadata` map, but an `object` is written into two places — that map AND `objectContributors`. The heal undid the first, so after a delete the row was gone while `registry.getObject()` — the surface data CRUD dispatches on — kept serving the object, keeping it readable and writable for the life of the process. No one-line fix existed: the registry's only removal verb was the package-scoped `unregisterObjectsByPackage`. This adds the missing name-addressed verb, `SchemaRegistry.unregisterObject(name, { force })`, honouring ADR-0029's single-owner/extender rules by mirroring the judgement the package-scoped verb already encodes, and calls it from the heal's tier-3 branch only — the tier that has established no lower layer serves the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lewjj7ukQkQ5WT6cJjpdHF
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
…istry-unregister-object
Contributor
📓 Docs Drift CheckThis PR changes 2 package(s): 16 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
…hipped object (#6808) Self-review of the tier-3 removal found a hole in it: on a control-plane kernel the two-tier delete authorization (`environmentId !== undefined`) does not run, and the no-row leg of `deleteMetaItem` reaches the registry heal without touching the repository's `assertAllowed` either — so the walk could arrive for a name a code package still ships and unregister it, taking that object off the whole data plane until restart (`assertObjectRegistered` fails closed). The limb now carries the same artifact refusal `removeOverlayEntry` applies one line up, asked through the protocol's own `isArtifactBacked` rather than a second open-coded predicate. Pinned on both sides, with the tenant-kernel `NOT_OVERRIDABLE` refusal pinned as its pair so neither gate can quietly disappear. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lewjj7ukQkQ5WT6cJjpdHF
…istry-unregister-object
os-zhuang
marked this pull request as ready for review
August 9, 2026 00:37
This was referenced Aug 9, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #6808
The defect
deleteMetaItemends its repository delete withrestoreArtifactRegistryView(the #6687 three-tier heal). Every verb that walk uses —removeRuntimeShadow,registerItem,removeOverlayEntry— addressesSchemaRegistry's genericmetadatamap. Anobject, though, is written into two places on the way in (applyRegistryWriteThrough→applyObjectRegistryMutation):The heal only undid the first. Re-measured on this branch's harness (real
SysMetadataRepositoryover an in-memory engine), pre-fix:The surviving half is the load-bearing one.
getObjectis what the data plane dispatches on (assertObjectRegistered, #3770), so the row was gone fromsys_metadatawhile the object stayed resolvable, syncable and writable for the life of the process — acreateDataagainst the deleted object still inserted rows. Reachable on the ordinary Studio delete path, and onrevertCommit's soft-remove limb, which #6807 had just wired to the same heal.Premise verdict (verified FIRST, as required)
VERIFIED.
objectContributors: Map<fqn, ObjectContributor[]>already carries{ packageId, ownership: 'own' | 'extend', priority, definition }per contribution, so both facts the guard needs — who owns the object, and who extends it — are onefind/filteraway on the list the removal is about to touch. No parallel ownership index was added, and the package-scoped route was not used as a fallback.The fix
packages/objectql/src/registry.ts— the verb that was missing.objectContributorsentry plus the per-object stateregisterObjectcreated: the merged-object cache (a warmresolveObjectwould otherwise outlive the removal) and_objectRevision(registry-derived caches — the engine's roll-up summary index — key on it).unregisterObjectsByPackagedoesn't touch it either —uninstallPackageowns that half.getObject's short-name/FQN lookup was extracted to a privateresolveObjectKey, and both call it.getObject's behaviour is unchanged (the ambiguity warning is byte-identical; the FQN fallback returnsundefinedfor an unknown key exactly asresolveObjectdid). A remover with its own resolution could remove an entrygetObjectnever served and leave the served one behind — this bug's shape, one layer down.The ADR-0029 guard is borrowed, not re-invented.
unregisterObjectsByPackagealready encodes the judgement (refuse, name the extenders,forceoverrides); this mirrors it with the address changed from package to name:ownership === 'extend' && packageId !== owner.packageId— the same relation the sibling verb expresses as "not the package being uninstalled". An extension the owner contributed goes away with the object it extends, exactly as on an uninstall.extendcontributions — already anassertSingleOwnerPerObjectviolation) counts every extender as other, so it is refused rather than silently torn down.unregisterObjectsByPackagekeeps its signature and semantics, untouched.packages/metadata-protocol/src/protocol.ts— the heal's object limb, and nothing else.The call sits in
restoreArtifactRegistryView's tier 3 only, besideremoveOverlayEntry— the tier that has already established that no lower layer serves the name. Tiers 1 and 2 concluded a packaged artifact or a MetadataService baseline still does, and an object that is still served must stay registered:assertObjectRegisteredfails closed, so retiring it there would turn "reset to artifact default" into a data-plane outage.…and it never retires a code-shipped object. Self-review of the first version of this limb found a hole in it, so the limb also carries the artifact refusal
removeOverlayEntryapplies one line up — asked through the protocol's ownisArtifactBacked(→SchemaRegistry.getArtifactItem, which forobjectreads the contributor definition and applies exactly the artifact test the sibling verb applies to the plain key) rather than a second open-coded predicate. This is not already covered by the gates in front of the heal: the two-tier delete authorization that refuses an artifact-backedobjectwithNOT_OVERRIDABLEruns only whenenvironmentId !== undefined, and the no-row leg of a control-plane delete runs the heal without touching the repository'sassertAllowedat all. Without the refusal, that path unregistered a name a code package still ships, taking the object off the whole data plane until restart. Both the refusal and its tenant-kernel pair (NOT_OVERRIDABLE) are pinned, so neither gate can quietly disappear.Because the heal runs after the repository delete has committed, the extender refusal is caught and logged by name rather than propagated (the row is gone either way, and a throw would turn a successful delete into a 500) — and deliberately not left to the heal's silent outer
catch, so a runtime that disagrees withsys_metadatais visible in the log rather than inferred later.No other line of
restoreArtifactRegistryViewis touched — #6780 is a disjoint fix on the same function (org-blind eviction) and the narrow file face is what keeps the two apart.Per-caller parity evidence (#6807's pins)
#6807 pinned that
deleteMetaItemandrevertCommit's soft-remove limb leave the same registry view, viaregistryShapeFor(plainKeyEntry,itemLabel,objectServed). Its own TSDoc named this gap as one the parity assertion did not close and would stay green through:That prediction held:
packages/objectql/src/protocol-commit-history.test.tsis green unchanged, withobjectServednowfalseon both sides instead oftrueon both. To keep the pin from freezing the fixed value the way it once froze the broken one, the object case now states the value outright (objectServed === falseasserted on the revert side and the delete side) in addition to comparing the two shapes, and the block's TSDoc records that the gap is closed. A parity pin that never names the value it agrees on can certify a shared bug as "consistent".Reverse verification — predicted BEFORE reverting, then run
Pass 1 — the removal itself. Reverted: only the new
registry.unregisterObject(name)block in the heal (the registry verb and all tests left in place), then@objectstack/metadata-protocolrebuilt so the objectql-side tests see it.objectql/protocol-delete-object-registry-heal.test.tsobjectql/registry-unregister-object.test.tsobjectql/protocol-commit-history.test.tsan object soft-remove leaves exactly the registry view deleteMetaItem leaves), 31 GREENmetadata-protocol/protocol.delete-object-registry-unregister.test.tsSignature-level, the reds were what was predicted, e.g.:
The green half is intended, not slack: the negatives (non-object type, tier-1 stop, missing-verb degradation, tier-1-stays) constrain the shape of the fix, not its presence, so they are trivially true with no call site at all. A "fix" that unregistered on every tier, or for every type, passes the red half and fails those.
Pass 2 — the artifact refusal. Reverted: only
&& !this.isArtifactBacked(singular, name)from the limb's condition. Predicted exactly 2 RED, one per package, and nothing else — the guard is orthogonal to the removal. Actual:Acceptance
registry.getObject(name)andregistry.getItem('object', name)(which special-cases back togetObject, which is exactly why listing looked clean while dispatch did not), plusgetAllObjects(). Pinned inprotocol-delete-object-registry-heal.test.tsand at the registry level.toThrow: acreateDatathat inserted a row before the delete is afterwards rejected withcode === 'OBJECT_NOT_FOUND',status === 404,object === 'myapp_invoice', and the data-row count is unchanged.Scope notes
packages/objectql/src/registry.ts,packages/metadata-protocol/src/protocol.ts(object limb only), tests in both packages, one changeset (@objectstack/objectql+@objectstack/metadata-protocol).MetadataFacade.register('object', …)writes where neither of its own object reads look #6725 is left open, as instructed. For the record:SchemaRegistry.unregisterObjectis the natural landing for it later.MetadataFacade.register('object', …)writes where neither of its own object reads look #6725 is the write-side half of the same map-vs-contributors split, and its removal side now has a verb to call; the remaining work there is on the write path, not on the registry's vocabulary.registerObject, so once that has happened the registry serves the org body with_provenance: 'org'and no layer holds the packaged definition any more — a restoration gap, not a removal gap, and undecidable at this seam with the information the walk has. The refusal above covers the case that IS decidable (the registry still serves the packaged definition). The tier-1 pin here asserts only that an artifact-backed object stays served, which is the invariant this PR must not break.Gates
Run on the merged branch (
origin/mainmerged in; PR #6812 and #6804 landed under this work and every line number was re-anchored):turbo run build --filter='!@objectstack/docs') — 71/71.check:in.github/workflows/lint.yml, enumerated fresh from the file and run one by one (both jobs: the 32-gate ESLint job and the typecheck job's spec/docs/i18n/API-surface/ledger gates) — all PASS, includingcheck:engine-double-contract,check:type-check-coverageandcheck:type-check-debt(34 ledger entries re-measured, none above its recorded number).turbo run typecheckacrosspackages/*,packages/*/*,apps/*— 120/120.turbo run testacross the workspace — 135/135 tasks. Touched packages re-run after the final commit:@objectstack/objectql2692 passed,@objectstack/metadata-protocol747 passed.