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
SchemaRegistry.uninstallPackage has exactly one step that can refuse: unregisterObjectsByPackage, which throws when the package owns an object another
package extends (ADR-0029 — the refusal that tells an operator to uninstall the
extenders first). That guard exists to keep a registry whole, and it was reached through mutations. So exercising the protection half-tore down the very package it
was protecting.
This is a consistency fix, not new policy — the invariant was already written into
this function, three lines below the defect, by #7221:
Runs AFTER the object verb because that one can refuse (ADR-0029 extenders): a refused
uninstall must remove nothing at all.
The unregisterItemsByPackage call that comment describes obeyed it. The unregisterNamespace call directly above it did the opposite.
Every mutation in uninstallPackage, and where the refusal sat
Enumerated rather than assumed, because the card names only the namespace:
step
before
after
unregisterNamespace
before the refusal
after
unregisterObjectsByPackage (the refusing verb)
2nd
1st
unregisterItemsByPackage
after
after
package record delete
after
after
Only the namespace was exposed at this level — the other two were already correctly
placed. The verb is safe as the first step because it reads objectContributors only; it
depends on nothing the steps below establish, so a straight hoist restores the ordering
with no compensating change.
The second limb: the refusing verb was itself partially mutating
Hoisting only the namespace would have left the card's own headline defect — "a
rejected uninstall leaves the package half-mutated" — alive in a second place. unregisterObjectsByPackage decided the refusal inline, one object at a time, inside
the walk that removes them. A package owning account (free) and contact (extended
by another package) therefore lost account on its way to refusing over contact. The
comment above says "remove nothing at all", not "nothing except the objects we got
through before we noticed".
Fixed with the same tool one level down: a refusal pass over every object, then a
mutation pass. Same predicate, same iteration order — so the same object still refuses
with the same message, and only the removals that used to precede the throw are gone.
Deliberately not a second public canUninstall(id) probe (that leaves two places
that must agree) and not a transactional collect-and-apply (no journal, no rollback,
nothing to unwind) — both were refused by name in triage; neither is here.
unregisterObjectsByPackage now documents the guarantee its callers depend on: a throw
is a no-op, which is what makes it safe to run ahead of a caller's own mutations. That
also covers its other callers (MetadataFacade.unregisterPackage, ObjectQL.unregisterObject), which were exposed to the same partial walk.
Tests
Six, in packages/objectql/src/registry.test.ts. Three witness the defect and go red
when registry.ts is reverted; three stay green under both versions, which is what
makes them regression pins rather than restatements of the fix:
Red on revert:
a refused uninstall leaves the namespace still resolving — the assertion the card
names. No existing test refused and then inspected the namespace, which is exactly
why the defect was invisible to a fully green suite.
refuses before removing anything — a free sibling object survives — the second-limb
pin, and the only multi-object case at the verb level. A single-object fixture passes
under the namespace-only fix too; this one does not.
a refused uninstall leaves the whole package intact, not just the namespace — the
same multi-object shape through uninstallPackage, asserting every limb the function
mutates (namespace, both objects, the item half, the package record).
Green under both (measured, by running them against the pre-fix registry.ts):
the refusal still names the first refusable object and all its extenders — message
identity, checked rather than claimed. With two refusable objects and three
extenders, the first object walked still wins and its extenders are still listed in
registration order, so the full message string is byte-identical to the pre-fix one.
force still removes the owner even with a free sibling ahead of it — the force: true
path skips the refusal pass and removes exactly what it removed before.
the successful path still releases the namespace after the object verb.
Reverse verification was run from a committed state, against the exact pre-fix registry.ts this branch merged (021622ada^2): 3 failed, 3 passed, and the other 93
tests in the file were unmoved — so nothing in the existing suite depended on the old
ordering.
Grade: latent
No in-tree caller reaches the refusal path today, so no shipped behaviour was observably
broken. This restores the invariant before one does. Not an incident.
One note for the next person to locate this defect
It had three different addresses in one day: triage measured registry.ts:2586-2608,
my branch point (f78dd23) had it at :2636-2667, and after merging origin/main it
sits at :2725. registry.ts took two unrelated merges in that window while being 3,000
lines long. Locate this seam by symbol — uninstallPackage and unregisterObjectsByPackage — never by the line numbers in a card.
Verification
pnpm --filter @objectstack/objectql test — 190 files, 3382 tests, all passing
pnpm check:durability-log-level, pnpm check:engine-double-contract, pnpm check:query-options-erasure, pnpm check:type-check-debt --re-measure
(on a full built closure), node scripts/check-engine-split-ratio.mjs, pnpm check:nul-bytes — all exit 0, no baseline raised, no ledger touched
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.
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
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#7970
SchemaRegistry.uninstallPackagehas exactly one step that can refuse:unregisterObjectsByPackage, which throws when the package owns an object anotherpackage
extends (ADR-0029 — the refusal that tells an operator to uninstall theextenders first). That guard exists to keep a registry whole, and it was reached
through mutations. So exercising the protection half-tore down the very package it
was protecting.
This is a consistency fix, not new policy — the invariant was already written into
this function, three lines below the defect, by #7221:
The
unregisterItemsByPackagecall that comment describes obeyed it. TheunregisterNamespacecall directly above it did the opposite.Every mutation in
uninstallPackage, and where the refusal satEnumerated rather than assumed, because the card names only the namespace:
unregisterNamespaceunregisterObjectsByPackage(the refusing verb)unregisterItemsByPackagepackagerecord deleteOnly the namespace was exposed at this level — the other two were already correctly
placed. The verb is safe as the first step because it reads
objectContributorsonly; itdepends on nothing the steps below establish, so a straight hoist restores the ordering
with no compensating change.
The second limb: the refusing verb was itself partially mutating
Hoisting only the namespace would have left the card's own headline defect — "a
rejected uninstall leaves the package half-mutated" — alive in a second place.
unregisterObjectsByPackagedecided the refusal inline, one object at a time, insidethe walk that removes them. A package owning
account(free) andcontact(extendedby another package) therefore lost
accounton its way to refusing overcontact. Thecomment above says "remove nothing at all", not "nothing except the objects we got
through before we noticed".
Fixed with the same tool one level down: a refusal pass over every object, then a
mutation pass. Same predicate, same iteration order — so the same object still refuses
with the same message, and only the removals that used to precede the throw are gone.
Deliberately not a second public
canUninstall(id)probe (that leaves two placesthat must agree) and not a transactional collect-and-apply (no journal, no rollback,
nothing to unwind) — both were refused by name in triage; neither is here.
unregisterObjectsByPackagenow documents the guarantee its callers depend on: a throwis a no-op, which is what makes it safe to run ahead of a caller's own mutations. That
also covers its other callers (
MetadataFacade.unregisterPackage,ObjectQL.unregisterObject), which were exposed to the same partial walk.Tests
Six, in
packages/objectql/src/registry.test.ts. Three witness the defect and go redwhen
registry.tsis reverted; three stay green under both versions, which is whatmakes them regression pins rather than restatements of the fix:
Red on revert:
a refused uninstall leaves the namespace still resolving— the assertion the cardnames. No existing test refused and then inspected the namespace, which is exactly
why the defect was invisible to a fully green suite.
refuses before removing anything — a free sibling object survives— the second-limbpin, and the only multi-object case at the verb level. A single-object fixture passes
under the namespace-only fix too; this one does not.
a refused uninstall leaves the whole package intact, not just the namespace— thesame multi-object shape through
uninstallPackage, asserting every limb the functionmutates (namespace, both objects, the item half, the package record).
Green under both (measured, by running them against the pre-fix
registry.ts):the refusal still names the first refusable object and all its extenders— messageidentity, checked rather than claimed. With two refusable objects and three
extenders, the first object walked still wins and its extenders are still listed in
registration order, so the full message string is byte-identical to the pre-fix one.
force still removes the owner even with a free sibling ahead of it— theforce: truepath skips the refusal pass and removes exactly what it removed before.
the successful path still releases the namespace after the object verb.Reverse verification was run from a committed state, against the exact pre-fix
registry.tsthis branch merged (021622ada^2): 3 failed, 3 passed, and the other 93tests in the file were unmoved — so nothing in the existing suite depended on the old
ordering.
Grade: latent
No in-tree caller reaches the refusal path today, so no shipped behaviour was observably
broken. This restores the invariant before one does. Not an incident.
One note for the next person to locate this defect
It had three different addresses in one day: triage measured
registry.ts:2586-2608,my branch point (
f78dd23) had it at:2636-2667, and after mergingorigin/mainitsits at
:2725.registry.tstook two unrelated merges in that window while being 3,000lines long. Locate this seam by symbol —
uninstallPackageandunregisterObjectsByPackage— never by the line numbers in a card.Verification
pnpm --filter @objectstack/objectql test— 190 files, 3382 tests, all passingpnpm --filter @objectstack/objectql typecheck— cleanpnpm linton both changed files — cleanpnpm check:durability-log-level,pnpm check:engine-double-contract,pnpm check:query-options-erasure,pnpm check:type-check-debt --re-measure(on a full built closure),
node scripts/check-engine-split-ratio.mjs,pnpm check:nul-bytes— all exit 0, no baseline raised, no ledger touchedGenerated by Claude Code