Skip to content

Commit b9e6d50

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/project-thread-ovj591
2 parents 9f0e34b + bda53bb commit b9e6d50

26 files changed

Lines changed: 1878 additions & 201 deletions
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security)!: under a `controlled_by_parent` declaration whose one master reference is `readonly` or `system`, every write that sets that reference is refused (ADR-0055 §1)
6+
7+
Clause-②: no (narrowing)
8+
9+
Metadata-protocol: none
10+
11+
<!-- adr-0087: not-required (already-registered cbp-master-relation-tiers-retired) registered before this change, and it covers it: that entry's stored-row listing (`os migrate meta --stored`) already reports this declaration as `master-not-writable`, with the prescription below. Nothing new moves: no spec key, authorable spelling, object definition, stored row or stored shape is added, removed, renamed or re-shaped, and no exported declaration is removed or narrowed. What changes is the answer the security plugin's write gate gives on an object that reached the registry with a declaration the parse already refuses. -->
12+
13+
**BREAKING** for a caller that relied on such a write landing. A non-protocol change, shipped as `patch`.
14+
15+
ADR-0055 §1 derives a `controlled_by_parent` detail's access from exactly one required `master_detail` field that every write of a detail supplies. The parse refuses that master when it is declared `readonly: true` or `system: true`, so an object with it reaches the runtime only through a door that does not parse: a raw `registerObject` or `registerApp`, a `defineStack` call with `strict: false`, or a stored object the boot registers under its `[metadata_spec_invalid]` diagnostic. On such an object the security plugin's write gate let writes that set the master reference through, and a detail could end up outside the master its access derives from, readable by nobody, while the write reported success.
16+
17+
- **Before.** An insert under that declaration was written when it named a master. An update could set the reference.
18+
- **Now.** Every write that sets the reference answers `INVALID_METADATA` (422) and writes nothing, before any master is asked: every insert, single or batch, whether or not it names a master, and every update whose body carries the reference key, whatever the value, `null` included. The message is the parse's own refusal of the declaration, prefixed with the write it refused, so it names the object, the field, each flag and the fix. It is the same for every caller the gate judges.
19+
- **Unchanged.**
20+
- The rows already stored: the security plugin still derives their read access from that field, so they keep their master and are read as before.
21+
- A delete, and an update whose body leaves the reference key out.
22+
- System contexts, every object declared with an ordinary required master, and every object that does not declare `controlled_by_parent`.
23+
24+
**What to do.** Fix the declaration as the parse says: remove `readonly: true` and `system: true` from the master reference and declare it as an ordinary required field. If the rows must outlive their parent, the object is not a detail: give it its own `sharingModel` (`private`, `public_read` or `public_read_write`).
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
fix(metadata-protocol)!: the metadata save doors and `composeStacks` refuse an object's `external` binding on a named managed datasource, from either side of the pair
7+
8+
Clause-②: yes (narrowing)
9+
10+
Metadata-protocol: narrowed
11+
12+
<!-- adr-0087: not-required (already-registered object-external-binding-managed-datasource-refused) registered by #25168 before this change, amended in this diff: its surface, replacement and reason now name composeStacks and the two metadata save doors -->
13+
14+
**BREAKING** (what the metadata API stores narrows), shipped as `minor` under the 18.x convention for breaking changes.
15+
16+
ADR-0015 §4.2 allows `object.external` only on a datasource whose `schemaMode` is not `'managed'` (an absent `schemaMode` is `'managed'` by the spec default). #25168 enforced the halves a parse and a single stack can see. A binding on a datasource stored or registered at runtime, or declared by another input of a composition, still got no verdict: the save door stored an `external` object on a managed datasource, stored a datasource re-saved as managed under objects bound to it with `external`, and `composeStacks` accepted the pair split across two inputs. The runtime then disagrees with itself about the object: its schema sync skips the table as federated while the boot validation and the write gate judge it as managed.
17+
18+
- **FROM:** `PUT /api/v1/meta/object/NAME` stored an object with `external` and a `datasource` naming a stored or registered datasource declared `schemaMode: 'managed'` or with no `schemaMode`, in draft and publish mode and under `force`. `PUT /api/v1/meta/datasource/NAME` stored a datasource as managed (or with no `schemaMode`) while stored or registered objects bound it with `external`. `composeStacks` accepted an `external` object of one input bound to a managed datasource another input declares.
19+
- **TO:**
20+
- The object save door refuses the object with `422 INVALID_METADATA`, the finding at `external`, nothing stored. The datasource it judges against is the one the door's own by-name read serves: the environment's stored row, else the definition the code layer registers.
21+
- The datasource save door refuses the datasource with `422 INVALID_METADATA`, one finding per bound object at `schemaMode`, the stored row unchanged. The objects it judges are the environment's active object rows and the registered objects no stored row overlays. An object's draft binds nothing until it is published.
22+
- Both are judged in draft and publish mode, and again when a draft is published (`publishMetaItem` and each item of a package publish), against the other half as it stands then. `force` does not skip them.
23+
- `composeStacks` refuses the cross-input pair with `STACK_CROSS_REFERENCE_INVALID` (422), in either input order and under every `manifest` strategy, `preserve` included. An input built with `strict: false` that holds the pair itself is refused here too, as with the artifact pass's other rules.
24+
- Every finding is the spec predicate's, so it reads as the parse's and `defineStack`'s do: "Object 'OBJECT' declares `external` on datasource 'NAME', whose `schemaMode` is 'managed' …".
25+
- **Unchanged:** the rule itself. A binding on a datasource declared `schemaMode: 'external'` or `'validate-only'` is accepted on every door. A datasource no layer holds gets no verdict: the absence of a definition is no evidence about its `schemaMode`. The datasource-admin service's update (`PATCH /api/v1/datasources/NAME`) does not pass through the metadata save door and is not judged by this change. Rows already stored in the refused shape still load as before; a save of either side that leaves the pair inconsistent is refused, so the next object save waits until the datasource is external or the object drops `external`.
26+
- **The fix for an author:** keep the pair consistent, in this order. To federate, save the datasource with `schemaMode: 'external'` (or `'validate-only'`) and its `external` settings first, then bind objects to it. To make a datasource managed, remove `external` from the objects that bind it first, then save the datasource.
27+
28+
This narrows the **Unchanged** bullet of the #25168 changeset, which said a binding on a datasource "registered by the host or another package, or stored at runtime" gets no verdict at the load doors: the metadata save doors now judge it, and `composeStacks` judges one another input declares.
29+
30+
`@objectstack/spec/data`: exports `externalBindingDatasourceRefusal` and its result type `ExternalBindingDatasourceRefusal`, the one predicate behind the rule, so a runtime door resolves the datasource it can see and asks it rather than copying the judgement. Its behaviour is unchanged.
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 `objectstack://objects` resources read the stdio transport's merged object view, so they list and describe every object `list_objects` and `describe_object` answer, and omit a disabled package's objects as those tools do
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's two object resources read: the protocol's merged listing, the one its `list_objects` / `describe_object` tools and `GET /api/v1/meta/object` already serve, instead of the metadata service's copy. No published signature moves: `MCPServerRuntime.bridgeResources` keeps its two parameters, and the view reaches it through a package-internal function `index.ts` does not publish. 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 object resources stop serving a disabled package's objects), shipped as `patch`: it changes which reads the stdio transport answers, not what a metadata author can write.
13+
14+
The long-lived stdio MCP server (`objectstack mcp`, `OS_MCP_STDIO_ENABLED`) answered `resources/read` of `objectstack://objects` and `objectstack://objects/NAME` from the `metadata` service. 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 `objectstack://objects` answered `{ objects: [], totalCount: 0 }` for such a package and `objectstack://objects/NAME` answered `404 RESOURCE_NOT_FOUND` for each of its objects, while `list_objects` and `describe_object` listed and described them.
15+
16+
- **Before.** On that kernel, an object a package registered during the boot was missing from `objectstack://objects`, and `objectstack://objects/NAME` answered `{ error: 'Object "NAME" not found', code: 'RESOURCE_NOT_FOUND', status: 404 }` for it.
17+
- **Now.** Both resources read the view `list_objects` and `describe_object` answer from: the protocol's merged listing, the one `GET /api/v1/meta/object` serves. `objectstack://objects/NAME` answers the same body `describe_object` answers for that name.
18+
- **Also changed.** A disabled package's objects, where the `metadata` service still held a copy, were listed by `objectstack://objects` and described by `objectstack://objects/NAME`. They are now omitted, and `objectstack://objects/NAME` answers `404 RESOURCE_NOT_FOUND` for them, as `describe_object` answers "not found". When the protocol's merged listing throws, both resource reads fail with that error, as `list_objects` does, instead of answering 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 described as before. The completeness verdict is still the `metadata` service's: a loader outage still withholds `totalCount` and answers `partial: true`, and a miss during an outage still answers `503 SERVICE_UNAVAILABLE`. A host with no `protocol` service, or one without the merged listing, keeps reading the `metadata` service. A host that calls `MCPServerRuntime.bridgeResources` itself reads the `metadata` service as before.
20+
- **What to do.** Nothing. A stdio client that read a disabled package's object through these resources was reading a stale copy the rest of the platform no longer serves: re-enable the package to restore it, as REST says.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 31 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -178,24 +178,35 @@ substitute. See the [Data API](/docs/api/data-api).
178178
### `MISSING_REQUIRED_FIELD`
179179
**Cause:** A required field was not provided in the request body.
180180
**Fix:** Include the missing field. Check `fields` for the field name — **except** on the
181-
`controlled_by_parent` master-reference paths below, where the status is `422` and no
182-
`fields` array is sent; read the field name out of the message there.
181+
`controlled_by_parent` stored-master path below, where the status is `422` and no `fields`
182+
array is sent; read the field name out of the message there.
183183
**Retry:** `no_retry`
184184

