fix(runtime): the dispatcher's /packages doors scope to the vetted organization, not the raw session claim (#20477) - #20491
Conversation
…ach measurement, fix pending) Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…xecution context, not the raw session claim `resolveActiveOrganizationId` returned `session.activeOrganizationId` as stored, so the nine `/packages` doors that ask it kept a member removed from an organization inside it under a walled posture. It now returns the `tenantId` `resolveAuthzContext` vetted onto the execution context (ruling B on #15409), through the helper RestServer and the `/meta` doors share. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…n where dispatch() now reads it Its organization arrived through a stubbed `getSession`, the raw source this card retires; after the fix every measurement in the file had moved to the one-rung branch and only its section-0 control caught it. The organization now lands on the execution context's `tenantId`, and the dead session stub is gone. Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…ted organization source Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289 Co-authored-by: Claude <noreply@anthropic.com>
…spatcher-vetted-org-source
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 26 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 c24f2d173ddd568bf75b723f92a9712417496b7d && git checkout c24f2d173ddd568bf75b723f92a9712417496b7d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fc0db22bcfdbdb778945317fc4de6dc46aab966d 1e0553b290fc64d78c097fedca212a4de8c668fe && git checkout -B drift-repro fc0db22bcfdbdb778945317fc4de6dc46aab966d && git merge --no-ff 1e0553b290fc64d78c097fedca212a4de8c668fe
node scripts/docs-audit/affected-docs.mjs --json fc0db22bcfdbdb778945317fc4de6dc46aab966d
|
Contract reviewServed-tier: Inputs, and nothing else: card #20477 (body and all 3 comments — triage's grade Check-runs on the head, read at 18:42 UTC (newest run per name, 33 names): 27 success, 3 skipped (Build Docs, Console Pin Gate, Packed-tarball smoke opt-in), 0 failure, 3 still in progress — Lint & Repo Gates, Test Core (3/6), Test Core (4/6). Green at the read: Check Changeset, Build Core, all five Type Check runs, Test Core 1/2/5/6, Temporal Conformance, the Dogfood gates, both single-writer guards, the card/branch claim guard, Governed Surface Queue Guard. No verdict is inferred for the three running; the seat lands on their conclusions. ① Derived judgmentsThe four questions the brief asks, judged against ruling B ( (1) Does
(2) Does any path reach a
(3) Are a current member's and an anonymous caller's answers unchanged? YES — right.
(4) Does "⛔ No second vetting path" hold? YES.
Each accept-set / public-surface change the diff implies:
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS |
…re it touches the registry (objectstack-ai#20492) (objectstack-ai#20514) Fixes objectstack-ai#20492 Clause-②: no ## H0 first: who reaches this door with no organization (measured before any edit) Measured on unmodified `origin/main` `4a1df19656`. The probe drove `DELETE /packages/:id` through `dispatch()` with real identity resolution (`resolveRequestScope` → `resolveExecutionContext` → `resolveAuthzContext`) under an `isolated` posture. It used a registry double that really uninstalls and a protocol double that refuses an org-less `deletePackage` the way the real one does. The probe was throwaway and is not committed. | Arm | Answer | Passes `requireManageMetadata` | Package in the live registry afterwards | |---|---|---|---| | (a1) removed from `org_alpha`, was its org admin (the auto-grant is revoked on removal), still a member of `org_beta` | 403 | no | kept | | (a1') same, but the `organization_admin` auto-grant row survived | 403 | no | kept | | **(a2)** removed from `org_alpha`, held an operator-authored `manage_metadata` set granted **scoped to `org_alpha`**, and that row survived the removal | 400 `TENANT_SCOPE_REQUIRED` | **yes** | **removed** | | **(a2b)** same as (a2), with no membership left anywhere | 400 | **yes** | **removed** | | (a3) removed from `org_alpha`, holds an unscoped `manage_metadata` set (the objectstack-ai#20491 rig's shape; a platform-level author) | 400 | yes | removed | | (b1) fresh sign-up with no organization and no grant rows (the implicit `everyone` only) | 403 | no | kept | | (b2) fresh sign-up with no organization; `org_alpha`'s `everyone` position is bound to a `manage_metadata` set (not a default binding) | 400 | yes, but only in the fixture (see notes) | removed | | control: a current `org_alpha` member holding the alpha-scoped set | 200 | yes | removed, as asked | **Readout.** Under the shipped default grants, both populations are refused 403. `organization_admin` withholds `manage_metadata`, and the baseline bound to `everyone` refuses 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 confers `manage_metadata`. On unmodified `main`, 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 the `DELETE` door changes nothing for them. They still pass the capability gate, which lives in `packages/core` and 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, after `requireManageMetadata` and `requireWritablePackage`. If a persisted delete will run (`protocol.deletePackage` is present) and there is no organization, the door refuses `400 TENANT_SCOPE_REQUIRED` **before** `registry.uninstallPackage(id)`. The refusal comes from a new `requireUninstallOrganizationScope` guard. The same organization value is then handed to `deletePackage`, 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. - **H1 holds.** `deletePackage` refuses (a) `organizationId` together with `allTenants: true`, and (b) neither of them. The door never sends `allTenants`, 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 with `allTenants` ⇒ refused". The door therefore mirrors all of it up front. It asks only when the persisted half will run: with no `deletePackage` there is no refusal to mirror, and the in-memory uninstall proceeds as before. - **Accept set unchanged (`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 gets `422 WRITABLE_PACKAGE_REQUIRED` first. An unknown id with no organization was 400 before and is 400 now. - **Envelope and message.** The envelope is `code: TENANT_SCOPE_REQUIRED`, `httpStatus: 400`, `details.packageId`. The sentence is the door's own, for the same reason `requireWritablePackage`'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. - **No `isSystem` bypass.** The protocol refuses an org-less uninstall whoever asks, so the mirror does too. ## Pins (H2): `packages-uninstall-refuse-before-mutate.test.ts` This file reuses the objectstack-ai#20491 rig: `dispatch()` with real identity resolution under `isolated`, and one shared permission set, so only the organization separates the arms. It adds a **real** `SchemaRegistry` holding the package and one object it owns, plus a protocol double that keeps the stored rows and records every `deletePackage` request. - For each refused population (the removed member whose claim is dropped, and the caller who never selected an organization), there are two pins: - **the answer:** `400 TENANT_SCOPE_REQUIRED` (code plus `httpStatus`), and `deletePackage` is never asked. - **unchanged afterwards:** `GET /packages/:id` answers 200, `GET /packages` still lists it, the registry still holds the package and its object, and the stored rows are untouched. - One pin checks that the rig really drops the removed member's claim. - **Control:** a current member gets 200. The package and its object leave the registry, `GET` answers 404, and `deletePackage` receives `{ packageId, organizationId: org_alpha }` and removes the rows. - **The mirror's reach:** a host with no persisted half still uninstalls an org-less caller, answering 200. An `isSystem` caller with no organization is refused 400 and the registry keeps the package. ## Ablation (H3) The check was moved back after `uninstallPackage` through `node scripts/ablation-replace.mjs`, in WRAP mode with its own restore, plus a script-level `trap` restore against the absolute path. - **Landing proof.** The anchor went from 1 hit to 0, the replacement from 0 to 1, and the blob from `86b509c9da22` to `7ff97007f460`. An on-disk order check printed `uninstallPackage at line 2034, scope guard at line 2036 -> MUTATED (uninstall first)`. - **Resolution path.** The subject is imported by relative path (`../http-dispatcher.js` → `src`), so the mutated source is what ran and no `dist/` leg applies. - **Result on `src/domains/packages*`:** 3 failed and 435 passed (438 total). Exactly the refused-caller "unchanged" pins went red: both "afterwards … untouched" pins, plus the `isSystem` pin's registry assertion, which is the same fact for the third refused caller. The "answer" pins stayed green, because the door still refuses before `deletePackage` in that position. That is the expected direction: the ablation separates the response from the side effect, which is this card's whole point. - **Restore proven twice.** The tool reported `blob after restore 86b509c == HEAD`, `git diff HEAD empty`. The trap reported `RESTORE PROVEN: blob 86b509c… == HEAD`. `git status --porcelain` was empty afterwards. ## Fixture triage (five existing files) Four fixtures sent their allow-path `DELETE` callers 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`: the `DELETE /:id` case alone carries `tenantId` for its allow-path callers. - `packages-read-delete-response-conformance.test.ts`: the session names `org_acme`, backed by a `sys_member` row. - `packages-readonly-gate.test.ts`: `admin()` acts in `org_acme`. - `packages-uninstall-envelope.test.ts`: `authed()` acts in `org_acme`. The fifth file, `packages-vetted-org-source.test.ts` (objectstack-ai#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 asserts `state.calls` is empty. The registry half is left to the new file, because this rig's registry cannot uninstall. ## Verification (final head `eca5d389a3`, after merging `origin/main` `9449512a31`) - **Build:** `turbo run build --filter='@objectstack/runtime...'` (30/30), then `--filter='./packages/*' --filter='./packages/*/*'` (71/71) for the gates that read every `dist/`. - **`@objectstack/runtime` tests:** `vitest run --project local` gave 286 files and 4170 passed, 1 skipped. `--project repo` gave 3 files and 718 passed. - **`@objectstack/runtime` typecheck:** `tsc --noEmit` passed. `check:test-typecheck` was OK with the debt ledger unchanged. The touched test files are in the `tsconfig.test.json` program (`--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-loads` and `check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`, no `dist/`). They were re-run after the full build and exited 0. - **Roster gates whose rosters sit under touched directories:** `check-changeset-fixed`, `check:authz-resolver`, `check:error-code-casing`, `check:filter-alias-parity`, `check:route-ledger-census` and `check:error-status-conformance` all exited 0. - **`node scripts/check-issue-citations.mjs --base origin/main`:** exit 0. 3 citations, all resolve. - **`pnpm lint`** (the full `eslint . --no-inline-config`) exited 0 in 30s. - **Not run locally:** `packages/objectql/**`, `packages/metadata-protocol/**` and `packages/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.sh` could not take the shared verify lock on this host: no usable `flock`. The shared verify lock is declared Linux-only (`flock` is util-linux, and a stock macOS does not 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. NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run build --filter='@objectstack/runtime^...' --concurrency=2 --output-logs=errors-only NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 [src/domains/zz-h0-probe-20492.test.ts | src/domains/packages … | the whole project] bash ablate.sh (the H3 ablation above) NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run build --filter='@objectstack/runtime...' --concurrency=2 --output-logs=errors-only NODE_OPTIONS=--max-old-space-size=4096 pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2 && … --project repo --maxWorkers=2 && pnpm --filter @objectstack/runtime typecheck NODE_OPTIONS=--max-old-space-size=4096 pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' --concurrency=2 --output-logs=errors-only ## Patch round 1 (head `cadfe0b7ae`, after merging `origin/main` `9e9bb46417`) *Added by the `domain:cli` seat from the dev's round-1 report on objectstack-ai#20492; the dev writes the body once.* - **Why:** `Lint & Repo Gates` went red on `eca5d389a3` in `check:error-code-provenance`. The door's new refusal stamps `metadata-protocol`'s wire code `TENANT_SCOPE_REQUIRED` from `@objectstack/runtime`, and the gate asks for a decision on the record. - **What:** ONE `PROVENANCE_WAIVERS` entry in `packages/spec/src/api/error-code-ledger.zod.ts` (`package: '@objectstack/runtime'`, `code: 'TENANT_SCOPE_REQUIRED'`, `registeredUnder: '@objectstack/metadata-protocol'`), and an `'@objectstack/spec': patch` line in this PR's changeset. Nothing else under `packages/spec/**` changed; the registered union and `ErrorCode` are unchanged. The entry rides this PR because the gate holds waivers live in both directions (claim `5878081156`, amended in place). - **Verification at `cadfe0b7ae`:** - `check:error-code-provenance` is green: 330 stamp sites, 313 listed, 17 waived, 10 waivers all live. - `dispatch-gates --commands` derives 87 families now that spec paths apply, and all 87 ran. - `check:merge-driver` and the `check:generated` aggregate fail on this host alone (the global pnpm v11 shim rejects `pnpm -s`), and identically on a clean `origin/main`. All 15 `check:generated` members pass run one by one. - The spec liveness test's JSON truncation reproduces on a clean `origin/main` on this host. - CI: 35/35 completed, 0 red. - **Declared narrowing, UNLOCKED as above:** this round's commands are the full turbo build, the spec and runtime test and typecheck runs, and the gate derivation. ## Acceptance notes - **(Finding for the seat, not fixed here: `packages/core`, read-only for this card.)** When the session claim is dropped (objectstack-ai#15409 ruling B), the grants are re-resolved with no tenant. `resolveUserAuthzGrants` keeps every org-scoped `sys_user_permission_set` grant 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 at `dispatch()`: `requireManageMetadata` passes. `Seam: core resolveUserAuthzGrants (permission-set filter) → runtime: every capability gate reading executionContext.systemPermissions`. The other env-wide `/packages` doors this population now reaches with no organization (`PATCH /:id/disable` and `/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. - **(b2) is fixture-level.** The probe's `find` ignores the context argument, so it cannot say whether the real driver scopes the `sys_position` read for a tenant-less system context. It is recorded as an inference and NOT MEASURED against a real driver. - **Not in this card, per the triage:** a persistence failure part-way through `deletePackage` (not a refusal) still runs after the registry uninstall and answers `400 PACKAGE_DELETE_PARTIAL` with `registryRemoved: true`. - **Pre-existing:** `packages-capability-gate`, `packages-uninstall-envelope` and `packages-readonly-gate` let an allowed uninstall reach `setPackageDisabled` without redirecting `OS_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. - **The objectstack-ai#20477 changeset** (`.changeset/20477-dispatcher-vetted-org-source.md`, not yet released) says the removed member's uninstall "is refused `400 TENANT_SCOPE_REQUIRED` and 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](https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289)_ --------- Co-authored-by: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20477
Clause-②: no
What was wrong (H0, measured before any edit)
The runtime dispatcher's nine organization-scoped
/packagesdoors take the caller's organization from one source,HttpDispatcher.resolveActiveOrganizationId. That source returned the auth session'sactiveOrganizationIdas stored.resolveAuthzContextvets that claim onto the execution context astenantId, and under a wall-enforcing posture it drops a claim that no membership backs (maintainer ruling B on #15409, implemented by PR #15794). The dispatcher never read the vetted value.Measured on unmodified
origin/mainsource (851af0c27), with real identity resolution (dispatch()→resolveRequestScope→resolveExecutionContext→resolveAuthzContext) under anisolatedposture. The subject is a member removed fromorg_alphawhose session still names it. They hold the same permission set as a current member, so RBAC cannot separate the arms.org_alpha's rowsGET /packages/:id/commits(read)dispatch(), and the plugin's explicit mount over a real socketorg_alpha's commitcmt_alphaPOST /packages/:id/discard-drafts(write)org_alpha's draftDELETE /packages/:id(write)org_alpha's rowAll nine doors handed the protocol
org_alphaon both transports: 20 of 20 subject cases red, 56 of 56 controls green. So reach is served, and the p0 grade stands. Every effect stayed inside the test's own in-memory stack.The fix
The fix is in the one source, not at the nine sites.
resolveActiveOrganizationIdnow returnscontext.executionContext.tenantIdthroughmetaCallerOrganizationIdfrom@objectstack/rest. That is the helperRestServerand the dispatcher's/metadoors already share (#20408).@objectstack/restis already a dependency of@objectstack/runtime(domains/meta.tsimports from it), so no dependency edge is added.packages/coreis untouched.domains/packages.tsis unchanged. Thedomain-handler-registry.tsedit is only the dep's contract comment.Hypotheses
domains/packages.ts, at 9 sites (git grepat HEAD).dispatch(): thecreateHonoAppcatch-all andcreateDispatcherPlugin's explicit mounts.dispatch()runsresolveRequestScope, which writesexecutionContextbefore any domain handler. Only the declared liveness route (/health) skips it, and that route reads no organization.401 ANONYMOUS_DENYbefore any protocol call.Session organization claim droppedline namesorg_alpha. So the green subject cannot come from a rig that never presented the stale claim.scripts/ablation-replace.mjsput the original raw-session body back, verbatim fromBASE(anchor 1 → 0, blobd6ad749b8ced→0414fe725ad2).d6ad749b8ced), andgit diff HEADis empty.The pins
packages/runtime/src/domains/packages-vetted-org-source.test.tshas 76 cases. It covers each of the nine doors × both transports × four callers: a current member, the ex-member switched to a real membership, an anonymous caller, and the ex-member with the stale claim. It adds the claim-drop control and the uninstall refusal.alphain the answer) nor written (its partition is byte-identical afterwards).400 TENANT_SCOPE_REQUIRED, and nothing is deleted. The protocol double copies that refusal frommetadata-protocol'sdeletePackage.One existing test migrated
packages-seed-apply-org-scope.test.tspassed its organization in through a stubbedgetSession, which is the raw source this PR retires. After the fix, every measurement in that file had silently moved to its one-rung branch, and only its §0 control went red. The organization now lands on the execution context'stenantId, wheredispatch()puts it, and the dead session stub is removed.Behaviour notes
@objectstack/core, not in better-auth. So these doors used to get no organization for a key caller. This matchesRestServerand the landed/metadoors. It is reasoned from source, not measured with a key./metasibling does the same: its pin lands the ex-member'sPUTenv-wide on both transports.Verification
Run on the final head
1e0553b290, after mergingorigin/mainat3062e5001.pnpm --workspace-concurrency=2 --filter '@objectstack/runtime^...' --filter @objectstack/runtime buildexited 0.@objectstack/runtimelocalproject ran 285 files: 4160 passed, 1 skipped.test:reporan 3 files: 575 passed.check:test-typecheckis OK.tsc --listFilesconfirms both touched test files are in the test program.pnpm lintexited 0.node scripts/check-issue-citations.mjs --base origin/mainreports that every added citation resolves.dispatch-gates --commandsderived 61 commands, and 60 of them ran with exit 0. The--ranreconciliation reads 61 derived, 60 run, 1 NOT-MEASURED, 0 UNRUN.pnpm check:dual-build-cjs-loads, which exited 3 withPREREQUISITE NOT METbecause the whole workspace'sdist/is not built in this worktree. It is not a pass. This diff touches no manifest, export or build config.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.
Every build and test command listed above ran under the same declaration, and each printed this block with its own command.
Acceptance notes
DELETE /packages/:idrunsregistry.uninstallPackage(id)before the protocol's org-scoped persistence call.400 TENANT_SCOPE_REQUIRED, "Refusing to uninstall"). By then the package has already been removed from the live registry.dispatch()with a realSchemaRegistryand the realObjectStackProtocolImplementation:GET /packages/:idanswered 200 before the refused DELETE and 404 after it, while the stored rows were kept./metasibling behaves the same way.Generated by Claude Code