Repository navigation
fix(runtime): DELETE /packages/:id refuses an org-less uninstall before it touches the registry (#20492) - #20514
Conversation
…y is touched DELETE /packages/:id ran registry.uninstallPackage(id) and only then reached deletePackage's organization-scope refusal, so a refused request (400 TENANT_SCOPE_REQUIRED) had already taken the package and its objects out of the running process. The door now asks the same question up front, from the one organization value it hands deletePackage, and only when the persisted half will run. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…, and scope the uninstall fixtures to an organization New pins drive DELETE /packages/:id through dispatch() with real identity resolution and a real SchemaRegistry: a removed member and a caller who never selected an organization are refused 400 TENANT_SCOPE_REQUIRED with the package still served, listed and registered and its stored rows untouched; a member uninstalls as before. Fixtures whose allow-path callers carried no organization now act in one, since the door refuses an org-less uninstall first, as the persisted delete does. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…install-refuse-before-mutate
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 4 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 142 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin b1487a4df1cb60e271c359757660d389a4a0845e && git checkout b1487a4df1cb60e271c359757660d389a4a0845e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e9bb464170446bf993244d2b0058b935300a247 cadfe0b7ae566e941d05ab948026f498ef24c664 && git checkout -B drift-repro 9e9bb464170446bf993244d2b0058b935300a247 && git merge --no-ff cadfe0b7ae566e941d05ab948026f498ef24c664
node scripts/docs-audit/affected-docs.mjs --json 9e9bb464170446bf993244d2b0058b935300a247
|
Contract reviewServed-tier: Inputs read: card #20492 (body and all 3 comments: triage grade Check-runs on the head at 21:22 UTC (newest per name): 33 check-runs; 28 completed — 25 ① Derived judgmentsJudged against the card's binding direction, triage's grade (1) Every path that reaches (2) The organization value the door checks is the value it hands (3) An admitted caller's uninstall behaves as before — YES, right. With an organization present the guard returns (4) The five edited existing test files only move allow-path callers into an organization; no assertion weakened — YES, right.
Other derived judgments.
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…install-refuse-before-mutate
…stamp as a provenance waiver The DELETE /packages/:id door now answers deletePackage's organization-scope refusal itself, before the registry is touched, with the code the protocol registers. The door mirrors the producer; it is not a second emitter, so the row stays under @objectstack/metadata-protocol and the stamp is waived. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Delta review of patch round 1. Inputs read: card #20492 (body and all 6 comments: triage grade Check-runs on the head at 21:56 UTC: 42 runs, 35 distinct names, every one ① Derived judgmentsThe delta. Beyond
Clause-② path limb on the ledger file.
The four questions, re-stated against this head. The runtime half is blob-identical to round 0 (
Other derived judgments. The PR body's ② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…zation applies only global grants (objectstack-ai#20515) (objectstack-ai#20540) Fixes objectstack-ai#20515 Clause-②: yes (narrowing) ## What changed `resolveUserAuthzGrants` (`packages/core/src/security/resolve-authz-context.ts`) now states one rule, once, as the module-private predicate `grantAppliesInTenant`: a grant row with no organization is global and applies everywhere; a row scoped to an organization applies only while that organization is the active tenant. With no active organization, only the global grants apply. There is no "every organization" option and no keep-all fallback. Three sites ask the predicate: - **§4** `sys_user_position` (the triage's `:802`). - **§6** `sys_user_permission_set` (the triage's `:829`). - **§6a** the `sys_position` rows whose bound permission sets the resolver collects. This one is a **declared deviation** from the claim's two sites; see H6 below for the measurement that put it here. With a tenant it is a no-op, because the driver's tenant scope already returned only that organization's rows and the organization-less ones. The §4 and §6 comments, which already stated this rule, are now true. §3 (`sys_member`) is not edited. `@objectstack/plugin-security`: `buildContextForUser(ql, userId, nowMs?, tenantId?)` takes the organization to resolve in (H3): - `resolveDelegatorContext` resolves the on-behalf-of delegator in the live principal's organization. That is an **enforcement** input: the D10 intersection. - `explainAccessForCaller` resolves an explained user in the caller's organization. No second check was added to `requireManageMetadata` or to any other door. The `plugin-sharing` `adminOrgScope` guard is untouched. **Not in this card, per triage:** revoking custom organization-scoped grants when a member is removed. Once this rule holds, those grants no longer apply. ## H0: the defect at the public door, before and after These readings use the objectstack-ai#20492 rig (`dispatch()` with real identity resolution, `resolveRequestScope` into `resolveExecutionContext` into `resolveAuthzContext`, under an `isolated` posture). The base is unmodified `397572ed5`, which already includes PR objectstack-ai#20514. The "after" column is the committed pin file `packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts`: every cell of it has an arm there, and the file is green at `4e3e4f5e4`. Patch round 1 added the three arms that were probe-only readings at `c9e6463ce`: the control at `PATCH disable`, the platform admin with `org_alpha` active at `PATCH disable`, and the org-less no-grant row. The gate is observable at two doors: - **`PATCH /packages/:id/disable`** is the door where the capability gate's effect is fully visible. It asks no organization, so a caller who passes the gate switches the package off for the whole environment (200). A caller refused by the gate gets 403. - **`DELETE /packages/:id`**: since PR objectstack-ai#20514, an org-less caller who passes the gate reaches the door's own organization check (400 `TENANT_SCOPE_REQUIRED`). A caller refused by the gate gets 403. | Arm (every session names `org_alpha`) | DELETE, base | DELETE, after | PATCH disable, base | PATCH disable, after | |---|---|---|---|---| | removed from `org_alpha`, still in `org_beta`, holds an `org_alpha`-scoped `manage_metadata` set (a2) | 400 `TENANT_SCOPE_REQUIRED` (gate passed) | **403 `PERMISSION_DENIED`** | **200 (package disabled)** | **403** | | same, no membership left anywhere (a2b) | 400 (gate passed) | **403** | **200** | **403** | | control: current `org_alpha` member, same grant, `org_alpha` active | 200 | 200 | 200 | 200 | | removed member holding the same set **globally** | 400 | 400 (unchanged) | 200 | 200 (unchanged) | | platform admin (unscoped `admin_full_access`), `org_alpha` active | 200 | 200 | 200 | 200 | | platform admin, no active organization | 400 | 400 (unchanged) | 200 | 200 (unchanged) | | org-less user with no grant | 403 | 403 | 403 | 403 | The base readings come from a throwaway probe at `397572ed5`; the first "after" reading was taken at `514e681cd`, and the committed file reproduces every "after" cell. The committed file adds a third removed-member arm: `org_alpha` bound the set to **its own copy of `org_member`**, and the member is still an `org_member` in `org_beta`. That arm is refused 403 on both doors. With the §6a predicate ablated it passes the gate (see Verification). ## H1: census of the no-tenant callers (source, `packages/**`, at `c9e6463ce`) | Caller | Tenant it passes | Class | What it loses under the rule | Right? | |---|---|---|---|---| | `resolveAuthzContext` first resolution (`resolve-authz-context.ts:428`) | the API key's `active_organization_id`, else the session's `activeOrganizationId` | sometimes none | every organization-scoped §4, §6 and §6a grant, when the key or session names no organization | Yes, per ruling. A request with no active organization acts in none. Most exposed are `group`-posture principals with no active organization: their data wall still spans every member organization, but their organization-scoped grants now need that organization active. | | `resolveAuthzContext` dropped-claim re-resolution (`:548`) | none, by construction | never | the left organization's grants: **the defect**. It also loses every other organization's scoped grants. | Yes. This is the fix. | | `hasPlatformAdminStanding` (`:1184`, `{ nowMs }` only) | none | never | nothing. `PLATFORM_ADMIN` derives only from the unscoped `admin_full_access` user grant or the declared-administrator config (H2). | Yes | | plugin-auth `customSession` (`auth-manager.ts:3987`) | `activeOrganizationId ?? undefined` | sometimes | `positions[]` loses organization-scoped `sys_user_position` names when no organization is active. `isPlatformAdmin` is unchanged. | Yes. Its docblock already scopes the payload to the active organization. | | plugin-auth `isPlatformAdminUserId` (`:7009`), through `hasPlatformAdminStanding` | none | never | nothing | Yes | | plugin-hono-server `makeExecutionContextResolver` (`current-user-endpoints.ts:424`) | `activeOrganizationId ?? undefined` | sometimes | same as `resolveAuthzContext` | Yes | | explainer `buildContextForUser` (`explain-engine.ts:581`) | **was none; now the caller's choice** | H3 | see H3 | changed | | `resolveDelegatorContext` into `buildContextForUser` (`explain-engine.ts:686`), an **enforcement** path | was none (the live tenant was stamped on afterwards); now the live principal's tenant | always, when the live principal has one | **Before:** every organization's delegator grants. **Under the rule with no caller change:** only global grants, a regression for an OAuth agent acting for an organization admin. **Now:** the delegator's grants in the organization the request runs in. | fixed here | | `explainAccessForCaller` into `buildContextForUser` (`security-plugin.ts:4690`) | was none; now the caller's tenant | sometimes | grants the explained user holds in organizations other than the caller's | changed (H3) | | invitation placement `assertIssuable` (`invitation-placement.ts:153`) | the invitation's `organizationId ?? undefined` | always in practice (a better-auth invitation belongs to an organization) | nothing in practice | Yes | | automation `runAs:'user'` (`service-automation/src/plugin.ts:909`) | the triggering run's `tenantId` | sometimes | organization-scoped grants for a run triggered with no organization | Yes. The run matches the user's own direct request in that state. | | transports calling `resolveAuthzContext`: `rest-server.ts:2968`, `runtime/src/security/resolve-execution-context.ts:217`, `sharing-plugin.ts:945`, `marketplace-install-local-plugin.ts:1806`, `service-datasource/admin-routes.ts:480`, `service-settings/settings-service-plugin.ts:296`, `service-storage/storage-service-plugin.ts:1152` | session or API key | sometimes | same as the first row | Yes | | MCP stdio (`mcp/src/plugin.ts:164`), API key only | the key's organization | sometimes (an org-less key, allowed under `single` / `group`) | organization-scoped grants for an org-less key | Yes, per ruling | **No caller needs every organization's grants**, so **no option was added** to `ResolveUserAuthzGrantsOptions` and the grants-cache key is unchanged (H4 moot). `tenantId` already keys cache entries; a new pin checks, with the cache on, that an `org_a` entry and a no-tenant entry never serve each other. `Clause-②` stays `yes (narrowing)`: the `yes` arm is now carried by `buildContextForUser`'s new optional parameter, a public widening of `@objectstack/plugin-security`. ## H2: platform-admin standing is global (measured) This was measured on a real `SqlDriver` (better-sqlite3) with the shipped `bootstrapPlatformAdmin`, under `single`, after the fix: - the minted `admin_full_access` row reads `organization_id: null`; - `hasPlatformAdminStanding` answers `true`; - an org-less resolution answers posture `PLATFORM_ADMIN`, with `manage_metadata` held. `hasPlatformAdminStanding` loses nothing, so it needs no answer of its own. Core pins cover both polarities: the unscoped grant is `PLATFORM_ADMIN` with and without a tenant; an organization-scoped `admin_full_access` confers no standing with or without that tenant. ## H3: the explainer takes a tenant, (b), not the triage's (a) The explainer's own contract decided it. The module header says the report "can never drift from enforcement". `buildContextForUser`'s docblock says it is called "with the exact arguments" enforcement uses. Its parity suite asserts, field by field, that it equals `resolveUserAuthzGrants`. Option (a), an explicit every-organization option, breaks that parity by construction. It would also have kept every organization's grants in an **enforcement** path, because `resolveDelegatorContext` builds the D10 delegator leg through `buildContextForUser`. So `buildContextForUser` takes the organization to resolve in, and each caller names it. Pinned in `explain-engine.test.ts`, `security-plugin.test.ts` and the parity suite, which now runs two cases in `org1` on both sides. **What the explainer shows for the removed member, against enforcement:** | Explained by | Explain shows | Enforcement (the member's own session: claim dropped, no tenant) | Agree? | |---|---|---|---| | a caller with no active organization, or in `org_beta` | global grants only; no `manage_metadata` | refused | yes | | an admin in `org_alpha` (the left organization) | the `org_alpha`-scoped `manage_metadata` set | refused | **no**: explain overstates | | (before this PR) anyone | every organization's grants | passed (the defect) | yes, both wrong | The disagreement in the second row is the objectstack-ai#20431 class (explain ≠ enforce). It is reported, not fixed here. The explain API resolves the explained user in the caller's organization, and it does not model the session arm's membership check. Explain-of-another-user's record-level Layer 0 still evaluates with no active organization; that is pre-existing and unchanged. ## H5: section 3, measured Measured on a real `SqlDriver` over the shipped per-organization built-in catalog (`bootstrapBuiltinRoles` for `org_jia` and `org_yi`). The user is a current member of `org_jia` (`admin`) and `org_yi` (`member`), with no organization active. - §3 projects **both** organizations' roles: `positions: [org_admin, org_member, everyone]`. - **The gate that read them was §6a.** At the pre-§6a state, those names pulled every organization's copy of `org_admin`, `org_member` and `everyone`, and their bindings. A member removed from `org_jia` and still in `org_yi` kept `org_jia`'s `org_member`-bound `manage_metadata` set with no tenant. A user with no membership at all picked up `org_jia`'s `everyone` binding. - **After the §6a predicate:** the same resolutions carry no organization's bindings. With `org_jia` active, only `org_jia`'s apply. The verdict on §3 itself: **not the same class once §6a holds**, and not edited. Its rows are the user's own current memberships, not grant rows, and every capability a role name can confer now arrives through organization-scoped rows that answer the rule. What remains is display: `positions[]` with no active organization names every membership's role. ## H6: the smallest fix that satisfies the stated rule Sections 4 and 6 alone did not satisfy the rule. The real-driver measurement in H5 shows the removed member keeping the left organization's `manage_metadata` through §6a after the §4 and §6 fix, so §6a asks the same predicate. - It is written as the **grant rule**, not as a second tenant wall. The driver's `applyTenantScope` stays the one spelling of the wall, as the objectstack-ai#10103 comment requires. - With a tenant it filters nothing: the driver already returned only that organization's rows and the organization-less ones. ## Verification (head `4e3e4f5e4`, after merging `origin/main` `288611e3e` with a true merge; the first round's merge was `31d281d3b`) - **Build:** `turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2`: 71/71. - **Tests at `4e3e4f5e4`:** - **`@objectstack/dogfood`, the whole suite:** `vitest run --shard=1/3`, `2/3` and `3/3`, all exit 0. 47 files and 378 passed; 47 files and 321 passed, 1 skipped; 46 files and 451 passed, 1 file and 2 tests skipped. That is 141 files and 1150 tests passed. - `sharing-rule-org-less-caller.dogfood.test.ts` alone: 16 passed. That is the 13 it had, plus 3 for the organization-scoped persona. - `@objectstack/core` `vitest run --project local`: 56 files, 1518 passed. - `@objectstack/plugin-security` `explain-engine`, `security-plugin` and `per-organization-catalog`: 361 passed. - `@objectstack/runtime` the door file (13 pins) plus `packages-uninstall-refuse-before-mutate`: 21 passed. - `@objectstack/plugin-sharing` `sharing-rule-positions-name-authority`: 7 passed. - Typecheck: `@objectstack/runtime` and `@objectstack/dogfood` exit 0; the runtime test-typecheck ledger is unchanged. - **Full suites at `c9e6463ce`'s source, before the first merge** (the two `origin/main` merges since then brought main's own `packages/rest` changes, `rest-server.ts`, `meta-item-read-gate.ts` and four test files, and main's `packages/runtime` test `meta-list-projection-parity.test.ts`; this PR's patch round moved its runtime door-pin file. For those suites, the verdict is the head's `Test Core` runs): | Package | Files | Tests | |---|---|---| | `plugin-security` | 143 | 3052 passed, 16 skipped | | `runtime` local | 287 | 4181 passed | | `rest` local | 221 | 4231 passed | | `plugin-auth` | 115 | 2464 | | `plugin-hono-server` | 27 | 324 | | `service-automation` | 149 | 1837 | | `plugin-sharing` | 37 | 913 | | `plugin-approvals` | 51 | 791 | | `organizations` | 8 | 108 | | `mcp` | 32 | 344 | | `cloud-connection` | 30 | 397 | | `service-datasource` | 34 | 693 | | `service-settings` | 33 | 584 | | `service-storage` | 40 | 627 | | `client` | 50 | 641 | All green. The consumer direction is the downstream importers of `@objectstack/core` named in the H1 census, plus their own consumers `plugin-approvals` and `client`. This table omitted `@objectstack/dogfood` in the first round, and its shard 3/3 was red on `c9e6463ce`. The whole dogfood suite is the first bullet above. - **Typecheck:** `@objectstack/core`, `@objectstack/plugin-security` and `@objectstack/runtime` `typecheck` all exit 0. Each `check:test-typecheck` is OK with its debt ledger unchanged. - **Fixture triage (dogfood, patch round 1):** `sharing-rule-org-less-caller.dogfood.test.ts` (objectstack-ai#8158's HTTP proof) gave its exposed org-less persona `manage_sharing` through a grant scoped to `org_8158_a`. That grant reached `adminOrgScope` only through the defect, so shard 3/3 went red on "the refusal names the ORGANIZATION". - The exposed persona now holds the set **globally**. It keeps pinning `adminOrgScope` (objectstack-ai#8158's defence in depth) with every assertion unchanged: 403, the "active organization" message, by-name and by-id refused, evaluate / delete / create refused, no cross-tenant read. - A new persona holds the grant **as objectstack-ai#8158 filed it**: scoped to `org_8158_a`, no membership, no active organization. It is refused 403 `PERMISSION_DENIED` at the capability gate ("requires the manage_sharing capability"), with no rows returned, and refused by name too. Its session is pinned to carry no active organization. - The control keeps the scoped grant with `org_8158_a` active and still reads only its own tenant. - The new persona's red direction without the fix is the old test's green on `main`: that case measured exactly this persona reaching `adminOrgScope`'s message. - Census of the rest of `packages/qa/dogfood`: - No other fixture writes an organization-scoped `sys_user_permission_set` or `sys_user_position` row. The two other hits, in `membership-actor-attribution`, are reads of the auto-grant row. - `test/armed.ts:215` resolves through the real `resolveAuthzContext` (whatever the session carries), and its users are armed through memberships. - The `authz-conformance.matrix.ts` rows cite §3/§4/§6 as enforcement sites, and none of them states the old no-tenant reading. - The whole suite is green, as above. - **Fixture triage (plugin-sharing, one file, two cases):** `sharing-rule-positions-name-authority.test.ts` gave an org-less caller `manage_sharing` through an organization-scoped grant, which is exactly the defect's behaviour. The grant is re-spelled as **global**, the one way an org-less caller still holds it; it is still `sharing_admin`, never `admin_full_access`. Three explain fixtures were re-judged to resolve in `org1`, where the scoped set applies. - **Ablations**, each through `scripts/ablation-replace.mjs` in WRAP mode, with a script-level `trap` restore on the absolute path. Core resolves from `src` in both the core and runtime suites, and `explain-engine` is imported relatively, so no `dist/` leg applies. Each is labelled with the source state it was measured at. 1. **Re-run at `4e3e4f5e4`** (resolver blob `1f0d2889e626`, which includes §6a). The predicate was put back to the old skip condition. Anchor 1 to 0, blob `1f0d2889e626` to `a03a16f630a3`. - **Red:** 5 core pins (§4/§6 with no tenant; §6a with no tenant; `u_ex`; `u_gone`; cache on) and the 6 removed-member door pins (DELETE and disable, for a2, a2b and the position-bound arm). - **Green, 7 door pins:** both controls, the global grant, the platform admin, the org-less no-grant caller, and the claim-drop proof. - Restored: blob == HEAD, `git diff HEAD` empty. - The first round's run of this ablation was taken before §6a landed (blob `490bd8a377af`) and is superseded. 2. Measured before the first merge; `92716c91af53` is still `explain-engine.ts`'s blob at `4e3e4f5e4`. `buildContextForUser` stopped passing its tenant. Blob `92716c91af53` to `372ec2454029`. **Red:** 6 pins, which are the three re-judged fixtures, explain-in-an-organization, delegator-in-`org_alpha` and the route caller-in-`org_alpha`. Restored and proven the same way. 3. Measured before the first merge; `1f0d2889e626` is still the resolver's blob at `4e3e4f5e4`. The §6a predicate was replaced by a filter that keeps every row. Blob `1f0d2889e626` to `404c23da10ec`. **Red:** both §6a core pins and 4 door pins: the position-bound arm, plus the a2 arm, whose `org_beta` membership also reaches `org_alpha`'s `org_member` binding. Restored and proven the same way. - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at `4e3e4f5e4` derived 72 commands. That is 68, plus four `@objectstack/spec` families: `check:empty-state`, `check:liveness`, `check:strictness-ledger` and `check:variant-docs`. All 72 were run with exit codes recorded before any pipe, and all ended 0. - `check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`): ablation 1's restore left core's source newer than its `dist/`. Core was rebuilt and the gate re-run: 0. - `--ran`: `72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN`. - **Lint, a declared narrowing:** `eslint --no-inline-config --format json` over the 10 changed `.ts` files at `4e3e4f5e4` gave 10 files, 0 errors, 0 warnings. - The population is `eslint.config.mjs`'s `**/*.{ts,...}` object minus `NEVER_LINTED` and `packages/spec/**`, and all 10 files are in it. - The config enables no type-aware linting (no `parserOptions.project`, as the config itself states), so this diff cannot move any untouched file's verdict. The full `pnpm lint` is CI's. ## Acceptance notes - **Review nit ①5, not carried:** `explainAccessForCaller` reads `tenantId` where `resolvePermissionSetsForContext` reads `organizationId ?? tenantId`. Patch round 1 does not otherwise touch `security-plugin.ts`, so it is left as reviewed. - **Explain ≠ enforce for a removed member explained from the left organization** (H3, second row). This is the objectstack-ai#20431 family, reported and not fixed. The explained user is resolved in the caller's organization, without the membership check the session arm applies. - **§6a no-tenant page cap:** the organization-less `sys_position` read is installation-wide and capped at 200 rows, so with many organizations the organization-less rows can fall outside the page. That was already true before this change; the predicate only decides which of the returned rows apply. - **The position-name fold with no tenant** (`resolvePermissionSetsForContext` requesting position names as permission-set names, loaded through `dbLoaderForContext`) is a separate seam. NOT MEASURED here. - **`group` posture:** a principal with no active organization keeps a data wall spanning every member organization, but it now holds no organization-scoped grant until one is active. That is the ruling; it is named here because it is the most visible population. --- _Generated by [Claude Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20492
Clause-②: no
H0 first: who reaches this door with no organization (measured before any edit)
Measured on unmodified
origin/main4a1df19656. The probe droveDELETE /packages/:idthroughdispatch()with real identity resolution (resolveRequestScope→resolveExecutionContext→resolveAuthzContext) under anisolatedposture. It used a registry double that really uninstalls and a protocol double that refuses an org-lessdeletePackagethe way the real one does. The probe was throwaway and is not committed.requireManageMetadataorg_alpha, was its org admin (the auto-grant is revoked on removal), still a member oforg_betaorganization_adminauto-grant row survivedorg_alpha, held an operator-authoredmanage_metadataset granted scoped toorg_alpha, and that row survived the removalTENANT_SCOPE_REQUIREDorg_alpha, holds an unscopedmanage_metadataset (the #20491 rig's shape; a platform-level author)everyoneonly)org_alpha'severyoneposition is bound to amanage_metadataset (not a default binding)org_alphamember holding the alpha-scoped setReadout. Under the shipped default grants, both populations are refused 403.
organization_adminwithholdsmanage_metadata, and the baseline bound toeveryonerefuses high-privilege bits. (a2) passes, though. When the resolver drops the stale claim, it re-resolves grants with no tenant.resolveUserAuthzGrants's permission-set filter keeps every org-scoped grant when no tenant is active, so a grant scoped to the organization the person left still confersmanage_metadata. On unmodifiedmain, that person took the package out of the running process and got a 400 back. That is the triage's stated p0 trigger. After this PR theDELETEdoor changes nothing for them. They still pass the capability gate, which lives inpackages/coreand is outside this card; it is listed below for the seat.What changed
DELETE /packages/:id(packages/runtime/src/domains/packages.ts) now resolves the caller's organization once, afterrequireManageMetadataandrequireWritablePackage. If a persisted delete will run (protocol.deletePackageis present) and there is no organization, the door refuses400 TENANT_SCOPE_REQUIREDbeforeregistry.uninstallPackage(id). The refusal comes from a newrequireUninstallOrganizationScopeguard. The same organization value is then handed todeletePackage, so the door's check and the protocol's cannot disagree. There is no compensating re-install. The protocol keeps its own refusal as the second line.deletePackagerefuses (a)organizationIdtogether withallTenants: true, and (b) neither of them. The door never sendsallTenants, so (a) is unreachable from it and (b) is exactly "no organization". Nothing about the package or its rows enters the condition. The producer's declared request type says the same thing: "Omitted together withallTenants⇒ refused". The door therefore mirrors all of it up front. It asks only when the persisted half will run: with nodeletePackagethere is no refusal to mirror, and the in-memory uninstall proceeds as before.Clause-②: no). Every request refused now was refused before, with the same code and status, and the rest are answered as before. A read-only package still gets422 WRITABLE_PACKAGE_REQUIREDfirst. An unknown id with no organization was 400 before and is 400 now.code: TENANT_SCOPE_REQUIRED,httpStatus: 400,details.packageId. The sentence is the door's own, for the same reasonrequireWritablePackage's is: the protocol's remedy ("pass organizationId … or allTenants: true") names request keys an HTTP caller cannot send. The new text tells the caller to select an organization they belong to, and says that nothing changed.isSystembypass. The protocol refuses an org-less uninstall whoever asks, so the mirror does too.Pins (H2):
packages-uninstall-refuse-before-mutate.test.tsThis file reuses the #20491 rig:
dispatch()with real identity resolution underisolated, and one shared permission set, so only the organization separates the arms. It adds a realSchemaRegistryholding the package and one object it owns, plus a protocol double that keeps the stored rows and records everydeletePackagerequest.400 TENANT_SCOPE_REQUIRED(code plushttpStatus), anddeletePackageis never asked.GET /packages/:idanswers 200,GET /packagesstill lists it, the registry still holds the package and its object, and the stored rows are untouched.GETanswers 404, anddeletePackagereceives{ packageId, organizationId: org_alpha }and removes the rows.isSystemcaller with no organization is refused 400 and the registry keeps the package.Ablation (H3)
The check was moved back after
uninstallPackagethroughnode scripts/ablation-replace.mjs, in WRAP mode with its own restore, plus a script-leveltraprestore against the absolute path.86b509c9da22to7ff97007f460. An on-disk order check printeduninstallPackage at line 2034, scope guard at line 2036 -> MUTATED (uninstall first).../http-dispatcher.js→src), so the mutated source is what ran and nodist/leg applies.src/domains/packages*: 3 failed and 435 passed (438 total). Exactly the refused-caller "unchanged" pins went red: both "afterwards … untouched" pins, plus theisSystempin's registry assertion, which is the same fact for the third refused caller. The "answer" pins stayed green, because the door still refuses beforedeletePackagein that position. That is the expected direction: the ablation separates the response from the side effect, which is this card's whole point.blob after restore 86b509c9da22 == HEAD,git diff HEAD empty. The trap reportedRESTORE PROVEN: blob 86b509c9da22… == HEAD.git status --porcelainwas empty afterwards.Fixture triage (five existing files)
Four fixtures sent their allow-path
DELETEcallers with no organization to a protocol double that accepted it. The real protocol refuses that request, so these fixtures could not happen for real, and they went red once the door refused first. Each one now acts in an organization. That is a spelling fix: none of those files is about organization scope.packages-capability-gate.test.ts: theDELETE /:idcase alone carriestenantIdfor its allow-path callers.packages-read-delete-response-conformance.test.ts: the session namesorg_acme, backed by asys_memberrow.packages-readonly-gate.test.ts:admin()acts inorg_acme.packages-uninstall-envelope.test.ts:authed()acts inorg_acme.The fifth file,
packages-vetted-org-source.test.ts(#20477's pin), is re-judged. Its generic "the protocol is handed no organization" loop excludes the uninstall door, because that door now refuses before any protocol call. The door's dedicated pin now also assertsstate.callsis empty. The registry half is left to the new file, because this rig's registry cannot uninstall.Verification (final head
eca5d389a3, after mergingorigin/main9449512a31)turbo run build --filter='@objectstack/runtime...'(30/30), then--filter='./packages/*' --filter='./packages/*/*'(71/71) for the gates that read everydist/.@objectstack/runtimetests:vitest run --project localgave 286 files and 4170 passed, 1 skipped.--project repogave 3 files and 718 passed.@objectstack/runtimetypecheck:tsc --noEmitpassed.check:test-typecheckwas OK with the debt ledger unchanged. The touched test files are in thetsconfig.test.jsonprogram (--listFilesOnly) with 0 errors.dispatch-gates --repo objectstack-ai/objectstack --commands: 61 derived commands, all exit 0. Reconciled with--ran(exit codes recorded):61 derived, 61 run, 0 NOT-MEASURED, 0 UNRUN.check:dual-build-cjs-loadsandcheck:type-check-debtfirst exited 3 (PREREQUISITE NOT MET, nodist/). They were re-run after the full build and exited 0.check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity,check:route-ledger-censusandcheck:error-status-conformanceall exited 0.node scripts/check-issue-citations.mjs --base origin/main: exit 0. 3 citations, all resolve.pnpm lint(the fulleslint . --no-inline-config) exited 0 in 30s.packages/objectql/**,packages/metadata-protocol/**andpackages/core/**are untouched and read-only for this card.@objectstack/runtime's public surface is byte-unchanged (no export, no spec key, no wire shape), so no import-side sweep is owed.Declared narrowing — verification ran UNLOCKED.
scripts/pm/os-verify-lock.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held for this
run, nor for any sibling agent in this container while it ran.
Patch round 1 (head
cadfe0b7ae, after mergingorigin/main9e9bb46417)Added by the
domain:cliseat from the dev's round-1 report on #20492; the dev writes the body once.Lint & Repo Gateswent red oneca5d389a3incheck:error-code-provenance. The door's new refusal stampsmetadata-protocol's wire codeTENANT_SCOPE_REQUIREDfrom@objectstack/runtime, and the gate asks for a decision on the record.PROVENANCE_WAIVERSentry inpackages/spec/src/api/error-code-ledger.zod.ts(package: '@objectstack/runtime',code: 'TENANT_SCOPE_REQUIRED',registeredUnder: '@objectstack/metadata-protocol'), and an'@objectstack/spec': patchline in this PR's changeset. Nothing else underpackages/spec/**changed; the registered union andErrorCodeare unchanged. The entry rides this PR because the gate holds waivers live in both directions (claim5878081156, amended in place).cadfe0b7ae:check:error-code-provenanceis green: 330 stamp sites, 313 listed, 17 waived, 10 waivers all live.dispatch-gates --commandsderives 87 families now that spec paths apply, and all 87 ran.check:merge-driverand thecheck:generatedaggregate fail on this host alone (the global pnpm v11 shim rejectspnpm -s), and identically on a cleanorigin/main. All 15check:generatedmembers pass run one by one.origin/mainon this host.Acceptance notes
packages/core, read-only for this card.) When the session claim is dropped ([decision · p0] a SESSION whoseactiveOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 ruling B), the grants are re-resolved with no tenant.resolveUserAuthzGrantskeeps every org-scopedsys_user_permission_setgrant when no tenant is active (!(org && tenantId && org !== tenantId)). So a grant scoped to the organization the person was removed from still confers its capabilities. H0 (a2) and (a2b) measured this atdispatch():requireManageMetadatapasses.Seam: core resolveUserAuthzGrants (permission-set filter) → runtime: every capability gate reading executionContext.systemPermissions. The other env-wide/packagesdoors this population now reaches with no organization (PATCH /:id/disableand/enable,POST /packages,PATCH /:id) are NOT MEASURED. No cleanup of custom org-scoped grants on member removal was found in plugin-security, organizations or plugin-auth (the org-admin auto-grant is the only reconciled one). That was not exhaustively measured.findignores the context argument, so it cannot say whether the real driver scopes thesys_positionread for a tenant-less system context. It is recorded as an inference and NOT MEASURED against a real driver.deletePackage(not a refusal) still runs after the registry uninstall and answers400 PACKAGE_DELETE_PARTIALwithregistryRemoved: true.packages-capability-gate,packages-uninstall-envelopeandpackages-readonly-gatelet an allowed uninstall reachsetPackageDisabledwithout redirectingOS_HOME, so they write a state file under the real ObjectStack home. This was already true before this PR; the fixture change keeps their allow-path exactly where it was. Carrier: none./packagesdomain takes the organization from the raw session claim (resolveActiveOrganizationId, 9 sites), bypassing ruling B on #15409: a session naming a left organization may read and write it #20477 changeset (.changeset/20477-dispatcher-vetted-org-source.md, not yet released) says the removed member's uninstall "is refused400 TENANT_SCOPE_REQUIREDand deletes nothing". Before this PR that was true of the stored rows only, not the registry. It becomes wholly true when this lands, so both entries can ship in one release unchanged.Generated by Claude Code