Skip to content

fix(runtime): the dispatcher's /packages doors scope to the vetted organization, not the raw session claim (#20477) - #20491

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20477-dispatcher-vetted-org-source
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20477-dispatcher-vetted-org-source

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20477
Clause-②: no

What was wrong (H0, measured before any edit)

The runtime dispatcher's nine organization-scoped /packages doors take the caller's organization from one source, HttpDispatcher.resolveActiveOrganizationId. That source returned the auth session's activeOrganizationId as stored. resolveAuthzContext vets that claim onto the execution context as tenantId, 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/main source (851af0c27), with real identity resolution (dispatch() → resolveRequestScope → resolveExecutionContext → resolveAuthzContext) under an isolated posture. The subject is a member removed from org_alpha whose session still names it. They hold the same permission set as a current member, so RBAC cannot separate the arms.

Door Transport What it did with org_alpha's rows
GET /packages/:id/commits (read) dispatch(), and the plugin's explicit mount over a real socket 200, served org_alpha's commit cmt_alpha
POST /packages/:id/discard-drafts (write) both 200, deleted org_alpha's draft
DELETE /packages/:id (write) both 200, deleted org_alpha's row

All nine doors handed the protocol org_alpha on 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. resolveActiveOrganizationId now returns context.executionContext.tenantId through metaCallerOrganizationId from @objectstack/rest. That is the helper RestServer and the dispatcher's /meta doors already share (#20408).

  • @objectstack/rest is already a dependency of @objectstack/runtime (domains/meta.ts imports from it), so no dependency edge is added.
  • There is no second vetting path: packages/core is untouched.
  • domains/packages.ts is unchanged. The domain-handler-registry.ts edit is only the dep's contract comment.

Hypotheses

  • H1 (every caller has the vetted context): holds.
    • The dep's only caller is domains/packages.ts, at 9 sites (git grep at HEAD).
    • Both HTTP entries reach the domain through dispatch(): the createHonoApp catch-all and createDispatcherPlugin's explicit mounts. dispatch() runs resolveRequestScope, which writes executionContext before any domain handler. Only the declared liveness route (/health) skips it, and that route reads no organization.
    • The domain's first statement is the anonymous-deny floor. It refuses a context with no resolved principal before any of the nine sites is reached.
    • So no call site runs without a resolved context.
  • H2 (controls unchanged): holds.
    • A current member reaches their own organization on every door, on both transports.
    • The ex-member, switched to an organization they belong to, reaches that one and never the one they left.
    • An anonymous caller gets 401 ANONYMOUS_DENY before any protocol call.
    • A control asserts the resolver really dropped the ex-member's claim: its Session organization claim dropped line names org_alpha. So the green subject cannot come from a rig that never presented the stale claim.
  • H3 (ablation): holds, in the direction predicted.
    • Starting from the committed fix, scripts/ablation-replace.mjs put the original raw-session body back, verbatim from BASE (anchor 1 → 0, blob d6ad749b8ced → 0414fe725ad2).
    • Predicted before the run: exactly the 20 left-organization cases red and the 56 controls green, plus the migrated seed-apply §0 control red.
    • Measured: 21 failed and 65 passed of 86, and the red set is exactly those 21 names.
    • The tool proved the restore: the blob after restore equals the blob at HEAD (d6ad749b8ced), and git diff HEAD is empty.

The pins

packages/runtime/src/domains/packages-vetted-org-source.test.ts has 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.

  • The subject cases assert two things: the protocol is handed no organization, and the left organization's partition is neither read (no alpha in the answer) nor written (its partition is byte-identical afterwards).
  • The ex-member's uninstall gets the protocol's refusal, 400 TENANT_SCOPE_REQUIRED, and nothing is deleted. The protocol double copies that refusal from metadata-protocol's deletePackage.

One existing test migrated

packages-seed-apply-org-scope.test.ts passed its organization in through a stubbed getSession, 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's tenantId, where dispatch() puts it, and the dead session stub is removed.

Behaviour notes

  • API-key callers. The doors now use the organization the key is bound to. The raw read found no session for a key, because API keys resolve in @objectstack/core, not in better-auth. So these doors used to get no organization for a key caller. This matches RestServer and the landed /meta doors. It is reasoned from source, not measured with a key.
  • The ex-member after the fix is exactly a session with no active organization, so the doors reach the env-wide package state. The landed /meta sibling does the same: its pin lands the ex-member's PUT env-wide on both transports.

Verification

