Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
1f3f87f
wip(security): walled catalog refusal at the /meta doors and the data…
claude Oct 11, 2026
d039e4d
test(security): pin the walled catalog refusal on isolated and group,…
claude Oct 11, 2026
8d4d5ad
chore(security): changeset and ADR-0131 anchors for the walled catalo…
claude Oct 11, 2026
70f4feb
test(runtime): pin the dispatcher PUT's walled catalog refusal (ADR-0…
claude Oct 11, 2026
376af55
Merge remote-tracking branch 'origin/main' into claude/issue-15204-wa…
claude Oct 11, 2026
de89dd4
chore(spec,security): register the walled catalog refusal as an ADR-0…
claude Oct 11, 2026
d2a6934
Merge remote-tracking branch 'origin/main' into claude/issue-15204-wa…
claude Oct 11, 2026
26c70ab
fix(runtime): the permission-set clone door asks the walled catalog r…
claude Oct 11, 2026
763c52b
docs(permissions): census counts after the clone door's walled check;…
claude Oct 11, 2026
5354b16
chore(spec): the walled catalog refusal's changeset and ADR-0087 entr…
claude Oct 11, 2026
2243159
chore(spec): the walled catalog refusal's changeset and ADR-0087 surf…
claude Oct 11, 2026
a337a2f
docs(permissions,spec): the walled caveat on authorEnvironmentSets, t…
claude Oct 11, 2026
637f126
docs(references): regenerate the permission reference for the authorE…
claude Oct 11, 2026
a33c037
Merge origin/main (7de0265fc7) into claude/issue-15204-wall-catalog-r…
claude Oct 12, 2026
645b5d7
Merge remote-tracking branch 'origin/claude/issue-15204-wall-catalog-…
claude Oct 12, 2026
9dbf6bc
feat(security): refuse catalog-carrying package writes under a wall (…
claude Oct 12, 2026
6d7837e
test(dogfood): give the walled package refusal's operator control its…
claude Oct 12, 2026
df8411b
chore(changeset): the walled package refusal, registered as walled-ca…
claude Oct 12, 2026
1ce9bb8
docs(permissions): census row 55b names the /packages delete refusal
claude Oct 12, 2026
f4d6a8f
fix(security): ask the walled verdict before reading a package's cata…
claude Oct 12, 2026
0d0a420
Merge origin/main (W landed as 06cc45dc36) into claude/issue-15204-wa…
claude Oct 12, 2026
b7e2eaf
docs(permissions): the walled package refusal on the package delete a…
claude Oct 12, 2026
54b82a0
feat(security): refuse the remaining /packages catalog writes under a…
claude Oct 12, 2026
4c653ef
test(dogfood): each walled /packages door pin reads only its own request
claude Oct 12, 2026
fadbef5
chore(changeset): the walled /packages verbs, registered as walled-ca…
claude Oct 12, 2026
ae37477
fix(runtime): type the walled package readers' query options
claude Oct 12, 2026
2868402
Merge remote-tracking branch 'origin/main' into claude/issue-15204-wa…
claude Oct 12, 2026
8199d57
Merge remote-tracking branch 'origin/claude/issue-15204-wall-package-…
claude Oct 12, 2026
0c6a5ee
Merge origin/main (117817ec28) into claude/issue-15204-wall-package-r…
claude Oct 12, 2026
73977f5
Merge remote-tracking branch 'origin/claude/issue-15204-wall-package-…
claude Oct 12, 2026
3eaad0f
ci(drift): the shard timing-drift check exit-grades on `schedule` alone
claude Oct 12, 2026
944ebdb
ci(ceilings): exit-grade the suite-duration ceilings on `schedule` al…
claude Oct 12, 2026
d29211e
Merge remote-tracking branch 'origin/claude/issue-15204-wall-package-…
claude Oct 12, 2026
0e4fdb4
Merge origin/main (48cca4ae00, W2 landed as #22935) into claude/issue…
claude Oct 12, 2026
2bc34da
fix(runtime): refuse the walled catalog discard-drafts door; judge du…
claude Oct 12, 2026
5ef2293
docs: name discard-drafts and capability in the walled catalog packag…
claude Oct 12, 2026
f10bbe8
Merge origin/main (0ede0f8ab3) into claude/issue-15204-wall-catalog-p…
claude Oct 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/15204-walled-catalog-package-verbs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
'@objectstack/runtime': minor
'@objectstack/spec': minor
---

Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's

Clause-②: yes (narrowing)

<!-- adr-0087: registered walled-catalog-package-verbs-retired -->

**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant").

**Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then:
- published the operator's pending draft adding `manage_users`, and that member's next resolution carried it;
- rolled the package back, and the member lost it;
- reverted the operator's commit, and the member lost it;
- copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy;
- discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door);
- adopted the environment's catalog rows into their own package.

**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs:

- `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft.
- `POST /api/v1/packages/:id/revert`, when the package has such a draft.
- `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft.
- `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability.
- `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing.
- `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one.
- `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one.

**Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read.

**The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete.
13 changes: 13 additions & 0 deletions content/docs/api/metadata-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,19 @@ Uninstall a package. Under a `group` or `isolated` tenancy posture, a package
holding positions or permission sets is the platform operator's to delete: any
other caller is refused `403 PERMISSION_DENIED` (ADR-0131 D3).

The package's other write verbs follow the same rule under a wall, judged by
the items each one acts on:
- `POST /packages/:id/publish-drafts`, `POST /packages/:id/discard-drafts` and
`POST /packages/:id/revert`, by the package's pending drafts;
- `POST /packages/:id/rollback`, by the commits it would undo;
- `POST /packages/:id/commits/:commitId/revert`, by that commit's own items,
whatever package the URL names;
- `POST /packages/:id/duplicate`, by the source's published items it copies;
- `POST /packages/:id/adopt-orphans`, by the environment's orphaned rows.

When those items include a position, a permission set or a capability, only the platform
operator may run the verb.

### `PATCH /packages/:id/enable`

Enable a disabled package.
Expand Down
5 changes: 4 additions & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -352,7 +352,10 @@ Environment-authored sets and other packages' rows survive. Under a `group` or
`isolated` tenancy posture, removing a package that carries positions or
permission sets is the platform operator's (ADR-0131 D3): that delete, and an
`install-local` install, upgrade or uninstall of such a package, refuse any
other caller `403` before anything changes.
other caller `403` before anything changes. So do the package verbs that
publish, discard, roll back, revert, duplicate or adopt catalog items
(`publish-drafts`, `discard-drafts`, `revert`, `rollback`,
`commits/:commitId/revert`, `duplicate`, `adopt-orphans`).

## One authoritative store — the record is a projection (ADR-0094)

Expand Down
2 changes: 1 addition & 1 deletion content/docs/permissions/system-context.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ One of the largest consumers of the flag;
| 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` |
| 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` |
| 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` |
| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` delete of a package holding stored positions or permission sets. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageDelete` |
| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` write doors (delete, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`) when the items they act on include a position, a permission set or a capability. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` |
| 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` |
| 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` |
| 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` |
Expand Down
Loading
Loading