185185
<Callout type="warn">
186-
**`MISSING_REQUIRED_FIELD` is `400` with one exception: an absent `controlled_by_parent`
187-
master reference answers `422`, without `fields`.**
186+
**`MISSING_REQUIRED_FIELD` is `400` with one exception: a `controlled_by_parent` detail
187+
whose stored master reference is null answers `422`, without `fields`.**
188188

189189
An object whose `sharingModel` is `controlled_by_parent` derives its access from a master
190190
record, so the gate authorizing writes to it resolves that master *before* the executor —
191-
and the executor is where required-field validation runs. Which of the two refuses first
192-
depends on how the master reference is declared. A `master_detail` that is `required` and
193-
neither `readonly` nor `system` reaches validation and answers the documented
194-
`400 VALIDATION_FAILED` with `fields`. The other two declarable shapes never reach it and
195-
answer `422 MISSING_REQUIRED_FIELD` with no `fields`:
196-
197-
- a `master_detail` that is `required` + `readonly`
198-
- a `master_detail` that is `required` + `system`
191+
and the executor is where required-field validation runs. An update or delete **by id** of
192+
a detail record whose *stored* master reference is null answers
193+
`422 MISSING_REQUIRED_FIELD`, whatever the declaration: the caller sent no such field, so
194+
nothing could be named in `fields` and no payload would fix it.
195+
196+
A request that leaves the master reference out, or empties it, does not answer this code.
197+
On a `master_detail` that is `required` and neither `readonly` nor `system`, validation
198+
answers it with the documented `400 VALIDATION_FAILED` and `fields`. A master reference
199+
marked `readonly` or `system` is not one an author's write supplies, so it cannot be the
200+
master (ADR-0055 §1): `ObjectSchema` refuses that declaration at parse
201+
(`master-not-writable`), and on an object that reaches the runtime without the parse, every
202+
write that sets the reference answers `422 INVALID_METADATA`:
203+
204+
- every insert, single or batch, whether or not it names a master;
205+
- every update whose body carries the reference key, whatever the value, `null` included.
206+
207+
Its message starts `[Security] Configuration defect:` and carries the parse's own refusal,
208+
naming the object, the field, the flag and the fix. A delete, and an update that leaves the
209+
key out, are answered as on any detail.
199210

