Skip to content

fix(metadata-protocol): uninstall no longer orphans env-wide sys_metadata rows (#7705) - #7771

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-7705-delete-package-org-scope
Aug 11, 2026
Merged

os-zhuang merged 2 commits into
mainfrom
claude/issue-7705-delete-package-org-scope

Conversation

@claude

@claude claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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:

  • (a) the caller supplies an organizationId and strict equality drops rows stored env-wide (organization_id IS NULL);
  • (b) the protocol's this.engine is scoped differently from the data plane's — an org-injecting wrapper, a separate sys_metadata registration, or a visibility rule — so an identical where returns different rows on the two seams.

(b) is falsified. findData — the GET /api/v1/data/sys_metadata path that returned 3 rows — issues this.engine.find(object, options) (protocol.ts:6803) on the very same engine instance deletePackage uses. The engine injects no org predicate of its own: a bare new ObjectQL() carries zero middlewares, and the driver receives the author-supplied where verbatim (measured — the driver saw exactly {package_id} and {package_id, organization_id}, nothing added). So the $or here is not a workaround over a deeper defect, and PD#5 is satisfied.

(a) is the mechanism, measured end-to-end on a real ObjectQL engine over a real SqlDriver/SQLite, seeded through the real saveMetaItem write path:

uninstall rows selected env-wide rows after receipt
{packageId, organizationId}before 1 of 4 3 survive (orphaned) deletedCount: 1, success: true
{packageId, organizationId}after 4 of 4 0 survive deletedCount: 4, success: true

Note the receipt is worse than the card's success:false — a nonzero deletedCount and success: true reported 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/:name save 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 at packages/runtime/src/domains/packages.ts:782, whose persisted: 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, in deletePackage:

if (request.organizationId) {
    where.$or = [
        { organization_id: request.organizationId },
        { organization_id: null },
    ];
}

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 no organizationId, 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 existing deletePackage suites are (protocol-package-lifecycle.test.ts, durable-package.test.ts stub engine.find to hand back the rows the test wants and mock deleteMetaItem so 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's filter() — seeds rows through the real saveMetaItem, runs the real uninstall, and asserts which rows survive in SQLite afterwards:

  1. env-wide rows are removed, and deletedCount equals what was seeded in scope (4);
  2. another organization's rows for the same package survive;
  3. another package's rows survive;
  4. a no-org uninstall still clears the whole package (guards the other door against a future over-narrowing).

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 (deletedCount 4 → 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.unregisterPackage removes only object contributors — every non-object item the package shipped stays registered #7221 (in-memory MetadataFacade.unregisterPackage) untouched — this is the persisted layer only.
  • No content/docs/releases/ edits; a .changeset/ entry is included instead. No docs/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) reads authService?.auth?.api ?? authService?.api with no getApi() fallback and is already carried as an open finding (#4127 batch 5) — it decides which of the two behaviours a given deployment gets.

Gates

gate result
@objectstack/metadata-protocol ✅ 1066
@objectstack/objectql ✅ 3237
@objectstack/runtime ✅ 2033 (130 files)
@objectstack/rest ✅ 1462
pnpm build (closure) ✅ 71/71
pnpm check:type-check-debt ✅ none above recorded — see note
pnpm check:query-options-erasure ✅ 67 unswept, none new
pnpm check:tenant-chokepoint ✅ 19 bindings, all reads scoped
pnpm check:engine-double-contract ✅ 150 pinned
pnpm check:driver-memory-census ✅ (no new declaration — pin uses the real SqlDriver)
pnpm check:nul-bytes / check:error-code-casing / changeset gates
eslint (changed files)

Ratchet note, recorded rather than paid around: the first draft of the pin raised @objectstack/runtime TEST_DEBT 227 → 228 (a single TS2554registry.registerObject requires packageId). 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, or deletePackage'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 to packages/runtime's own typecheck script, whose tsconfig excludes *.test.ts; it only appears under the ratchet's raw tsc --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

claude added 2 commits August 11, 2026 14:14
…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
@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Aug 11, 2026 2:20pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/metadata-protocol.

3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/concepts/metadata-lifecycle.mdx (via @objectstack/metadata-protocol)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/metadata-protocol)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/metadata-protocol)

1 release-owned page(s) also reference the affected code. These are read-only:

  • content/docs/releases/v9.mdx (via @objectstack/metadata-protocol)

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.

@os-zhuang
os-zhuang marked this pull request as ready for review August 11, 2026 14:36
@os-zhuang
os-zhuang added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit ecd83fd Aug 11, 2026
26 checks passed
@os-zhuang
os-zhuang deleted the claude/issue-7705-delete-package-org-scope branch August 11, 2026 14:51
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Aug 17, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

protocol.deletePackage finds zero sys_metadata rows the data plane finds 3 of — uninstall leaves orphaned rows (persistence half of #7557)

2 participants