Skip to content

Commit 75877cc

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-15953-s6-owed-notes-shared-helpers
2 parents 84dbd2d + 76815b9 commit 75877cc

51 files changed

Lines changed: 1966 additions & 762 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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire the view-level `api` data source — a view binds its object or static rows, and a metadata-declared API call is the page element's binding (#23082)
6+
7+
**BREAKING** — `provider: 'api'` on a view's `data` (`ViewDataSchema`, with its `read` / `write` request blocks) is now refused at parse. The arm took any `url` and any `headers`, so a credential or a cross-origin call written into a view was stored as metadata, served to every client that read the view and sent by the console as written. The page element's API binding (`ElementApiDataSourceSchema`, `dataSource` on a page component) is the one door for a metadata-declared API call: same-origin, no credential key, read with the console's own signed-in fetch. One operation with two implementations that behave differently keeps the governed one — the view-level source is re-homed on the page element and removed, not aligned.
8+
9+
Clause-②: no (narrowing)
10+
11+
Measured before removal: no producer wrote `provider: 'api'` on a view outside documentation in objectstack (`bcebd8409`), objectui (`origin/main` `f228d710` — the console's approval-requests source builds `ApiDataSource` in code from an `ApiDataSourceConfig`, never a `ViewData`) or hotcrm (`0f84c9c`), each with a lit `provider: 'object'` control. A renderer reader existed in objectui, measured at `7578323a544e` (`ListView` routed the block to `ApiDataSource`; `ObjectGantt` built one from `read` / `write`), and is now unreachable from authored metadata.
12+
13+
## FROM → TO
14+
15+
| you wrote (17.7 and earlier) | write instead |
16+
| --- | --- |
17+
| `data: { provider: 'api', read: { url, method?, params?, headers? } }` on a list or form view | `data: { provider: 'object', object: '<object_name>' }` (or `{ provider: 'value', items: [...] }`), and the read as a page component's `dataSource: { provider: 'api', read: { url: '/api/...' }, path? }` |
18+
| `data: { provider: 'api', write: { url, method, body? } }` on a form view | nothing — a form submits to its object; a submit-to-endpoint is not a view concern |
19+
| `read.headers: { Authorization: '...' }` | nothing — a credential never lives in metadata; the page element binding refuses a credential key and the console sends its signed-in fetch |
20+
21+
**The one-line fix:** bind every `provider: 'api'` view to its object or to static rows, and move the endpoint read onto a page component's `dataSource`.
22+
23+
**What an author who still writes it sees.** `tsc` fails at the authoring site (`ViewData` no longer has the arm), and the parse — `defineView()`, `defineStack({ views })`, `os validate`, `PUT /api/v1/meta/view/:name` — refuses it at `data.provider` with the prescription:
24+
25+
> `view.data.provider: 'api'` was removed in @objectstack/spec 17 (ADR-0049 enforce-or-remove) — a view-level API source took any `url` and any `headers`, so a credential or a cross-origin call written there was stored with the view, served to every client that read it and sent by the console as written; a metadata-declared API call now has one door, the page element's binding, which is same-origin and credential-free. Bind the view to its object — `data: { provider: 'object', object: '<object_name>' }` — or to static rows — `data: { provider: 'value', items: [...] }` — and move the API read onto a page component: `dataSource: { provider: 'api', read: { url: '/api/...' } }` (with an optional `path` into the response) under a page's `regions[].components[]`, which the console reads with its own signed-in fetch.
26+
27+
## The retirement kit
28+
29+
- **A value retirement, not a key**: `data` survives one arm lighter, so there is no `retiredKey()` tombstone and no `RETIRED_KEYS_BY_MAJOR` row. The prescription rides the discriminated union's own `error` map, keyed on the input's `provider === 'api'` — the retired bare-string `sort` clause's shape — so every other unknown provider keeps zod's own report, which lists the live arms (`object`, `value`, `schema`).
30+
- **No D2 conversion, deliberately.** The replacement lives on a different metadata type (a page element), and the only mechanical edit a conversion could make — deleting the view's `data` so the view falls back to querying its bound object — would change what the view shows without telling anyone. An authored source or a stored `sys_metadata` row still carrying the arm meets the prescription at parse instead.
31+
- **The D3 entry `view-data-api-provider-retired`** asks the author, per such view, which page composes the endpoint it read and what the view itself should now bind.
32+
- **`HttpRequestSchema` / `HttpMethodSubsetSchema` stay exported** from `@objectstack/spec/shared` and `@objectstack/spec/ui`, unchanged. After this change no schema in the package composes `HttpRequestSchema`; that orphan is recorded, not acted on here.
33+
- **Ledger:** `view.list.data` and `view.form.data` stay `live` (their surviving arms are read) and carry the arm's retirement record.
34+
- **Docs and the published skill** no longer teach a view-level `api` source; both point at the page element binding.
35+
- **No deprecation window**, per the project's startup-stage posture.
36+
37+
⚠️ **The out-of-repo consumer population is NOT MEASURED.** `@objectstack/spec` is published, and `objectstack-ai/cloud`'s own view metadata was not readable from this change's session; a cloud view carrying an `api` source is refused at parse with the prescription above.
38+
39+
<!-- adr-0087: registered view-data-api-provider-retired -->
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/lint": minor
4+
"@objectstack/plugin-security": minor
5+
---
6+
7+
feat(spec)!: a `controlled_by_parent` object derives its master from exactly one required `master_detail` — the parse refuses anything else, and the runtime's two fallback tiers retire (#23100)
8+
9+
Clause-②: no (narrowing)
10+
11+
ADR-0055 §1 says a `controlled_by_parent` object "must declare exactly one required `master_detail` field … Validation error otherwise (fail closed)". Until now neither the parse nor the runtime kept it. `ObjectSchema` accepted such an object with no master at all. The security runtime derived a master through a three-tier fallback the record never stated: a required `master_detail`, then any `master_detail`, then a required `lookup`, taking the first match by field order within a tier. The maintainer ruling (letter B) enforces the record, under ADR-0049 (enforce-or-remove).
12+
13+
**BREAKING — what moves for consumers.**
14+
15+
- **`@objectstack/spec`.** `ObjectSchema` (raw `.parse()` / `.safeParse()`, `ObjectSchema.create()`, `defineStack`, `os validate` and the metadata save door) refuses an object declaring `sharingModel: 'controlled_by_parent'` unless exactly one of its fields is a `master_detail` declared `required: true`. With none, the issue sits at `sharingModel` and names each field that relied on a retired tier. With two or more, it sits at `fields` and names them. A plain object literal is judged like a raw parse: an omitted `required` on its `master_detail` is refused, while `ObjectSchema.create()` keeps forcing an omitted `required` to `true`. The step-18 semantic migration entry `cbp-master-relation-tiers-retired` carries the prescription for `os migrate meta`. The step-18 conversion `cbp-master-detail-required-defaulted` covers the one lossless shape: a `controlled_by_parent` object whose one `master_detail` omits `required`, which Studio's field designer writes. It gets `required: true`, exactly what `ObjectSchema.create()` does. It runs on stored rows, built artifacts and `os migrate meta --from 17`. A live authoring parse still refuses the shape and names the fix.
16+
- **`@objectstack/plugin-security`.** The relation resolver derives a master from that one field and nothing else. An object that declares a non-required `master_detail` only, a required `lookup` only, or two required `master_detail` fields resolves no master. Every read of it is denied, and every write and insert is refused with `422 INVALID_METADATA`. The plugin reports such an object once per object (`[security/controlled-by-parent] … resolves no master relation`), naming the shape it relied on. The `422` message now says "not exactly one required master_detail field".
17+
- **`@objectstack/lint`.** `security-controlled-by-parent-no-relation` now refuses the non-required `master_detail` and required `lookup` shapes as well. `security-controlled-by-parent-ambiguous-relation` reports two or more required `master_detail` fields as "no master is derived", since the runtime no longer picks one by field order. Two required `lookup` fields are now a no-relation object, not a tie. `security-master-detail-ungranted` no longer treats a lookup-bound `controlled_by_parent` object as a detail.
18+
19+
**FROM → TO — HotCRM's three objects.** These are the only producers measured on the retired third tier: the read-only HotCRM clone at `0f84c9c`, 8 `controlled_by_parent` objects, 5 of them already on one required `master_detail`. HotCRM's pinned engine is unaffected until it bumps past the release that carries this change.
20+
21+
| object | FROM | TO |
22+
|---|---|---|
23+
| `crm_campaign_member` | `crm_campaign: Field.lookup('crm_campaign', { required: true })` | `crm_campaign: Field.masterDetail('crm_campaign', { required: true, deleteBehavior: 'restrict' })` |
24+
| `crm_event_attendee` | `crm_event: Field.lookup('crm_event', { required: true })` | `crm_event: Field.masterDetail('crm_event', { required: true, deleteBehavior: 'restrict' })` |
25+
| `crm_article_feedback` | `crm_knowledge_article: Field.lookup('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` | `crm_knowledge_article: Field.masterDetail('crm_knowledge_article', { required: true, deleteBehavior: 'cascade' })` |
26+
27+
`deleteBehavior: 'restrict'` keeps what the two lookups do today: a campaign or an event with members or attendees cannot be deleted. Without it a `master_detail` cascades, and a detail's rows are deleted with their master. An object whose rows must outlive their parent is not a detail. It leaves `controlled_by_parent` for `private`, `public_read` or `public_read_write`.
28+
29+
**Remedy, in one line.** On every object with `sharingModel: 'controlled_by_parent'`, declare exactly one `master_detail` field with `required: true`, pointing at the master. Every other parent is a `lookup`.
30+
31+
**Unchanged.**
32+
33+
- An object with one required `master_detail` resolves and derives exactly as before, chain walk included. So does a required `master_detail` beside other `lookup` fields, or beside a `master_detail` that is not required. Every in-tree `controlled_by_parent` object is in that set: `examples/app-crm` (1), `examples/app-showcase` (2), the CLI's eval corpus (4) and the dogfood fixtures.
34+
- Stored metadata whose one `master_detail` merely omits `required` keeps serving, converted as above. A stored row in any other retired shape is not rewritten: it still registers, under its `[metadata_spec_invalid]` diagnostic, and is denied until it is corrected and saved. Those shapes are an explicit `required: false`, two masters, or a required `lookup`. None of them is converted: turning a `lookup` into a `master_detail` changes what deleting the master does to every row, choosing between two masters is the pick ADR-0055 §1 refuses, and leaving `controlled_by_parent` is the author's decision.
35+
- No export, signature or error code is added or removed.
36+
37+
<!-- adr-0087: registered cbp-master-detail-required-defaulted, cbp-master-relation-tiers-retired -->

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

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -182,13 +182,16 @@ record, so the gate authorizing writes to it resolves that master *before* the e
182182
and the executor is where required-field validation runs. Which of the two refuses first
183183
depends on how the master reference is declared. A `master_detail` that is `required` and
184184
neither `readonly` nor `system` reaches validation and answers the documented
185-
`400 VALIDATION_FAILED` with `fields`. The other four declarable shapes never reach it and
185+
`400 VALIDATION_FAILED` with `fields`. The other two declarable shapes never reach it and
186186
answer `422 MISSING_REQUIRED_FIELD` with no `fields`:
187187

188-
- a `master_detail` with no `required`
189188
- a `master_detail` that is `required` + `readonly`
190189
- a `master_detail` that is `required` + `system`
191-
- a `required` `lookup`, when the object declares no `master_detail`
190+
191+
A `master_detail` with no `required`, and a `required` `lookup` on an object that declares no
192+
`master_detail`, are not master references at all: the master is exactly one `master_detail`
193+
declared `required: true` (ADR-0055 §1). `ObjectSchema` refuses such an object at parse, and
194+
one that reaches the runtime unparsed answers every write `422 INVALID_METADATA`.
192195

193196
The full matrix is on the protocol page:
194197
[`MISSING_REQUIRED_FIELD`](/docs/protocol/kernel/error-handling#missing_required_field).
@@ -197,17 +200,16 @@ An update or delete **by id** whose *stored* master reference is null answers th
197200
`422`, whatever the declaration — the caller sent no such field, so nothing could be named
198201
in `fields` and no payload would fix it.
199202

200-
The refusal itself is correct and is not going to be relaxed: on those four shapes
203+
The refusal itself is correct and is not going to be relaxed: on those two shapes
201204
required-field validation does not fire (it skips `system` and `readonly` fields before its
202-
required check, and never fires on a field that is not `required`), so the gate is the only
205+
required check), so the gate is the only
203206
thing standing between the request and a detail record with a null master reference — which
204207
the `controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, leaving
205208
a record readable by nobody.
206209

207-
These shapes are **authorable today**: lint reports a `master_detail` without `required` as
208-
a warning only, and does not report the `readonly`, `system`, or fallback-`lookup` shapes at
209-
all. **Branch on `code`, treat `fields` as optional, and read the status off the response**
210-
rather than deriving it from the table at the end of this page.
210+
These shapes are **authorable today**: `os lint` reports both at error, but the publish gate
211+
does not run that rule. **Branch on `code`, treat `fields` as optional, and read the status
212+
off the response** rather than deriving it from the table at the end of this page.
211213
</Callout>
212214

213215
### `INVALID_FORMAT`

‎content/docs/permissions/authorization.mdx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -517,10 +517,13 @@ Five mechanisms — four CI-time, one runtime — make the security posture a
517517
wider than internal (`security-external-wider-than-internal`), `'*'`
518518
wildcards carrying View/Modify All outside the platform admin set,
519519
high-privilege `isDefault` (everyone-suggested) sets, the reserved word
520-
"role" in security identifiers, a `controlled_by_parent` object with no
521-
relation the platform can derive access from (ADR-0055: no required
522-
`master_detail`, no `master_detail` at all, and no required `lookup` — so
523-
the runtime denies every read and refuses every write), and the ADR-0091
520+
"role" in security identifiers, a `controlled_by_parent` object without
521+
exactly one required `master_detail` to derive access from (ADR-0055 §1:
522+
`security-controlled-by-parent-no-relation` when it declares none — a
523+
`master_detail` without `required: true` and a required `lookup` derive
524+
nothing — and `security-controlled-by-parent-ambiguous-relation` when it
525+
declares two or more; either way the runtime denies every read and refuses
526+
every write), and the ADR-0091
524527
grant-lifecycle rules (a seed grant already expired at authoring time; a
525528
delegation row missing its mandatory `reason`) — every error rule mirrors
526529
a runtime gate.

‎content/docs/protocol/kernel/error-handling.mdx‎

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -497,35 +497,40 @@ depends on how the reference is declared:
497497
| Master reference declared as | Refused by | Status | `code` | `fields` |
498498
|---|---|:---:|---|:---:|
499499
| `master_detail` + `required`, not `readonly`/`system` | required-field validation | 400 | `VALIDATION_FAILED` | yes |
500-
| `master_detail` with no `required` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
501500
| `master_detail` + `required` + `readonly` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
502501
| `master_detail` + `required` + `system` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
503-
| a `required` `lookup`, when the object declares no `master_detail` | the master-access gate | **422** | `MISSING_REQUIRED_FIELD` | **absent** |
504502
505-
Only the first row is the documented 400 shape. On the other four, messages are prefixed
503+
Only the first row is the documented 400 shape. On the other two, messages are prefixed
506504
`[Security] Missing master reference:` and name the object and the field, but no `fields`
507505
array rides along.
508506
507+
Two declarations are not rows of this table, because they name **no master at all**. A
508+
`controlled_by_parent` object derives its master from exactly one `master_detail` field
509+
declared `required: true` (ADR-0055 §1), so an object whose only relation is a `master_detail`
510+
without `required`, or a `required` `lookup`, derives none — and neither does one with two
511+
required `master_detail` fields. `ObjectSchema` refuses such an object at parse, naming it;
512+
one that reaches the runtime unparsed has every read denied and every write refused with
513+
`422 INVALID_METADATA`, whatever the request carries.
514+
509515
The same `422 MISSING_REQUIRED_FIELD` also answers an update or delete **by id** whose
510516
*stored* master reference is null, whatever the declaration. That is a different path: the
511517
caller supplied no such field, so there is no request field to name in `fields` and no
512518
payload that would fix it.
513519
514520
**Why the gate refuses rather than handing over.** Required-field validation skips
515521
provenance-flagged fields before its required check is reached (`system` and `readonly`
516-
fields are skipped outright) and never fires on a field that is not `required` at all. On
517-
those four shapes the gate is the only thing refusing the write, and letting it through was
522+
fields are skipped outright). On those two shapes the gate is the only thing refusing the
523+
write, and letting it through was
518524
measured to create a detail record whose master reference is null — a record the
519525
`controlled_by_parent` read filter (`fk IN (readable masters)`) can never match, so it is
520526
readable by nobody and answers 422 on every later write by id. The refusal is correct; only
521527
its status departs from the rule above.
522528
523-
**These shapes are authorable today.** `os lint` refuses the three `master_detail` shapes at
524-
*error* (`relationship/master-detail-required`), but that rule is not part of the publish
525-
gate — `os build`, `os validate` and the metadata save door do not run it — and it does not
526-
report the fallback-`lookup` shape at all. So a stack can publish clean and still reach the
527-
422. Branch on `code`, and read the status off the response rather than deriving it from this
528-
page.
529+
**These shapes are authorable today.** `os lint` refuses both at *error*
530+
(`relationship/master-detail-required`), but that rule is not part of the publish gate —
531+
`os build`, `os validate` and the metadata save door do not run it. So a stack can publish
532+
clean and still reach the 422. Branch on `code`, and read the status off the response rather
533+
than deriving it from this page.
529534
530535
#### `INVALID_FIELD`
531536
**HTTP Status:** 400

0 commit comments

Comments
 (0)