Run on the final head 1e0553b290, after merging origin/main at 3062e5001.

  • Build: pnpm --workspace-concurrency=2 --filter '@objectstack/runtime^...' --filter @objectstack/runtime build exited 0.
  • Tests: the @objectstack/runtime local project ran 285 files: 4160 passed, 1 skipped. test:repo ran 3 files: 575 passed.
  • Typecheck: exited 0, and check:test-typecheck is OK. tsc --listFiles confirms both touched test files are in the test program.
  • Lint: pnpm lint exited 0.
  • Citations: node scripts/check-issue-citations.mjs --base origin/main reports that every added citation resolves.
  • Derived gates: dispatch-gates --commands derived 61 commands, and 60 of them ran with exit 0. The --ran reconciliation reads 61 derived, 60 run, 1 NOT-MEASURED, 0 UNRUN.
    • The NOT-MEASURED one is pnpm check:dual-build-cjs-loads, which exited 3 with PREREQUISITE NOT MET because the whole workspace's dist/ 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.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.

pnpm --filter @objectstack/runtime exec vitest run --project local --maxWorkers=2

Every build and test command listed above ran under the same declaration, and each printed this block with its own command.

Acceptance notes

  • Out of scope, found while measuring, reported for the seat to file (class a). DELETE /packages/:id runs registry.uninstallPackage(id) before the protocol's org-scoped persistence call.
    • The protocol refuses any caller with no active organization (400 TENANT_SCOPE_REQUIRED, "Refusing to uninstall"). By then the package has already been removed from the live registry.
    • Measured through dispatch() with a real SchemaRegistry and the real ObjectStackProtocolImplementation: GET /packages/:id answered 200 before the refused DELETE and 404 after it, while the stored rows were kept.
    • It is not fixed here: it is a different defect class from this card, in code this diff does not touch.
  • ADR-0123 boundary, noted only. ADR-0123 D2 refuses a caller with no organization when they write tenant-scoped data through the security middleware. The package verbs run as protocol calls, so that caller's publish-drafts, discard-drafts, revert, rollback and adopt-orphans reach the env-wide package state.
    • That is the existing behaviour for every session with no active organization, and the landed /meta sibling behaves the same way.
    • Whether ADR-0123's write refusal should also cover metadata-package verbs is a question for the maintainer. It is not a finding here.

Generated by Claude Code

hotlong and others added 5 commits September 29, 2026 02:06
…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>
@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 28, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 3 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/automation/webhooks.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/kernel/cluster.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via HttpDispatcher (symbol, a top-level class))

⛔ 1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-5.mdx (via HttpDispatcher (symbol, a top-level class))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 26 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json fc0db22bcfdbdb778945317fc4de6dc46aab966d → packageMentionDocs.

Which tree this was computed on

This run read content/docs from c24f2d173ddd568bf75b723f92a9712417496b7d — the merge of head 1e0553b290fc64d78c097fedca212a4de8c668fe into base fc0db22bcfdbdb778945317fc4de6dc46aab966d, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs fc0db22bcfdbdb778945317fc4de6dc46aab966d → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 1e0553b290fc64d78c097fedca212a4de8c668fe
Local-runs: none

Inputs, and nothing else: card #20477 (body and all 3 comments — triage's grade 5875528399, the claim 5875591867, the os-dev report 5876166009); ruling B on #15409 read at its source (5550400167); PR #20491's body, its 5-file list and the net diff against main (merge-base 3062e5001, +479/−56); the check-runs on the head; and the source at the head, read with git show / git grep against the local object store (no checkout, no build, no test, no gate run). Every line number below is at the head.

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 judgments

The four questions the brief asks, judged against ruling B (5550400167: a session whose activeOrganizationId is not backed by a membership, under a wall-enforcing posture, resolves with no active organization) and triage's direction (5875528399: fix the one source; return what RestServer reads; no second vetting path; pins on both transports; the current member unchanged).

(1) Does resolveActiveOrganizationId now return exactly the vetted organization RestServer reads, for every caller? YES — right.

(2) Does any path reach a /packages site before the execution context is resolved? NONE — right.

  • dispatch() (http-dispatcher.ts:2489) runs, in order: root canonicalisation, the liveness carve-out (domainRegistry.resolveLiveness, which matches only a route registered liveness: true — that is /health at :729), then resolveRequestScope at :2584, then the auth gate, then project membership, then the domain resolve. /packages declares no liveness route.
  • The dispatcher plugin's explicit package mounts (dispatcher-plugin.ts:1348-1356) all call dispatcher.dispatch(...); the environment-scoped base (:1768) mounts the same registrar a second time. The declarative-endpoint fallback runs resolveRequestScope too and never reaches this domain.
  • HttpDispatcher.handlePackages (the public thin delegate, :2182) has zero non-test callers anywhere in the tree at the head; the domain body is reached through createPackagesDomain (packages.ts:218) from the registry.
  • When the identity step yields no context (an anonymous caller, or a non-store fault swallowed at :665-676 — a store fault re-raises as 503), handlePackagesRequest's FIRST statement (packages.ts:875-883) refuses through shouldDenyAnonymous before any of the nine sites. The nine sit under GET/POST/DELETE branches; the OPTIONS pass-through reaches none of them.

