diff --git a/.changeset/30215-uninstall-forgets-engine-manifest.md b/.changeset/30215-uninstall-forgets-engine-manifest.md
new file mode 100644
index 00000000000..52d422d7308
--- /dev/null
+++ b/.changeset/30215-uninstall-forgets-engine-manifest.md
@@ -0,0 +1,12 @@
+---
+'@objectstack/objectql': patch
+---
+
+fix(objectql): an uninstalled package no longer reads as read-only until a restart
+
+Clause-②: yes (widening) — `@objectstack/objectql`'s exported `SchemaRegistry` gains the public method `onPackageUninstalled(listener)`, which returns its unsubscribe. Nothing is refused that was accepted before.
+Metadata-protocol: none
+
+An administrator who uninstalled a package through `DELETE /api/v1/marketplace/install-local/:id` got a 200 "withdrawn from the running kernel", but on the same instance the package id still read as a booted code package until the server restarted. Uninstalling a `.custom` pair that an older runtime had installed as code, as the pair install now prescribes, left `POST /api/v1/packages//ensure-customization` answering `created: true` with `writable: false`, so nothing could be customized into the new pair. The engine's manifest map, which the ADR-0070 writability check reads first, kept the id after the registry had withdrawn the package.
+
+`SchemaRegistry.uninstallPackage` now announces each completed uninstall to `onPackageUninstalled` listeners, after the package record is gone (never for a refused uninstall), and the engine drops its manifest entry there, so the id is released on whichever door withdrew the package, not per door. A reinstall of the same id registers it again as before. A listener that throws does not turn the completed uninstall into a refusal; it is logged at `error`.
diff --git a/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts b/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts
new file mode 100644
index 00000000000..173e941fbca
--- /dev/null
+++ b/packages/objectql/src/engine-uninstall-forgets-manifest.test.ts
@@ -0,0 +1,152 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * #30215 — an uninstalled package stops reading as a booted code package on
+ * the running instance.
+ *
+ * `registerApp` puts a package's manifest in the engine's `manifests` map, and
+ * ADR-0070's `isWritablePackage` reads that map FIRST: an id in it is a code
+ * package, read-only. Every uninstall door (the protocol's `deletePackage`,
+ * `DELETE /packages/:id`, the install-local DELETE) withdraws the package
+ * through `SchemaRegistry.uninstallPackage`, and nothing took the id out of
+ * the engine's map. So an administrator who uninstalled a package got a 200,
+ * then `ensure-customization` on the same instance answered `writable: false`
+ * for it until the server restarted.
+ *
+ * Pinned here, against a real engine and its own registry:
+ * - the registry's uninstall drops the engine's manifest entry, so the
+ * predicate answers writable again;
+ * - an uninstall the registry REFUSES (ADR-0029: another package extends an
+ * object this one owns) removes nothing, the manifest entry included;
+ * - a reinstall of the same id puts the entry back (read-only again);
+ * - the registry's announcement: once per withdrawn package, never for an id
+ * it does not hold, never after unsubscribe, and a throwing listener does
+ * not turn a completed uninstall into a refused one.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest';
+import { isWritablePackage } from '@objectstack/metadata-protocol';
+import { ObjectQL } from './engine.js';
+import { SchemaRegistry } from './registry.js';
+
+const PKG = 'com.example.tasksapp';
+
+function manifestsOf(engine: ObjectQL): Map {
+ return (engine as unknown as { manifests: Map }).manifests;
+}
+
+function bootWithPackage(): ObjectQL {
+ const engine = new ObjectQL();
+ engine.registerApp({
+ id: PKG,
+ namespace: 'tasks_app',
+ objects: [{ name: 'tasks_app_task', fields: { name: { type: 'text' } } }],
+ } as any);
+ return engine;
+}
+
+describe('ObjectQL — an uninstall forgets the package\'s manifest (#30215)', () => {
+ it('reads a registered code package as read-only, and writable once the registry uninstalls it', () => {
+ const engine = bootWithPackage();
+ expect(manifestsOf(engine).has(PKG)).toBe(true);
+ expect(isWritablePackage(engine, PKG)).toBe(false);
+
+ expect(engine.registry.uninstallPackage(PKG)).toBe(true);
+
+ expect(engine.registry.getPackage(PKG)).toBeUndefined();
+ expect(manifestsOf(engine).has(PKG)).toBe(false);
+ expect(isWritablePackage(engine, PKG)).toBe(true);
+ });
+
+ it('keeps the manifest entry when the registry refuses the uninstall (an extended object)', () => {
+ const engine = bootWithPackage();
+ engine.registry.registerObject(
+ { name: 'tasks_app_task', fields: {} } as any, 'com.example.addon', undefined, 'extend',
+ );
+
+ expect(() => engine.registry.uninstallPackage(PKG)).toThrow();
+
+ expect(engine.registry.getPackage(PKG)).toBeDefined();
+ expect(manifestsOf(engine).has(PKG)).toBe(true);
+ expect(isWritablePackage(engine, PKG)).toBe(false);
+ });
+
+ it('puts the entry back on a reinstall of the same id', () => {
+ const engine = bootWithPackage();
+ engine.registry.uninstallPackage(PKG);
+ expect(isWritablePackage(engine, PKG)).toBe(true);
+
+ engine.registerApp({
+ id: PKG,
+ namespace: 'tasks_app',
+ objects: [{ name: 'tasks_app_task', fields: { name: { type: 'text' } } }],
+ } as any);
+
+ expect(manifestsOf(engine).has(PKG)).toBe(true);
+ expect(isWritablePackage(engine, PKG)).toBe(false);
+ });
+
+ it('leaves every other package\'s manifest entry in place', () => {
+ const engine = bootWithPackage();
+ engine.registerApp({ id: 'com.example.notesapp', namespace: 'notes_app' } as any);
+
+ engine.registry.uninstallPackage(PKG);
+
+ expect(manifestsOf(engine).has('com.example.notesapp')).toBe(true);
+ expect(isWritablePackage(engine, 'com.example.notesapp')).toBe(false);
+ });
+});
+
+describe('SchemaRegistry.onPackageUninstalled (#30215)', () => {
+ afterEach(() => { vi.restoreAllMocks(); });
+
+ function registryWith(id: string): SchemaRegistry {
+ const registry = new SchemaRegistry();
+ registry.installPackage({ id, name: id, version: '1.0.0', type: 'app' } as any, 'code');
+ return registry;
+ }
+
+ it('announces a withdrawn package once, after its record is gone', () => {
+ const registry = registryWith(PKG);
+ const seen: Array<{ id: string; recordGone: boolean }> = [];
+ registry.onPackageUninstalled((id) => {
+ seen.push({ id, recordGone: registry.getPackage(id) === undefined });
+ });
+
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ registry.uninstallPackage(PKG);
+ registry.uninstallPackage(PKG); // nothing left to withdraw
+
+ expect(seen).toEqual([{ id: PKG, recordGone: true }]);
+ });
+
+ it('announces nothing for an id the registry does not hold, or after unsubscribe', () => {
+ const registry = registryWith(PKG);
+ const listener = vi.fn();
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ const unsubscribe = registry.onPackageUninstalled(listener);
+
+ registry.uninstallPackage('com.example.never-installed');
+ expect(listener).not.toHaveBeenCalled();
+
+ unsubscribe();
+ registry.uninstallPackage(PKG);
+ expect(listener).not.toHaveBeenCalled();
+ });
+
+ it('a throwing listener neither refuses the uninstall nor starves the next listener, and is logged at error', () => {
+ const registry = registryWith(PKG);
+ const error = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const next = vi.fn();
+ registry.onPackageUninstalled(() => { throw new Error('boom'); });
+ registry.onPackageUninstalled(next);
+
+ expect(registry.uninstallPackage(PKG)).toBe(true);
+
+ expect(registry.getPackage(PKG)).toBeUndefined();
+ expect(next).toHaveBeenCalledWith(PKG);
+ expect(error).toHaveBeenCalledTimes(1);
+ expect(String(error.mock.calls[0][0])).toContain(`package "${PKG}" was uninstalled`);
+ expect(String(error.mock.calls[0][0])).toContain('boom');
+ });
+});
diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts
index e7f68d2a7da..947b0720cde 100644
--- a/packages/objectql/src/engine.ts
+++ b/packages/objectql/src/engine.ts
@@ -734,6 +734,12 @@ export class ObjectQL implements IObjectQLEngine {
constructor(hostContext: Record = {}) {
// Use provided logger or create a new one
this.logger = hostContext.logger || createLogger({ level: 'info', format: 'pretty' });
+ // [#30215] `registerApp` writes `manifests`; an uninstall withdraws the
+ // package through the registry's own verb, on every door, so the entry goes
+ // with the registry's record. Left behind, ADR-0070's `isWritablePackage`
+ // (which reads this map first) kept an uninstalled package read-only until
+ // a restart.
+ this._registry.onPackageUninstalled((packageId) => { this.manifests.delete(packageId); });
// Pick up production hardening switches from env so deployers can
// enforce strict-body without code changes:
// OBJECTQL_STRICT_HOOKS=1 → unresolved hooks throw at bind time
diff --git a/packages/objectql/src/registry-module-mock.ts b/packages/objectql/src/registry-module-mock.ts
index 9acf5f775ac..bc64b3eccfb 100644
--- a/packages/objectql/src/registry-module-mock.ts
+++ b/packages/objectql/src/registry-module-mock.ts
@@ -183,6 +183,11 @@ export function createRegistryModuleMock(
// a refused registration is put back. The double runs the body as given:
// it records nothing, so it has nothing to put back.
restoringOnRefusal: vi.fn((register: () => unknown) => register()),
+ // [#30215] Every engine subscribes in its constructor, so its manifest map
+ // forgets a package the registry uninstalls. The double never uninstalls,
+ // so it never announces: it records the subscription and answers the
+ // unsubscribe the real member answers.
+ onPackageUninstalled: vi.fn(() => () => {}),
reset: vi.fn(),
metadata: { get: vi.fn(() => new Map()) },
...instanceOverrides,
diff --git a/packages/objectql/src/registry.ts b/packages/objectql/src/registry.ts
index edb21cc8c27..9b0443fea23 100644
--- a/packages/objectql/src/registry.ts
+++ b/packages/objectql/src/registry.ts
@@ -3619,6 +3619,47 @@ export class SchemaRegistry {
this.initialDisabledPackageIds = new Set(ids);
}
+ /** See {@link onPackageUninstalled}. */
+ private packageUninstalledListeners = new Set<(packageId: string) => void>();
+
+ /**
+ * [#30215] Be told, once per package, that {@link uninstallPackage} has
+ * withdrawn it — after its record is gone, never for a refused uninstall
+ * (that one removes nothing). Returns the unsubscribe.
+ *
+ * For a holder of state keyed by package id OUTSIDE this registry, so it is
+ * withdrawn on every uninstall door at once (the protocol's `deletePackage`,
+ * `DELETE /packages/:id`, the install-local DELETE), never per door. The
+ * engine is the one today: its manifest map (`registerApp` → `manifests`) is
+ * the code-package signal ADR-0070's `isWritablePackage` reads first, and an
+ * id left in it kept an uninstalled package read-only until a restart.
+ */
+ onPackageUninstalled(listener: (packageId: string) => void): () => void {
+ this.packageUninstalledListeners.add(listener);
+ return () => { this.packageUninstalledListeners.delete(listener); };
+ }
+
+ /**
+ * Tell every {@link onPackageUninstalled} listener. Never throws: the
+ * uninstall has already happened, so a throwing listener must not read as a
+ * refused one to the door that called {@link uninstallPackage}. It is logged
+ * at `error` instead, because that listener's state now disagrees with this
+ * registry while everything looks healthy.
+ */
+ private announcePackageUninstalled(id: string): void {
+ for (const listener of this.packageUninstalledListeners) {
+ try {
+ listener(id);
+ } catch (err) {
+ console.error(
+ `[Registry] package "${id}" was uninstalled, but a listener keeping state keyed by its id failed to drop it `
+ + `(${(err as Error)?.message ?? err}) — that state still names the package until the next restart, `
+ + 'while the registry no longer does. Restart the runtime to bring the two back in line.',
+ );
+ }
+ }
+ }
+
// ==========================================
// Restore on refusal
// ==========================================
@@ -8219,6 +8260,9 @@ export class SchemaRegistry {
if (collection) {
collection.delete(id);
this.log(`[Registry] Uninstalled package: ${id}`);
+ // …and, last, whoever keeps its own state keyed by this package's id
+ // (the engine's manifest map) drops it, on every uninstall door at once.
+ this.announcePackageUninstalled(id);
return true;
}
return false;