Skip to content

feat(metadata-protocol)!: cross-tenant uninstall must be declared — deletePackage refuses a call naming neither an organization nor allTenants (#7780) - #7995

Merged
huangyiirene merged 3 commits into
mainfrom
claude/issue-7780-uninstall-explicit-alltenants
Aug 12, 2026
Merged

feat(metadata-protocol)!: cross-tenant uninstall must be declared — deletePackage refuses a call naming neither an organization nor allTenants (#7780)#7995
huangyiirene merged 3 commits into
mainfrom
claude/issue-7780-uninstall-explicit-alltenants

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

Fixes#7780

⚠️PR opened by the domain:metadata PM seat on the implementing dev's behalf — it had the PR body ready but no GitHub write access in its container (four of five sibling dispatches hit this today: branches push fine, PR/comment creation does not). Body is the dev's own changeset. Its full report is still owed and will be appended as a comment.

⚠️Breaking change (!), minor on three packages. A caller that omits the organization today starts getting a 400. That is the point of the change, not a side effect.

The defect

protocol.deletePackage selected rows with { package_id } and added an organization predicate only when the caller supplied one. With no organizationId the predicate matched every organization's rows — measured during #7705 at 5 of 5 deleted, including a foreign organization's.

Nobody chose that. It fell out of a missing argument, and the two doors of DELETE /api/v1/packages/:id disagreed about which semantic they invoked:

  • the direct-mount REST registrar (packages/rest/src/package-routes.ts) passes no organization ⇒ cross-tenant;
  • the dispatcher twin (packages/runtime/src/domains/packages.ts) resolves one ⇒ org-scoped.

Worse, the two are indistinguishable at the call site: resolveActiveOrganizationId (#4127) is entirely catch-wrapped, so any throw on the auth seam returns undefined. An accidental org-less call and a deliberate environment-wide one are byte-identical, and the accident silently selected the widest possible reading of a destructive operation.

Maintainer ruling (2026-08-12), quoted unchanged

跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」。

What changes

  • deletePackage gains allTenants?: boolean, the explicit carrier for cross-tenant semantics.
  • Neither organizationId nor allTenants: true ⇒ refused with TENANT_SCOPE_REQUIRED (HTTP 400), deleting nothing. An explicit allTenants: false is treated as undeclared — it is not an affirmative request for cross-tenant semantics, so it cannot authorise them.
  • Both supplied ⇒ refused with the same code. They are contradictory, not redundant — one scopes to a tenant, the other clears every tenant — and both silent resolutions are worse than a refusal: narrow-first makes allTenants: true silently inert; explicit-first ignores a named organization and deletes every tenant's rows, which is the original defect wearing a flag. Rejecting is also the only reading that stays correct when a request is composed from two places (a resolver supplying the org, config supplying the flag). The message names both offending parameters.
  • The REST direct-mount door now declares allTenants: true. ⭐ This was a PM assumption handed over as falsifiable, and the dev measured it: packages/rest carries no org plumbing at all, so of the two remedies the ruling allows, only declaring the intent is available there. Observable behaviour is unchanged; what changed is that the width is now stated at the call site instead of inferred from an absent argument.

What deliberately does NOT change

The no-organization branch is still not narrowed to organization_id IS NULL. #7705 proved that narrowing orphans every org-scoped row — the same defect pointed the other way. The remedy is explicitness, not narrowing.

Scope, verified by the PM

The card's sibling reassignOrphanedMetadata carries the same shape one notch wider (its where starts as {}; measured 3-of-3 cross-org rebinding). It was dispatched as a measurement and a sentence, not a fix — and the tripwire confirms scope held: package-duplicate-adopt-org-scope.integration.test.ts is not in this diff, so the pin that would have gone red on an unsanctioned widening never fired.

#7705 case 4 (which pinned that a no-org uninstall still clears the package) is rewritten, not deleted — that pin was deliberate and its replacement encodes the ruled semantics.

Callers updated

Any caller omitting organizationId that intends an environment-wide uninstall must now pass allTenants: true; the refusal message names both remedies. Updated in-tree: durable-package.test.ts, protocol-package-lifecycle.test.ts, package-first-authoring.dogfood.test.ts.

⚠️Cross-seat note for domain:spec (#6017): this adds one line to packages/spec/src/api/error-code-ledger.zod.ts — the TENANT_SCOPE_REQUIRED registration. It is additive registration rather than an acceptance-face change, and it is cleanly separable if that seat wants it lifted out.


Generated by Claude Code

…licitly (#7780)
`protocol.deletePackage` selected rows with `{ package_id }` and added an
organization predicate only when the caller supplied one, so a call with no
`organizationId` matched EVERY organization's rows — measured during #7705 at
5 of 5 deleted, including a foreign org's.
The two doors of `DELETE /api/v1/packages/:id` disagreed about which semantic
they invoked: the direct-mount REST registrar passes no org (cross-tenant), the
dispatcher twin resolves one (org-scoped). Neither chose it; it fell out of a
missing argument. `resolveActiveOrganizationId` (#4127) is entirely
catch-wrapped, so an accidental org-less call and a deliberate env-wide one are
byte-identical at the call site.
Maintainer ruling (2026-08-12):
跨租户卸载必须显式声明,缺省缺参永远不等于「全部租户」.
`deletePackage` gains `allTenants?: boolean`, the explicit carrier. The tenant
scope must be readable off the request, and there are two ways to violate that,
both answering `TENANT_SCOPE_REQUIRED` (400) and deleting nothing:
organizationId | allTenants | result
---------------|----------------|--------------------------------
set | absent / false | org-scoped delete (unchanged)
absent | true | cross-tenant delete
absent | absent / false | 400
set | true | 400
An explicit `allTenants: false` is treated as undeclared — it is not an
affirmative request for cross-tenant semantics, so it cannot authorise them.
Both-supplied is refused rather than resolved: the two are contradictory, and
resolving narrow-first makes the flag silently inert while resolving
explicit-first ignores a named org and deletes every tenant's rows — the
original defect wearing a flag. Refusing is also the only reading that stays
correct when a request is composed from two places, which is exactly what the
catch-wrapped resolver makes real.
The REST door declares `allTenants: true`: `packages/rest` has no org to
resolve, so of the two remedies the ruling allows only declaring is available.
The no-org branch is still NOT narrowed to `organization_id IS NULL` — #7705
proved that orphans every org-scoped row. The remedy is explicitness.
#7705 case 4 pinned the old no-org-clears-everything behaviour deliberately. It
is rewritten, not deleted, into the halves of the ruled contract, and the reason
it existed is carried by the case that goes red if anyone narrows the branch.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VcwwwtZHpwrjWZihjpUvS
@vercel

vercelBot commented Aug 12, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectstackIgnoredIgnoredAug 12, 2026 11:14am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/metadata-protocol, @objectstack/rest, @objectstack/spec.

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

  • content/docs/ai/agents.mdx(via @objectstack/spec)
  • content/docs/ai/connect-mcp.mdx(via @objectstack/rest)
  • content/docs/ai/skills-reference.mdx(via @objectstack/spec)
  • content/docs/ai/skills.mdx(via @objectstack/spec)
  • content/docs/api/client-sdk.mdx(via @objectstack/spec)
  • content/docs/api/environment-routing.mdx(via @objectstack/spec)
  • content/docs/api/error-catalog.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx(via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/api/index.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/automation/approvals.mdx(via @objectstack/spec)
  • content/docs/automation/connectors.mdx(via @objectstack/spec)
  • content/docs/automation/flows.mdx(via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx(via packages/spec)
  • content/docs/automation/hooks.mdx(via @objectstack/spec)
  • content/docs/automation/index.mdx(via @objectstack/spec)
  • content/docs/automation/webhooks.mdx(via @objectstack/spec)
  • content/docs/automation/workflows.mdx(via @objectstack/spec)
  • content/docs/concepts/architecture.mdx(via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx(via packages/spec)
  • content/docs/concepts/index.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx(via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx(via @objectstack/metadata-protocol, packages/spec)
  • content/docs/concepts/north-star.mdx(via @objectstack/spec)
  • content/docs/data-modeling/analytics.mdx(via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx(via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx(via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx(via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx(via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx(via @objectstack/spec)
  • content/docs/data-modeling/index.mdx(via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx(via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx(via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx(via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx(via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx(via @objectstack/spec)
  • content/docs/deployment/cli.mdx(via @objectstack/spec)
  • content/docs/deployment/tenancy-modes.mdx(via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx(via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx(via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx(via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx(via @objectstack/spec)
  • content/docs/getting-started/examples.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx(via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx(via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx(via @objectstack/spec)
  • content/docs/kernel/cluster.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx(via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx(via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/data-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/email-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/examples.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/index.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx(via @objectstack/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx(via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx(via @objectstack/spec)
  • content/docs/kernel/services-checklist.mdx(via @objectstack/metadata-protocol, @objectstack/spec)
  • content/docs/kernel/services.mdx(via @objectstack/spec)
  • content/docs/permissions/authentication.mdx(via @objectstack/rest)
  • content/docs/permissions/authorization.mdx(via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx(via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx(via @objectstack/spec)
  • content/docs/permissions/positions.mdx(via @objectstack/spec)
  • content/docs/permissions/rls.mdx(via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx(via @objectstack/spec)
  • content/docs/permissions/system-context.mdx(via packages/rest, packages/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx(via @objectstack/spec)
  • content/docs/plugins/development.mdx(via @objectstack/spec)
  • content/docs/plugins/index.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/plugins/packages.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx(via @objectstack/spec)
  • content/docs/protocol/diagram.mdx(via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/http-protocol.mdx(via @objectstack/metadata-protocol, @objectstack/rest, @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx(via packages/rest, @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx(via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx(via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx(via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx(via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx(via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx(via @objectstack/spec)
  • content/docs/ui/actions.mdx(via @objectstack/spec)
  • content/docs/ui/apps.mdx(via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx(via @objectstack/spec)
  • content/docs/ui/dashboards.mdx(via @objectstack/spec)
  • content/docs/ui/field-grouping-and-order.mdx(via @objectstack/spec)
  • content/docs/ui/forms.mdx(via @objectstack/spec)
  • content/docs/ui/index.mdx(via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx(via @objectstack/spec)
  • content/docs/ui/setup-app.mdx(via @objectstack/spec)
  • content/docs/ui/translations.mdx(via @objectstack/spec)
  • content/docs/ui/views.mdx(via @objectstack/spec)

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

  • content/docs/releases/implementation-status.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/index.mdx(via @objectstack/spec)
  • content/docs/releases/v12.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v13.mdx(via @objectstack/spec)
  • content/docs/releases/v16.mdx(via @objectstack/spec)
  • content/docs/releases/v17.mdx(via @objectstack/rest, @objectstack/spec)
  • content/docs/releases/v9.mdx(via @objectstack/metadata-protocol, @objectstack/spec)

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.

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling labels Aug 12, 2026
…7 chain and regenerate docs (#7780)
Two mechanical CI fixes on top of the accepted change.
1. `Check Changeset` — the changeset declares a breaking change and carried no
ADR-0087 disposition. Registered rather than exempted, and the exemption was not
available anyway: `no-migration-prescription` is refused when the changeset body
carries a consumer-rewrite prescription, and this one does ("Callers that must be
updated"). `unpublished` is false (all three bumped packages publish) and
`already-registered` is false (the id is new here).
The substantive reason is the same one the gate asks for: a consumer really does
have to act — an uninstall that succeeded yesterday now answers 400 until it
states its tenant scope — and which scope a given caller meant is an intent no
transform can recover, because the catch-wrapped resolver makes an accidental
org-less call byte-identical to a deliberate environment-wide one. That is the
disposition `rest-requireauth-default-flip` (step 12) took for its own default
flip, and `semantic/` already carries runtime-contract entries of this shape
(`api-runtime-create-withdrawn`), so this is not a metadata-shaped entry forced
into a metadata chain.
New entry `semantic/17.package-uninstall-explicit-all-tenants.ts`, folded in with
`gen:migration-registry`; `spec-changes.json` and `docs/protocol-upgrade-guide.md`
regenerated as its projections.
2. `check:docs` — `TENANT_SCOPE_REQUIRED` was added to the error-code ledger and
the generated reference docs were never regenerated. Ran `gen:schema` then
`gen:docs`. Eleven pages move rather than the two CI named: the shared error
envelope renders the code-enum arity, so `… +261 more` becomes `… +262 more`
wherever it appears. All generated output, hand-edited nowhere. No
`content/docs/releases/**` page is touched.
Merged `origin/main` (clean) before regenerating, so the artifacts are generated
against current main rather than the `59ac0c6` base.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017VcwwwtZHpwrjWZihjpUvS
@huangyiireneClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — flipped ready, auto-merge SQUASH armed @ a54b870

All 26 jobs concluded before arming: 25 success, 1 skipped (Console Pin Gate — does not apply), zero failures.TypeScript Type Check green at 11:30:53Z and Check Changeset green — the two that were red. Read per-job, not off an aggregate.

The dev's full report is posted on #7780 (5266213654) — it could not post it itself; GitHub write is 403 in these containers because the objectstack-ai org has no Claude GitHub App connection.

The patch round did the harder of the two available answers

I told the dev to decide and justify the ADR-0087 disposition rather than cargo-cult not-required from #7920. It registered instead — semantic/17.package-uninstall-explicit-all-tenants.ts — and showed the shortcut was never available anyway:

the gate refuses no-migration-prescription when the changeset body carries a consumer-rewrite prescription, and this one does.

with a precedent for the shape (rest-requireauth-default-flip took the same disposition for its own default flip, and semantic/ already carries runtime-contract entries like api-runtime-create-withdrawn). That is the answer I wanted and not the cheap one.

What I checked before arming

  • Scope held.package-duplicate-adopt-org-scope.integration.test.ts is absent from the diff and stayed green. reassignOrphanedMetadata was measured (3 of 3 cross-org rebinding — my figure reproduced exactly) and deliberately not fixed, not extended, not filed as decided.
  • protocol.deletePackage finds zero sys_metadata rows the data plane finds 3 of — uninstall leaves orphaned rows (persistence half of #7557) #7705 case 4 rewritten, not deleted — the no-org branch is still not narrowed to organization_id IS NULL, and the case that would go red under that narrowing is the one carrying the original's purpose.
  • check:type-check-debt exit 0, 35 entries re-measured, ledger diff empty, --lowernever run.
  • Generated docs committed as generated — eleven reference pages moved, not the two CI named, all tracing to the single added enum member changing the shared envelope's arity (+261 more+262 more). ⛔ No content/docs/releases/** page touched.

⚠️ A detail of my brief was falsified, and it was mine to get wrong

The dispatch quoted if (request.organizationId) where.organization_id = … around :11626. That is the pre-#7705 strict-equality predicate; current main is the $or widened form.

I quoted the card's original snippet without re-reading it at source — the same class of error as citing a stale line number, one layer up. The ruling and fix are unaffected; §3's pin reasoning depended on the $or form, and the dev caught that rather than building on my text. Recorded here so the next brief re-reads code at source instead of inheriting it from the card.

Out of scope, now filed

The §4.1 corollary — the direct-mount REST door's four catch-alls are status-blind (sendError(res, 500, 'INTERNAL_ERROR', …) regardless of .status/.code), so a coded 4xx that the dispatcher twin maps correctly returns 500 there, and that registrar matches first in production. Filed as #8016, unassigned, with the honest caveat the dev attached: the mechanism was read from code, not reproduced end-to-end. It is unreachable for this PR's refusal because the REST door declares the flag — which is why it is a separate card rather than a rider.


Generated by Claude Code

@huangyiirene
huangyiirene added this pull request to the merge queueAug 12, 2026
Merged via the queue into main with commit 3da3da5Aug 12, 2026
27 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-7780-uninstall-explicit-alltenants branch August 12, 2026 11:51
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Product question: an uninstall with no organizationId deletes EVERY organization's rows for that package (measured 5 of 5, including a foreign org's)

2 participants

@huangyiirene@claude