fix(metadata-protocol): uninstall no longer orphans env-wide sys_metadata rows (#7705) - #7771
Conversation
…data rows (#7705) `protocol.deletePackage` selected the rows to remove with a strict `organization_id` equality, which matches nothing against rows stored env-wide (`organization_id IS NULL`). An uninstall issued by a session with an active organization therefore removed only whichever rows happened to be org-scoped and left every env-wide row behind, while reporting a nonzero `deletedCount` and `success: true` over the survivors. Measured, not assumed. The card offered two candidates and the second is falsified: `findData` — the `GET /api/v1/data/sys_metadata` path that returned three rows — issues `this.engine.find` on the same engine instance, and the engine injects no org predicate of its own, so the protocol's engine is NOT scoped differently from the data plane's. On a real ObjectQL engine over SQLite, an org-scoped uninstall of a package holding three env-wide rows and one org-scoped row deleted 1 of 4. An org-scoped uninstall now matches its own organization OR env-wide — the `$or` shape this package already uses for the #3115 orphaned-draft fix, and the shape the SQL driver's own tenant wall uses (#2734). Both directions that must not widen are unchanged and pinned: another organization's rows for the same package stay out of scope, and another package's rows are never touched. The no-org branch stays package-wide on purpose — the direct-mount REST door passes no organization, so narrowing it to env-wide-only would orphan every org-scoped row instead. The pin uses a real engine and a real driver and asserts the CONSEQUENCE: `{success: true, deletedCount: 0}` against a package with no rows is indistinguishable from this bug, so it seeds rows through the real save path, runs the real uninstall, and asserts which rows survive in SQLite afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKUiYq4A42J7Aa1QfkKyiX
…ete-package-org-scope
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also reference the affected code. These are read-only:
|
…istory (objectstack-ai#7779) (objectstack-ai#7814) `protocol.listCommits` selected the ADR-0067 timeline with the strict `organization_id` equality that objectstack-ai#7705 (PR objectstack-ai#7771) had just replaced one function above it. `organization_id = '<org>'` matches no row whose column is NULL, so a session with an active organization was shown none of the commits recorded env-wide. Live, not latent, and measured before the fix was written — which the card set as step one. `recordPackageCommit` stores `request. organizationId ?? null`, and the only door into a publish (the dispatcher's `POST /packages/:id/publish-drafts`) forwards an org only when `resolveActiveOrganizationId` yields one. That resolver answers `undefined` both for a session with no active organization and for ANY throw on the auth seam, since its whole body is `catch`-wrapped. So a publish made before an org is selected, or during a transient auth blip, records its commit env-wide permanently — the timeline is append-only. Driven on a real engine over SQLite, a no-org publish wrote `organization_id: null` and the org-scoped read of that same package then returned `[]`. The blast radius is wider than the audit/observability one the card projected, and that is a finding rather than a detail: `rollbackToPackageCommit` derives the set of commits it must undo FROM THIS LIST. A commit the list could not see was silently never reverted — measured pre-fix, an org-scoped rollback past an env-wide commit answered `{success: true, revertedCommits: []}` with that commit's changes still live. A rollback that reports success and rolls back nothing is a correctness defect, not a reporting one. An org-scoped read now matches its own organization OR env-wide — the `$or` shape this package already uses for objectstack-ai#3115, the shape objectstack-ai#7705 applied to the sibling `deletePackage` read, and the shape the SQL driver's own tenant wall uses (objectstack-ai#2734). Both directions that must not widen are pinned: another organization's commits stay invisible, another package's are never returned, and newest-first ordering is unchanged. The no-org branch is deliberately left package-wide rather than narrowed to `organization_id IS NULL` — narrowing would hide every org-scoped commit from that door instead, re-creating the bug pointed the other way, which is why objectstack-ai#7705 left its own no-org branch alone. The whole shape, or none of it. The pin uses a real engine and a real driver and asserts the CONSEQUENCE. Both existing `deletePackage` suites stubbed `engine.find`, which is why neither could see the sibling defect; the question here is whether `organization_id = 'org'` matches a NULL column, which is a property of the driver's SQL and not of a stub's `filter()`. It seeds through the real publish path and reads back what landed in SQLite. Reverse-verified with the direction predicted first: restoring the strict equality was predicted to turn exactly the two positive cases red and leave all four others green, because strict equality is NARROWER than the `$or` — it cannot reach another org's rows or another package's, and does not touch the no-org branch. Measured on revert: exactly that, 2 failed / 4 passed, both failures `[1]` vs `[2]`. KNOWN REMAINING GAP, reported on objectstack-ai#7779 rather than fixed here — this card holds `protocol.ts`, a serialized file, for `listCommits` alone. `revertCommit` and `rollbackToPackageCommit`'s own target lookups still carry the identical strict equality. The consequence is now loud rather than silent: the rollback above reports `success: false` naming the commit it could not resolve, instead of claiming success over a no-op. Strictly better and non-destructive, but not the whole repair, so the new suite asserts it and the remainder cannot drift unnoticed. Ratchets unchanged: runtime's TEST_DEBT measured exactly 227, its recorded ceiling, with zero errors attributable to the new file; the query-options-erasure ratchet holds at 67 non-test sites. Claude-Session: https://claude.ai/code/session_01Kp1rUCEFGp3eYRztsRx1B1 Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: os-zhuang <jack@objectstack.ai>
Fixes #7705
Part of #7557
What was measured, and what it falsified
The card dispatched this diagnose-first, because its named mechanism might not be the mechanism. Two candidates were live:
organizationIdand strict equality drops rows stored env-wide (organization_id IS NULL);this.engineis scoped differently from the data plane's — an org-injecting wrapper, a separatesys_metadataregistration, or a visibility rule — so an identicalwherereturns different rows on the two seams.(b) is falsified.
findData— theGET /api/v1/data/sys_metadatapath that returned 3 rows — issuesthis.engine.find(object, options)(protocol.ts:6803) on the very same engine instancedeletePackageuses. The engine injects no org predicate of its own: a barenew ObjectQL()carries zero middlewares, and the driver receives the author-suppliedwhereverbatim (measured — the driver saw exactly{package_id}and{package_id, organization_id}, nothing added). So the$orhere is not a workaround over a deeper defect, and PD#5 is satisfied.(a) is the mechanism, measured end-to-end on a real
ObjectQLengine over a realSqlDriver/SQLite, seeded through the realsaveMetaItemwrite path:{packageId, organizationId}— beforedeletedCount: 1,success: true{packageId, organizationId}— afterdeletedCount: 4,success: trueNote the receipt is worse than the card's
success:false— a nonzerodeletedCountandsuccess: truereported over surviving rows.Why the miss is the common case, not a corner
Env-wide is where a package's metadata normally lands (the REST
PUT /meta/:type/:namesave path does not thread the session's active org; AI-authored metadata is written env-wide too), while the door that resolves an org and passes it — the dispatcher twin atpackages/runtime/src/domains/packages.ts:782, whosepersisted:envelope the issue quotes — is the one a user with an active session hits. The other door (packages/rest/src/package-routes.ts:440) passes no org at all.The fix
packages/metadata-protocol/src/protocol.ts— one line of behaviour, indeletePackage:Deliberately the same shape as the #3115 "orphaned draft" fix in this package (
sys-metadata-repository.ts:896-899), and the same shape the SQL driver's own implicit tenant wall already uses (field = :tenant OR field IS NULL, #2734). Only author-supplied predicates are strict — which is what made this silent.The no-org branch is deliberately NOT narrowed to
organization_id IS NULL(the other half of the #3115 shape). The direct-mount REST door passes noorganizationId, so restricting that branch to env-wide rows would orphan every org-scoped row instead — the same bug, re-created on the other door.The pin
packages/runtime/src/package-uninstall-org-scope.integration.test.ts(new, 4 cases).{success: true, deletedCount: 0}against a package with no rows is indistinguishable from this bug, so a call-shaped assertion proves nothing here — and that is exactly what both existingdeletePackagesuites are (protocol-package-lifecycle.test.ts,durable-package.test.tsstubengine.findto hand back the rows the test wants and mockdeleteMetaItemso nothing is ever deleted). Neither could have caught this.This pin therefore uses a real engine and a real driver — the whole question is whether
organization_id = 'org'matches a NULL column, which is a property of the driver's SQL, not of a stub'sfilter()— seeds rows through the realsaveMetaItem, runs the real uninstall, and asserts which rows survive in SQLite afterwards:deletedCountequals what was seeded in scope (4);Cases 2–4 are the control: a fix that over-widens the predicate deletes data that should have stayed, which is worse than the orphaning this closes.
Reverse verification — direction predicted BEFORE the revert
Predicted: restoring the strict equality turns only case 1 red (
deletedCount4 → 1, the three env-wide rows surviving) and leaves 2–4 green, because strict equality is narrower than the$or— it cannot reach another org's or another package's rows, and it does not touch the no-org branch.Measured on revert: exactly that.
1 failed | 3 passed, with['reprob_a','reprob_b','reprob_foreign','reprob_v']surviving where['reprob_foreign']was expected. Prediction held.Scope held
protocol.ts:12085(listCommits,sys_metadata_commit) left untouched — it carries the byte-identical strict-equality pattern and the measured mechanism does implicate it (same predicate, same engine, same NULL semantics). Reported, not fixed here, per the card's fence — it wants its own card. Different table, different symptom (missing commit history, not orphaned rows).MetadataFacade.unregisterPackageremoves only object contributors — every non-object item the package shipped stays registered #7221 (in-memoryMetadataFacade.unregisterPackage) untouched — this is the persisted layer only.content/docs/releases/edits; a.changeset/entry is included instead. Nodocs/adr/**changes.Observation, not fixed here
An uninstall with no organization (the REST door) matches
{package_id}alone and therefore deletes every organization's rows for that package — measured: 5 of 5, including a foreign org's row. That is pre-existing and unchanged by this PR (and case 4 pins it deliberately, since narrowing it would re-create the orphaning). Whether a full uninstall should be cross-tenant is a product question, not this card's defect. Flagging for a separate card. Related:resolveActiveOrganizationId(http-dispatcher.ts:1687) readsauthService?.auth?.api ?? authService?.apiwith nogetApi()fallback and is already carried as an open finding (#4127 batch 5) — it decides which of the two behaviours a given deployment gets.Gates
@objectstack/metadata-protocol@objectstack/objectql@objectstack/runtime@objectstack/restpnpm build(closure)pnpm check:type-check-debtpnpm check:query-options-erasurepnpm check:tenant-chokepointpnpm check:engine-double-contractpnpm check:driver-memory-censusSqlDriver)pnpm check:nul-bytes/check:error-code-casing/ changeset gateseslint(changed files)Ratchet note, recorded rather than paid around: the first draft of the pin raised
@objectstack/runtimeTEST_DEBT 227 → 228 (a singleTS2554—registry.registerObjectrequirespackageId). The ledger was not raised; the error was fixed by passing the platform package id explicitly, which the object registration wanted anyway — the objects must not be owned by the package under test, ordeletePackage's registry unregistration would tear the table out from under the post-uninstall assertions. Re-measured at 227, the recorded number. Note this is invisible topackages/runtime's owntypecheckscript, whose tsconfig excludes*.test.ts; it only appears under the ratchet's rawtsc --noEmit.CI has not converged yet — opened as a draft immediately per #6644 L2; the reviewer reads CI.
🤖 Generated with Claude Code
https://claude.ai/code/session_01GKUiYq4A42J7Aa1QfkKyiX
Generated by Claude Code