Uh oh!
There was an error while loading. Please reload this page.
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:
|
Uh oh!
There was an error while loading. Please reload this page.
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