Repository navigation
fix(security)!: under a wall a catalog-carrying package is the platform operator's — install-local and the /packages delete refuse tenants (ADR-0131 D3) - #22935
Conversation
… door (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
… with single and assignment controls (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…g refusal Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…ll-catalog-refusal
…087 semantic entry; census row 55b Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…ll-catalog-refusal # Conflicts: # content/docs/permissions/system-context.mdx
…efusal (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
… row 55b names the clone door Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…y name the clone door Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…ace: exact doors, capability's unchanged accept set, the D12 delegate Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…he clone door and the Setup env-door write (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
…nvironmentSets describe Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude <noreply@anthropic.com>
Resolve content/docs/permissions/system-context.mdx: keep row 55b (this branch) and main's row 58 rewording (#22875); counts re-derived with pnpm gen:system-context-census. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…refusal' into claude/issue-15204-wall-package-refusal
…ADR-0131 D3) Under group/isolated, below the platform operator rung, install-local refuses an install whose incoming version declares a position, permission set or capability, an upgrade where either version declares one, and an uninstall of a package whose installed version declares one; DELETE /packages/:id refuses a package holding stored catalog rows. Both ask walledCatalogWriteVerdict once per declared type. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
… own packages Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…talog-package-writes-retired Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…log items; alias objectql in cloud-connection tests Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
…package-refusal Resolve content/docs/permissions/system-context.mdx: keep this branch's row 55b (it names the /packages delete refusal); counts re-derived with pnpm gen:system-context-census. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
📓 Docs Drift CheckThis PR changes 4 package(s): 47 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 11 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 145 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin f1694e51a053e436e3b209aaaac3da6e28a34def && git checkout f1694e51a053e436e3b209aaaac3da6e28a34def
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a2240c4dcfc3a19f058cb0b3a4f15a351908acde 944ebdb98d6e53c7882c614705141d7ac776edf6 && git checkout -B drift-repro a2240c4dcfc3a19f058cb0b3a4f15a351908acde && git merge --no-ff 944ebdb98d6e53c7882c614705141d7ac776edf6
node scripts/docs-audit/affected-docs.mjs --json a2240c4dcfc3a19f058cb0b3a4f15a351908acde
|
|
Seat note: What reds on Why it is not this PR's: the diff does not touch No fix exists yet, and this PR's one re-run is already spent (6118076783). So the seat makes no change here. A sighting goes on #22949. The PR stays draft, and the seat lands it when the check clears, by ruling or by fix, with the contract review and the ACCEPT draft unchanged. Generated by Claude Code |
Resolve content/docs/permissions/system-context.mdx: main's count-free prose (#22950), this branch's row 55b; no count lines. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP
`--check-drift` takes the run's event (`--event`), required. On `schedule`, the profile scripts/test-shard-timings.json is measured on, a DRIFT exits 1 as before; on every other event it prints the same text, raises one `::warning::` annotation and exits 0. A missing or empty event is refused. Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2 Co-authored-by: Claude <noreply@anthropic.com>
…one, with a 20s floor The ceiling check takes the run's event (`--event`, required). On `schedule`, the profile the ceilings were measured on, a package over its ceiling reds the step as before; on every other event the same table prints, each package over its ceiling is a `::warning::` annotation, and the step exits 0. Refusals stay red on every event. A package whose effective ceiling is under CEILING_FLOOR_SECONDS (20s) is never red and prints "below floor"; the header records the floor's reading. ci.yml passes `--event "$GITHUB_EVENT_NAME"` to both timing steps, which keep no `if:` and no `continue-on-error`. Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2 Co-authored-by: Claude <noreply@anthropic.com>
Seat ACCEPT: #22935 (stage W2, the walled catalog refusal at the package doors, ADR-0131 D3) at
|
…r with its posture-naming reason on every install, upgrade and uninstall gesture objectstack W2 (objectstack-ai/objectstack#22935) refuses, below the platform-operator rung under `group` / `isolated`, an install or upgrade of a package that carries security catalog items and the uninstall of such a package, at install-local and at `DELETE /api/v1/packages/:id`, with a `403` whose message names the posture. Measured with real renders fed those exact envelopes: the package page's Install and Reinstall (the console's upgrade on an install-local runtime), its local Uninstall, Installed Apps' Uninstall, the catalog page's organization install, and the Packages page's Delete app and Import each already show the door's sentence, so these are pins only. Part of objectui#7611 (item 2 of release record 6158162134). Claude-Session: https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z Co-authored-by: Claude <noreply@anthropic.com>
…ll refusal reaches the operator with its posture-naming reason (objectui#7611 item 2) (objectstack-ai#12346) Part of objectstack-ai#7611 Clause-②: no This PR covers item 2 of the card's release record (comment 6158162134) and nothing else: package install, upgrade and uninstall under a wall, in the console. The card stays open for items 1, 3 and 4. The seat's claim is comment 6158502886. ## Result The console already shows the walled refusal's reason on every path that can receive it. The release record called this "not measured", so I measured it first. Each install, upgrade and uninstall gesture shows the door's posture-naming sentence verbatim. **The PR is pins only.** No source file changes, and the changeset has empty frontmatter. ## The refusal's wire shape, read at objectstack `4806b18aea` objectstack W2 (objectstack-ai/objectstack#22935, merge `4806b18aea`, carried by `@objectstack/*` `18.0.0-next.0`) refuses two things below the platform-operator rung under `group` / `isolated`: an install or upgrade of a package that carries security catalog items, and the uninstall of such a package. **install-local** (`@objectstack/cloud-connection`, `refuseWalledCatalogPackage`) refuses on two requests: - `POST /api/v1/marketplace/install-local`, for an install and also an upgrade, with VERB `Installing`; - `DELETE /api/v1/marketplace/install-local/MANIFEST_ID`, for an uninstall, with VERB `Uninstalling`. ```text 403 { "success": false, "error": { "code": "FORBIDDEN", "message": "VERB \"MANIFEST_ID\" is refused: the package carries security catalog items. VERDICT", "details": { "manifestId": "MANIFEST_ID", "catalogItems": [ { "type": "TYPE", "name": "NAME" } ] } } } ``` **`DELETE /api/v1/packages/:id`** (`@objectstack/runtime`, `refuseWalledCatalogPackageDelete`) answers through the dispatcher's `deps.error(message, 403)`, so `buildApiError` derives the code from the status (`standardErrorCodeForHttpStatus`): ```text 403 { "success": false, "error": { "code": "PERMISSION_DENIED", "message": "Deleting package \"PACKAGE_ID\" is refused: the package carries security catalog items. VERDICT", "httpStatus": 403 } } ``` VERDICT is the sentence from `walledCatalogWriteVerdict` (`@objectstack/metadata-core`). It is written for the first catalog type the package declares, checked in this order: position, permission set, capability. For example: > Creating, changing or deleting a permission set requires the platform operator: this deployment runs the 'group' tenancy posture, where the security catalog is shared by every organization (ADR-0131 D3). Holding the `manage_metadata` capability inside an organization does not admit it. An organization's administrators assign the catalog's positions and permission sets to their own members instead. **No posture field.** Neither envelope carries one. The posture appears only in the message. An upgrade goes to the same door as an install: the door checks both the incoming and the installed manifest, and answers with the `Installing` sentence. ## The console's paths, measured Each case renders the real component. A stubbed `fetch` answers a real `Response` carrying the exact envelope above, with status text `Forbidden`. Each case runs under both `group` and `isolated`. `marketplaceApi` is not mocked. | Gesture | Console path | Request | What the operator reads | |---|---|---|---| | install | package page, Install (`installLocal`) | `POST install-local` | the door's sentence, verbatim | | upgrade | package page, Reinstall over an installed older version (3.2.0, latest 4.0.1) | `POST install-local`: no version is named, so the door installs `latest` over the installed one | the door's sentence, verbatim | | uninstall | package page, local menu, Uninstall from runtime (`uninstallLocal`) | `DELETE install-local/MANIFEST_ID` | the door's sentence, verbatim | | uninstall | Installed Apps, the row's Uninstall (`uninstallLocal`) | same | the door's sentence, verbatim; the row stays | | install | catalog page, Your organization, Install (`installLocal` with `versionId: 'latest'`) | `POST install-local` | the door's sentence, verbatim | | delete | Packages page, package sheet, Delete app (`apiJson`) | `DELETE /api/v1/packages/:id` | the door's sentence plus ` (PERMISSION_DENIED)` | | install | Packages page, Import (`apiJson`, manifest inline) | `POST install-local` | the door's sentence plus ` (FORBIDDEN)` | None of the five marketplace cases shows `Forbidden`, `HTTP_403` or a bare `FORBIDDEN`. Neither Packages page case shows `Forbidden` or this page's own fallback, `Request failed (403)`. The marketplace pins assert that each gesture's success sentence is absent, and that each request left the browser with the expected method, path and body. **The `DELETE /api/v1/packages/:id` path (Zone 2, item 3).** The console calls it from one place: `PackageDetailSheet`'s Delete app on the Packages page (`views/metadata-admin`). That page also has a second door into install-local: Import, which posts the file's manifest inline. Both sit outside the claimed `console/marketplace/` surface. Both are measured here in one new test file beside the page, and no source there changes. **Wording (Zone 2, item 4): verbatim, the existing convention.** Three reasons: - **It is the convention.** `doInstallLocal` already shows the install-mode refusals as sent ("each message names its own remedy, and the page has nothing to add to it"). Both uninstall callers show `e.message`. The Packages page shows the envelope through `readEnvelopeFailureText`. - **It reads well for a tenant admin.** The sentence names the action and the package, the reason (security catalog items), the posture, who may do it (the platform operator), that the admin's own `manage_metadata` does not admit it, and what they can do instead. - **Localizing would need a server contract the console does not have.** The install-local refusal shares its code, `403 FORBIDDEN`, with the same door's missing-capability refusal, which ends ``requires the `manage_metadata` capability.``. The `DELETE /api/v1/packages/:id` refusal carries only the status-derived `PERMISSION_DENIED`. So the console could tell a walled refusal apart only by parsing the server's prose. That is a second reading of the refusal, and the server's own sentence already does the job. ## Tests All runs are at head `ef8ab4ae4`. - `pnpm exec vitest run --maxWorkers=2 packages/app-shell/src/console/marketplace/ packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx` → `Test Files 34 passed (34)`, `Tests 229 passed (229)`. That is the 32 existing marketplace suites, plus the new marketplace pin (11 tests) and the new Packages page pin (4 tests). - `pnpm --filter @object-ui/app-shell type-check` echoes `tsc --noEmit && tsc -p tsconfig.test.json` and exits 0. `tsc -p tsconfig.test.json --listFiles` lists both new test files. - Build of the dependency closure, `turbo run build --filter=@object-ui/app-shell^...`: exit 0. All 29 packages were cache hits. - eslint, as a proven narrowing. First, the population comes from eslint's own config: `--print-config` on a new file resolves 119 rules, so the file is linted, and the changeset `.md` matches no `files` glob. Second, `--format json` reports 2 files, 0 errors and 0 warnings, both from the repo root with `--no-inline-config` and from `packages/app-shell`, the way its `lint` script runs. Third, untouched files are unaffected: the resolved config has `parserOptions.project` and `projectService` unset, so linting is not type-aware, and no rule under `eslint-rules/` reads the filesystem. The diff adds two files and edits none, so it cannot change any other file's lint result. - Repo checks, all exit 0: `node scripts/check-changeset-presence.mjs` (the changeset has empty frontmatter, which this gate accepts as a complete answer), `pnpm check:changeset-claims`, `pnpm check:pending-changeset-literals`, `pnpm check:new-line-citations` (0 new citations), `pnpm check:control-bytes`, `pnpm check:test-path-roots`, `pnpm check:vi-mock-specifiers`. Also derived from the workflows and run: `check-changeset-fixed`, `check-changeset-no-major`, `check-changeset-overwrite`, `check-vi-mock-inherit`, `check-vi-mock-override-shape`, `check-lint-coverage`, `check-type-check-coverage`, and `pnpm check:` `unreferenced-sources` / `esm-specifiers` / `metadata-write-doors` / `handler-key-reads` / `self-import` / `i18n-keys` / `phantom-deps`. - NOT MEASURED: `pnpm check:sdui-registration-pins`. It exits 2 because there is no console build to read. It reads bundle-time registrations, and this diff ships no code. **Ablation (one leg, on the committed tree):** run with objectstack's `scripts/ablation-replace.mjs` in wrap mode. It replaced `readApiError`'s `payload?.error?.message // the declared envelope` with `undefined`. The anchor count went from 1 to 0, and the blob from `4fd74da91c1c` to `922b64be38e3`. The marketplace pin then ran at `Tests 10 failed | 1 passed (11)`, which is the direction predicted beforehand. Each failure was `Unable to find an element with the text: Installing "com.example.crm" is refused: …`, with the page showing the bare `FORBIDDEN`: the exact shape of a dropped reason. The one pass is the harness CONTROL, which shows a bare form either way. The restore was proven: the blob after restore equals HEAD (`4fd74da91c1c`), `git diff HEAD` is empty, and the marker count is 0. ## Acceptance notes These are observations only. None is filed, and none has a carrier. - `uninstallLocal` throws an `Error` with neither `code` nor `status`, while `installLocal` carries both. Both of its callers show only `e.message`, so nothing user-visible depends on it today. - The install-local 403 lists the declared items in `details.catalogItems`. The console does not show them. The sentence already says the package carries security catalog items, so listing the items would be a feature request, not part of this item. - CI: `Spec Main Shape Gate` is red on `main` and on every PR (advisory, owned by objectui#12325). `OAuthConsentPage.public-client-12301` is a known timing flake (objectui#12344). Files: `packages/app-shell/src/console/marketplace/__tests__/walledCatalogPackageRefusal-7611.test.tsx`, `packages/app-shell/src/views/metadata-admin/PackagesPage.walledCatalogRefusal-7611.test.tsx`, `.changeset/7611-walled-package-refusal.md`. Implemented by the os-dev agent dispatched by the `domain:ui#1` seat, session `https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z`. --- _Generated by [Claude Code](https://claude.ai/code/session_01CsyMRsFJBKNEMa4oPY1b5z)_ Co-authored-by: Claude <noreply@anthropic.com>
Part of #15204
Clause-②: yes (narrowing)
Stage W2 of #15204 (seat note 6114823168): the walled catalog refusal at the package doors. Stage W (#22895) refused catalog writes at
/meta, the clone door and the data door. This PR refuses them where a package carries them. It asks W's predicate,walledCatalogWriteVerdict, frommain.The ruling this executes
ADR-0131 D3: "tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)". A package that declares positions, permission sets or capabilities is the operator's instrument. Seat note 6114823168: under
group/isolated, below the operator rung (and notisSystem),install-localrefusesThe whole package is refused, since a partial install is not well-formed. The note also asked for a measurement of
DELETE /api/v1/packages/:id, and for that door to be refused the same way if the measurement showed it reachable.Measured first
The harness was a booted
examples/app-crmstack (bootStack,multiTenant: 'posture-only', plusMarketplaceInstallLocalPlugin), with two organizations. Organization A's owner holdsmanage_metadatathrough a grant scoped to organization A (rungTENANT_ADMIN). Organization B's member holds the operator's permission sets.isolatedgroupinstall-localupgrade of the operator's package, addingmanage_usersto its setmanage_usersinstall-localuninstall of that packageDELETE /packages/:idof a runtime package the operator saved a set into (PUT /meta/permission/:name?package=)PATCH /packages/:id/disableof such a packageThose catalog items are stored as
sys_metadatarows bound to the package (package_id). The running registry does not hold them under the package, so the/packagesrefusal reads the stored rows, the same rowsdeletePackagedeletes.disablechanges nothing any organization resolves, so it is not refused.What changed
install-local(@objectstack/cloud-connection): the admission carries the caller's posture rung and the effective tenancy posture, both from its one authorization read.refuseWalledCatalogPackageaskswalledCatalogWriteVerdictonce per catalog type that the incoming version, the installed version or the running registry under that id declares. It answers403 FORBIDDEN, the envelope of the door's sibling refusals, and runs before any registration or ledger write (install) or before the ledger is touched (uninstall).error.details.catalogItemslists the items.DELETE /packages/:id(@objectstack/runtime):refuseWalledCatalogPackageDeletereads the package's stored rows, and asks the predicate per stored catalog type, after the capability and writable-package gates and before anything is deleted. It answers403 PERMISSION_DENIED, this domain's 403. Thepackages.tscomment that calledrequireManageMetadata"the operator gate" now says what that gate checks: the capability orisSystem, and no rung.@objectstack/objectqlexportsdeclaredSecurityCatalogNames, the engine's own reading of what a manifest declares. The door judges the declarations the engine registers, not a second reading of them.cloud-connectionnow depends onobjectqldirectly (it was already in its closure throughruntime).walled-catalog-package-writes-retired; the changeset carries FROM → TO per door./packagesdelete and anchorsrefuseWalledCatalogPackageDelete, with the counts regenerated.capabilities.mdxgains the clause from W's contract review (6115069587 ③ 3 ii): under a wall a sub-operator caller reads the walled refusal first,FORBIDDENon REST andPERMISSION_DENIEDon the dispatcher, before the code-onlyNOT_CREATABLE.Tests
wall-package-refusal.dogfood.test.ts, 19 tests. Under each ofisolatedandgroup:/packagesdelete refused.B's resolution does not move. Controls under both postures: A installs, upgrades and uninstalls a catalog-free package and deletes a catalog-free runtime package; the operator installs, upgrades, uninstalls and deletes catalog-carrying packages.
singlecontrol: the samemanage_metadataholder does all of it as before.Ablation, install-local:
refuseWalledCatalogPackagewas forced to admit,cloud-connectionrebuilt, and the marker confirmed indist/byablation-dist-preflight. The 10 install-local refusal pins went red (5 per posture), and the other 9 stayed green. Restored to theHEADblob and rebuilt; the marker is absent fromdist/and the tree is clean.Ablation,
/packages: the same forrefuseWalledCatalogPackageDelete(runtime). The 2/packagesrefusal pins went red, and the other 17 stayed green. Restored and rebuilt; the marker is absent.Full suites (pre-merge code head
f4d6a8f104, the same W2 code as this head):objectql401 files / 7,817 passed;runtime355 files / 5,126 passed, 19 skipped;cloud-connection43 files / 517 passed. The full dogfood suite was stopped when W landed, before it finished, so it is not counted. The pin files are counted on the merged head below.dispatch-gates --ran(pre-merge, 141 derived): 140 exit 0.check-engine-split-ratio --days 90refused on this shallow clone (exit 2, nothing measured).Round-1 findings, fixed:
check:test-source-alias:cloud-connection's tests now reach@objectstack/objectql, so that package'svitest.config.tsgained an anchored source alias.cloud-connection's own suite: fake registries lack the private catalog reader. Both doors now ask the predicate before reading anything, so undersingle, with notenancyservice, or for the operator, neither door reads anything new.On the merged head
0d0a42082d:wall-package-refusal19/19 andwall-catalog-refusal17/17;check:system-context-census,check-adr-0087-registration --base origin/main,check-changeset-no-majorandcheck:test-source-aliaseach exit 0;gen:schema,gen:docs,gen:system-context-census) leaves 0 changed bytes.The 124 gates
dispatch-gatesderives for this head are running; their verdict goes in the os-dev-report addendum on refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write undersingleand refused under a wall (ADR-0131 D2/D3/D5/D13) #15204.Acceptance notes
manage_metadatamay install packages at all, or author the environment's objects, views and flows. This PR refuses only catalog-carrying packages, per 6114823168.refuseSecurityCatalogNameConflicts(the shadow refusal) is unchanged; this refusal runs before it.🤖 Generated with Claude Code
https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP