Skip to content

Commit 64dd083

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20116-stage-2-type-and-widget
2 parents e9f9390 + dfd8e39 commit 64dd083

80 files changed

Lines changed: 4634 additions & 805 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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/formula": minor
3+
"@objectstack/plugin-security": minor
4+
"@objectstack/spec": patch
5+
---
6+
7+
A row-level write check that orders a field against a bound (`>`, `>=`, `<`, `<=`) is refused with `INVALID_FILTER` / 400 when that field holds a list or an object on the record being written, instead of comparing the list's string form and admitting the write (#19886).
8+
9+
**BREAKING** — an accept-set narrowing, shipped by `@objectstack/formula` and `@objectstack/plugin-security` as `minor` under the repo's launch-window convention for accept-set narrowings. The hand-migration prescription is registered under protocol major 18 as `rls-predicate-stored-list-ordering-refused`.
10+
11+
Clause-②: no (narrowing)
12+
13+
**Security fix for RLS write checks.** Measured through the real plugin-security on driver-sql and driver-memory: `record.tags > 'a'`, with `tags` a `json` field holding `['m']`, compared `'m' > 'a'` and admitted and stored the write. `record.meta < 'a'` with `meta` holding `{ a: 1 }` compared `'[object Object]' < 'a'` and did the same, and so did a `multiple` lookup. `using` stands in as the check for a policy that declares no `check`, so the same predicate in `using` was enforced the same way on writes. No shipped row-level or sharing-rule predicate orders a field at all.
14+
15+
What changes:
16+
17+
- `@objectstack/formula`: `matchesFilterCondition` refuses `$gt` / `$gte` / `$lt` / `$lte` and `$between` on a field whose value on the record is a list or a plain object, whatever the comparand, with the same `INVALID_FILTER` / 400 and the same message as the stage 2d refusals. The refusal is per record: a record whose `json` field holds one scalar is compared as before. `null` and `Date` values are unchanged, and so is every equality against a stored list (`$eq`, `$ne`, implicit equality, `$in`, `$nin`). `$between` is not produced by the CEL lowering, so it reaches this only through a filter passed to `matchesFilterCondition` directly.
18+
- `@objectstack/plugin-security`: a check insert or by-id update whose post-image holds a list or an object in an ordered field is refused 400 and stores nothing. That includes a by-id update that edits another field of a row whose stored `json` column holds a list, because the post-image merges the stored row.
19+
- `@objectstack/spec`: the migration registry carries the entry. The stage 2a entry `rls-predicate-array-comparand-refused` now ends "Scalar != and ==, null, Date comparands, and { $field } references between single-valued columns evaluate exactly as before", which is true since stage 2d.
20+
21+
Three moves, named:
22+
23+
1. **The write check now matches driver-sql's read.** driver-sql refuses every ordering comparison, and `$between`, on a column it stores as JSON text, by declared type (400, #7398). The in-process check now refuses the same predicate on the same row (400).
24+
2. **driver-memory's read parts from the write check.** driver-memory, a test driver, compares a stored list element by element on a read and keeps returning those rows (`record.tags > 'a'` reads a row holding `['m']`), while the check now refuses writing it. This is declared on #15104, as for stage 2d's `{ $field }` half.
25+
3. **A list written into a scalar field under an ordering check now answers 400.** `status: ['m']` into a `text` field under `record.status > 'a'`, or `amount: [500]` into a `number` field under `record.amount > 10`, was admitted, and driver-sql stored it as the text `'["m"]'` / `'[500]'`. It is now refused before anything is stored.
26+
27+
**The explain answer.** `security/explain` evaluates the business RLS predicate in-process on the fetched record, so it now answers `INVALID_FILTER` / 400 where the record holds a list or an object under an ordering predicate (this stage). It already answered 400 for a `{ $field }` comparison against a list-holding column (stage 2d). For both, per operation:
28+
29+
| explain operation | driver | the enforced operation answers | same as explain's 400? |
30+
|---|---|---|---|
31+
| `read` | driver-sql | 400 `INVALID_FILTER` (the driver's refusal) | yes |
32+
| `update` | driver-memory | 400 `INVALID_FILTER` (the post-image check) | yes |
33+
| `update` | driver-sql | 403 `PERMISSION_DENIED`: the pre-image gate fails closed on the driver's 400 | no — both deny |
34+
| `read` | driver-memory | the rows its element-wise read admits | no — the test driver's read |
35+
36+
Explain itself is unchanged.
37+
38+
**What to change.** Order a single-valued column (`record.priority > 2`), or test membership in the list with `in` (`record.status in ['open', 'pending']`). A `json` or `multiple` field has no ordering.
39+
40+
<!-- adr-0087: registered rls-predicate-stored-list-ordering-refused -->
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
fix(spec)!: a `decision` branch with no `expression` — the key absent, or `null` — is refused at authoring (#19961)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: registered flow-decision-branch-expression-absent-refused -->
11+
12+
**BREAKING** — an accept-set narrowing on one authored flow-node slot, shipped as
13+
`minor` under the launch-window convention (`check-changeset-no-major` refuses
14+
`major` until GA; breaking-ness is carried by this banner and the ADR-0087
15+
disposition above, not by the level).
16+
17+
**What changed.** `DecisionConditionSchema` declares a branch `{ label, expression }`
18+
with `expression` a required `z.string()`. Nothing enforced that: a decision node's
19+
`config` is an open record no schema is parsed against, and the expression ledger's
20+
resolver skipped an absent value as "not authored". So `conditions: [{ label: 'y' }]`
21+
passed `FlowSchema.parse`, `AutomationEngine.registerFlow` and `objectstack validate`,
22+
and the run then failed at that branch — the executor evaluates every branch it
23+
reaches, and a branch with no `expression` is a condition with no `source`, which
24+
`evaluateCondition` refuses. The ledger now marks the slot `required` (reconciled
25+
against the schema's own `required` list), and the branch is refused at all three
26+
doors through the walk and the function that already refuse a blank one — by
27+
`FlowSchema.parse` with a `custom` issue anchored at the slot (for example
28+
`nodes.1.config.conditions.0.expression`), by `registerFlow` and `objectstack validate`
29+
through that same parse, and by `validateStackExpressions` for a stack handed to it
30+
directly — with one message, led by the published `PREDICATE_SLOT_STRING_REFUSAL`
31+
sentence. `expression: null` is refused the same way, and so is a branch that wrote
32+
its predicate under `condition` (the edge's spelling), which has no `expression`
33+
either. The Studio flow designer writes the refused shape when a branch row's
34+
expression cell is left empty. Where such a branch already sits, the whole flow is
35+
refused: registered from the metadata
36+
registry or `sys_metadata` at boot, it is skipped with a `failed to register flow`
37+
warn naming it while the flows beside it register; a `defineStack({ flows })` source
38+
throws `StackSchemaInvalidError` for the whole stack; an artifact file is refused
39+
whole at load.
40+
41+
## FROM → TO
42+
43+
| you wrote | write instead |
44+
|:--|:--|
45+
| `conditions: [{ label: 'high' }]` on a `decision` node | the predicate you meant — `{ label: 'high', expression: 'record.amount > 10000' }` |
46+
| `conditions: [{ label: 'high', condition: 'record.amount > 10000' }]` | the same predicate under `expression` |
47+
| `conditions: [{ label: 'high', expression: null }]` | the predicate you meant, or `expression: 'false'` to keep the branch and never take it |
48+
49+
**One-line fix:** write the predicate under `expression`. `expression: 'false'` keeps
50+
the branch and its label and never takes it — a change of behaviour, not a preserved
51+
one: a run that reached the branch used to FAIL there, and now routes on to the next
52+
branch or the declared fallback. ⚠️ Do not drop a decision's only branch: the node
53+
then routes by its out-edges alone, and the out-edge that branch labelled is no
54+
longer held back.
55+
56+
**Unchanged.** A branch carrying a non-blank predicate parses, registers and
57+
validates as before; a blank one keeps its refusal and its own prescription
58+
(`flow-predicate-slot-blank-string-refused`); a `decision` with no `conditions`, or
59+
an empty list, still routes by its out-edges; an absent screen field `visibleWhen`
60+
is still legal (that slot is not required); and `PREDICATE_SLOT_STRING_REFUSAL`
61+
keeps its name and its text.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(spec): `os migrate meta` guidance for the `ui-*` and `plugin-*` migration entries states each lesson in words instead of citing tracker numbers
6+
7+
Clause-②: no
8+
9+
The ADR-0087 semantic entries of the `ui-*` family (component props rows, form-field
10+
and list-view refusals, the react-tier `ListView` aliases, and the retired
11+
interaction, notification, embed, widget and i18n vocabularies) and of the `plugin-*`
12+
family (the plugin manifest, runtime, health-monitor and security-scanner retirements)
13+
are printed by `os migrate meta` as the header, `why:` and `verify:` lines of a manual
14+
change. Their text sent the reader to issue-tracker numbers — some of which no longer
15+
resolve — for what a ruling, measurement or fix had decided; it now says what was
16+
decided, in the sentence being read. The same holds for the two `surface` headers that
17+
carried a number. ADR ids are kept.
18+
19+
Text only: no entry id, `from` / `to`, conversion or matching logic changes, and the
20+
chain rewrites exactly what it rewrote before. The generated migration registry,
21+
`spec-changes.json` and the protocol upgrade guide carry the same text.
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/rest': minor
4+
---
5+
6+
fix(runtime): the dispatcher's `/meta/:type` list prunes what `RestServer`'s list prunes, through one list gate that `@objectstack/rest` publishes (#20237)
7+
8+
Clause-②: yes
9+
10+
`GET /meta/:type` has two implementations: `RestServer`, and the runtime
11+
dispatcher's `/meta` domain. On a host that mounts only the `${prefix}/*`
12+
catch-all (`@objectstack/hono`'s `createHonoApp`, the documented embed shape,
13+
and any adapter built on the public `HttpDispatcher` API), the dispatcher is the
14+
only one that answers. Its list branch applied **no** per-caller filter. An
15+
authenticated member who does not hold `crm_admin` got these answers through
16+
`dispatch()`:
17+
18+
```
19+
request before after (= RestServer)
20+
GET /meta/doc?include=content the set-gated doc listed WITH its body the doc left out
21+
GET /meta/book the set-gated book listed the book left out
22+
GET /meta/app an app whose requiredPermissions the that app left out; the other app
23+
member lacks, and an ungated app with pruned of its gated entry
24+
its requiredPermissions-gated entry
25+
GET /meta/dashboard (anyone) every widget minus the widget whose service is off
26+
```
27+
28+
The plural spellings (`/meta/docs`, `/meta/books`, `/meta/apps`) answer the
29+
same. Holders are still listed everything in full. Object lists are masked as
30+
before. An anonymous caller still gets `401 UNAUTHENTICATED`.
31+
32+
**One gate, not two.** `RestServer`'s list filters moved unchanged into
33+
`createMetaListReadGate`, beside the item gate in
34+
`packages/rest/src/meta-item-read-gate.ts`. It covers the ADR-0046 §6.7 doc
35+
and book audience prunes, the app nav filter (`requiredPermissions`, the
36+
ADR-0045 §3 publish gate and the docs-audience entry arm) and the ADR-0057
37+
D10 dashboard widget gate. `RestServer`'s list route and the dispatcher's list
38+
branch both call it, over the same ports the item gate takes, and each rewraps
39+
the pruned items in its own list envelope. There is no second audience
40+
resolver in `packages/runtime`. `RestServer`'s list answers are unchanged.
41+
42+
Every exit of the dispatcher's list branch now runs the gate and the ADR-0106
43+
object mask: the protocol list and the two fallbacks, the runtime metadata
44+
service's list and the ObjectQL registry. The fallbacks used to serve
45+
unmasked object schemas as well as ungated docs, books and apps. A gate input
46+
that cannot be read (a doc list's books read throws) is answered as that fault,
47+
never as the unfiltered list.
48+
49+
**`@objectstack/rest`'s published export surface widens**, and that is why this
50+
changeset declares `Clause-②: yes`. Its only export subpath (`.`) gains one
51+
value:
52+
53+
- `createMetaListReadGate(sources, metaType)`: the list gate. It takes the
54+
same `MetaItemReadGateSources` the item gate takes, and it answers a judge
55+
from a list's items to the items this caller may be served.
56+
57+
It is public because the runtime dispatcher's `/meta` domain in
58+
`@objectstack/runtime` consumes this one gate. `@objectstack/rest` cannot import
59+
the runtime, so the shared decision has to live here and travel as an export,
60+
the way `createMetaItemReadGate` does. A caller outside the platform does not
61+
need it.
62+
63+
Nothing is removed or renamed from the package's exports, and no authorable key
64+
moves. The dispatcher's prunes are not the widening: they pull a second
65+
transport back to the rules the contract already declares (ADR-0046 §6.7,
66+
ADR-0045 §3, `apps.mdx`'s `requiredPermissions` row), which is why
67+
`@objectstack/runtime` stays a `patch`.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: retire the flattened view overlay's `owner` and `hidden` keys — accepted at the save door, stored, and read by nothing (#20230)
6+
7+
**BREAKING** — `owner` and `hidden` are removed from the flattened view overlay:
8+
the lean `view` body with no `config` that `PUT /api/v1/meta/view/:name` (the
9+
Studio and MCP save) accepts, members 3 and 4 of the `view` metadata door, and
10+
the same members in the assembled-manifest `viewItems:` channel. ADR-0049
11+
enforce-or-remove; triage direction, verbatim: 「follow #20085's disposition for
12+
the same key pair」. This completes the family: the view item record's `owner` /
13+
`hidden` are retired in this same release by its own entry, with the same texts.
14+
15+
⚠️ **This supersedes one sentence of the view item retirement's note in this same
16+
release.** That note says the flattened overlay's own `owner` / `hidden` are
17+
untouched and that a `{ object, viewKind, hidden: true }` overlay still parses.
18+
True of that change alone; after this one, such an overlay is refused too. Read the
19+
two notes together: after this release, neither door accepts either key.
20+
21+
Clause-②: no (narrowing)
22+
23+
The overlay door declared both keys separately from the view item's pair. A bound
24+
overlay such as `{ object, viewKind, hidden: true }` saved clean and one row was
25+
stored with the key, and nothing ever read it. Both view-switcher read paths
26+
(`GET /meta/view?object=` and `getViewsByObject`) filter on `viewKind` + `object`
27+
and sort on `order`, so `hidden: true` hid nothing, and a view with `owner` set
28+
was listed for every user who can read the object.
29+
30+
Writer census, taken before removal: no writer of either overlay key in this
31+
framework or its examples, in objectui at its pinned commit and at `main` (the
32+
toolbar writes only `rowHeight`, `sort`, `hiddenFields`, `columnState` and
33+
`inlineEdit`; the switcher only `label`, `isPinned`, `isDefault` and `sortOrder`),
34+
or in the HotCRM app. The cloud repository was not reachable from the census.
35+
36+
### FROM → TO
37+
38+
| removed | what to write instead |
39+
| --- | --- |
40+
| flattened overlay `owner` | delete the key. Nothing restricts a view to one user today; a view is visible to everyone who can read its object. |
41+
| flattened overlay `hidden` | delete the key. To take a view out of the switcher, delete the view item (or stop shipping it from source). |
42+
43+
**The one-line fix: delete `owner:` and `hidden:` from every view body you save.**
44+
`os migrate meta --from 17` lists the mechanical edits for existing sources.
45+
46+
⚠️ Runtime behaviour is deliberately **unchanged**. Neither key ever changed what
47+
a view showed or to whom. What changes is the answer an author gets: a save that
48+
carries either key is refused `422 INVALID_METADATA`, with the prescription
49+
located at the key, instead of being stored with no effect. The prescriptions are
50+
the view item's own texts, so the family answers with one voice on both doors.
51+
52+
### Stored rows
53+
54+
Every read of a stored `view` row replays the conversion chain before the row is
55+
served or badged, and the D2 conversion strips both keys there. What that leaves
56+
depends on what else the row holds:
57+
58+
- **A row with any other view key** (a column state, a sort, a default flag, an
59+
order): served and badged valid without the keys. A GET then a PUT of the whole
60+
row saves (if it was otherwise valid), so the console's next read-merge-write of
61+
it saves, and `os migrate meta --stored --apply` rewrites it.
62+
- **A hide-only row**, holding nothing but its identity (`name`, `object`,
63+
`viewKind`, `label`) and `owner` / `hidden`, such as
64+
`{ object, viewKind, hidden: true }`: the strip leaves identity only, which the
65+
`view` door refuses ("only identity fields"). The row is served badged invalid
66+
(it was badged valid before this release). A whole-row re-save, or one that adds
67+
only identity (a rename sets `label`), answers `422 INVALID_METADATA`.
68+
`--apply` reports it `failed` and leaves it as stored; every read strips it
69+
again. A write that adds a real view key, such as a toolbar toggle, saves.
70+
**Fix: delete the row** (it never changed what anyone saw), or add the
71+
personalization setting its author meant and save that.
72+
73+
### The retirement kit
74+
75+
- **Tombstones on both overlay members.** `retiredKey()` in
76+
`flattenedViewOverlayFields()`, with the view item's prescription texts. Both
77+
members `.strip()`, so a bare deletion would have dropped the key in silence
78+
(ADR-0104).
79+
- **D2 conversion `view-overlay-owner-hidden-removed`** (step 18, retired from the
80+
load path). A lossless delete from the flattened spelling (no `config`, no
81+
container slot) in `views` (stack sources and stored rows) and `viewItems`
82+
(assembled artifacts). It is disjoint from `view-item-owner-hidden-removed` by
83+
`config`, so no row is judged by both.
84+
- **D3 semantic entry `view-overlay-owner-hidden-retired`**: the family's one D3
85+
record, naming its D2 conversion. The view item record's pair is a separate
86+
family with its own conversion and its own D3 entry; the two share the
87+
prescription texts.
88+
- **`RETIRED_KEYS_BY_MAJOR[18]`**: `ui/ViewMetadata:owner`, `ui/ViewMetadata:hidden`.
89+
`ui/ViewMetadata` is unemitted (its `z.undefined()` guards have no JSON Schema
90+
form), so no build gate judges these rows and the four surface ratchets are
91+
byte-identical on this retirement. The rows are pinned by the retirement test.
92+
- **No liveness row**: the `view` ledger walks the container keys only.
93+
- **No deprecation window**, per the project's startup-stage posture.
94+
95+
⚠️ **The out-of-repo population is NOT MEASURED.** `@objectstack/spec` is published,
96+
and production `sys_metadata` rows are not reachable from the repository. Stored
97+
rows are stripped on read by the conversion above, and a hide-only row among them
98+
needs the fix above. A client that still sends either key is refused at its next
99+
save.
100+
101+
<!-- adr-0087: registered view-overlay-owner-hidden-removed, view-overlay-owner-hidden-retired -->

0 commit comments

Comments
 (0)