Skip to content

fix(runtime): DELETE /packages/:id refuses an org-less uninstall before it touches the registry (#20492) - #20514

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20492-uninstall-refuse-before-mutate
Sep 28, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-20492-uninstall-refuse-before-mutate

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #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 #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 #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 86b509c9da22 == HEAD, git diff HEAD empty. The trap reported RESTORE PROVEN: blob 86b509c9da22… == 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 (#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 #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 ([decision · p0] a SESSION whose activeOrganizationId points at a left organization reads AND writes that organization — measured through better-auth's own remove-member endpoint #15409 ruling B), the grants are re-resolved with no tenant. 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 [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 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

hotlong and others added 3 commits September 29, 2026 04:49
…y is touched

DELETE /packages/:id ran registry.uninstallPackage(id) and only then reached
deletePackage's organization-scope refusal, so a refused request (400
TENANT_SCOPE_REQUIRED) had already taken the package and its objects out of
the running process. The door now asks the same question up front, from the
one organization value it hands deletePackage, and only when the persisted
half will run.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
…, and scope the uninstall fixtures to an organization

New pins drive DELETE /packages/:id through dispatch() with real identity
resolution and a real SchemaRegistry: a removed member and a caller who never
selected an organization are refused 400 TENANT_SCOPE_REQUIRED with the
package still served, listed and registered and its stored rows untouched;
a member uninstalls as before. Fixtures whose allow-path callers carried no
organization now act in one, since the door refuses an org-less uninstall
first, as the persisted delete does.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/spec, touching 10 documentable anchor(s).

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

  • content/docs/api/client-sdk.mdx (via packages.uninstall (sdk, the route ledger binds it to DELETE /packages/:id))
  • content/docs/api/environment-routing.mdx (via /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/api/metadata-api.mdx (via /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/data-modeling/formulas.mdx (via /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/deployment/publish-and-preview.mdx (via /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/kernel/contracts/metadata-service.mdx (via /api/v1/packages/:id (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/permissions/permission-sets.mdx (via /api/v1/packages/:id (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/permissions/system-context.mdx (via handlePackagesRequest (symbol, a top-level function), /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/protocol/kernel/error-handling.mdx (via /api/v1/packages/:id (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))

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

  • content/docs/releases/v15.mdx (via /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/releases/v17/17-0.mdx (via /api/v1/packages/:id (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/releases/v17/17-4.mdx (via /api/v1/packages/:id (route, a path literal in a comment on a changed line), /packages/:id (route, a path literal in a comment on a changed line; a path literal in reason))
  • content/docs/releases/v17/17-5.mdx (via packages.get (sdk, the route ledger binds it to GET /packages/:id))

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
  • 2 name(s) were too generic to anchor anything (single lowercase words)
  • 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 — 142 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 9e9bb464170446bf993244d2b0058b935300a247 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from b1487a4df1cb60e271c359757660d389a4a0845e — the merge of head cadfe0b7ae566e941d05ab948026f498ef24c664 into base 9e9bb464170446bf993244d2b0058b935300a247, 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 b1487a4df1cb60e271c359757660d389a4a0845e && git checkout b1487a4df1cb60e271c359757660d389a4a0845e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9e9bb464170446bf993244d2b0058b935300a247 cadfe0b7ae566e941d05ab948026f498ef24c664 && git checkout -B drift-repro 9e9bb464170446bf993244d2b0058b935300a247 && git merge --no-ff cadfe0b7ae566e941d05ab948026f498ef24c664

node scripts/docs-audit/affected-docs.mjs --json 9e9bb464170446bf993244d2b0058b935300a247

⚠️ 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 9e9bb464170446bf993244d2b0058b935300a247 → 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: eca5d389a33c1479674dba46a49b08c1b3c6ba31
Local-runs: none

Inputs read: card #20492 (body and all 3 comments: triage grade 5877470267, claim 5878081156, os-dev-report 5878755305); PR #20514 body, its 8-file list, and the net diff against main at the head (605 lines: +443 / −15); the check-runs on the head. The base the checkout serves is 9449512a31, and its packages/runtime/src/domains/packages.ts blob is 17d9ae0411, the exact pre-image the diff names, so the surrounding code was read at the PR's own base. Nothing was built, run or re-run.

Check-runs on the head at 21:22 UTC (newest per name): 33 check-runs; 28 completed — 25 success, 3 skipped (Console Pin Gate, Build Docs, Packed-tarball smoke (opt-in)); 5 still in_progress — Test Core (1/6), Test Core (3/6), Test Core (4/6), Test Core (5/6), Lint & Repo Gates; 0 failed. Completed green include Build Core, Test Core (2/6), Test Core (6/6), TypeScript Type Check, Type Check · source gates / workspace / consumer gates / debt ledger, Dogfood Regression Gate (1/3, 2/3, 3/3 and the aggregate), Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Check Changeset, Check PR Size, Governed Surface Queue Guard, Flag docs affected by code changes, Check Documentation Links, and the three claim guards. The 5 running checks are recorded as running; no verdict is inferred for them. (An earlier read at 21:18 UTC saw 32 runs, 21 completed, 0 failed.)

① Derived judgments

Judged against the card's binding direction, triage's grade 5877470267: refuse before mutating, from the same organization source deletePackage reads, ⛔ no compensating re-install.

(1) Every path that reaches registry.uninstallPackage(id) has already passed the organization-scope refusal deletePackage would give — YES, right. The runtime's non-test source has exactly one call site of registry.uninstallPackage( (the DELETE /packages/:id branch of packages.ts, line 1957 at base). In the diff the new guard sits immediately before that call, behind the same predicate the persisted half uses (protocol && typeof protocol.deletePackage === 'function'), after requireManageMetadata (403) and requireWritablePackage (422), both unchanged. deletePackage (packages/metadata-protocol/src/protocol.ts, #7780) has two refusals: (a) organizationId together with allTenants: true, and (b) neither. This door never sends allTenants (its request is { packageId, organizationId?, keepData? }, unchanged by the diff), so (a) is unreachable from it and (b) reduces to "no vetted organization", which is exactly what requireUninstallOrganizationScope asks. When no persisted delete will run, deletePackage gives no refusal, so none is mirrored and the in-memory uninstall proceeds as before — that is triage's "when the persisted delete will refuse", not a wider gate. No compensating re-install anywhere in the diff: the refusal returns before anything is touched. Right.

(2) The organization value the door checks is the value it hands deletePackage — YES, right. One read: const organizationId = await deps.resolveActiveOrganizationId(_context) is hoisted above the guard, and the diff deletes the second read that used to sit inside the deletePackage try-block, so the call below uses the same binding. The protocol binding is likewise resolved once and reused. The source is the dispatcher's resolveActiveOrganizationId, which since #20491 returns metaCallerOrganizationId(context.executionContext) — the vetted tenantId (a typeof-guarded property read in packages/rest/src/meta-item-read-gate.ts, cannot throw), so a dropped claim resolves to undefined and is refused, and a member's organization is the one deletePackage scopes by. Moving this read out of the try-block moves no reachable behaviour, because it cannot throw; resolveProtocol (a deps.resolveService lookup) was already outside the try-block and merely runs earlier. Right.

(3) An admitted caller's uninstall behaves as before — YES, right. With an organization present the guard returns null; the sequence uninstallPackage → setPackageDisabled (best-effort) → deletePackage({ packageId, organizationId, keepData? }) → the #7557 PACKAGE_DELETE_PARTIAL / 404 / 200 envelope logic is byte-for-byte the base's; only the timing of two pure reads moved ahead of the mutation. The control pin asserts the whole chain: 200 with registryRemoved: true, deletePackage received { packageId, organizationId: 'org_alpha' }, the package and its object left the real SchemaRegistry, GET answers 404, the stored rows are gone. A host with no protocol, or one whose protocol lacks deletePackage, is untouched (guard skipped; pinned: org-less caller still uninstalls, 200). Right.

(4) The five edited existing test files only move allow-path callers into an organization; no assertion weakened — YES, right.

  • packages-capability-gate.test.ts: authed() / system() gain an optional tenantId; WriteCase gains an optional tenantId; only the DELETE /:id row carries org_a; the two allow-path its pass wc.tenantId. Every deny-path call (authed([]), authed(['studio.access','setup.access'])) is unchanged and still asserts 403 plus target-not-called. Assertions identical.
  • packages-read-delete-response-conformance.test.ts: adds a sys_member row for u_admin in org_acme and session.activeOrganizationId: 'org_acme' on getSession; the UninstallPackageApiResponseSchema assertions are untouched.
  • packages-readonly-gate.test.ts: admin() gains tenantId: 'org_acme'; system() is unchanged and its only DELETE is on the platform-scoped package, refused 422 by requireWritablePackage before the new guard; the "unknown id keeps its 404" case uses a dispatcher with no protocol, so the guard is skipped there. Assertions identical.
  • packages-uninstall-envelope.test.ts: authed() gains tenantId: 'org_acme'; every Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphaned sys_metadata rows #7557 envelope assertion unchanged.
  • packages-vetted-org-source.test.ts: the dropped-claim subject loop now excludes the deletePackage door (DOORS.filter), because the fact it asserted — "the protocol is handed no organization" — is no longer how that door behaves: the protocol is not asked at all. The door's dedicated pin keeps 400 TENANT_SCOPE_REQUIRED and state.store equals the seed, and ADDS state.calls equals [] — strictly stronger than the removed loop iteration (no protocol call at all implies the left organization's partition was neither written nor read into the answer). The controls loop still runs the DELETE door unfiltered (member → [org_alpha], exmember_beta → [org_beta], anonymous refused before any call). Right; re-judged, not weakened.

Other derived judgments.

  • requireUninstallOrganizationScope is a module-private function (no export); @objectstack/runtime gains no export, spec key, route or wire shape. Right.
  • Wire on the refused arm: same code TENANT_SCOPE_REQUIRED (ledger entry #7780 in packages/spec/src/api/error-code-ledger.zod.ts) and same httpStatus 400 as the protocol's own throw carried through errorFromThrown; details.code is promoted to error.code and httpStatus rides in the body by buildApiError (packages/runtime/src/error-envelope.ts), which the new pin asserts. Only the message sentence is the door's own (it names the HTTP caller's remedy and states nothing changed) and details.packageId is added — the same pattern requireWritablePackage set. Right.
  • ⛔ No isSystem bypass on the guard — matches the protocol, which refuses an org-less uninstall whoever asks; an org-less isSystem caller was refused before (after mutation) and is refused now (before). Pinned. Right.
  • The accept set is unchanged, arm by arm: org-less + deletePackage present → 400 TENANT_SCOPE_REQUIRED before and after (including an unknown id, which at base reached the protocol after uninstallPackage returned false and was refused there, and including isSystem); org-less + no persisted half → 200 before and after; organization present → unchanged; read-only package → 422 first, both; anonymous / no manage_metadata → refused first, both. Every request keeps its verdict, code and status; what changes is only that a refused request now leaves the registry, the listing, the GET, and the stored rows untouched. Right, and it is exactly the card.
  • New file packages-uninstall-refuse-before-mutate.test.ts: real identity resolution through dispatch() under isolated, a real SchemaRegistry with the package and one owned object, a protocol double that keeps rows and refuses an org-less request the way the real one does; for each refused population (dropped claim, never-selected) an answer pin and an untouched-afterwards pin; a claim-drop control; a member control; a no-persisted-half host; the isSystem arm; OS_HOME redirected to a temp dir for the control arm's state-file write. Right.
  • The dev's H3 ablation (guard moved back after uninstallPackage: the three "untouched afterwards" pins go red, the answer pins stay green) is the correct discriminating shape for this card — the response and the side effect separate exactly there. Read as the dev's claim; the head's Test Core shards are the gate.

② Semver level

.changeset/20492-uninstall-refuse-before-mutate.md declares '@objectstack/runtime': patch, is non-empty, and carries Clause-②: no; Check Changeset is success on the head. The diff publishes a behaviour fix with no new export, no spec key, no new route, no new error code, no changed status — patch is the right level, and skip-changeset would have been wrong (a consumer-visible fix ships). Clause-②: — the PR body, the changeset and the claim all say no, and the diff bears it out: a refusal that already existed moves earlier; no request enters or leaves the accept set. Right.

③ Boundary flags

  • open_questions: [] — nothing to answer.
  • Dev flag, H0 (a2 / a2b): a member removed from org_alpha who holds an operator-authored manage_metadata set granted scoped to org_alpha still passes requireManageMetadata, because resolveUserAuthzGrants keeps every org-scoped grant when no tenant is active. Seam packages/core → every runtime capability gate; the other env-wide /packages doors NOT MEASURED. Escalated: filed by the seat as [finding] resolveUserAuthzGrants keeps EVERY organization-scoped grant when no organization is active, so a member removed from an organization keeps that organization's capabilities (measured: manage_metadata passes on DELETE /packages/:id) #20515 (packages/core); out of this PR's read-only surface. This PR's door fix stands on its own regardless: whatever the capability gate admits, an org-less caller now changes nothing at this door, and the dropped claim (fix(runtime): the dispatcher's /packages doors scope to the vetted organization, not the raw session claim (#20477) #20491) already keeps that caller out of the left organization's rows. Not blocking. Note for the seat: triage's own p1/p0 rule (5877470267) said a requireManageMetadata pass by either population regrades to p0 — the measured pass needs a non-default org-scoped grant, so the regrade question is the seat's and triage's, not this record's, and does not alter the PR's correctness.
  • Dev flag, (b2) fixture-level: whether the real driver scopes the sys_position read for a tenant-less system context is NOT MEASURED. Answered: outside this card's direction; recorded in the PR; no action in this PR.
  • Dev flag, pre-existing OS_HOME real-home write in packages-capability-gate, packages-uninstall-envelope, packages-readonly-gate: Answered: pre-existing and not widened by this PR (the allow-path is exactly where it was); the new file redirects OS_HOME. Carrier none stands; a fixture-hygiene item, not blocking.
  • Dev flag, .changeset/20477-dispatcher-vetted-org-source.md "deletes nothing": Answered: no edit needed; unreleased, and the sentence becomes wholly true when this PR lands.
  • Deviations (4): label-write re-run under the fleet gate with matching read-back; H0 probe file untracked and never committed (verified: the 8-file list carries no zz-h0-probe file); AGENTS.md model-free trailer pair governs over the harness reminder; verify ran UNLOCKED with the narrowing declared in the PR body (this host has no flock) — the head's check-runs are the serialized answer. All answered; none touches the diff.
  • Serial constraints: the claim reports none of the 10 open PRs touched packages.ts, registry.ts or protocol.ts; the head's "No other open PR may claim the same single-writer path" and "same issue" guards are success. Cleared.
  • Read-only boundary: the diff touches only packages/runtime/src/domains/** and one changeset; packages/objectql, packages/metadata-protocol, packages/core, packages/spec untouched. Held.

Implemented-by: claude/issue-20492-uninstall-refuse-before-mutate
Reviewed-by: local_1d2a197c-c20e-4e90-9be8-413d4d432289

VERDICT: PASS

hotlong and others added 2 commits September 29, 2026 05:29
…stamp as a provenance waiver

The DELETE /packages/:id door now answers deletePackage's organization-scope
refusal itself, before the registry is touched, with the code the protocol
registers. The door mirrors the producer; it is not a second emitter, so the
row stays under @objectstack/metadata-protocol and the stamp is waived.

Claude-Session: https://claude.ai/code/session_local_1d2a197c-c20e-4e90-9be8-413d4d432289
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: cadfe0b7ae566e941d05ab948026f498ef24c664
Local-runs: none

Delta review of patch round 1. Inputs read: card #20492 (body and all 6 comments: triage grade 5877470267, claim 5878081156 as amended, os-dev-report round 0 5878755305, REWORK 5879035901, os-dev-report round 1 5879429651, triage regrade 5879439577); PR #20514 body including the seat-written ## Patch round 1 section, its 9-file list, its 5 commits, the net diff against main at the head (629 lines: +458 / −15), and the two PR comments (docs-drift 5878722033, round-0 record 5879026678); the check-runs on the head. Compare reads via REST: 9449512a31...9e9bb46417 (main between the two bases), 9449512a31...eca5d389a3 (round 0), 9e9bb46417...32da13cbe3 (the merge against the new base), eca5d389a3...cadfe0b7ae, the commit cadfe0b7ae alone, and 9e9bb46417...0bbe4005e8 (main's tip at read). Surrounding source was read at the checkout 9449512a31, which serves every pre-image blob the diff names (ledger 4b551580a0, packages.ts 17d9ae0411, the five edited tests) and whose protocol.ts / registry.ts main has not touched since. Nothing was built, tested or gate-run locally; the record template was printed from scripts/pm/record-recognisers.mjs --template as the brief prescribes.

Check-runs on the head at 21:56 UTC: 42 runs, 35 distinct names, every one completed at read — 30 success, 5 skipped (Auto Label, Check PR Size, Build Docs, Console Pin Gate, Packed-tarball smoke (opt-in)), 0 failure, 0 in progress. Lint & Repo Gates (109144731678), the check that was red on eca5d389a3, is success (completed 21:43 UTC). Also success: Build Core, TypeScript Type Check, Type Check · source gates / workspace / consumer gates / debt ledger, Test Core (1/6 … 6/6) and its aggregate, Dogfood Regression Gate (1/3 … 3/3) and its aggregate, Dogfood Verify CLI, Temporal Conformance (live PG + MySQL), Governed Surface Queue Guard, Check Changeset, Spec property liveness, Flag docs affected by code changes, Check Documentation Links, filter, and the three claim guards plus "Part-of PR must not also close its card". Seven names ran twice (a second trigger at 21:54 UTC, after the seat's body edit): Check Changeset and the four claim/part-of guards were success both times; Auto Label and Check PR Size were success first and skipped on the rerun. Newest run per name is what the tally above reads. The PR is mergeable: clean.

① Derived judgments

The delta. Beyond eca5d389a3 the head carries three commits: 9e9bb46417 (main's own: spec migration guidance, 39 files, none on this PR's paths), the merge 32da13cbe3, and the fix cadfe0b7ae.

  • The merge of origin/main — clean, right. 9e9bb46417...32da13cbe3 lists exactly round 0's 8 files with blobs byte-identical to 9449512a31...eca5d389a3 (packages.ts 86b509c9da22, the changeset 39248fe46a84, the five edited tests, the new test 89108401b48d): the merge resolved nothing by hand. Main's one commit between the bases touches neither packages.ts, protocol.ts, registry.ts nor the ledger. Main's tip at read is 0bbe4005e8, one commit past the merged base (qa scenario requires skip: cli / core qa / spec qa, 25 files), again none on this PR's paths or its read-only surface — so the net diff against main at read is the PR diff.
  • The fix commit — exactly the REWORK's one change, right. cadfe0b7ae touches 2 files, +15 / −0: 12 lines appending ONE element to PROVENANCE_WAIVERS in packages/spec/src/api/error-code-ledger.zod.ts (package: '@objectstack/runtime', code: 'TENANT_SCOPE_REQUIRED', registeredUnder: '@objectstack/metadata-protocol'), and 3 lines in .changeset/20492-uninstall-refuse-before-mutate.md ('@objectstack/spec': patch, a blank line, one bullet). No other packages/spec/** path in the PR's diff (the other spec paths in eca5d389a3...cadfe0b7ae are main's 9e9bb46417). ⛔ The code was NOT added under @objectstack/runtime's owner key — the seat's refusal held; TENANT_SCOPE_REQUIRED is listed once, under @objectstack/metadata-protocol (ledger line 629, #7780).

Clause-② path limb on the ledger file.

  • (a) Registered union and ErrorCode unchanged — YES, right. The only ledger hunk is at line 1762+, inside the PROVENANCE_WAIVERS array literal (readonly ProvenanceWaiver[]). No owner-key row is added or moved, so the union the ledger derives and the ErrorCode type are the base's. Had a runtime row been added beside the waiver, the gate's dead-weight direction would have refused it; the dev did not.
  • (b) Widens any accept set or public surface — NO, right, with the grain named. PROVENANCE_WAIVERS is an exported const of @objectstack/spec (re-exported by src/api/index.ts; api-surface/api.json lists PROVENANCE_WAIVERS (const) by name and kind, not by element), so the export set and every type are unchanged; the value gains one element. Its only reader is the provenance gate, which writes nothing (check-generated.ts records it as "no artifact"), and the AUTO-GENERATED page content/docs/references/api/error-code-ledger.mdx renders the union and the schemas, not the waiver rows — no generated file is owed. On the wire nothing moves: the door's refused and admitted arms are exactly round 0's. The one thing the entry does widen is the GATE's admission for the pair @objectstack/runtime × TENANT_SCOPE_REQUIRED — a waiver admits the pair, not the site, so a second runtime stamp of that code would ride the same entry. That is the mechanism's designed grain (the FLOW_* entries carry it too), the gate's third direction ratchets the entry out when its site goes, and at this head there is exactly one runtime stamp site (packages.ts:468, the objlit inside requireUninstallOrganizationScope; at base the only stamp sites in packages/** non-test source are protocol.ts:19262 and :19277). Not a widening this PR invents.
  • (c) The reason is true of this head's code — YES, right. Read against the head: the door "mirrors the producer's refusal; it is not a second emitter" — deletePackage keeps both of its throws and is still called on the admitted arm. "Asks deletePackage's organization-scope question BEFORE registry.uninstallPackage" — the guard's short-circuit return sits in the hunk @@ -1954,6 +2013,27 @@ immediately above registry.uninstallPackage(id). "Answers with the code deletePackage refuses with (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)" — TENANT_SCOPE_REQUIRED, 400, the ledger row's own citation. "A refused uninstall changes nothing ([finding] DELETE /packages/:id through the dispatcher removes the package from the live registry, then refuses with TENANT_SCOPE_REQUIRED: a 400 that leaves the uninstall half applied #20492)" — the return precedes every mutation. "The door never sends allTenants" — the request it builds is { packageId, organizationId, keepData? }, unchanged. "Its condition is exactly the producer's 'no organization'" — deletePackage's refusals are its first executable statements, (a) organizationId && allTenants === true, (b) !organizationId && allTenants !== true; with no allTenants (a) is unreachable and (b) is the truthiness test !organizationId, which is the guard's if (organizationId) return null read the other way (an empty string falls on the same side at both). "The protocol keeps its own refusal as the second line and stays the registered emitter" — true. The shape matches the neighbouring entries: class sentence, site, mechanism, citations.
  • (d) Spec changeset level — patch, right. One element in an exported table that only a repo gate reads; no schema, union, export, route or wire shape. Precedent in the tree: .changeset/16160-retire-system-write-organization-provenance-waiver.md graded a waiver-only ledger change '@objectstack/spec': patch, Clause-②: no. minor would announce a capability no consumer can observe; omitting the line would ship a changed @objectstack/spec dist unannounced. Check Changeset is success on the head, twice. The bullet's text ("gains one entry … the registered code union and ErrorCode are unchanged") is true of the diff.

The four questions, re-stated against this head. The runtime half is blob-identical to round 0 (packages.ts 86b509c9da22, the five tests, the new test), and the round-0 findings were re-read on this diff.

  • (1) Every path that reaches registry.uninstallPackage(id) has already passed the organization-scope refusal deletePackage would give — YES, right. @objectstack/runtime's non-test source has exactly one call site of registry.uninstallPackage( (the DELETE /packages/:id branch). At the head the branch runs requireManageMetadata (403) → requireWritablePackage (422) → resolveProtocol and ONE organization read → when protocol.deletePackage is a function, requireUninstallOrganizationScope (returns 400 TENANT_SCOPE_REQUIRED before anything is touched) → registry.uninstallPackage(id). Nothing precedes deletePackage's two refusals inside the protocol, and the door's predicate is (b) exactly, as judged in (c). When no persisted delete will run there is no refusal to mirror and the in-memory uninstall proceeds — triage's "when the persisted delete will refuse", not a wider gate. ⛔ No compensating re-install anywhere in the diff.
  • (2) The organization value the door checks is the value it hands deletePackage — YES, right. const organizationId = await deps.resolveActiveOrganizationId(_context) is read once above the guard; the second read that used to sit inside the deletePackage try-block is deleted (diff line −605); the deletePackage({ packageId: id, organizationId, … }) call below uses that binding. The source is the dispatcher's resolveActiveOrganizationId → metaCallerOrganizationId(context.executionContext) — the vetted tenantId (fix(runtime): the dispatcher's /packages doors scope to the vetted organization, not the raw session claim (#20477) #20491), a typeof-guarded property read that cannot throw. resolveProtocol → deps.resolveService, a lookup whose steps swallow their own failures and fall through; running it earlier changes no reachable outcome.
  • (3) An admitted caller's uninstall behaves as before — YES, right. With an organization the guard returns null; uninstallPackage → setPackageDisabled → deletePackage({ packageId, organizationId, keepData? }) → the Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphaned sys_metadata rows #7557 PACKAGE_DELETE_PARTIAL / 404 / 200 envelope logic is byte-for-byte the base's; only the timing of two pure reads moved. The control pin asserts the chain end to end (200, registryRemoved: true, deletePackage received { packageId, organizationId: 'org_alpha' }, package and object gone from the real SchemaRegistry, GET 404, stored rows removed). A host with no deletePackage skips the guard and still uninstalls an org-less caller (pinned).
  • (4) The five edited existing test files only move allow-path callers into an organization; no assertion weakened — YES, right. Re-read at this head: packages-capability-gate.test.ts — optional tenantId on authed(), system() and WriteCase; only the DELETE /:id row carries org_a; every deny-path call and every expect unchanged. packages-read-delete-response-conformance.test.ts — a sys_member row for u_admin in org_acme and session.activeOrganizationId: 'org_acme'; assertions untouched. packages-readonly-gate.test.ts — admin() gains tenantId: 'org_acme'; system() unchanged; assertions untouched. packages-uninstall-envelope.test.ts — authed() gains tenantId: 'org_acme'; every Package disable and uninstall never reach the metadata/data layer: a disabled package's objects still serve rows, and uninstall leaves 7 orphaned sys_metadata rows #7557 assertion unchanged. packages-vetted-org-source.test.ts — the dropped-claim loop excludes the deletePackage door because its old fact ("the protocol is handed no organization") is no longer how the door behaves (the protocol is not asked at all); the door's dedicated pin keeps 400 TENANT_SCOPE_REQUIRED and state.store equals the seed and ADDS state.calls equals [] — strictly stronger. The controls loop still runs the DELETE door unfiltered.

Other derived judgments. requireUninstallOrganizationScope is module-private; @objectstack/runtime gains no export, spec key, route or wire shape. The refused arm's envelope is unchanged from round 0 (code TENANT_SCOPE_REQUIRED, httpStatus 400, details.packageId; message the door's own, as requireWritablePackage's is). No isSystem bypass, matching the protocol; pinned. Triage's pins (5877470267: an org-less caller on the dispatcher is refused and GET /packages/:id still answers 200 with the listing unchanged; a member uninstalls as before) are exactly what packages-uninstall-refuse-before-mutate.test.ts asserts through dispatch() with real identity resolution and a real SchemaRegistry. The accept set is unchanged arm by arm; what changes is only that a refused request leaves the registry, the listing, the GET and the stored rows untouched — the card.

The PR body's ## Patch round 1 section — true of this head. Its "why" is the REWORK's quoted red (check:error-code-provenance, the objlit at packages.ts:468), which the diff bears out. Its "what" is exactly commit cadfe0b7ae's two files, and "nothing else under packages/spec/**" and "the registered union and ErrorCode are unchanged" are true of the diff; the claim amendment it cites is on the card. "CI: 35/35 completed, 0 red" matches this read (35 names, 42 runs with the 7 reruns, 0 failure). The local counts (330 sites / 313 listed / 17 waived / 10 waivers live — consistent with 9 existing entries plus this one; 87 derived families run) and the host-only reds (pnpm -s under the pnpm v11 shim; the liveness JSON truncation, both reproduced on clean origin/main) are the dev's report, not re-run here; the head's green Lint & Repo Gates is the gate verdict. One wording note for the seat, not a falsity: the ## Verification heading still reads "final head eca5d389a3" — that section describes round 0's runs and the Patch round 1 heading names the final head, so "final" there is stale, not wrong.

② Semver level

.changeset/20492-uninstall-refuse-before-mutate.md declares '@objectstack/runtime': patch and '@objectstack/spec': patch, is non-empty, and carries Clause-②: no; Check Changeset is success on the head (both runs). The diff publishes a behaviour fix with no new export, spec key, route, error code or status (runtime), and one element in an exported gate-read table with no type, union or export change (spec) — patch is the right level for both, skip-changeset would have been wrong (both packages ship a consumer-visible change), and minor would overstate the spec half. Clause-②: — the PR body, the changeset and the claim all say no, and the diff bears it out on both limbs: a refusal that already existed moves earlier, and a waiver adds no code to the registered union and widens no accept set. Right.

③ Boundary flags

  • open_questions: [] in both rounds — nothing to answer.
  • Dev flag, H0 (a2 / a2b): a removed member holding an org-scoped manage_metadata grant passes requireManageMetadata (packages/core, resolveUserAuthzGrants). Escalated: filed by the seat as [finding] resolveUserAuthzGrants keeps EVERY organization-scoped grant when no organization is active, so a member removed from an organization keeps that organization's capabilities (measured: manage_metadata passes on DELETE /packages/:id) #20515 (packages/core, p0), and triage regraded this card p1 → p0 on it (5879439577) — queue order only, the direction unchanged. Out of this PR's read-only surface; this PR's door fix stands on its own: whatever the capability gate admits, an org-less caller now changes nothing at this door. Not blocking.
  • Dev flag, round 1: the family that should have caught the round-0 red (check:error-code-provenance scored silent for a runtime-only diff; the dev names the change-KIND lead that fired check:dispatcher-error-vocabulary as the derivation it should ride). Escalated to the seat as a scripts/pm/dispatch-gates.mjs derivation note; not this PR's surface, not blocking.
  • Dev flags, round 1, host-level: the spec liveness test's JSON truncation at about 64 KB, and the global pnpm v11 shim rejecting pnpm -s (check:merge-driver, the check:generated aggregate) — both reproduced by the dev on a clean origin/main. Answered: host configuration, carrier none; the head's Lint & Repo Gates is green, and the diff carries no scripts/ or .githooks/ change (verified on the 9-file list).
  • Round-0 flags, unchanged: (b2) fixture-level NOT MEASURED against a real driver — outside the card's direction; the pre-existing real-home OS_HOME write in three fixtures — not widened, the new file redirects OS_HOME; the [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 changeset's "deletes nothing" — becomes wholly true when this lands, no edit. All answered.
  • Deviations, round 1 (5): PR body not patched by the dev — the seat wrote the section, judged true above; a diagnostic WRAP mutation of scripts/git-merge-regen.mjs with proven restore — the diff carries no scripts/ path; a detached comparison worktree created and removed; spec typecheck at the ratchet's pinned 6144 ceiling; commit trailers the AGENTS.md model-free pair — the five commit messages confirm (Claude-Session plus Co-authored-by: Claude; the two merge commits carry git's default message). All answered; none touches the diff.
  • Read-only boundary: the claim (amended in place) admits exactly one packages/spec/** file and one entry; the diff touches exactly that. packages/objectql, packages/metadata-protocol, packages/core untouched. Held.
  • Serial constraints: the claim guards ("same issue", "same single-writer path", "card must claim this branch") are success on the head, both runs. Cleared.
  • Docs-drift comment (5878722033): advisory, not a gate; it lists hand-written pages naming /packages/:id by path literal. The PR changes an ordering behind an unchanged wire, so no doc edit is owed by this contract; the seat may schedule a re-verification separately.

Implemented-by: claude/issue-20492-uninstall-refuse-before-mutate
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 22:08
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 28, 2026
Merged via the queue into main with commit d1c01ff Sep 28, 2026
44 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20492-uninstall-refuse-before-mutate branch September 28, 2026 23:02
veigajoao pushed a commit to veigajoao/objectstack that referenced this pull request Sep 29, 2026
…zation applies only global grants (objectstack-ai#20515) (objectstack-ai#20540)

Fixes objectstack-ai#20515
Clause-②: yes (narrowing)

## What changed

`resolveUserAuthzGrants`
(`packages/core/src/security/resolve-authz-context.ts`) now states one
rule, once, as the module-private predicate `grantAppliesInTenant`: a
grant row with no organization is global and applies everywhere; a row
scoped to an organization applies only while that organization is the
active tenant. With no active organization, only the global grants
apply. There is no "every organization" option and no keep-all fallback.

Three sites ask the predicate:

- **§4** `sys_user_position` (the triage's `:802`).
- **§6** `sys_user_permission_set` (the triage's `:829`).
- **§6a** the `sys_position` rows whose bound permission sets the
resolver collects. This one is a **declared deviation** from the claim's
two sites; see H6 below for the measurement that put it here. With a
tenant it is a no-op, because the driver's tenant scope already returned
only that organization's rows and the organization-less ones.

The §4 and §6 comments, which already stated this rule, are now true. §3
(`sys_member`) is not edited.

`@objectstack/plugin-security`: `buildContextForUser(ql, userId, nowMs?,
tenantId?)` takes the organization to resolve in (H3):

- `resolveDelegatorContext` resolves the on-behalf-of delegator in the
live principal's organization. That is an **enforcement** input: the D10
intersection.
- `explainAccessForCaller` resolves an explained user in the caller's
organization.

No second check was added to `requireManageMetadata` or to any other
door. The `plugin-sharing` `adminOrgScope` guard is untouched.

**Not in this card, per triage:** revoking custom organization-scoped
grants when a member is removed. Once this rule holds, those grants no
longer apply.

## H0: the defect at the public door, before and after

These readings use the objectstack-ai#20492 rig (`dispatch()` with real identity
resolution, `resolveRequestScope` into `resolveExecutionContext` into
`resolveAuthzContext`, under an `isolated` posture). The base is
unmodified `397572ed5`, which already includes PR objectstack-ai#20514. The "after"
column is the committed pin file
`packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts`:
every cell of it has an arm there, and the file is green at `4e3e4f5e4`.
Patch round 1 added the three arms that were probe-only readings at
`c9e6463ce`: the control at `PATCH disable`, the platform admin with
`org_alpha` active at `PATCH disable`, and the org-less no-grant row.

The gate is observable at two doors:

- **`PATCH /packages/:id/disable`** is the door where the capability
gate's effect is fully visible. It asks no organization, so a caller who
passes the gate switches the package off for the whole environment
(200). A caller refused by the gate gets 403.
- **`DELETE /packages/:id`**: since PR objectstack-ai#20514, an org-less caller who
passes the gate reaches the door's own organization check (400
`TENANT_SCOPE_REQUIRED`). A caller refused by the gate gets 403.

| Arm (every session names `org_alpha`) | DELETE, base | DELETE, after |
PATCH disable, base | PATCH disable, after |
|---|---|---|---|---|
| removed from `org_alpha`, still in `org_beta`, holds an
`org_alpha`-scoped `manage_metadata` set (a2) | 400
`TENANT_SCOPE_REQUIRED` (gate passed) | **403 `PERMISSION_DENIED`** |
**200 (package disabled)** | **403** |
| same, no membership left anywhere (a2b) | 400 (gate passed) | **403**
| **200** | **403** |
| control: current `org_alpha` member, same grant, `org_alpha` active |
200 | 200 | 200 | 200 |
| removed member holding the same set **globally** | 400 | 400
(unchanged) | 200 | 200 (unchanged) |
| platform admin (unscoped `admin_full_access`), `org_alpha` active |
200 | 200 | 200 | 200 |
| platform admin, no active organization | 400 | 400 (unchanged) | 200 |
200 (unchanged) |
| org-less user with no grant | 403 | 403 | 403 | 403 |

The base readings come from a throwaway probe at `397572ed5`; the first
"after" reading was taken at `514e681cd`, and the committed file
reproduces every "after" cell. The committed file adds a third
removed-member arm: `org_alpha` bound the set to **its own copy of
`org_member`**, and the member is still an `org_member` in `org_beta`.
That arm is refused 403 on both doors. With the §6a predicate ablated it
passes the gate (see Verification).

## H1: census of the no-tenant callers (source, `packages/**`, at
`c9e6463ce`)

| Caller | Tenant it passes | Class | What it loses under the rule |
Right? |
|---|---|---|---|---|
| `resolveAuthzContext` first resolution
(`resolve-authz-context.ts:428`) | the API key's
`active_organization_id`, else the session's `activeOrganizationId` |
sometimes none | every organization-scoped §4, §6 and §6a grant, when
the key or session names no organization | Yes, per ruling. A request
with no active organization acts in none. Most exposed are
`group`-posture principals with no active organization: their data wall
still spans every member organization, but their organization-scoped
grants now need that organization active. |
| `resolveAuthzContext` dropped-claim re-resolution (`:548`) | none, by
construction | never | the left organization's grants: **the defect**.
It also loses every other organization's scoped grants. | Yes. This is
the fix. |
| `hasPlatformAdminStanding` (`:1184`, `{ nowMs }` only) | none | never
| nothing. `PLATFORM_ADMIN` derives only from the unscoped
`admin_full_access` user grant or the declared-administrator config
(H2). | Yes |
| plugin-auth `customSession` (`auth-manager.ts:3987`) |
`activeOrganizationId ?? undefined` | sometimes | `positions[]` loses
organization-scoped `sys_user_position` names when no organization is
active. `isPlatformAdmin` is unchanged. | Yes. Its docblock already
scopes the payload to the active organization. |
| plugin-auth `isPlatformAdminUserId` (`:7009`), through
`hasPlatformAdminStanding` | none | never | nothing | Yes |
| plugin-hono-server `makeExecutionContextResolver`
(`current-user-endpoints.ts:424`) | `activeOrganizationId ?? undefined`
| sometimes | same as `resolveAuthzContext` | Yes |
| explainer `buildContextForUser` (`explain-engine.ts:581`) | **was
none; now the caller's choice** | H3 | see H3 | changed |
| `resolveDelegatorContext` into `buildContextForUser`
(`explain-engine.ts:686`), an **enforcement** path | was none (the live
tenant was stamped on afterwards); now the live principal's tenant |
always, when the live principal has one | **Before:** every
organization's delegator grants. **Under the rule with no caller
change:** only global grants, a regression for an OAuth agent acting for
an organization admin. **Now:** the delegator's grants in the
organization the request runs in. | fixed here |
| `explainAccessForCaller` into `buildContextForUser`
(`security-plugin.ts:4690`) | was none; now the caller's tenant |
sometimes | grants the explained user holds in organizations other than
the caller's | changed (H3) |
| invitation placement `assertIssuable` (`invitation-placement.ts:153`)
| the invitation's `organizationId ?? undefined` | always in practice (a
better-auth invitation belongs to an organization) | nothing in practice
| Yes |
| automation `runAs:'user'` (`service-automation/src/plugin.ts:909`) |
the triggering run's `tenantId` | sometimes | organization-scoped grants
for a run triggered with no organization | Yes. The run matches the
user's own direct request in that state. |
| transports calling `resolveAuthzContext`: `rest-server.ts:2968`,
`runtime/src/security/resolve-execution-context.ts:217`,
`sharing-plugin.ts:945`, `marketplace-install-local-plugin.ts:1806`,
`service-datasource/admin-routes.ts:480`,
`service-settings/settings-service-plugin.ts:296`,
`service-storage/storage-service-plugin.ts:1152` | session or API key |
sometimes | same as the first row | Yes |
| MCP stdio (`mcp/src/plugin.ts:164`), API key only | the key's
organization | sometimes (an org-less key, allowed under `single` /
`group`) | organization-scoped grants for an org-less key | Yes, per
ruling |

**No caller needs every organization's grants**, so **no option was
added** to `ResolveUserAuthzGrantsOptions` and the grants-cache key is
unchanged (H4 moot). `tenantId` already keys cache entries; a new pin
checks, with the cache on, that an `org_a` entry and a no-tenant entry
never serve each other. `Clause-②` stays `yes (narrowing)`: the `yes`
arm is now carried by `buildContextForUser`'s new optional parameter, a
public widening of `@objectstack/plugin-security`.

## H2: platform-admin standing is global (measured)

This was measured on a real `SqlDriver` (better-sqlite3) with the
shipped `bootstrapPlatformAdmin`, under `single`, after the fix:

- the minted `admin_full_access` row reads `organization_id: null`;
- `hasPlatformAdminStanding` answers `true`;
- an org-less resolution answers posture `PLATFORM_ADMIN`, with
`manage_metadata` held.

`hasPlatformAdminStanding` loses nothing, so it needs no answer of its
own. Core pins cover both polarities: the unscoped grant is
`PLATFORM_ADMIN` with and without a tenant; an organization-scoped
`admin_full_access` confers no standing with or without that tenant.

## H3: the explainer takes a tenant, (b), not the triage's (a)

The explainer's own contract decided it. The module header says the
report "can never drift from enforcement". `buildContextForUser`'s
docblock says it is called "with the exact arguments" enforcement uses.
Its parity suite asserts, field by field, that it equals
`resolveUserAuthzGrants`.

Option (a), an explicit every-organization option, breaks that parity by
construction. It would also have kept every organization's grants in an
**enforcement** path, because `resolveDelegatorContext` builds the D10
delegator leg through `buildContextForUser`. So `buildContextForUser`
takes the organization to resolve in, and each caller names it.

Pinned in `explain-engine.test.ts`, `security-plugin.test.ts` and the
parity suite, which now runs two cases in `org1` on both sides.

**What the explainer shows for the removed member, against
enforcement:**

| Explained by | Explain shows | Enforcement (the member's own session:
claim dropped, no tenant) | Agree? |
|---|---|---|---|
| a caller with no active organization, or in `org_beta` | global grants
only; no `manage_metadata` | refused | yes |
| an admin in `org_alpha` (the left organization) | the
`org_alpha`-scoped `manage_metadata` set | refused | **no**: explain
overstates |
| (before this PR) anyone | every organization's grants | passed (the
defect) | yes, both wrong |

The disagreement in the second row is the objectstack-ai#20431 class (explain ≠
enforce). It is reported, not fixed here. The explain API resolves the
explained user in the caller's organization, and it does not model the
session arm's membership check. Explain-of-another-user's record-level
Layer 0 still evaluates with no active organization; that is
pre-existing and unchanged.

## H5: section 3, measured

Measured on a real `SqlDriver` over the shipped per-organization
built-in catalog (`bootstrapBuiltinRoles` for `org_jia` and `org_yi`).
The user is a current member of `org_jia` (`admin`) and `org_yi`
(`member`), with no organization active.

- §3 projects **both** organizations' roles: `positions: [org_admin,
org_member, everyone]`.
- **The gate that read them was §6a.** At the pre-§6a state, those names
pulled every organization's copy of `org_admin`, `org_member` and
`everyone`, and their bindings. A member removed from `org_jia` and
still in `org_yi` kept `org_jia`'s `org_member`-bound `manage_metadata`
set with no tenant. A user with no membership at all picked up
`org_jia`'s `everyone` binding.
- **After the §6a predicate:** the same resolutions carry no
organization's bindings. With `org_jia` active, only `org_jia`'s apply.

The verdict on §3 itself: **not the same class once §6a holds**, and not
edited. Its rows are the user's own current memberships, not grant rows,
and every capability a role name can confer now arrives through
organization-scoped rows that answer the rule. What remains is display:
`positions[]` with no active organization names every membership's role.

## H6: the smallest fix that satisfies the stated rule

Sections 4 and 6 alone did not satisfy the rule. The real-driver
measurement in H5 shows the removed member keeping the left
organization's `manage_metadata` through §6a after the §4 and §6 fix, so
§6a asks the same predicate.

- It is written as the **grant rule**, not as a second tenant wall. The
driver's `applyTenantScope` stays the one spelling of the wall, as the
objectstack-ai#10103 comment requires.
- With a tenant it filters nothing: the driver already returned only
that organization's rows and the organization-less ones.

## Verification (head `4e3e4f5e4`, after merging `origin/main`
`288611e3e` with a true merge; the first round's merge was `31d281d3b`)

- **Build:** `turbo run build --filter='./packages/*'
--filter='./packages/*/*' --concurrency=2`: 71/71.
- **Tests at `4e3e4f5e4`:**
- **`@objectstack/dogfood`, the whole suite:** `vitest run --shard=1/3`,
`2/3` and `3/3`, all exit 0. 47 files and 378 passed; 47 files and 321
passed, 1 skipped; 46 files and 451 passed, 1 file and 2 tests skipped.
That is 141 files and 1150 tests passed.
- `sharing-rule-org-less-caller.dogfood.test.ts` alone: 16 passed. That
is the 13 it had, plus 3 for the organization-scoped persona.
- `@objectstack/core` `vitest run --project local`: 56 files, 1518
passed.
- `@objectstack/plugin-security` `explain-engine`, `security-plugin` and
`per-organization-catalog`: 361 passed.
- `@objectstack/runtime` the door file (13 pins) plus
`packages-uninstall-refuse-before-mutate`: 21 passed.
- `@objectstack/plugin-sharing` `sharing-rule-positions-name-authority`:
7 passed.
- Typecheck: `@objectstack/runtime` and `@objectstack/dogfood` exit 0;
the runtime test-typecheck ledger is unchanged.
- **Full suites at `c9e6463ce`'s source, before the first merge** (the
two `origin/main` merges since then brought main's own `packages/rest`
changes, `rest-server.ts`, `meta-item-read-gate.ts` and four test files,
and main's `packages/runtime` test
`meta-list-projection-parity.test.ts`; this PR's patch round moved its
runtime door-pin file. For those suites, the verdict is the head's `Test
Core` runs):

  | Package | Files | Tests |
  |---|---|---|
  | `plugin-security` | 143 | 3052 passed, 16 skipped |
  | `runtime` local | 287 | 4181 passed |
  | `rest` local | 221 | 4231 passed |
  | `plugin-auth` | 115 | 2464 |
  | `plugin-hono-server` | 27 | 324 |
  | `service-automation` | 149 | 1837 |
  | `plugin-sharing` | 37 | 913 |
  | `plugin-approvals` | 51 | 791 |
  | `organizations` | 8 | 108 |
  | `mcp` | 32 | 344 |
  | `cloud-connection` | 30 | 397 |
  | `service-datasource` | 34 | 693 |
  | `service-settings` | 33 | 584 |
  | `service-storage` | 40 | 627 |
  | `client` | 50 | 641 |

All green. The consumer direction is the downstream importers of
`@objectstack/core` named in the H1 census, plus their own consumers
`plugin-approvals` and `client`.

This table omitted `@objectstack/dogfood` in the first round, and its
shard 3/3 was red on `c9e6463ce`. The whole dogfood suite is the first
bullet above.
- **Typecheck:** `@objectstack/core`, `@objectstack/plugin-security` and
`@objectstack/runtime` `typecheck` all exit 0. Each
`check:test-typecheck` is OK with its debt ledger unchanged.
- **Fixture triage (dogfood, patch round 1):**
`sharing-rule-org-less-caller.dogfood.test.ts` (objectstack-ai#8158's HTTP proof) gave
its exposed org-less persona `manage_sharing` through a grant scoped to
`org_8158_a`. That grant reached `adminOrgScope` only through the
defect, so shard 3/3 went red on "the refusal names the ORGANIZATION".
- The exposed persona now holds the set **globally**. It keeps pinning
`adminOrgScope` (objectstack-ai#8158's defence in depth) with every assertion
unchanged: 403, the "active organization" message, by-name and by-id
refused, evaluate / delete / create refused, no cross-tenant read.
- A new persona holds the grant **as objectstack-ai#8158 filed it**: scoped to
`org_8158_a`, no membership, no active organization. It is refused 403
`PERMISSION_DENIED` at the capability gate ("requires the manage_sharing
capability"), with no rows returned, and refused by name too. Its
session is pinned to carry no active organization.
- The control keeps the scoped grant with `org_8158_a` active and still
reads only its own tenant.
- The new persona's red direction without the fix is the old test's
green on `main`: that case measured exactly this persona reaching
`adminOrgScope`'s message.
  - Census of the rest of `packages/qa/dogfood`:
- No other fixture writes an organization-scoped
`sys_user_permission_set` or `sys_user_position` row. The two other
hits, in `membership-actor-attribution`, are reads of the auto-grant
row.
- `test/armed.ts:215` resolves through the real `resolveAuthzContext`
(whatever the session carries), and its users are armed through
memberships.
- The `authz-conformance.matrix.ts` rows cite §3/§4/§6 as enforcement
sites, and none of them states the old no-tenant reading.
    - The whole suite is green, as above.
- **Fixture triage (plugin-sharing, one file, two cases):**
`sharing-rule-positions-name-authority.test.ts` gave an org-less caller
`manage_sharing` through an organization-scoped grant, which is exactly
the defect's behaviour. The grant is re-spelled as **global**, the one
way an org-less caller still holds it; it is still `sharing_admin`,
never `admin_full_access`. Three explain fixtures were re-judged to
resolve in `org1`, where the scoped set applies.
- **Ablations**, each through `scripts/ablation-replace.mjs` in WRAP
mode, with a script-level `trap` restore on the absolute path. Core
resolves from `src` in both the core and runtime suites, and
`explain-engine` is imported relatively, so no `dist/` leg applies. Each
is labelled with the source state it was measured at.
1. **Re-run at `4e3e4f5e4`** (resolver blob `1f0d2889e626`, which
includes §6a). The predicate was put back to the old skip condition.
Anchor 1 to 0, blob `1f0d2889e626` to `a03a16f630a3`.
- **Red:** 5 core pins (§4/§6 with no tenant; §6a with no tenant;
`u_ex`; `u_gone`; cache on) and the 6 removed-member door pins (DELETE
and disable, for a2, a2b and the position-bound arm).
- **Green, 7 door pins:** both controls, the global grant, the platform
admin, the org-less no-grant caller, and the claim-drop proof.
     - Restored: blob == HEAD, `git diff HEAD` empty.
- The first round's run of this ablation was taken before §6a landed
(blob `490bd8a377af`) and is superseded.
2. Measured before the first merge; `92716c91af53` is still
`explain-engine.ts`'s blob at `4e3e4f5e4`. `buildContextForUser` stopped
passing its tenant. Blob `92716c91af53` to `372ec2454029`. **Red:** 6
pins, which are the three re-judged fixtures,
explain-in-an-organization, delegator-in-`org_alpha` and the route
caller-in-`org_alpha`. Restored and proven the same way.
3. Measured before the first merge; `1f0d2889e626` is still the
resolver's blob at `4e3e4f5e4`. The §6a predicate was replaced by a
filter that keeps every row. Blob `1f0d2889e626` to `404c23da10ec`.
**Red:** both §6a core pins and 4 door pins: the position-bound arm,
plus the a2 arm, whose `org_beta` membership also reaches `org_alpha`'s
`org_member` binding. Restored and proven the same way.
- **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` at `4e3e4f5e4` derived 72 commands. That is
68, plus four `@objectstack/spec` families: `check:empty-state`,
`check:liveness`, `check:strictness-ledger` and `check:variant-docs`.
All 72 were run with exit codes recorded before any pipe, and all ended
0.
- `check:type-check-debt` first exited 3 (`PREREQUISITE NOT MET`):
ablation 1's restore left core's source newer than its `dist/`. Core was
rebuilt and the gate re-run: 0.
  - `--ran`: `72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN`.
- **Lint, a declared narrowing:** `eslint --no-inline-config --format
json` over the 10 changed `.ts` files at `4e3e4f5e4` gave 10 files, 0
errors, 0 warnings.
- The population is `eslint.config.mjs`'s `**/*.{ts,...}` object minus
`NEVER_LINTED` and `packages/spec/**`, and all 10 files are in it.
- The config enables no type-aware linting (no `parserOptions.project`,
as the config itself states), so this diff cannot move any untouched
file's verdict. The full `pnpm lint` is CI's.

## Acceptance notes

- **Review nit ①5, not carried:** `explainAccessForCaller` reads
`tenantId` where `resolvePermissionSetsForContext` reads `organizationId
?? tenantId`. Patch round 1 does not otherwise touch
`security-plugin.ts`, so it is left as reviewed.

- **Explain ≠ enforce for a removed member explained from the left
organization** (H3, second row). This is the objectstack-ai#20431 family, reported and
not fixed. The explained user is resolved in the caller's organization,
without the membership check the session arm applies.
- **§6a no-tenant page cap:** the organization-less `sys_position` read
is installation-wide and capped at 200 rows, so with many organizations
the organization-less rows can fall outside the page. That was already
true before this change; the predicate only decides which of the
returned rows apply.
- **The position-name fold with no tenant**
(`resolvePermissionSetsForContext` requesting position names as
permission-set names, loaded through `dbLoaderForContext`) is a separate
seam. NOT MEASURED here.
- **`group` posture:** a principal with no active organization keeps a
data wall spanning every member organization, but it now holds no
organization-scoped grant until one is active. That is the ruling; it is
named here because it is the most visible population.

---
_Generated by [Claude
Code](https://claude.ai/code/session_01N8TPEsoJxPsdSdNKGnNGEN)_

---------

Co-authored-by: Claude <noreply@anthropic.com>
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/m tests tooling

Projects

None yet

1 participant