Repository navigation
Commit d1c01ff
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
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` (#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 (#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 #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>
1 parent fb194c7 commit d1c01ff
9 files changed
Lines changed: 458 additions & 15 deletions
File tree
- .changeset
- packages
- runtime/src/domains
- spec/src/api
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
41 | 41 | | |
42 | 42 | | |
43 | 43 | | |
44 | | - | |
45 | | - | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
46 | 47 | | |
47 | | - | |
| 48 | + | |
48 | 49 | | |
49 | 50 | | |
50 | 51 | | |
| |||
140 | 141 | | |
141 | 142 | | |
142 | 143 | | |
143 | | - | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
144 | 150 | | |
145 | 151 | | |
146 | 152 | | |
| |||
158 | 164 | | |
159 | 165 | | |
160 | 166 | | |
161 | | - | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
162 | 172 | | |
163 | 173 | | |
164 | 174 | | |
| |||
182 | 192 | | |
183 | 193 | | |
184 | 194 | | |
185 | | - | |
| 195 | + | |
186 | 196 | | |
187 | 197 | | |
188 | 198 | | |
| |||
191 | 201 | | |
192 | 202 | | |
193 | 203 | | |
194 | | - | |
| 204 | + | |
195 | 205 | | |
196 | 206 | | |
197 | 207 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
110 | 110 | | |
111 | 111 | | |
112 | 112 | | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
113 | 121 | | |
114 | 122 | | |
| 123 | + | |
115 | 124 | | |
116 | 125 | | |
117 | 126 | | |
| |||
161 | 170 | | |
162 | 171 | | |
163 | 172 | | |
164 | | - | |
| 173 | + | |
165 | 174 | | |
166 | 175 | | |
167 | 176 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
129 | 129 | | |
130 | 130 | | |
131 | 131 | | |
132 | | - | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
133 | 138 | | |
134 | 139 | | |
135 | 140 | | |
136 | | - | |
| 141 | + | |
137 | 142 | | |
138 | 143 | | |
139 | 144 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
66 | 66 | | |
67 | 67 | | |
68 | 68 | | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
69 | 72 | | |
70 | 73 | | |
71 | 74 | | |
72 | | - | |
| 75 | + | |
73 | 76 | | |
74 | 77 | | |
75 | 78 | | |
| |||
0 commit comments