Uh oh!
There was an error while loading. Please reload this page.
feat(metadata-protocol)!: cross-tenant uninstall must be declared — deletePackage refuses a call naming neither an organization nor allTenants (#7780) - #7995
Conversation
…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
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 3 package(s): 108 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 7 release-owned page(s) also reference the affected code. These are read-only:
|
…nstall-explicit-alltenants
…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
huangyiirene
commented
Aug 12, 2026
ACCEPT — flipped ready, auto-merge SQUASH armed @ |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7780
domain:metadataPM 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.!), 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.deletePackageselected rows with{ package_id }and added an organization predicate only when the caller supplied one. With noorganizationIdthe 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/:iddisagreed about which semantic they invoked:packages/rest/src/package-routes.ts) passes no organization ⇒ cross-tenant;packages/runtime/src/domains/packages.ts) resolves one ⇒ org-scoped.Worse, the two are indistinguishable at the call site:
resolveActiveOrganizationId(#4127) is entirelycatch-wrapped, so any throw on the auth seam returnsundefined. 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
deletePackagegainsallTenants?: boolean, the explicit carrier for cross-tenant semantics.organizationIdnorallTenants: true⇒ refused withTENANT_SCOPE_REQUIRED(HTTP 400), deleting nothing. An explicitallTenants: falseis treated as undeclared — it is not an affirmative request for cross-tenant semantics, so it cannot authorise them.allTenants: truesilently 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.allTenants: true. ⭐ This was a PM assumption handed over as falsifiable, and the dev measured it:packages/restcarries 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
reassignOrphanedMetadatacarries the same shape one notch wider (itswherestarts 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.tsis 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
organizationIdthat intends an environment-wide uninstall must now passallTenants: 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.domain:spec(#6017): this adds one line topackages/spec/src/api/error-code-ledger.zod.ts— theTENANT_SCOPE_REQUIREDregistration. 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