Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
2868402
Merge remote-tracking branch 'origin/main' into claude/issue-15204-wa…
claude Oct 12, 2026
0c6a5ee
Merge origin/main (117817ec28) into claude/issue-15204-wall-package-r…
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
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
31 changes: 31 additions & 0 deletions .changeset/15204-walled-catalog-package-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
'@objectstack/cloud-connection': minor
'@objectstack/runtime': minor
'@objectstack/objectql': minor
'@objectstack/spec': minor
---

Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it

Clause-②: yes (narrowing)

<!-- adr-0087: registered walled-catalog-package-writes-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 (managed packages, Studio).").

**Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator):
- the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`;
- the owner's `install-local` uninstall of that package took the set away from that member;
- the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same.

The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it.

**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):

- `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items.
- `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched.
- `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted.

**Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves.

**The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers.
48 changes: 36 additions & 12 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1066,17 +1066,27 @@ jobs:
# second red on wall time against `timeout-minutes`. Both constants, their
# measured basis and a self-test battery each live in the script.
#
# THE RED IS A `schedule` RED ALONE (C2, both timing steps, ruling
# https://github.com/objectstack-ai/objectstack/issues/22949#issuecomment-6119186511):
# the dataset's weights are measured on the hourly `schedule` runs, so
# only those exit 1 on DRIFT. On `merge_group`, `pull_request`, `push`
# and `workflow_dispatch` the step prints the same verdict, raises the
# DRIFT as a `::warning::` annotation and exits 0. The event reaches the
# script as `--event "$GITHUB_EVENT_NAME"`, which it requires; the rule
# and its self-test live there (TIMING_GRADED_EVENT, timingGateMode).
#
# EXECUTED WINDOWS ONLY. Measurements come from samplesFromSummary() in
# measure-test-shard-timings.mjs, which drops cache HITs and failed
# suites, and the prediction is summed over the same executed packages.
# A replay's near-zero window would read as a shard far FASTER than
# predicted and vouch for a rotted dataset, so a shard whose packages all
# replayed is NOT MEASURED, never "fast".
#
# NO `if:` and NO `continue-on-error` (the red is the point), and ABOVE
# the attestation pair for the #6082 reason documented on the upload
# above: a failed unguarded step skips that pair, so a drift red also
# withholds this shard's attestation, the fail-closed direction. A shard
# NO `if:` and NO `continue-on-error` (the red is the point, and the
# `schedule`-only red is the script's `--event`, so neither is needed),
# and ABOVE the attestation pair for the #6082 reason documented on the
# upload above: a failed unguarded step skips that pair, so a drift red
# also withholds this shard's attestation, the fail-closed direction. A shard
# with no packages runs no turbo and writes no summary; that is NOT
# MEASURED here rather than a usage error from the script.
#
Expand Down Expand Up @@ -1109,7 +1119,7 @@ jobs:
exit 0
fi
node scripts/partition-test-shards.mjs --check-drift "${SUMMARIES[@]}" \
--label "Test Core (${{ matrix.shard }}/6)"
--label "Test Core (${{ matrix.shard }}/6)" --event "$GITHUB_EVENT_NAME"

