Skip to content

Commit 4f47fda

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-9111-hydrate-overlay-raw-type
2 parents b3f6993 + 739fe5b commit 4f47fda

133 files changed

Lines changed: 7450 additions & 972 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: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/types": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
`error.code` is a closed vocabulary at every door (#9106, maintainer ruling
8+
2026-08-16): the runtime dispatcher's thrown-error exits
9+
(`HttpDispatcher.errorFromThrown`, `dispatcher-plugin`'s `errorResponseBase`,
10+
`endpoint-executor`'s `endpointErrorAnswer` — the actions door among them) now
11+
serve the narrowed `code` the shared resolver (`resolveThrownHttpError`,
12+
`@objectstack/types`) has always computed, exactly as the REST door has since
13+
#8016. A thrown code that is not a member of `StandardErrorCode ∪
14+
ERROR_CODE_LEDGER` no longer reaches `error.code`.
15+
16+
It is not dropped: `ApiErrorSchema` declares a new optional `declaredCode`
17+
field — the open, author-authored channel — and the demoted spelling rides
18+
there. Presence means demotion: the field is absent whenever the producer's
19+
code is a vocabulary member (it is already in `error.code`) or the producer
20+
declared none. The #7867 sandbox passthrough capability is preserved — a
21+
metadata app's own thrown `.code` still crosses the QuickJS boundary and still
22+
reaches the wire.
23+
24+
For a metadata app that throws its own code (e.g.
25+
`Object.assign(new Error('pick another'), { code: 'DUPLICATE' })` in an action
26+
body) and reads it back from an actions-door failure:
27+
28+
- FROM: `error.code === 'DUPLICATE'`
29+
- TO: `error.code` is the closed member the status derives (e.g.
30+
`VALIDATION_ERROR` on a 400) and `error.declaredCode === 'DUPLICATE'`.
31+
One-line fix: branch on `error.declaredCode` for app-specific spellings;
32+
branch on `error.code` for platform conditions.
33+
34+
Platform producers are unaffected: every registered code reaches `error.code`
35+
verbatim, as before (post-#8846 the dispatcher-vocabulary gate holds that set
36+
registered). Measured before landing (the ruling's binding precondition): no
37+
existing consumer of the actions door branches on author-authored strings in
38+
`error.code`.
39+
40+
`@objectstack/types` adds `demotedDeclaredCode(thrown)` — the one definition of
41+
"which spelling a boundary surfaces beside the closed `code`".
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): the builder forces `required: true` on a `master_detail` reference under `controlled_by_parent` (#9138 — #8772 maintainer ruling, Direction 2)
6+
7+
**BREAKING** accept-face narrowing on the authoring builder, landing after the
8+
v17.0.0 cut (the lockstep launch-window convention ships it as `minor`; the
9+
prescription is registered under protocol major 18, where `os migrate meta`
10+
users will look).
11+
12+
A `controlled_by_parent` object derives ALL of its record access from the
13+
master its `master_detail` reference names (ADR-0055). A master reference that
14+
is not `required` arms the worst measured failure shape: an insert may omit
15+
the master FK, the row lands with a null FK that the derived read filter
16+
(`masterFK IN (accessible master ids)`) can never match — unreadable by
17+
everyone — and every later by-id write answers `422 MISSING_REQUIRED_FIELD`.
18+
#8772 measured that only the security gate closed that shape while the
19+
declaration surface accepted it.
20+
21+
`ObjectSchema.create()` now makes the unsafe shape impossible to newly
22+
declare:
23+
24+
- an **omitted** `required` on a `master_detail` reference under
25+
`sharingModel: 'controlled_by_parent'` is **forced to `true`** in the
26+
emitted object;
27+
- an **explicit `required: false`** there is **refused** with a located error
28+
naming the object, the field, the consequence and the fix — an explicitly
29+
authored contradiction is not silently rewritten (ADR-0032).
30+
31+
## FROM → TO
32+
33+
```ts
34+
// before — parsed green; the null-FK trap stayed armed behind the security gate
35+
export const InvoiceLine = ObjectSchema.create({
36+
name: 'invoice_line',
37+
sharingModel: 'controlled_by_parent',
38+
fields: { invoice: { type: 'master_detail', reference: 'invoice', required: false } },
39+
});
40+
41+
// after — refused at build with the prescription; omitting `required` is fine
42+
// (the builder emits `required: true` by construction)
43+
export const InvoiceLine = ObjectSchema.create({
44+
name: 'invoice_line',
45+
sharingModel: 'controlled_by_parent',
46+
fields: { invoice: { type: 'master_detail', reference: 'invoice', required: true } },
47+
});
48+
```
49+
50+
**What stays accepted:** everything outside that one shape. Raw
51+
`ObjectSchema.parse()` / `.safeParse()` — the path metadata at rest rehydrates
52+
through — still accept the old shape unrewritten, so existing installs keep
53+
loading; the security gate's derived enforcement stays; the lint rule
54+
`relationship/master-detail-required` stays `warning` until its own v18
55+
promotion (#9139). The 2026-08-15 survey measured the shipped first-party
56+
surface at exactly 3 `controlled_by_parent` objects, all already
57+
`required: true` — zero first-party migration.
58+
59+
<!-- adr-0087: registered cbp-master-detail-required-forced -->
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/lint": patch
3+
---
4+
5+
fix(lint): drop the `element:filter` entry from `COMPONENT_FIELD_SPECS` (#9220)
6+
7+
The whole `element:filter` element retired at element grain (ADR-0049 — no
8+
renderer ever shipped for it), so every `ElementFilterProps` key is a
9+
`retiredKey()` tombstone and no spec-conformant page carries `fields` on it.
10+
The field-binding rule's job (resolve a field NAME against the object) is not
11+
the question a retired key raises: an authored key is already reported by name
12+
with the element-retirement prescription through the #5068 props gate, and the
13+
binding entry would only add a second finding about a key that no longer
14+
exists — the #5775/#6629 residue class the package's own
15+
`component-field-specs-liveness` gate refuses.
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): retire the `element:filter` element at element grain (#9220, ADR-0049)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
`element:filter` never had a renderer or reader anywhere. Measured at
12+
retirement (objectstack `2f65b1b42`, objectui `5ffcc14`; cloud per the origin
13+
card's recorded sweep): objectui registers no renderer for it — its
14+
`renderers/basic/elements.tsx` header deferred the element to "owning plugins"
15+
that never materialized — Studio's designer palette carries it as a no-renderer
16+
`PALETTE_EXCLUSIONS` entry ("list surfaces own filtering (userFilters / filter
17+
builder)"), and the 2026-06 page-liveness audit recorded it rendering "Unknown
18+
component type". Every one of its six authorable keys — `object`, `fields`,
19+
`targetVariable`, `layout`, `showSearch`, `aria` — was a capability claim
20+
nothing kept: an author (human or AI) who configured a filter element got a
21+
success receipt for a component that renders nothing (the ADR-0078 shape).
22+
\#9198 retired `targetVariable` per-key on the two input elements and recorded
23+
this wider finding; per-key retirement would have been the wrong grain here, so
24+
the whole element retires at once.
25+
26+
**What is refused:** any authored key on `element:filter` `properties`. All six
27+
keys are `retiredKey()` tombstones — refused at `tsc` (typed `never`) and at
28+
the parse, message carrying the element-grain prescription. The
29+
`ComponentPropsMap` row deliberately STAYS so the #5068 props gate keeps
30+
dispatching on the type and refusing loudly — deleting the row would demote the
31+
type to an unregistered custom string the gate deliberately skips.
32+
33+
**What stays accepted:** a bare `element:filter` node with empty `properties`
34+
(the migrated shape) still parses at the node level — `PageComponentSchema.type`
35+
is an open union, so a node-level refusal is not expressible; the node was
36+
always inert and stays inert. `element:filter` is removed from the
37+
`PageComponentType` enum (de-advertisement — docs, palette derivations, and the
38+
authorable vocabulary), which changes no parse outcome. Filtering on list
39+
surfaces is unchanged and was never this element's: use a view's `userFilters`
40+
quick-filter bar or the list toolbar's filter builder.
41+
42+
The retirement kit:
43+
44+
- tombstones at the schema (`packages/spec/src/ui/component.zod.ts`), enum
45+
removal at `packages/spec/src/ui/page.zod.ts`
46+
- ADR-0087 registration: retired-key entries `ui/ElementFilterProps:object` /
47+
`:fields` / `:targetVariable` / `:layout` / `:showSearch` / `:aria` and the
48+
D2 conversion `element-filter-removed` (protocol 18), wired into the step-18
49+
chain — `os migrate meta --from 17` strips the keys from old sources (pure
50+
lossless deletes; none ever had an effect to lose) and leaves the bare node
51+
- the #9198 conversion's negative-control fixture moves from `element:filter`
52+
to an open-union custom type (same assertion — the strip dispatches on the
53+
component type, not the key name)
54+
- pin tests (`component.test.ts` — refusal carries the prescription; the
55+
bare migrated node parses clean and materializes nothing)
56+
- generated baselines/docs follow the schema (`authorable-surface/`,
57+
`json-schema.manifest/`, spec-changes, upgrade guide, reference docs)
58+
59+
## FROM → TO
60+
61+
```ts
62+
// before — parsed green; nothing anywhere rendered it
63+
{
64+
type: 'element:filter',
65+
properties: { object: 'order', fields: ['status'], layout: 'sidebar' },
66+
}
67+
68+
// after — delete the component; filtering belongs to the list surface
69+
// (view.userFilters / the list toolbar's filter builder)
70+
```
71+
72+
<!-- adr-0087: registered element-filter-removed -->
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
feat(spec): retire the inert `targetVariable` key from `element:text_input` and `element:record_picker` (#9198, ADR-0049)
6+
7+
**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep
8+
launch-window convention ships it as `minor`; the migration prescription is
9+
registered under protocol major 18, where `os migrate meta` users will look).
10+
11+
`targetVariable` on the two SDUI input elements was a declarative hint with
12+
zero readers in any repo — its own describe text said the live binding
13+
"resolves via the variable whose `source` equals this component id"
14+
(`PageVariableSchema`), and that reverse lookup
15+
(`usePageVariableBinding(schema?.id)` in the console renderer) is the only
16+
binding mechanism that exists. Measured (objectstack-ai/objectui#3834,
17+
re-verified at retirement): no renderer, hook or runtime in objectui,
18+
framework or cloud reads the key. An author — human or AI — who read the
19+
manifest, wrote `targetVariable`, and skipped the variable's `source` got an
20+
input that wrote nothing, with a success receipt and no diagnostic anywhere.
21+
Same disposition as the sibling inert hint settled by retirement in objectui
22+
PR #4794.
23+
24+
**What is refused:** an authored `targetVariable` on `element:text_input` or
25+
`element:record_picker` properties. Both keys are `retiredKey()` tombstones —
26+
refused at `tsc` (typed `never`) and at the parse, message carrying the
27+
prescription.
28+
29+
**What stays accepted:** every text input / record picker without the key,
30+
byte-identically — including the working binding (`variables[].source`), which
31+
is untouched. `targetVariable` on `element:filter` is a different surface and
32+
is not part of this disposition. Runtime behaviour is unchanged: nothing ever
33+
read the key, so removing it removes no behaviour.
34+
35+
The retirement kit:
36+
37+
- tombstones at the schema (`packages/spec/src/ui/component.zod.ts`)
38+
- ADR-0087 registration: retired-key entries
39+
`ui/ElementTextInputProps:targetVariable` +
40+
`ui/ElementRecordPickerProps:targetVariable` and the D2 conversion
41+
`element-input-target-variable-removed` (protocol 18), wired into the step-18
42+
chain — `os migrate meta --from 17` strips the key from old sources (pure
43+
lossless delete; it never had an effect to lose)
44+
- pin tests (`component.test.ts` — refusal carries the prescription; clean
45+
parses materialize nothing)
46+
- generated baselines/docs follow the schema (`authorable-surface/`,
47+
`json-schema.manifest/`, spec-changes, upgrade guide, reference docs)
48+
49+
## FROM → TO
50+
51+
```ts
52+
// before — parsed green; the hint bound nothing
53+
{
54+
id: 'email_input',
55+
type: 'element:text_input',
56+
properties: { inputType: 'email', targetVariable: 'contact_email' },
57+
}
58+
59+
// after — delete the key; declare the binding on the page variable instead
60+
{
61+
id: 'email_input',
62+
type: 'element:text_input',
63+
properties: { inputType: 'email' },
64+
}
65+
// page.variables: [{ name: 'contact_email', type: 'string', source: 'email_input' }]
66+
```
67+
68+
<!-- adr-0087: registered element-input-target-variable-removed -->
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/plugin-email": minor
4+
"@objectstack/service-messaging": minor
5+
---
6+
7+
feat(messaging): `IEmailService` gains a render-only `renderTemplate({ template, locale, data, timezone }) → { subject, html, text }`, and the inbox channel consumes it — localized `sys_email_template` content now reaches `sys_inbox_message` (#9225)
8+
9+
A template-path notify node with `channels: ['inbox', 'email']` delivered a
10+
localized email and an inbox row whose title was the topic and whose body was
11+
empty: the locale ladder + `{{var}}` renderer (ADR-0053 format filters
12+
included) lived inside plugin-email's `sendTemplate`, unreachable without
13+
sending mail (maintainer-ruled seam, 2026-08-17, on #9225).
14+
15+
- `IEmailService.renderTemplate` (new contract method, `packages/spec`)
16+
resolves a `sys_email_template` bundle by `(name, locale)` with the same
17+
documented en-US ladder as `sendTemplate`, validates required variables, and
18+
returns the rendered `{ subject, html, text }` — strictly render-only: no
19+
transport call, no queueing, no `sys_email` row. Implemented ONCE in
20+
plugin-email by extracting the resolver `sendTemplate` already used;
21+
`sendTemplate` now delivers what the shared resolver renders, byte for byte.
22+
- The messaging inbox channel consumes it the way the email channel consumes
23+
`sendTemplate`: a delivery whose payload carries a notify `template`
24+
reference renders `subject` into the row's `title` and `text` into
25+
`body_md`, per recipient, at delivery time. A registered email service
26+
without the method — or no email service at all — fails the delivery LOUDLY
27+
(`TEMPLATE_UNSUPPORTED`, graded permanent) instead of silently degrading to
28+
topic-as-title; renderer failure codes (`TEMPLATE_NOT_FOUND` /
29+
`TEMPLATE_INACTIVE` / `MISSING_VARIABLES`) land on the delivery row and are
30+
graded permanent, mirroring the email channel.
31+
32+
The result shape follows what `sys_email_template` rows carry
33+
(`subject`/`body_html`/`body_text?`): `html` is the rendered `body_html`,
34+
`text` is the rendered `body_text` or, when the row declares none, derived
35+
from the rendered HTML.
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/objectql": patch
4+
---
5+
6+
fix(spec): enforce the list-comparand rule at the shared compile face, so a scalar `in`/`nin` no longer reaches a driver (#9228)
7+
8+
<!-- adr-0087: not-required (no-migration-prescription) No authorable key is
9+
added, renamed, retired or tombstoned. The change moves an existing runtime rule
10+
from `@objectstack/objectql` to `@objectstack/spec` and calls it from
11+
`parseFilterAST`; the authorable metadata surface is byte-identical. -->
12+
13+
`FieldOperatorsSchema` has always declared `$in` / `$nin` as `z.array(z.any())`
14+
and `$between` as `z.tuple([min, max])`, and #5869 / PR #6209 built the gate that
15+
enforces it — but only at `@objectstack/objectql`'s lowering seam. That covers
16+
every query reaching a driver **through the engine** and nothing else. A caller
17+
that lowers a filter with `parseFilterAST` and calls a driver directly — an
18+
embedder, and this repo's own driver conformance suites — met no gate at all:
19+
`parseFilterAST([['name', 'notin', 'alpha']])` returned `{ name: { $nin:
20+
'alpha' } }`, a shape the contract forbids, and handed it over.
21+
22+
That path was carried by mingo's own coercion of a non-array `$in`/`$nin`
23+
operand. mingo 7.2.3 removed the coercion, so from 7.2.4 on the same input
24+
escapes as an unhandled third-party `TypeError: b.filter is not a function` —
25+
no `code`, no `status`, no field name — straight to the caller. It is the sole
26+
failure blocking the `mingo` 7.2.2 -> 7.2.4 bump.
27+
28+
**Fixed at the shared face, with exactly one implementation.** The rule now
29+
lives in `@objectstack/spec`'s `data/filter-comparand-shape.ts`, the same place
30+
the comparand-TYPE door (#7872 / PR #8234) was promoted to for the same reason
31+
("enforced once at the shared compile face for all five drivers"), and
32+
`parseFilterAST` runs it on everything it returns — shape first, then type, the
33+
order the engine's own seam already applied. `@objectstack/objectql`'s
34+
`assertListComparandShapes` is now a delegating wrapper whose only remaining job
35+
is the engine's `find('deal'): ` caller prefix; no driver was patched (both
36+
driver families are under the #5499 investment freeze).
37+
38+
**The accept/reject delta is narrow and one-directional.** Newly refused, with
39+
the ADR-0112 `INVALID_FILTER` / 400 envelope: a non-array `$in` / `$nin`
40+
comparand and a non-`[min, max]` `$between` comparand, reaching a driver via a
41+
direct `parseFilterAST` call. Nothing else changes — every filter the engine
42+
accepted still lowers byte-identically, `$in: []` / `$nin: []` remain legitimate
43+
declared predicates, list MEMBER types stay unjudged here, and a field spec with
44+
no `$` key is still not descended into. The same inputs were already refused
45+
with the same envelope on every engine verb and at the REST ingress, so no
46+
authored metadata in the repo or in `objectui` produces a shape that newly
47+
fails: a survey of `examples/**`, `content/docs/**`, fixtures, seeded platform
48+
objects and objectui's view definitions found every membership rule already
49+
carrying an array.
50+
51+
`parseFilterAST` gains an optional second argument, `context` — the caller
52+
prefix both doors in `@objectstack/spec` already take. It is additive and
53+
defaulted; existing calls are unaffected.

0 commit comments

Comments
 (0)