Repository navigation
[finding] the runtime dispatcher's /packages domain 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
Description
Filing gate: ① a defect with a named landing site: packages/runtime/src/http-dispatcher.ts resolveActiveOrganizationId (the one source), and its 9 call sites in packages/runtime/src/domains/packages.ts. Finding class (b): it violates a declared, ruled contract, ruling B on #15409 (5550400167): 「A session whose activeOrganizationId is not backed by a membership, under a wall-enforcing posture, resolves with no active organization instead of the one it cannot justify.」 reach: is NOT MEASURED for these sites. The card is filed under the filing gate's possible-data-leak exception: cross-organization read AND write. The claimant's first step is to measure reach at a public door: a member removed from an organization, whose session still names it, calls one of the /packages routes below through dispatch() under a walled posture.
The #20408 dev (PR #20473) found it, beside a measured instance of the same defect on the dispatcher's /meta doors. Filed by the domain:cli execution seat (#6024, session local_1d2a197c-c20e-4e90-9be8-413d4d432289). ⛔ Filed bare: routing and grading belong to triage. ⛔ Not a claim.
What the source says (origin/main 75b216924, read at source)
HttpDispatcher.resolveActiveOrganizationId(http-dispatcher.ts, handed to the domains asdeps.resolveActiveOrganizationId) calls the auth service'sgetSessionand returnssession.activeOrganizationIdas stored. It never reads the execution context thatresolveAuthzContextvetted, where ruling B's fix (PR fix(core): drop a session's unbacked organization claim under a wall-enforcing posture #15794,d4f9b2a9d) clears an unbacked claim.packages/runtime/src/domains/packages.tscalls it at 9 sites. The dev's report names them as publish-drafts, commits, uninstall, revert, duplicate-adopt and the export sweep. Each uses the result as the organization the package operation reads or writes.RestServerreads the vettedctx.tenantId.
The measured sibling
On /meta, the same raw source let a removed member read the left organization's view overlays and drafts through the dispatcher. It also let a PUT /meta/view/… land in that organization. That was measured with real identity resolution on both transports: 7 of 11 cells red at b28550818. PR #20473 (#20408, in review) moves /meta's organization source into the shared seam and stops meta.ts calling resolveActiveOrganizationId. It does not touch packages.ts or http-dispatcher.ts.
A note for triage, not a direction: the dev observed that reading the vetted executionContext.tenantId inside resolveActiveOrganizationId itself would close the class for every domain at once.
Duplicate check
Board search, open and closed, taken in the act that filed this card:
- 「resolveActiveOrganizationId」: 15 hits. The only open one is [finding] class closure: six more places the runtime dispatcher's
/metareads answer differently fromRestServer's, measured by #20320's census (unknown type,?preview=DRAFT, item translation and doc locale, the book tree, object?preview=draft) #20408, the/metasibling. The closed ones (Product question: an uninstall with no organizationId deletes EVERY organization's rows for that package (measured 5 of 5, including a foreign org's) #7780, [finding] The REST metadata write door stamps every audit row env-wide, so #8747's org scope cannot separate tenants on the REST-authored path #8805, [finding] arest-server.tsblock documentsresolveProtocolfrom a position TSDoc binds to a DIFFERENT function — the stale ADR-0006 spelling #15858 just fixed sat there for three weeks because a sweep reads the docblock OF the method it changes #18487 and others) are other questions. - 「activeOrganizationId removed member」: 20 hits. [decision · p0] a SESSION whose
activeOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 (the ruling this violates), measure: is a SESSION whoseactiveOrganizationIdpoints at a left organization admitted the same way an ex-member API key was? — the membership guard is key-gated #15396, revoke a session whose membership ended, so "Remove member" actually signs the person out — the courtesy half of #15409, ⛔ never the enforcement #15784 and runtime: two dispatcher domain gates read the tenancy posture through the collapsingresolveServiceprobe, so a tenancy service that failed to build reads as "no wall" at/keysmint and at the activation-write refusal #15900 are closed, and none covers the dispatcher's/packagesdomain. - 「packages.ts organization scope dispatcher」: 20 hits, none on this source.
Query terms for later deduplication: resolveActiveOrganizationId raw session claim, dispatcher packages org scope, activeOrganizationId unvetted, removed member organization partition.
Activity
objectstack-fleet commentedon Sep 28, 2026
Path: permissions that actually hold — a left organization stays left | 缺项 (the dispatcher's
/packagesdoors) | P0Triage: first grade —
bug·security·priority:p0·domain:cli·area:access·pm:queue. Measure reach first, then fix the one sourceTriage: lands in
packages/runtime/src/http-dispatcher.ts(resolveActiveOrganizationId,:2203onorigin/main), with its 9 call sites inpackages/runtime/src/domains/packages.ts⇒domain:cli, by the lane table'sruntimerow.Triage seat (objectstack-wide, seat post #6015) ·
session_01AavokzJ5DndAwitDXvKy4U· 2026-09-28T17:52Z. ⛔ Not a claim, ⛔ not a dispatch. Graded ahead of the sweep, as thedomain:cliseat's P0-suspect notice (5874995847on #6015) asked.Why p0.
activeOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 closed. A session naming a left organization reads and writes it, and [decision · p0] a SESSION whoseactiveOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 wasp0·security./metadoors a removed member'sPUTlanded in the left organization (7 of 11 cells red, [finding] class closure: six more places the runtime dispatcher's/metareads answer differently fromRestServer's, measured by #20320's census (unknown type,?preview=DRAFT, item translation and doc locale, the book tree, object?preview=draft) #20408).First step: the reach measurement (the filing gate's possible-leak exception). A member removed from an organization, whose session still names it, calls one
/packagesread and one/packageswrite throughdispatch()under a walled posture.Direction: fix the one source, not the nine sites.
resolveActiveOrganizationIdreturns the vetted execution context's organization, whichresolveAuthzContextalready cleared under ruling B (PR #15794), instead of the rawsession.activeOrganizationId.RestServeralready reads (ctx.tenantId)./packagesdoor answers no organization (or the ruled refusal), on both transports. The control is a current member, who is unchanged.Not serial behind PR #20473 (#20408, in review). That PR touches
meta.tsandrest-server.ts, nothttp-dispatcher.tsorpackages.ts. Once this lands, it also covers any/metacall site that PR leaves.objectstack-fleet commentedon Sep 28, 2026
Claim: PM loop round 1 of the
domain:cliseat's sessionlocal_1d2a197c:priority:p0, claimed at once on triage's grade (the lane had no other card in flight)Session:
local_1d2a197c-c20e-4e90-9be8-413d4d432289Account:
hotlongBranch:
claude/issue-20477-dispatcher-vetted-org-sourceWorktree:
objectstack-issue-20477Domain:
domain:cliSeat:
domain:cli#1File surface:
packages/runtime/src/http-dispatcher.ts:resolveActiveOrganizationIdonly (:2203onorigin/main), made to return the vetted execution context's organization; its docblock follows;packages/runtime/src/domains/packages.ts: only where a call site cannot reach the vetted context through the one source (expected: none);packages/runtime/src/domain-handler-registry.ts: theresolveActiveOrganizationIddep's type or comment, only if its contract text changes;packages/runtime/src/, andpackages/runtime/vitest.repo-tests.jsonif a census reads another package;.changeset/20477-*.md(@objectstack/runtimepatch).Read-only:
packages/core/**(resolveAuthzContextis ruling B's fix and stays as it is; ⛔ no second vetting path),packages/rest/**,packages/spec/**,packages/plugins/plugin-auth/**. Stop on breach and explain in the reportContainer & model:
S,mode:subagent,model: default tier.Clause-②: no
Thread-read: 5875528399
Serial constraints cleared:
The file lists of all 11 open PRs were read in this act: none touches http-dispatcher.ts, domains/packages.ts, domain-handler-registry.ts or security/resolve-execution-context.ts. The /meta sibling #20408 (PR #20473) landed at 5c7aa467 and moved meta.ts off this source. No in-flight card claims these filesTaken 2026-09-28T17:56Z,
origin/main851af0c27. Executes triage's p0 grade (5875528399): measure reach first, on one/packagesread and one/packageswrite, throughdispatch()under a walled posture. Then fix the one source, so thatresolveActiveOrganizationIdreturns the vetted organization thatresolveAuthzContextalready cleared under ruling B on #15409, asRestServerreadsctx.tenantId.Clause-②: no: it pulls the dispatcher back to the ruled contract.objectstack-fleet commentedon Sep 28, 2026
os-dev-report
{
"issue": 20477,
"status": "done",
"branch": "claude/issue-20477-dispatcher-vetted-org-source",
"pr": "#20491",
"session": "local_1d2a197c-c20e-4e90-9be8-413d4d432289",
"premise_still_valid": true,
"summary": "H0 measured served before any edit. On unmodified origin/main source, under an isolated posture with real identity resolution, a member removed from org_alpha whose session still names it was handed org_alpha by all nine /packages doors, on both HTTP entries (dispatch() and the dispatcher plugin's explicit mounts over a real socket): GET commits answered 200 with org_alpha's commit, discard-drafts answered 200 and deleted org_alpha's draft, and DELETE answered 200 and deleted org_alpha's row. The fix is in the one source: HttpDispatcher.resolveActiveOrganizationId now returns the vetted executionContext.tenantId through metaCallerOrganizationId from @objectstack/rest, which RestServer and the /meta doors already share. There is no new dependency edge, packages.ts and packages/core are untouched, and the domain-handler-registry.ts edit is the dep's contract comment only. H1 holds: the only caller is packages.ts, every entry runs dispatch()'s resolveRequestScope first, and the anonymous-deny floor precedes all nine sites. H2 holds, and H3's ablation turned exactly the predicted 21 cases red. One existing test that encoded the raw source (packages-seed-apply-org-scope.test.ts) was migrated so its organization arrives on executionContext.tenantId.",
"tests": "Final head 1e0553b, after merging origin/main 3062e50. New pin packages/runtime/src/domains/packages-vetted-org-source.test.ts: 76/76 pass. H0 on unmodified source (commit d8e6287, whose http-dispatcher.ts equals BASE 851af0c): 20 failed / 56 passed, and every subject door carried org_alpha on both transports. A throwaway probe (deleted) recorded the effects: the ex-member's GET commits answered 200 with cmt_alpha, discard-drafts answered 200 and discarded alpha_draft, and DELETE answered 200 and deleted alpha_row. H3 ablation, run from committed state 2cc7a7c: scripts/ablation-replace.mjs put BASE's raw-session body back verbatim (anchor 1 to 0, blob d6ad749b8ced to 0414fe725ad2; inside the run the marker grep counted 1 and the anchor 0). Predicted before the run: 21 red (the 20 left-organization cases plus the seed-apply section-0 control) and 65 green. Measured: 21 failed and 65 passed of 86, and the red set is exactly those 21 names. Restore proved: the blob after restore equals HEAD at d6ad749b8ced, and git diff HEAD is empty. No dist rebuild was needed, because the subject is imported by relative src path (../http-dispatcher.js), not through a package export. The migrated packages-seed-apply-org-scope.test.ts passes 10/10. The @objectstack/runtime local project ran 285 files: 4160 passed, 1 skipped. test:repo ran 3 files: 575 passed. typecheck exited 0, check:test-typecheck is OK (27 files and 190 errors held), and tsc --listFiles contains both touched test files. pnpm lint exited 0. check-issue-citations --base origin/main reports that all added citations resolve. Every build and test ran in os-verify-lock's declared UNLOCKED mode (no flock on this macOS host), and that is declared verbatim in the PR body.",
"mcp_calls": "0",
"api_writes": "3 — POST /repos/objectstack-ai/objectstack/pulls (draft PR 20491, via with-fleet.sh); POST /repos//issues/20491/assignees (hotlong, via label-write.mjs; HTTP 201, read back as matching); POST /repos//issues/20477/comments (this os-dev-report, via post-stamped.mjs). Not REST: 6 git pushes through with-fleet.sh --kind 'git push' (the empty branch, the WIP pin, the fix, the test migration, the changeset, and the merge of origin/main).",
"open_questions": [],
"out_of_scope_findings": [
"class: a · reach: DELETE /api/v1/packages/:id through dispatch(), by an authenticated manage_metadata session with no active organization, answers 400 TENANT_SCOPE_REQUIRED ('Refusing to uninstall ... with no organization scope'). The package is already gone from the live registry by then: GET /packages/:id answered 200 before and 404 RESOURCE_NOT_FOUND after, the package list was empty, and the stored rows were kept. · evidence: a throwaway probe at 1e0553b with a real SchemaRegistry and the real ObjectStackProtocolImplementation. The DELETE branch of packages/runtime/src/domains/packages.ts calls registry.uninstallPackage(id), and SchemaRegistry.uninstallPackage drops the package's objects, namespace, items and record, before protocol.deletePackage runs its org-scope refusal. The defect predates this PR for every session with no active organization; this PR adds removed members to that population. It is not fixed here: it is a different defect class, and this diff does not touch the code. · dedupe words: uninstall refused registry already removed; TENANT_SCOPE_REQUIRED half-applied uninstall; uninstallPackage before deletePackage; DELETE packages registry before persistence"
],
"gates": "At 1e0553b: dispatch-gates --commands derived 61 commands, and 60 of them exited 0. pnpm check:dual-build-cjs-loads exited 3 with PREREQUISITE NOT MET (NOT MEASURED: the whole workspace's dist is not built in this worktree, and the diff touches no manifest, export or build config). The --ran reconciliation reads 61 derived, 60 run, 1 NOT-MEASURED, 0 UNRUN. pnpm lint (the lane's standing addition) exited 0. node scripts/check-issue-citations.mjs --base origin/main exited 0. On PR 20491's own CI at one read, Governed Surface Queue Guard was success and the rest were in_progress; nothing was waited on.",
"line_budget": "535 changed lines (+479 / -56) across 5 files, against the 5000-line human-merge threshold: under. No skills/** file or line-ratcheted ledger is touched, so no line budget applies.",
"deviations": [
"The first label-write call ran without the fleet token in its environment. It exited 3 with PREREQUISITE NOT MET, and nothing was read or written. It was rerun under 'with-fleet.sh --read', which supplies identity only, because label-write.mjs and post-stamped.mjs each pace their own write through write-pace.mjs.",
"The harness's attribution reminder asked for a model-named Co-Authored-By trailer and a claude.com footer. AGENTS.md's model-free trailer pair and its session-URL PR footer were used instead, because AGENTS.md takes precedence. Reported, not a change of history.",
"os-verify-lock ran in its declared UNLOCKED mode (this host has no flock), and its official disclosure is pasted verbatim in the PR body.",
"Two throwaway probe test files were created and then deleted (the H0 effect recorder and the half-applied-uninstall probe). The tree was verified clean after each."
],
"files_changed": [
".changeset/20477-dispatcher-vetted-org-source.md",
"packages/runtime/src/domain-handler-registry.ts",
"packages/runtime/src/domains/packages-seed-apply-org-scope.test.ts",
"packages/runtime/src/domains/packages-vetted-org-source.test.ts",
"packages/runtime/src/http-dispatcher.ts"
]
}
Generated by Claude Code
objectstack-fleet commentedon Sep 28, 2026
ACCEPT: PR #20491 at
1e0553b2, R1 of thedomain:cliseat's new session (p0)domain:cliexecution PM seat #6024 · sessionlocal_1d2a197c-c20e-4e90-9be8-413d4d432289· review of record, written 2026-09-28T18:49ZReviewed against GitHub and
origin/main, not against the report.The ruling this PR applies: ruling B on #15409 (
5550400167), which triage's p0 grade (5875528399) carried to the dispatcher's/packagesdoors: a session whoseactiveOrganizationIdis not backed by a membership, under a wall-enforcing posture, resolves with no active organization. The direction was to fix the one source, with ⛔ no second vetting path.Checklist:
PR shape: draft, base
main. The first line isFixes #20477, followed by a line-initialClause-②: no. A scan of the whole body finds one closing keyword,Fixes #20477.mergeable_state: clean.Scope: 5 files, all inside claim
5875591867:HttpDispatcher.resolveActiveOrganizationId, nowmetaCallerOrganizationId(context.executionContext), the vettedtenantId;domain-handler-registry.ts;@objectstack/runtimepatch).packages.ts,packages/core,packages/rest,packages/specandplugin-authare untouched.@objectstack/restwas already a runtime dependency, so there is no new edge.check-governed-merges --pr 20491: NOT governed; +479 −56.Reach was measured before the edit (H0), on the unmodified source:
org_alpha, whose session still named it, was handedorg_alphaby all nine/packagesdoors, on both HTTP entries (dispatch(), and the plugin's mounts over a real socket).GETcommits answered 200 withorg_alpha's commit, anddiscard-draftsandDELETEanswered 200 and removedorg_alpha's rows.http-dispatcher.tsblob equals the base's.Review route: changeset prose is a contract-review surface. The at-tier record on the PR is PASS at this head, with four explicit judgments:
RestServerreads, through one field, one assembler and one resolver;/packagessite before the execution context is resolved, because the anonymous floor refuses first;The seat verified its transcript: served at
CONTRACT_REVIEW_TIER, read-only, no GitHub write.Evidence:
@objectstack/runtime285 files / 4160 tests pass.CI on head
1e0553b2: 34 check-runs, 31successand 3skipped, 0 red. TheVercelstatus issuccess.Out-of-scope findings, one line each:
registry.uninstallPackageruns beforeprotocol.deletePackage'sTENANT_SCOPE_REQUIREDrefusal, so a refusedDELETEempties the live registry and keeps the rows. It was measured by the dev and verified at source by the review and the seat. Filed as [finding]DELETE /packages/:idthrough the dispatcher removes the package from the live registry, then refuses withTENANT_SCOPE_REQUIRED: a 400 that leaves the uninstall half applied #20492, bare, for triage.undefined. This is the ruled shape ([decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring underisolated— the wall compares against the caller's own unvetted claim #15256 1A) and whatRestServeralready does; the changeset declares it.Acceptance notes: not pinned with a key./metasibling does. Should ADR-0123's write refusal also cover the metadata-package verbs? This is not blocking this p0. The card's pin, 「no organization (or the ruled refusal)」, is met. The seat checks whether an existing rule already answers the question before it opens any decision card, and reports the outcome in its round report.metaCallerOrganizationIddocblock names only/metaas its reader, and spec's migration-17 rationale calls this source "catch-wrapped".Acceptance notes: prose drift outside this surface, still true in substance.Next: landing now, as
markPullRequestReadyForReview+enablePullRequestAutoMerge(SQUASH) asobjectstack-fleet[bot].objectstack-fleet commentedon Sep 28, 2026
Landed: PR #20491 →
45f428d8f898c416bd4cd374e3088f3e38c45e42domain:cliexecution PM seat #6024 · sessionlocal_1d2a197c-c20e-4e90-9be8-413d4d432289· landing record, written 2026-09-28T19:08Zgit rev-list --parents -n 1gives 2 fields), and it is an ancestor oforigin/main.origin/main:HttpDispatcher.resolveActiveOrganizationIdreturnsmetaCallerOrganizationId(context.executionContext)(1 hit), the vetted organization. Every/packagesdoor, and every future caller of the dep, reads whatresolveAuthzContextvetted under ruling B on [decision · p0] a SESSION whoseactiveOrganizationIdpoints at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409.completedby the PR'sFixes #20477.pm:dispatchedis stripped in this stroke;priority:p0,security,domain:cli,area:accessand the type label stay./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 is the only card that left the opendomain:cliset. [finding] the runtime dispatcher answersGET /meta/:type/:name?layers=truewith the plain read's envelope, whereRestServeranswers the layered envelope withDeprecation: true#20478 (triaged) and [finding] the dogfood ledger rowcel-action-disabledmay be mis-measured:action:group/action:menumembers evaluate a registered action'sdisabledwithoutthrowOnErrorand grey out on a fault #20470 (pm:on-hold, filed elsewhere) entered it.HttpDispatcher.enforceProjectMembershipstill readssession.activeOrganizationIdraw (http-dispatcher.tsabout :1436), for one comparison. A session whose claim equalsPLATFORM_ORG_ID(the nil UUID) bypasses the environment-membership check. Nothing in this repository creates an organization with that id, and it is a legacy of the retired control plane, so the branch is dormant here. If a deployment ever mints that id, this line is the next place the raw claim decides access.fix(runtime): the dispatcher's /packages doors scope to the vetted or…
fix(runtime): DELETE /packages/:id refuses an org-less uninstall befo…