(3) Are a current member's and an anonymous caller's answers unchanged? YES — right.

  • Current member: the backed claim survives the resolver untouched, so authz.tenantId equals what the old raw getSession read returned. Pinned in the new file's controls describe, 9 doors × 2 transports. The member switched to an organization they belong to: pinned (reaches that one, never the left one). Anonymous: 401 ANONYMOUS_DENY before any protocol call, as before — pinned with state.calls empty. Single-posture deployments: the drop requires postureEnforcesWall, so nothing moves — as the changeset says.
  • The one population that moves besides the ex-member is an API-key caller: undefined before (the raw read found no session for a key) and the key's bound organization now. It is declared in the changeset body and the PR; it is the ruled shape ([decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring under isolated — the wall compares against the caller's own unvetted claim #15256 1A: a key IS its organization binding) and what RestServer already does. Not measured with a key — a declared narrowing, handled in ③.
  • The fault case the old docblock listed ("any failure reaching the auth service" answered undefined and the request went on org-less) does not widen: on that path executionContext.userId is also unset, and the domain's floor already refused it at 401 before this PR.

(4) Does "⛔ No second vetting path" hold? YES.

  • Five files, none under packages/core, packages/rest, packages/spec or plugin-auth (the claim's read-only surface). The new body compares nothing against a membership; it reads the value the one resolver vetted, through a helper that is a field read. @objectstack/rest was already a @objectstack/runtime dependency (runtime/package.json; domains/meta.ts:78 and domains/packages.ts:103 import from it at the head) and rest does not depend on runtime — no new edge, no cycle.

Each accept-set / public-surface change the diff implies:

  • http-dispatcher.ts — private resolveActiveOrganizationId body, raw session claim to vetted executionContext.tenantId; the docblock keeps its first line naming the method (the TSDoc re-point trap it recorded). Right.
  • domain-handler-registry.ts — DomainHandlerDeps.resolveActiveOrganizationId (the type is exported from the runtime index, index.ts:106): signature unchanged, contract text now states the vetted value and undefined for anonymous / no organization / dropped claim. Right; no type-surface change.
  • The nine doors' answer to a removed member whose session still names the left organization, under a walled posture, on both HTTP entries: the protocol is handed no organization (env-wide partition), and DELETE /packages/:id gets the protocol-17 refusal 400 TENANT_SCOPE_REQUIRED with nothing deleted. This is exactly triage's pin — "answers no organization (or the ruled refusal), on both transports". Right.
  • The reach measurement triage put first: the pin commit d8e6287dad carries only the test file, and its http-dispatcher.ts blob (5a14864eab4) equals BASE 851af0c27's — so H0 (20 of 20 subject cases red, 56 controls green; GET commits 200 with cmt_alpha, discard-drafts and DELETE 200 with the left organization's rows gone) was taken on the unmodified source, as the report says. The fix blob d6ad749b8ce is identical at 65534cfc7a, 2cc7a7cf82 and the head, so the merge of origin/main changed no touched file. Right.
  • New pin domains/packages-vetted-org-source.test.ts (406 lines): both entries (dispatch(); the plugin's mounts over a real socket on a LiteKernel + HonoServerPlugin, a declared runtime devDependency) × 9 doors × 4 callers, plus the claim-drop control (it reads the resolver's own Session organization claim dropped warn line naming org_alpha, so a green subject cannot come from a rig that never presented the stale claim) and the uninstall refusal with the store byte-identical afterwards. Real identity resolution under posture: 'isolated', one shared permission set so RBAC cannot separate the arms. Its relative specifiers stay inside the package (../http-dispatcher.js, ../dispatcher-plugin.js) and bare specifiers are outside the cross-package census by rule (跨包闸门看不见「裸包名导入 + 消费 spec schema」的测试 —— #17914 正是走这个盲区红了 main 并堵了合并队列,而修复把同一个陷阱重新装填在同一个文件上 #18236), so it stays in the local vitest project and vitest.repo-tests.json needs no entry. Right.
  • Migrated domains/packages-seed-apply-org-scope.test.ts: its organization now arrives on executionContext.tenantId, where dispatch() puts it, and the stubbed getSession — the retired source — is removed. Left in place, every measurement in that file would have silently run the one-rung branch. Right.
  • Two prose drifts outside the dev's write surface, neither a gate: rest's metaCallerOrganizationId docblock still names only /meta as its reader; spec's migration-17 rationale still calls this source "catch-wrapped". Both remain true in substance (the helper is the shared vetted read; an org-less call and a deliberate env-wide one are still indistinguishable at the call site). Noted; no action required for this card.

② Semver level

  • .changeset/20477-dispatcher-vetted-org-source.md: '@objectstack/runtime': patch. The diff publishes from @objectstack/runtime only (two src files, two test files, one changeset). A bug fix in a released package takes patch and never skip-changeset (AGENTS.md post-task checklist 3). Check Changeset is success on the head. Level matches what the diff publishes.
  • No spec key, export, config field or exported type signature is added, removed or renamed; DomainHandlerDeps.resolveActiveOrganizationId keeps its signature. No minor, no breaking marker, no migration text is owed.
  • Clause-②: no — right. No authorable accept set widens or narrows; the runtime is pulled back to the ruled contract (ruling B) and to RestServer parity. The API-key convergence is a fix at an HTTP door toward the [decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring under isolated — the wall compares against the caller's own unvetted claim #15256 1A shape, spelled out in the changeset body where an upgrading consumer reads it. The changeset body carries the Clause-②: no line, matching the PR body and the claim.

③ Boundary flags

  • open_questions: none. Nothing to answer.
  • Dev flag, class a, out of scope — the half-applied uninstall. Verified at the head: in packages.ts's DELETE branch, registry.uninstallPackage(id) runs before protocol.deletePackage, so the protocol's TENANT_SCOPE_REQUIRED refusal lands after the live registry is already emptied (a GET /packages/:id answers 200 before and 404 after, rows kept). Pre-existing for every session with no active organization; this PR moves the ex-member into that population instead of the left organization's, which is the ruled direction. Code this diff does not touch. ESCALATED: the seat files it as its own card, with the dev's dedupe words.
  • Dev flag — ADR-0123 D2 boundary. After the fix an org-less session's publish-drafts, discard-drafts, revert, rollback and adopt-orphans reach the env-wide package state, as they do for every org-less session today and as the landed /meta sibling does. The card's pin — "no organization (or the ruled refusal)" — is met. Whether ADR-0123's write refusal should also cover metadata-package verbs is the maintainer's question, not this card's. ESCALATED as a needs-user-decision question for the seat; not blocking.
  • Dev flag — the API-key arm is reasoned from source, not measured with a key. ANSWERED: the key arm is keyPrincipal.tenantId at the resolver (:392), refused whole when the membership ended (:454-463), and RestServer already scopes a key caller by ctx.tenantId; the changeset and PR declare the change. Acceptable without a pin here because the target shape is ruled ([decision · p0] an ex-member API key reads AND writes another organization's rows on the single-kernel wiring under isolated — the wall compares against the caller's own unvetted claim #15256 1A). If the seat wants it pinned, it is one more caller in the same rig.
  • Dev flag — verification ran UNLOCKED (no flock on this macOS host), declared verbatim in the PR body. ANSWERED: the head's check-runs are the gate verdicts; the local runs are the dev's evidence only.
  • Dev flag — check:dual-build-cjs-loads NOT MEASURED locally (PREREQUISITE NOT MET, no workspace dist). ANSWERED: the diff touches no manifest, export or build config; Build Core is success on the head; the repo gates run inside Lint & Repo Gates, in progress at my read — recorded, not inferred.
  • Deviations reported: the label-write rerun under with-fleet.sh --read (nothing written on the failed attempt); the attribution trailers — verified on all four branch commits (d8e6287dad, 65534cfc7a, 48462a10ea, 2cc7a7cf82): Claude-Session: plus Co-authored-by: Claude, the model-free pair AGENTS.md prescribes; two throwaway probes deleted — the file list is the five files and nothing else. ANSWERED.
  • Serial constraint: fix(rest, runtime): the dispatcher's /meta doors scope to the vetted organization, and its item read, book tree and list answer what RestServer answers (#20408) #20473 (the /meta sibling) landed at 5c7aa467, before this branch's merge-base 3062e5001; meta.ts at the head calls the source nowhere. ANSWERED.
  • Stop-on-breach surface: packages/core, packages/rest, packages/spec, plugin-auth untouched — verified by the file list.

Implemented-by: claude/issue-20477-dispatcher-vetted-org-source
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 28, 2026 18:49
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit 45f428d Sep 28, 2026
36 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20477-dispatcher-vetted-org-source branch September 28, 2026 19:06
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant