Observation-class finding, surfaced by the #7221 dev while placing its own sweep and verified independently by the engine-core seat on origin/main before filing. Unassigned, deliberately not queued. ⛔ Pre-existing — #7221 / PR #7961 does not cause it and deliberately does not extend it.
The fact
SchemaRegistry.uninstallPackage (packages/objectql/src/registry.ts) runs its steps in this order:
uninstallPackage(id: string): boolean {
const pkg = this.getPackage(id);
if (!pkg) { … return false; }
// Unregister namespace
if (pkg.manifest.namespace) {
this.unregisterNamespace(pkg.manifest.namespace, id); // ← mutates
}
// Unregister objects (will throw if extenders exist)
this.unregisterObjectsByPackage(id); // ← can THROW
…
}
The comment on the second call states the hazard in its own words — "will throw if extenders exist" — and the mutation above it has already happened by then. unregisterObjectsByPackage throws when the package owns an object another package extends (ADR-0029, the refusal that tells the operator to uninstall the extenders first).
⇒ A refused uninstall is not a no-op. The namespace is gone, the objects are not, the package record is not. The caller gets an exception describing a refusal and is left with a registry in a state neither "installed" nor "uninstalled".
Why it is worth a card rather than a shrug
The refusal exists precisely to protect an operator from a half-torn-down registry — that is what ADR-0029's extender check is for. Reaching that protection through a mutation is the one path where the guard's purpose and its behaviour disagree. And because the throw propagates, nothing rolls the namespace back: the process now serves a package whose namespace no longer resolves, until it is restarted or the extenders are uninstalled and the operation retried.
⚠️ Reachability is the same argument as #7221's: nothing in-tree calls uninstallPackage on a refusal path today, so this is latent — filed at that grade deliberately, not as a live incident.
Shape of the fix (no recommendation forced)
- Reorder — run the refusing verb first, mutate after. Cheapest and most obviously correct; changes the observable order of an existing failure mode, which is why it deserves a deliberate decision rather than a silent edit.
- Pre-check — hoist the extender check into a
canUninstall(id) probe called before any mutation, leaving unregisterObjectsByPackage as-is for its other caller.
- Make it transactional — collect the mutations and apply them only if every step succeeds. Most robust, most invasive; probably over-built for one ordering hazard.
⚠️ Whichever is taken, it wants a test that asserts the namespace survives a refused uninstall — the defect is invisible to every current test because none of them refuses and then inspects the namespace.
Provenance
Reported by the #7221 dev as an out-of-scope observation (item 2 of its "found and deliberately not fixed" list) rather than ridden into that PR — correct call, and the reason it is a card instead of a sentence in a merged PR body. PR #7961 deliberately places its new item sweep after the throwing verb so the new code does not widen this wart; the pre-existing line above it was left untouched.
Refs #7221, PR #7961, ADR-0029.
Observation-class finding, surfaced by the #7221 dev while placing its own sweep and verified independently by the engine-core seat on
origin/mainbefore filing. Unassigned, deliberately not queued. ⛔ Pre-existing — #7221 / PR #7961 does not cause it and deliberately does not extend it.The fact
SchemaRegistry.uninstallPackage(packages/objectql/src/registry.ts) runs its steps in this order:The comment on the second call states the hazard in its own words — "will throw if extenders exist" — and the mutation above it has already happened by then.
unregisterObjectsByPackagethrows when the package owns an object another packageextends (ADR-0029, the refusal that tells the operator to uninstall the extenders first).⇒ A refused uninstall is not a no-op. The namespace is gone, the objects are not, the
packagerecord is not. The caller gets an exception describing a refusal and is left with a registry in a state neither "installed" nor "uninstalled".Why it is worth a card rather than a shrug
The refusal exists precisely to protect an operator from a half-torn-down registry — that is what ADR-0029's extender check is for. Reaching that protection through a mutation is the one path where the guard's purpose and its behaviour disagree. And because the throw propagates, nothing rolls the namespace back: the process now serves a package whose namespace no longer resolves, until it is restarted or the extenders are uninstalled and the operation retried.
uninstallPackageon a refusal path today, so this is latent — filed at that grade deliberately, not as a live incident.Shape of the fix (no recommendation forced)
canUninstall(id)probe called before any mutation, leavingunregisterObjectsByPackageas-is for its other caller.Provenance
Reported by the #7221 dev as an out-of-scope observation (item 2 of its "found and deliberately not fixed" list) rather than ridden into that PR — correct call, and the reason it is a card instead of a sentence in a merged PR body. PR #7961 deliberately places its new item sweep after the throwing verb so the new code does not widen this wart; the pre-existing line above it was left untouched.
Refs #7221, PR #7961, ADR-0029.