Skip to content

Commit 6f6e900

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-25208-8c2-carriers
2 parents 2c165f8 + 0b8bce4 commit 6f6e900

62 files changed

Lines changed: 3630 additions & 797 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
'@objectstack/service-analytics': patch
4+
'@objectstack/driver-turso': patch
5+
'@objectstack/spec': patch
6+
---
7+
8+
fix(plugin-security): a `controlled_by_parent` detail's read scope is the engine's `ReadableIds` leaf over its master — one statement per read, no master id list, 200 past the dialect's bind limit
9+
10+
Clause-②: no
11+
Metadata-protocol: none
12+
13+
- **`@objectstack/plugin-security`.** The read derivation for a `controlled_by_parent` object answers `{ masterFK: { $in: ReadableIds.of(master) } }` (ADR-0139 D6, the leaf's seventh consumer) in place of a master id list it pre-scanned under a system context and bound into `$in`. The engine composes the leaf at its executor boundary from this service's own `canReadObject` / `getReadFilter` of the master — recursively down a master chain — and the driver compiles one statement with the master inside a subquery. Measured before: 2 statements per find and 4 per find + count on a detail, and `DATABASE_ERROR` / 500 past ~32,800 masters (`too many SQL variables`). Now: 1 and 2, no scan of the master, and 200 with the right rows at 33,000 masters. Every row set is unchanged: plain, restricted, unrestricted (`viewAllRecords`), delegated (the ADR-0090 D10 intersection), two-level chains, null and dangling master pointers (still excluded), and a caller the master object does not admit (still nothing). A metadata cycle and a chain past the depth bound still answer zero rows, now from a registry-only walk that pre-empts the engine's own refusal. A write verb's predicate carries no leaf (ADR-0139 D1): a predicate write of a detail is narrowed by the read door's answer and judged per master, as before.
14+
- **`@objectstack/service-analytics`.** The engine-bound merges (`ObjectQLStrategy.withReadScope`, its FK attribute map, and the plugin's record-label pass) admit the leaf and hand the scope to the engine, which composes it as a find does. `NativeSQLStrategy.canHandle` declines a query whose base or joined object's read scope carries the leaf, so a `controlled_by_parent` object's analytics query routes to the ObjectQL strategy and serves the find path's rows. `compileScopedFilterToSql` refuses a scope carrying the leaf by name, in its existing `READ_SCOPE_COMPILE_FAILED` / 500 envelope: the `/api/v1/analytics/sql` dry-run echo of a `controlled_by_parent` object now refuses where it rendered the bound id list — it runs no composition and shows no statement it would not run. A filter an author writes keeps its judgement on every face.
15+
- **`@objectstack/driver-turso`.** The remote face resolves a composed leaf nested inside a composed leaf's scope through `toRemoteWhere` with the outer read's options, so a two-level `controlled_by_parent` chain on a remote Turso datasource answers rows instead of `INTERNAL_ERROR` / 500.
16+
- **`@objectstack/spec`.** One nested-leaf row in `READABLE_IDS_CASES`, so every driver's conformance suite measures the shape, and the comparand-shape door's docblock names both callers that pass `admitReadableIds`. No schema, accept set or export of a metadata key moves.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/mcp': patch
3+
---
4+
5+
fix(mcp)!: the stdio data bridge reads objects from the protocol's merged view, so `list_objects` and `describe_object` see every object `GET /api/v1/meta/object` lists, a package object declared `apiEnabled: false` is refused by every data verb, and so is every object of a disabled package (`404 OBJECT_PACKAGE_DISABLED`)
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What changes is which definition the stdio MCP transport consults: its object discovery and its API-exposure gate now read the protocol's merged view, the definition REST and the `/mcp` HTTP bridge already serve and enforce, instead of the metadata service's copy. An object's own `enable.apiEnabled` / `enable.apiMethods` declaration is now enforced where it was not read; the remedy is the declaration the author already wrote, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
11+
12+
**BREAKING** (the stdio data verbs' accept set narrows to the object's own declaration), shipped as `patch`: it changes which requests the stdio transport serves, not what a metadata author can write.
13+
14+
The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `list_objects` and `describe_object` from the `metadata` service, and gated its data verbs and its record resource on the same service's copy of the object. That service holds a copy of an object only where something put one there. On a kernel with an `environmentId` (`os dev`, `os serve`, `os start`, and the cloud single-environment runtime) nothing copies the objects a package registers during the boot. So `list_objects` answered `{ objects: [], totalCount: 0 }` for such a package, `describe_object` answered "not found" for each of its objects, and the gate read the missing copy as "no declaration" and served them. REST and the `/mcp` HTTP bridge listed and refused the same objects.
15+
16+
- **Before.** On that kernel, an object a package registered during the boot was missing from `list_objects` and answered `Object "NAME" not found` by `describe_object` and `validate_expression`. Declared `apiEnabled: false`, it was served by `query_records`, `get_record`, `aggregate_records` and the `objectstack://objects/NAME/records/ID` resource, and `create_record`, `update_record` and `delete_record` reached the store. Its `internal: true` fields were echoed in the `create_record` and `update_record` responses.
17+
- **Now.** `list_objects` and `describe_object` answer from the listing `GET /api/v1/meta/object` serves. Every data verb and the record resource refuse such an object `404 OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` for a verb its `apiMethods` leaves out, as `RestServer` and the HTTP bridge do. Its `internal: true` fields are omitted from the write responses, and its nested-relation conditions are gated on their targets.
18+
- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed and described; they are now omitted, as REST omits them. Every data verb and the record resource now refuse a disabled package's object `404 OBJECT_PACKAGE_DISABLED`, whatever the object declares: before, the stdio transport served it on every verb, because each verb asks the engine and never the protocol. When the metadata store cannot be read, `list_objects` and `describe_object` answer the store's `503 SERVICE_UNAVAILABLE`, as `GET /api/v1/meta/object` does, instead of the `metadata` service's listing.
19+
- **Unchanged.** Objects that already had a copy (every object on a kernel without an `environmentId`, a marketplace install on any kernel, and a runtime-authored object) are listed and gated as before. A host with no `protocol` service, or one without the merged reads, keeps reading the `metadata` service. If the protocol's by-name read fails, the gate judges the `metadata` service's copy, as it did before.
20+
- **What to do.** Nothing, if the declarations say what you mean. A stdio client that relied on reaching an object declared `apiEnabled: false` was relying on the defect: set `enable.apiEnabled` (or `enable.apiMethods`) on the object to the exposure you want. A stdio client that read a disabled package's objects was relying on the same kind of defect: re-enable the package to restore access, as REST says.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os create plugin` ships a starter test, so a new project's `npm test` passes
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
A project made by `os create plugin NAME` declared `"test": "vitest"` and contained no test file, so its first `npm test` exited 1 with `No test files found, exiting with code 1`. The template now writes `src/index.test.ts`, which runs the checks the kernel runs on a plugin when it loads one: a `name`, an `init` function, and the protocol's `PluginSchema`. It boots no kernel; its header links the `@objectstack/verify` harness for tests that need one. The `test` script is now `vitest run`, one pass that exits.
11+
12+
The starter test lives in `src/`, which `tsc` compiles, so the template also writes `tsconfig.build.json`: it extends `tsconfig.json` and excludes `src/**/*.test.ts`, and `build` and `dev` now run `tsc -p tsconfig.build.json`. `typecheck` still reads `tsconfig.json`, so a type error in a test still fails it, while `dist/` holds no compiled test and `npm test` after `npm run build` runs each test once.
13+
14+
**An existing project made by an earlier `os create plugin`** still has no test file. Add a `*.test.ts` file, for example a copy of the `src/index.test.ts` that a new `os create plugin NAME` writes. If you put it under `src/`, also copy `tsconfig.build.json` and point the `build` and `dev` scripts at it (`tsc -p tsconfig.build.json`), or `tsc` compiles the test into `dist/` and vitest runs it twice.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
feat(spec): `ISharingRuleService` declares the optional `declaredRuleNamed` reader that the catalog activation door uses for declared sharing rules
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: none
10+
11+
- **The contract member.** `ISharingRuleService` (`@objectstack/spec/contracts`) gains an optional `declaredRuleNamed?(name)` member. It returns `Promise<{ name: string; packageId?: string } | null>`: the rule the application declares under that name, or `null` when nothing servable is declared under it. The answer covers every organization, and it does not depend on whether the activation ledger has the rule switched off. `POST /api/v1/security/_activation/sharing_rule/:name` reads it to confirm the name it is asked to switch. Before this change the door read the method by duck type, and the contract did not mention it.
12+
- **Optional, so no implementer breaks.** An implementation that serves no declared rules can leave it out. A caller probes for it and refuses fail-closed when it is absent. The activation door still answers `503 SERVICE_UNAVAILABLE` in that case, so the door's behaviour does not change. `@objectstack/plugin-sharing`'s `SharingRuleService` already implements it with a compatible signature.
13+
- **The ledger's field description.** On `sys_metadata_activation`, the `metadata_type` description now lists every type the ledger holds: `flow`, `action`, `position`, `permission`, `sharing_rule` and `webhook`. It used to stop at `flow`, `permission`, ….
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/organizations': patch
3+
---
4+
5+
fix(organizations)!: the first organization no longer adopts organization-less rows — `claimOrphanOrgRows` is retired
6+
7+
Clause-②: no (narrowing)
8+
Metadata-protocol: none
9+
10+
<!-- adr-0087: not-required (runtime-export-retired @objectstack/organizations#claimOrphanOrgRows) The one author-reachable removal is a runtime export: the function `claimOrphanOrgRows` leaves `@objectstack/organizations`'s published entry. No key of any metadata schema is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite and no tombstone. -->
11+
12+
**BREAKING** for a walled store (`group` or `isolated`) that holds application rows with no `organization_id` when its first organization is created, and for code that imports `claimOrphanOrgRows`. It ships as `patch`: no metadata an author writes changes.
13+
14+
- **The first organization adopts nothing.** When the per-org seed replay landed no row and the new organization was the store's first, `OrganizationsPlugin` used to write that organization's id onto every organization-less row of every application object (`claimed N orphan seed row(s) for first organization …`). Being created first names no owner, so this was an owner by guess, which ADR-0131 D9 and D10 forbid. Under a wall, the only posture that mounts this plugin, the organization-ownership ceremony reports such a row, leaves it NULL and holds it against certification; the claim then gave the same row to whichever organization existed. Those rows now stay NULL.
15+
- **A first organization with no seed replay logs that it starts empty**, the line every later organization already logs. The organization count the claim needed is no longer read.
16+
- **Unchanged:** the per-org seed replay, the organization stamp on insert, the Default Organization bootstrap, and `claimOrgSeedOwnership`, which hands an organization's seeded rows to its owner.
17+
18+
**What an operator does.** On a store this release creates or certifies there is no such row, so nothing. On a walled store an earlier release created that holds organization-less application rows, run `os migrate organization-ownership`: it attributes every row an anchor names, and lists each row it cannot attribute with its remedy instead of guessing an owner.
19+
20+
The retired export:
21+
22+
- FROM `claimOrphanOrgRows` TO no replacement; run `os migrate organization-ownership` for organization-less rows.

‎content/docs/permissions/sharing-rules.mdx‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,80 @@ upgraded from a build that leaked these rows — converges on the next start.
242242
forces a reconcile on demand.
243243
</Callout>
244244

245+
### Declared rules are read-only: clone one, then switch it off
246+
247+
A sharing rule comes from one of two sources. The application **declares** it
248+
(a stack's `sharingRules`), or an organization **authors** its own as a
249+
`sys_sharing_rule` row. A declared rule is code. It is served from the
250+
registry and has no row, so it is read-only from every API (ADR-0131 D6,
251+
regime C):
252+
253+
- `DELETE {basePath}/sharing/rules/:idOrName` on a declared name answers
254+
`403 PERMISSION_DENIED`, switched off or not
255+
(`SharingRuleService.deleteRule`, `plugin-sharing/src/sharing-rule-service.ts`).
256+
- `PUT {basePath}/meta/sharing_rule/:name` is refused too. The `/meta` side
257+
of this rule is described in
258+
[Metadata lifecycle](/docs/concepts/metadata-lifecycle).
259+
260+
**To customise a declared rule, clone it and switch the original off.**
261+
Create your own rule under a **new name**, with the criteria, recipient and
262+
access level you want. Then switch the declared rule off through the
263+
activation door:
264+
265+
```http
266+
POST {basePath}/security/_activation/sharing_rule/:name
267+
{ "enabled": false }
268+
```
269+
270+
The door writes one `sys_metadata_activation` row (`metadata_type:
271+
'sharing_rule'`, no organization column). The same request then revokes
272+
every grant that carries the declared rule's name, and `{ "enabled": true }`
273+
re-derives them (`handleCatalogActivationWrite`,
274+
`runtime/src/domains/catalog-activation.ts`;
275+
`bindSharingRuleActivationReconcile` and
276+
`SharingRuleService.reconcileDeclaredRuleActivation`). The door confirms the
277+
name through the sharing-rule service's `declaredRuleNamed`, and an unknown
278+
name answers `404`. A deployment whose sharing-rule service does not provide
279+
that reader answers `503 SERVICE_UNAVAILABLE`: the door does not write a row
280+
for a name it cannot confirm is declared.
281+
282+
**A declared rule's name is taken in every organization.** A new row under a
283+
declared name is refused with `409 RESOURCE_CONFLICT`, and so is an update
284+
that renames a row onto one. The refusal message names the clone path. This
285+
applies to `POST {basePath}/sharing/rules`, to Setup's **New** and edit forms,
286+
and to scripts and imports through the data API
287+
(`DeclaredSharingRuleNameConflictError` and `bindDeclaredRuleNameGuard`,
288+
`plugin-sharing/src/sharing-rule-service.ts`). An update that keeps the
289+
row's name, such as switching your own clone's `active` field, is not
290+
affected.
291+
292+
**Who may flip the switch.** The ledger row is deployment-level, so the door
293+
is gated by the activation authority, not by `manage_sharing`:
294+
295+
- the caller must hold `manage_metadata` (`403 PERMISSION_DENIED` otherwise);
296+
- under a walled tenancy posture (`group` / `isolated`), the caller must also
297+
be the **platform operator**, because one row switches the rule off in
298+
every organization. An organization administrator is refused with
299+
`403 PERMISSION_DENIED`, and the message says to ask the platform operator;
300+
- under `single`, the `manage_metadata` holder is enough.
301+
302+
These checks are `refuseUngrantedActivationAuthoring` and
303+
`refuseUngrantedActivationWrite` (`runtime/src/domains/activation-gate.ts`,
304+
ADR-0126 §5). Rules an organization authors under its own names stay that
305+
organization's to create, and each is switched by its own `active` field.
306+
307+
**If the reconcile fails.** The grant reconcile runs in an `afterInsert` /
308+
`afterUpdate` hook on `sys_metadata_activation`, after the ledger row is
309+
written, and no transaction wraps the two. A reconcile that throws reaches
310+
the door, which answers the error instead of `200`. The ledger row has
311+
already been written, though, and it keeps its new value. Some of the rule's
312+
grants can then disagree with the switch until the next boot. At boot,
313+
`sweepOrphanedRuleGrants` revokes the grants of a rule that is switched off,
314+
and the rule backfill re-derives the grants of one that is switched on
315+
(`SharingServicePlugin`'s `kernel:bootstrapped` handler,
316+
`plugin-sharing/src/sharing-plugin.ts`). Sending the same switch again re-runs
317+
the reconcile without waiting for a restart.
318+
245319
### There is no "share every record" rule
246320

247321
The predicate is **mandatory on every authoring path**, whether you declare

0 commit comments

Comments
 (0)