Skip to content

Commit 0d326bf

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22739-import-ref-exposure
2 parents 679f2ae + 9859e4e commit 0d326bf

41 files changed

Lines changed: 649 additions & 2525 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-security': minor
3+
'@objectstack/spec': patch
4+
'@objectstack/objectql': patch
5+
'@objectstack/runtime': patch
6+
'@objectstack/lint': patch
7+
---
8+
9+
feat(plugin-security): a package's declared capabilities are served by the registry alone — the declared-capability seeder and its collision diagnostic are deleted
10+
11+
Clause-②: no
12+
13+
`SecurityPlugin` no longer seeds a `sys_capability` row for a capability a package declares (`defineCapability` / a stack's `capabilities`). The registry is that capability's one home (ADR-0131 D3): the security catalog read (`createSecurityCatalogReader`), `GET /api/v1/meta/capability` and the anchor predicates' declared-capability context read the declaration there, as they already did. This change does not alter how the platform's curated capabilities are served.
14+
15+
A capability name declared by two packages, or a package declaring a curated platform capability name, is refused at boot by the registry's one-holder rule (`SecurityCatalogNameConflictError`, `422`), which already refused it before the deleted runtime diagnostic could run.
16+
17+
In `@objectstack/spec`, `@objectstack/objectql`, `@objectstack/runtime` and `@objectstack/lint`, the shipped comments and the capability liveness evidence (`liveness/capability.json`) now name the registry instead of the retired declared-capability seeder. Their behaviour is unchanged.
18+
19+
Removed from `@objectstack/plugin-security`'s public exports, with no replacement:
20+
21+
- `CAPABILITY_NAME_COLLISION`
22+
- `capabilityNameCollisionDiagnostic`
23+
- `formatCapabilityNameCollisionDiagnostic`
24+
- `reportCapabilityNameCollisions`
25+
- `CapabilityNameCollisionDiagnostic` (type)
26+
27+
FROM → TO:
28+
29+
- FROM a package's declared capability materialized as a `managed_by: 'package'` `sys_capability` row at boot → TO the declaration served by the registry, with no row written. Read it with `GET /api/v1/meta/capability/:name` or the security catalog read, not from `sys_capability`.
30+
- FROM a name collision reported at boot through `reportCapabilityNameCollisions` → TO the same collision refused by the registry's one-holder rule. Delete any import of the five names above; there is nothing to call in their place.

‎.changeset/22565-flow-cel-unbound-root-refused.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ The user spellings are told to write `current_user`, with the guard for a flow t
3131
- a `type: 'flow'` action launching the flow on an undeclared object;
3232
- a `map` node feeding the flow whose `itemObject` is undeclared or absent;
3333
- a `subflow` or `map` parent that is itself such a flow.
34-
- **The runtime publish gate:** a flow write there is not judged for unbound roots yet. Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged.
34+
- **The runtime publish gate:** a flow write there is not judged for unbound roots, by design (rulings `5791822697` item 3 and `6104584601`). Its per-write snapshot carries no actions or other flows, so it cannot see those entrances. Every other expression verdict at that door is unchanged.
3535

3636
A `$` name has no CEL spelling (`$runId == "x"` does not parse), so the `$` roots stay the text-slot rule's.
3737

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
A flow whose `record-*` trigger names no start `config.objectName` is no longer judged for unbound flow CEL roots by `objectstack validate`. With no object named, the record-change trigger registers its hook with no object filter, so it fires on a write to any object, one the stack does not declare included, and hands that row to the run as `record`, its fields flattened beside it. The build door cannot know that record's keys, so it leaves the flow unjudged, as it already does for a record trigger on an object the stack does not declare. The trigger shape itself is not refused.
6+
7+
Clause-②: yes (widening: a flow whose `record-*` trigger names no start `config.objectName` opens, so `validate` no longer reports an unbound flow CEL root there)
8+
9+
**What changes.** On such a flow, `expression-invalid` no longer reports a root the flow does not bind (`user.id`, `foo.bar`, a field name no declared object has). The same flow with a declared `config.objectName` is judged exactly as before.
10+
11+
**The runtime publish gate, by design.** That gate does not judge unbound flow CEL roots on a flow write, and is not going to: who launches a flow and what record it is handed are read from other items of the stack, which makes this a whole-stack judgment that belongs to `objectstack validate`, and the gate's per-write snapshot is not widened to carry flows and actions. The stand-down there is the intended boundary, not a gap waiting on a wider snapshot.
12+
13+
**Who is affected.** Every shipped `record-*` trigger names its object, so no example app changes: `objectstack validate` over `app-crm`, `app-todo`, `app-multi-package` and `app-showcase` reports the same findings before and after.

‎.changeset/22677-flow-cel-record-entrance.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ Clause-②: no (narrowing)
1717

1818
A start `config.objectName` alone is no entrance: with no `record-*` trigger nothing hands the run a row. A parent's own `record` variable is not handed on either: a child gets its parent's context, not its variables. Everywhere else `record.X` is refused as `expression-invalid`, `error`, with the remedy below.
1919

20-
**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record` until that snapshot carries them.
20+
**Not judged.** The stand-downs of the unbound-root rule are unchanged. At the runtime publish gate this judgment still stands down: its per-write snapshot carries no actions and no other flows, so it cannot see an entrance, and a flow write there is not refused for `record`, by design (rulings `5791822697` item 3 and `6104584601`).
2121

2222
## FROM → TO
2323

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/rest': patch
3+
---
4+
5+
fix(rest): twenty more REST write routes answer a sandboxed hook's refusal in the hook's own words (#22719)
6+
7+
Clause-②: no
8+
9+
**What was wrong.** When a sandboxed hook refuses a write (`throw new Error('Locked rows cannot be changed here.')`), the error that reaches the route carries the QuickJS debug wrapper on `.message` (`hook 'guard' threw: Error: …`) and the sentence on `.innerMessage`. Twenty write routes built their error answer by hand from `.message`, so they shipped the wrapper, while the other write doors answer the sentence. Sixteen of them also answered the refusal as a server fault (`500`).
10+
11+
**What changes on the wire, for a sandboxed hook's refusal.**
12+
13+
| routes | before | after |
14+
| --- | --- | --- |
15+
| `POST /sharing/rules`, `DELETE /sharing/rules/:idOrName`, `POST /sharing/rules/:idOrName/evaluate` | `500 RULE_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested `{ success: false, error: { code, message } }`, the sentence |
16+
| `POST /security/suggested-bindings/:id/confirm` and `/dismiss`, `POST /security/permission-sets/:id/discard-overlay` | `500 SUGGESTION_*_FAILED` / `500 INTERNAL`, the wrapper | `400 VALIDATION_ERROR`, same nested `{ error: { code, message } }` shape, the sentence |
17+
| the nine `POST /approvals/requests/:id/*` writes (`approve`, `reject`, `recall`, `revise`, `resubmit`, `reassign`, `remind`, `request-info`, `comment`) | `500 APPROVAL_*_FAILED`, flat `{ code, error }`, the wrapper | `400 VALIDATION_ERROR`, nested, the sentence |
18+
| `POST /packages/publish` | `500 INTERNAL_ERROR`, the wrapper | `400 VALIDATION_ERROR`, the sentence |
19+
| `POST /datasources/:name/external/tables/:remote/draft` and `/import`, `/external/refresh-catalog`, `/external/validate` | `400 EXTERNAL_DATASOURCE_ERROR` / `400 EXTERNAL_IMPORT_ERROR`, the wrapper | the same status and code, the sentence |
20+
21+
A refusal that declares its own `code` keeps it (an unregistered spelling rides `declaredCode`), a refusal that declares its own `status` is answered at that status, and a `userMessage` the hook set rides the answer, as on every other write door.
22+
23+
**Also moved, on the sharing-rule, suggested-binding and approval routes.** These routes now ask the same classification as the `/data` door before their `500` arm. So a refusal from the service that declares its own `status` (or `statusCode`) and `code`, and that no route-specific arm reads, is answered at that status, in the nested envelope, instead of `500` with the route's own code. The external-datasource routes keep answering every refusal at `400` with their own code, and `POST /packages/publish` already answered a declared status.
24+
25+
**Unchanged.** A plain fault keeps each route's own `500` answer and code. The route-specific refusals (`VALIDATION_FAILED`, `PERMISSION_DENIED`, `RULE_NOT_FOUND`, `SHARING_NOT_ENABLED`, the approval prefixes, the typed security errors) answer exactly as before.
26+
27+
**Upgrading.** A client that reads both envelopes, as `@objectstack/client` does, needs no change. A client of the sharing-rule or approval routes that reads a string `body.error` reads `body.error.message` for these answers.

‎content/docs/permissions/authorization.mdx‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -195,20 +195,19 @@ export default defineStack({
195195
});
196196
```
197197

198-
At boot `bootstrapDeclaredCapabilities`
199-
(`packages/plugins/plugin-security/src/bootstrap-declared-capabilities.ts`) seeds
200-
each declaration into `sys_capability` with **`managed_by: 'package'` + `package_id`**
201-
provenance — idempotent, re-seeded on upgrade, and it **never clobbers**
202-
admin-authored rows, refuses to hijack a curated platform capability, and refuses
203-
to write into another package's capability.
198+
At boot each declaration becomes a registry item owned by its package — the
199+
capability's one home (ADR-0131 D3). No `sys_capability` row is written for it.
200+
The registry's one-holder rule refuses a second holder of a name at boot with a
201+
`422`, so a package can neither take a curated platform capability nor take
202+
over another package's.
204203

205204
This **replaces the implicit back-door** where a capability existed only as an
206205
untitled placeholder derived from whatever a permission set happened to reference
207206
in `systemPermissions[]`. That derivation is retired: a reference with no
208207
declaration still matches by name at runtime but gets no `sys_capability` row.
209-
An explicit `defineCapability` takes precedence and — for a derived placeholder
210-
an older database still holds — **claims** it, upgrading the row to package
211-
provenance with the authored label/description/scope. This is the ADR-0066 D1
208+
An explicit `defineCapability` is the attributable form: its label,
209+
description, scope and owning package live on the registry item, and it gets
210+
no row either. This is the ADR-0066 D1
212211
direction: retire the implicit `managed_by`-guessing back-doors in favour of
213212
explicit, attributable declarations.
214213

@@ -557,8 +556,8 @@ The complete, prioritized gap map lives in issue **#2561** (the production
557556
`sys_capability` row.
558557
A **package** now DEFINES its own capabilities explicitly via
559558
[`defineCapability` / `stack.capabilities`](#package-capability-declaration-adr-0066-d1),
560-
seeded with `managed_by:'package'` + `package_id` provenance by
561-
`bootstrap-declared-capabilities.ts` — replacing the implicit
559+
served by the registry with owning-package provenance (no row is written for
560+
it) — replacing the implicit
562561
derive-from-`systemPermissions` back-door (now retired).
563562
The authoring lint (ADR-0066 ⑨) is also **landed**: `validateCapabilityReferences`
564563
(`@objectstack/lint`) warns at author time (`os validate` / `os lint`) when a

‎content/docs/permissions/capabilities.mdx‎

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,8 @@ capability name travels through five stations:
4949
② REGISTER AppPlugin → metadata.registerInMemory('capability', name)
5050
│ (also: **/*.capability.ts files)
5151
▼
52-
③ SEED bootstrapDeclaredCapabilities → sys_capability row
53-
│ managed_by: 'package' + package_id provenance
52+
③ SERVE registry item (capability kind) → catalog read, /meta door
53+
│ owning-package provenance, one holder per name
5454
▼
5555
④ GRANT permission set: systemPermissions: ['export_data']
5656
│ assigned to positions / users
@@ -62,12 +62,13 @@ capability name travels through five stations:
6262
1. **Declare** — `defineCapability(...)` on the stack's `capabilities` array,
6363
or a `*.capability.ts` / `*.capability.yml` file the filesystem loader globs.
6464
2. **Register** — at boot, `AppPlugin` puts each stack-declared capability into
65-
the metadata registry under the `capability` kind, so boot seeders and
66-
runtime resolvers can list it.
67-
3. **Seed** — `bootstrapDeclaredCapabilities` (in `@objectstack/plugin-security`)
68-
reads them back and upserts each one into `sys_capability` with
69-
`managed_by: 'package'` and `package_id` provenance. This is what makes the
70-
capability *attributable* and package uninstall well-defined.
65+
the metadata registry under the `capability` kind, so the security catalog
66+
and runtime resolvers can list it.
67+
3. **Serve** — the registry is the capability's one home. It records the
68+
owning package as provenance, which is what makes the capability
69+
*attributable*. The security catalog read and
70+
`GET /api/v1/meta/capability/:name` answer its definition. No
71+
`sys_capability` row is written for it.
7172
4. **Grant** — an administrator (or a permission set your package ships) lists
7273
the name in [`systemPermissions`](/docs/permissions/permission-metadata#system-permissions).
7374
5. **Check** — a resource lists the name in `requiredPermissions`, and the
@@ -157,7 +158,8 @@ plain names. Three consequences worth internalising before you pick one:
157158

158159
You cannot take a platform name. A declaration whose `name` collides with a
159160
curated platform capability (`manage_users`, `manage_metadata`,
160-
`setup.access`, …) is refused loudly at boot — those are platform-owned.
161+
`setup.access`, …) is refused at boot by the registry's one-holder rule
162+
(`422`) — those are platform-owned, and the platform declares them itself.
161163

162164
## "Or" is one capability, granted twice
163165

@@ -193,36 +195,29 @@ the same way; a user who may edit the object still reads all of its list views,
193195
so a save of what they loaded keeps them.
194196
</Callout>
195197

196-
## What the seeder does, and what it refuses
198+
## One holder per name
197199

198-
`bootstrapDeclaredCapabilities` runs on `kernel:ready` and is idempotent: it
199-
re-seeds on every boot, so the row always reflects the shipped declaration. Four
200-
outcomes are refusals rather than writes, and each one leaves the capability
201-
behaving differently:
200+
Each capability name has exactly one holder in the registry. When a package
201+
registers a declaration, the registry records that package as its owner.
202+
Package uninstall then removes it with the rest of the package. Two cases are
203+
refused at boot, with a `422` that names the name and both holders:
202204

203205
| Situation | Outcome |
204206
|:--|:--|
205-
| No resolvable owning package (`_packageId` and `packageId` both absent) | **Refused — no row is written.** The declaration is inert |
206-
| Name collides with a curated platform capability | Refused; the platform keeps its own definition |
207-
| A row already exists owned by a **different** package | Skipped loudly; a package never writes into a foreign record |
208-
| A row was authored by an administrator (`managed_by: 'admin'`) | Never clobbered |
209-
210-
A pre-existing `managed_by: 'platform'` row for a *non-curated* name is a
211-
different case: that is the untitled placeholder the old implicit back-door
212-
derived from whatever a permission set happened to reference, and an explicit
213-
declaration **claims** it — upgrading the row to package provenance with your
214-
authored label, description and scope. The provenance and composition rules
215-
behind that behaviour are set out in
216-
[Package capability declaration](/docs/permissions/authorization#package-capability-declaration-adr-0066-d1).
207+
| The name is a curated platform capability | Refused; the platform holds its own definition |
208+
| Another installed package already declares the name | Refused; a package never takes over another package's capability |
209+
210+
Declaring a capability writes no `sys_capability` row. Its label, description,
211+
scope and provenance live on the registry item, and that is what the catalog
212+
read and the metadata door serve.
217213

218214
<Callout type="info">
219-
**The `sys_capability` row is a catalogue, not a gate.** It carries the label,
220-
the scope and the provenance that make a capability reviewable, attributable and
221-
uninstallable — but no authorization check reads it. Permission-set grants and
222-
resource `requiredPermissions` match capability names **as strings**; the only
223-
production reader of the table is the declared-capability boot seeder. The row's `active`
224-
flag is a catalogue/visibility flag with no authorization effect: clearing it
225-
revokes nothing. Withdraw a capability by withdrawing the **grant**.
215+
**No authorization check reads a `sys_capability` row.** Permission-set grants
216+
and resource `requiredPermissions` match capability names **as strings**. The
217+
boot writes no row for a capability, declared or curated: both are served by
218+
the registry. A row an older database still holds is a catalogue only, and
219+
clearing its `active` flag revokes nothing. Withdraw a capability by
220+
withdrawing the **grant**.
226221
</Callout>
227222

228223
## `capability` is code-only
@@ -267,7 +262,7 @@ to store arbitrary JSON on the authorization surface.
267262
- [ ] `label` and `description` are written for the administrator who will grant it in Setup
268263
- [ ] `scope` is `org` unless the privilege really is platform-wide
269264
- [ ] The declaration is reachable from the stack (`capabilities: [...]`) or a `*.capability.ts` file
270-
- [ ] The package resolves an owning package id — otherwise the seeder writes no row
265+
- [ ] No other installed package declares the same name — the registry refuses a second holder at boot
271266
- [ ] Something actually **requires** the name (`requiredPermissions`), and something **grants** it (`systemPermissions`)
272267

273268
## See also

‎content/docs/permissions/permission-sets.mdx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -150,9 +150,9 @@ the anchor decides *which* unit `unit*` means.
150150
- **Capabilities** are named privileges such as `manage_users`,
151151
`manage_metadata`, `setup.access`, `studio.access`. The platform's curated
152152
ones are declared `capability` metadata served by the registry
153-
(`GET /api/v1/meta/capability`) and have no `sys_capability` row; a
154-
capability a package declares with `defineCapability` is also seeded as a
155-
`sys_capability` row.
153+
(`GET /api/v1/meta/capability`); a capability a package declares with
154+
`defineCapability` is served the same way, under its owning package. Neither
155+
has a `sys_capability` row.
156156
Grant them via `systemPermissions`; declare them as prerequisites via an
157157
object/field/app `requiredPermissions` (an **AND**-gate checked before any
158158
CRUD grant).

0 commit comments

Comments
 (0)