200211
A `master_detail` with no `required`, and a `required` `lookup` on an object that declares no
201212
`master_detail`, are not master references at all: the master is exactly one `master_detail`
@@ -205,20 +216,14 @@ one that reaches the runtime unparsed answers every write `422 INVALID_METADATA`
205216
The full matrix is on the protocol page:
206217
[`MISSING_REQUIRED_FIELD`](/docs/protocol/kernel/error-handling#missing_required_field).
207218

208-
An update or delete **by id** whose *stored* master reference is null answers the same
209-
`422`, whatever the declaration — the caller sent no such field, so nothing could be named
210-
in `fields` and no payload would fix it.
211-
212-
The refusal itself is correct and is not going to be relaxed: on those two shapes
213-
required-field validation does not fire (it skips `system` and `readonly` fields before its
214-
required check), so the gate is the only
215-
thing standing between the request and a detail record with a null master reference — which
216-
the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, leaving
217-
a record readable by nobody.
219+
The `INVALID_METADATA` refusal is not going to be relaxed: required-field validation skips
220+
`system` and `readonly` fields before its required check, so on those two declarations the
221+
gate is the only thing standing between the request and a detail record outside its master
222+
— which the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match,
223+
leaving a record readable by nobody.
218224

219-
These shapes are **authorable today**: `os lint` reports both at error, but the publish gate
220-
does not run that rule. **Branch on `code`, treat `fields` as optional, and read the status
221-
off the response** rather than deriving it from the table at the end of this page.
225+
**Branch on `code`, treat `fields` as optional, and read the status off the response**
226+
rather than deriving it from the table at the end of this page.
222227
</Callout>
223228

224229
### `INVALID_FORMAT`
@@ -973,7 +978,7 @@ async function handleApiCall() {
973978
| 404 | `not_found` | `RECORD_NOT_FOUND`, `OBJECT_NOT_FOUND`, `ENDPOINT_NOT_FOUND` |
974979
| 409 | `conflict` | `CONCURRENT_MODIFICATION`, `UNIQUE_VIOLATION`, `DELETE_RESTRICTED` |
975980
| 413 | `validation` | `PAYLOAD_TOO_LARGE` — an upload over the storage settings' `max_upload_mb`, or an import over its row ceiling (see [above](#payload_too_large)) |
976-
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on an absent `controlled_by_parent` master reference (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
981+
| 422 | `validation` | `MISSING_REQUIRED_FIELD` on a `controlled_by_parent` detail whose stored master reference is null (see [above](#missing_required_field)) — this row is an exception to the 400 row, not a second home for the code |
977982
| 429 | `rate_limit` | `RATE_LIMIT_EXCEEDED`, `QUOTA_EXCEEDED` |
978983
| 500 | `server` | `INTERNAL_ERROR`, `DATABASE_ERROR` |
979984
| 501 | `server` | `NOT_IMPLEMENTED` — the storage routes answer it when the adapter cannot issue presigned URLs |

‎content/docs/data-modeling/external-datasources.mdx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,17 @@ export const Customer = ObjectSchema.create({
8989
That's it. `GET /api/v1/data/ext_customer` now returns live rows from the remote
9090
`customers` table; filters (`?region=EU`) push down to the remote query.
9191

92+
The object's **own** `datasource` must name a datasource declared
93+
`schemaMode: 'external'` or `'validate-only'` (ADR-0015 §4.2). An `external` binding on
94+
`'default'` (also the value when `datasource` is omitted) is refused by `ObjectSchema.create()`,
95+
which sees only the object. A binding on either `'default'` or a `managed` datasource is refused
96+
by `defineStack` and `composeStacks` (for a datasource the stack, or another input of the
97+
composition, declares), and at runtime by `PUT /api/v1/meta/object/<name>` with
98+
`422 INVALID_METADATA`. The same save door refuses
99+
`PUT /api/v1/meta/datasource/<name>` that would make a datasource `managed` while objects
100+
bind it with `external`: change the datasource to `external` before binding objects, and
101+
remove `external` from the objects before making it managed.
102+
92103
<Callout type="warn">
93104
**`field.columnName` no longer exists.** It was removed from `FieldSchema` in the
94105
16.x line (#2377, ADR-0049) — the SQL driver always used the field key as the
@@ -250,7 +261,7 @@ object must opt in:
250261

251262
```typescript
252263
defineDatasource({ /* ... */ external: { allowWrites: true } }); // datasource gate
253-
ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true } }); // object gate
264+
ObjectSchema.create({ /* ... */ datasource: 'warehouse', external: { remoteName: 'orders', writable: true } }); // object gate
254265
```
255266

256267
With either gate off, insert/update/delete on the federated object is rejected.

0 commit comments

Comments
 (0)