# Runs even when the suite failed — that is when it earns its keep. It
# answers TWO questions about a red suite, and needs both to be able to
Expand Down Expand Up @@ -1486,12 +1496,12 @@ jobs:
# ── The per-package suite-duration ratchet (#16468) ──────────────────
#
# A GATE, unlike the two reporting steps above: NO `continue-on-error`,
# so a package whose suite ran past its ceiling reds `Test Core`, naming
# the package and its slowest test files. The ceiling is the package's
# slowest executed run in the refresh window x 1.25, generated into
# scripts/test-shard-timings.json (`ceilings`) by the refresh lane and
# held by every later refresh; it rises only by a ruled entry in the
# script's RULED_CEILING_RAISES. A `provisional` package prints "no
# so on a `schedule` run a package whose suite ran past its ceiling reds
# `Test Core`, naming the package and its slowest test files. The
# ceiling is the package's slowest executed run in the refresh window
# x 1.25, generated into scripts/test-shard-timings.json (`ceilings`) by
# the refresh lane and held by every later refresh; it rises only by a
# ruled entry in the script's RULED_CEILING_RAISES. A `provisional` package prints "no
# ceiling: provisional" and is not red; a package absent from the dataset
# prints "no ceiling: new package" and is not red until the first refresh
# after it lands gives it a ceiling, while a package the dataset knows
Expand All @@ -1505,6 +1515,20 @@ jobs:
# samplesFromSummary, and every vitest file line); a capture that did not
# arrive leaves its packages ungraded and says so, never red.
#
# EXIT-GRADED ON `schedule` ALONE (C2, both timing steps, ruling
# https://github.com/objectstack-ai/objectstack/issues/22949#issuecomment-6119186511):
# the ceilings come from the hourly `schedule` runs, so only those red
# this job on a package over its ceiling. On `merge_group`,
# `pull_request`, `push` and `workflow_dispatch` the step prints the same
# table, raises each package over its ceiling as a `::warning::`
# annotation and exits 0; a real slowdown merged to `main` reds the next
# hourly run. A package whose ceiling is under the absolute floor (20s,
# CEILING_FLOOR_SECONDS) prints "below floor" and is never red. The
# event reaches the script as `--event "$GITHUB_EVENT_NAME"`, which it
# requires; a refusal (a malformed table or raise record) is red on every
# event. The rule, the floor's reading and their self-tests live in the
# script.
#
# NOT MEASURED, exit 0, while the dataset carries no `ceilings`: they are
# generated only where the run-summary artifacts are downloadable (the
# refresh lane), so the first refresh after this lands writes them, and
Expand All @@ -1515,7 +1539,7 @@ jobs:
# captured, so a red shard's packages are graded too.
- name: Check the per-package suite-duration ceilings
if: always()
run: node scripts/check-test-suite-ceilings.mjs --captures "$RUNNER_TEMP/test-timings" --label 'Test Core'
run: node scripts/check-test-suite-ceilings.mjs --captures "$RUNNER_TEMP/test-timings" --label 'Test Core' --event "$GITHUB_EVENT_NAME"


# ── Temporal conformance against live, non-UTC servers (ADR-0053 D-A3) ─────
Expand Down
4 changes: 3 additions & 1 deletion content/docs/api/metadata-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ Get package details.

### `DELETE /packages/:id`

Uninstall a package.
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).

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

Expand Down
7 changes: 7 additions & 0 deletions content/docs/permissions/capabilities.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,13 @@ has no counterpart in it — nothing in code would ever *require* the name, so t
result is an unreferenced grant target sitting in the live authorization
namespace. `job` and `agent` carry the same pair for the same reason.

Under a `group` or `isolated` tenancy posture, a caller below the platform
operator reads a different refusal, with the same `403` status: the security
catalog refusal (ADR-0131 D3), whose message names the posture, with the code
`FORBIDDEN` on the REST door and `PERMISSION_DENIED` on the runtime dispatcher.
It runs before the code-only refusal, so the block above is the answer the
platform operator reads, and every caller under `single`.

Two things this does **not** close:

- **The package-declaration channel is untouched.** `AppPlugin` registers stack
Expand Down
6 changes: 5 additions & 1 deletion content/docs/permissions/permission-sets.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -348,7 +348,11 @@ position/user bindings, and its pending audience-binding suggestions in the
same request (no ghost grants); the uninstall response reports the revocation
under `data.persisted.cleanups` (the dispatcher's `/packages` domain answers
the route, and it nests the protocol's uninstall report under `persisted`).
Environment-authored sets and other packages' rows survive.
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.

## 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 | 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, and at the data door on `sys_position`, `sys_permission_set` and `sys_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` |
| 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` |
| 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
1 change: 1 addition & 0 deletions packages/cloud-connection/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@objectstack/core": "workspace:*",
"@objectstack/metadata-core": "workspace:*",
"@objectstack/metadata-protocol": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/runtime": "workspace:*",
"@objectstack/spec": "workspace:*",
"@objectstack/types": "workspace:*"
Expand Down
Loading
Loading