Skip to content

Commit d283101

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-19938-fields-value-slot
# Conflicts: # packages/spec/dropped-refinements.baseline.json
2 parents 9670123 + 3875ae6 commit d283101

84 files changed

Lines changed: 4638 additions & 730 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.

‎.changeset/19543-list-doors-3-4.md‎

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/client': minor
4+
'@objectstack/runtime': minor
5+
---
6+
7+
feat!: retire the `GET /api/v1/automation` flow list in favour of `GET /api/v1/meta/flow`; `ListAiConversationsResponse` declares `hasMore` (#19543)
8+
9+
**BREAKING** — two sibling list doors that declared paging nobody honoured.
10+
11+
**The flow list is retired, with no alias and no transition window** (maintainer
12+
ruling: 「退役,统一走 /meta/flow」). Its contract described a capability no build
13+
ever delivered: the request declared `status`, `type`, `limit` (default 50) and
14+
`cursor`, and the route read none of them; the response declared `FlowSummary`
15+
rows with `total`, `nextCursor` and `hasMore`, and the route answered bare flow
16+
names beside a literal `hasMore: false`. Measured before removal on the main branch
17+
of this repository and cloud, and on objectui at its pinned commit and at main:
18+
zero callers of the route or of `client.automation.list` outside their own tests,
19+
while the Console flow-runs page and the Setup packaged-automation page already
20+
read `GET /api/v1/meta/flow`.
21+
22+
FROM → TO, per surface:
23+
24+
- `GET /api/v1/automation` (and its environment-scoped twin) → no longer mounted
25+
for `GET`. `POST /api/v1/automation` (create a flow) still lives at that path, so
26+
on the default Hono host a `GET` there answers the host's standard method
27+
mismatch — `405 METHOD_NOT_ALLOWED` with `Allow: POST` — the same answer any
28+
POST-only path gets. A transport that forwards every automation path to the
29+
dispatcher (the `@objectstack/hono` catch-all) is told the domain does not handle
30+
it and answers its own not-found `404`. Fix: read `GET /api/v1/meta/flow`;
31+
flows are metadata (ADR-0106), and it answers full definitions, so map each item
32+
to its `name` if you only need names. Per-flow runtime enablement and trigger
33+
binding is `GET /api/v1/automation/_status`, unchanged.
34+
- `client.automation.list` (`@objectstack/client`) → removed; calling it is a
35+
compile error. Fix: `client.meta.getItems('flow')`, or
36+
`client.automation.getRuntimeStatus()` for the enabled/bound state.
37+
- `ListFlowsRequestSchema`, `ListFlowsResponseSchema`, `FlowSummarySchema` and the
38+
types `ListFlowsRequest`, `ListFlowsRequestParsed`, `ListFlowsResponse`,
39+
`ListFlowsResponseParsed`, `FlowSummary` (`@objectstack/spec/api`) → removed,
40+
no replacement export (TS2305 on import). Fix: delete the import; the flow
41+
definition type is `Flow` from `@objectstack/spec/automation`.
42+
- `AutomationApiContracts.listFlows` → removed; the map has eight entries, none of
43+
them a `GET` at the bare path. Every other automation route is unchanged.
44+
45+
**`ListAiConversationsResponseSchema` gains a required `hasMore`** (the spec half
46+
of the same card; the server half is objectstack-ai/cloud#2426). The list is
47+
declared **newest first** and pages by keyset: `cursor` is the `id` of the last
48+
conversation the caller already holds, and `hasMore` says whether another page
49+
follows. `hasMore` is required rather than optional so a server that does not
50+
compute it is off-contract instead of silently spec-valid; no `nextCursor` is
51+
declared, because the next cursor is the last conversation's id, already on the
52+
page. Who notices: code that constructs a `ListAiConversationsResponse` must now
53+
set `hasMore`, and a response parsed with the schema is refused without it.
54+
`client.ai.conversations.list()` is unchanged — it still resolves to the
55+
conversation array.
56+
57+
Breaking ships as `minor` per the launch-window convention
58+
(`scripts/check-changeset-no-major.mjs`).
59+
60+
**Clause-②: yes (narrowing)** — the conversation list's response surface gains a
61+
declared `hasMore`; a route, an SDK method, three published schemas with their five
62+
types and a contract entry are removed, and a conversation-list response without
63+
`hasMore` is now refused.
64+
65+
<!-- adr-0087: registered automation-flow-list-route-retired -->
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec)!: an ARRAY under `$ne` is refused at the shared comparand-shape face and at `FieldOperatorsSchema.$ne`, with one remedy text naming `$nin` (#19886)
6+
7+
**BREAKING** — an accept-set narrowing at the runtime filter doors and at one published operator slot, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. Ruled on #19886 (record 5805254639, ruling A, class 1): "The shared comparand-shape face refuses an array under `$ne` for every driver, and `FieldOperatorsSchema.$ne` refuses it at parse — one remedy text, naming the declared list-negation operator by its spec spelling". No alias, no grace window. The hand-migration prescription is registered under protocol major 18 as `filter-ne-array-comparand-refused`.
8+
9+
## What changes
10+
11+
`$ne` already declared its comparand as "a literal, or a { $field } reference to another column of the same table". An array is neither. Two doors now say so.
12+
13+
**The shared comparand-shape face** (`assertListComparandShapes` in `@objectstack/spec/data`) refuses `{ field: { $ne: [...] } }` with `INVALID_FILTER` / 400. It refuses it at any depth under `$and` / `$or` / `$not`, and it refuses the empty array too. `parseFilterAST` lowers `['tags', 'ne', ['a']]` to that shape, and so do `!=`, `<>`, `neq`, `not_equals` and `notequals`. The face runs inside `parseFilterAST` and at the engine's lowering seam, so the refusal lands before any driver runs. The message is:
14+
15+
```text
16+
Operator "$ne" on field "tags" requires a single comparable value, but received an array (["a"]) at where.tags.$ne. For "none of these values" use {"$nin": […]} (authoring: nin, not_in, notin). The filter was NOT applied, and an unapplied filter would have returned the UNFILTERED result set.
17+
```
18+
19+
Its first sentence is `driver-memory`'s own wording for this condition. It names one remedy: `$nin`, the list-negation operator `FieldOperatorsSchema` declares ("Not in list"), with its authoring spellings.
20+
21+
**The operator slot** `FieldOperatorsSchema.$ne` refuses an array on parse. So do its documentation copy `EqualityOperatorSchema.$ne` and the `NormalizedFilter` AST that validates against it. They print the same sentence without ` on field "…"` and ` at <path>`, because a slot cannot see either. The issue's own `path` carries the location instead (`$ne`, `$and.0.stage.$ne`).
22+
23+
How each door answered `{ tags: { $ne: ['a'] } }` on `origin/main` `9e7824a4`, just before this change:
24+
25+
| door | before | after |
26+
|:--|:--|:--|
27+
| the shared face, `parseFilterAST`, the engine's lowering seam | **passed** it to the driver, at every depth | `INVALID_FILTER` / 400, before any driver runs |
28+
| `FieldOperatorsSchema`, `EqualityOperatorSchema`, the `NormalizedFilter` AST | **parsed it green** | refused on parse at `$ne` |
29+
| the analytics `where` door (`normalizeAnalyticsFilterTree`) | **compiled it**: `{ stage: { $ne: ['won', 'lost'] } }` became `stage` not-set OR `stage` not-equals `['won', 'lost']`, and both analytics strategies render a not-equals member from its first value, so `'lost'` was dropped and its rows were counted | `INVALID_FILTER` / 400 with the face's sentence |
30+
| `driver-sql`, `driver-memory` | refused, 400, in their own words | unchanged; the face now answers first |
31+
| `driver-mongodb`, the formula evaluator | refused, since earlier stages of this card, in their own words | unchanged; the face now answers first |
32+
33+
The analytics row's compiled tree was measured with the face's `$ne` arm removed, which is `main`'s face. Its rendering from the first value was read at source (`values[0]` in the native SQL strategy, `v0` in the ObjectQL strategy) and was not executed. A live `mongod`, MySQL, PostgreSQL and a live Turso server were NOT measured.
34+
35+
So on the SQL family and `driver-memory` the verdict does not move (400 before and after). What moves is the text and the moment: the refusal now arrives at the face with the `$nin` remedy, before any driver runs. On the analytics `where` door the verdict does move: a query that used to answer with a silently widened row set is now refused.
36+
37+
## What does NOT change
38+
39+
- **A stored filter carrier still saves the shape.** `FilterConditionSchema`, which every stored filter parses through (a dataset filter and measure filter, a dashboard widget filter, a report `runtimeFilter`, a rollup filter and the rest), does not parse a field's operator map through `FieldOperatorsSchema`. Its own walk judges `$eq` and does not judge `$ne`. The ruling names the face and the operator slot, not that walk. So `DatasetSchema` with `filter: { stage: { $ne: ['won', 'lost'] } }` still parses green, and every query that uses it is refused at the face.
40+
- `$ne: null` is the has-a-value predicate and is untouched. Every scalar, a `Date` and a `{ $field }` reference are untouched too. `['amount', '!=', { $field: 'budget' }]` still lowers to `{ amount: { $ne: { $field: 'budget' } } }` and passes.
41+
- The list operators keep their arrays: `$in`, `$nin` and `$between`, including `$in: []` and `$nin: []`.
42+
- A field spec with no `$` key (`{ author: { tags: { $ne: ['a'] } } }`) is still not descended into.
43+
- The ordering operators carrying an array, a nested array inside `$in`, and a `{ $field }` referent to a multi-valued field are not judged here. They are the same class and are scoped separately on the card.
44+
- The drivers are untouched. A caller that hands a raw `FilterCondition` straight to a driver, without `parseFilterAST`, still gets that driver's own refusal.
45+
- `ViewFilterRule` already refused an array on `not_equals` at authoring time.
46+
- The published JSON Schema cannot state the check. `z.toJSONSchema()` has no projection for it, so `data/FieldOperators`, `data/EqualityOperator` and `data/NormalizedFilter` still read `{}` at `$ne`. The three sites are declared in `dropped-refinements.baseline.json`.
47+
- No key is added, removed or renamed, no exported symbol moves, and the operator vocabulary is unchanged.
48+
49+
## FROM → TO
50+
51+
| you wrote | write instead |
52+
|:--|:--|
53+
| `{ stage: { $ne: ['won', 'lost'] } }`, meaning "none of these values" | `{ stage: { $nin: ['won', 'lost'] } }` |
54+
| `[['stage', 'not_equals', ['won', 'lost']]]` (or `ne`, `!=`, `<>`, `neq`, `notequals`) | `[['stage', 'not_in', ['won', 'lost']]]` (or `nin`, `notin`) |
55+
| `{ stage: { $ne: ['won'] } }`, meaning one value | `{ stage: { $ne: 'won' } }` |
56+
57+
On `driver-mongodb`, check what the query is supposed to return and do not assume the old rows were right. Before this card, MongoDB read `$ne` against an array as "not equal to that array and not holding it as an element", and `$nin` does not reproduce that. On the analytics `where` door, the old answer dropped every member after the first, so a chart built on it counted rows its filter named.
58+
59+
## Who is affected, measured
60+
61+
Nothing shipped authors the shape. Each count below was read against the named tree, with a control:
62+
63+
- **This repository**, `origin/main` `9e7824a4`. `$ne` followed by an array literal in `packages/**`, `examples/**`, `apps/**`, `scripts/**`, `content/**` and `skills/**` has 20 hits. All of them are tests, refusal code, comments or migration prose. The control, `$in` followed by an array literal in `packages/**` and `examples/**`, has 901 hits. The FilterArray triple on `ne`, `!=`, `<>`, `neq`, `not_equals` or `notequals` carrying an array has 0 hits. A CEL `!=` against a list literal in `packages/platform-objects/**`, `examples/**` and `packages/qa/**` (tests excluded) has 0 hits. `$ne` fed by a variable in non-test source has 11 sites, and none of them builds a list. Each passes a list's first member, a stored-form value, a named constant, a `{ $field }` reference, or a caller's comparand passed through: the better-auth adapter's `ne`, and the CEL lowering, which refuses a list under `!=` before it emits.
64+
- **objectui** at the `.objectui-sha` pin `f8a9d0fb05`: 2 hits, both in its own refusal test. The dataset filter builder gives `notEquals` a scalar arity, and the rollup summary editor gives only `in` / `notIn` a list. The control, `$in` with an array, has 46 hits.
65+
- **cloud** `main` `48d7066`: 0 hits. The control has 33 hits.
66+
67+
Deployed stacks were NOT measured. Every query that carries the shape is refused with `INVALID_FILTER` / 400 naming the field, the path and `$nin`, so a test suite that exercises the query finds each one. A clean re-save of a stored carrier does not find them, because the carrier schema still accepts the shape. Exercise stored filters, or grep them.
68+
69+
Clause-②: no (narrowing) — nothing is widened. One comparand shape in one slot, which `$ne`'s published description already excluded, is refused at the face and at the operator slot.
70+
71+
<!-- adr-0087: registered filter-ne-array-comparand-refused -->
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
"@objectstack/lint": minor
3+
---
4+
5+
`rls-predicate-unenforceable` now reports the RLS predicates the runtime refuses on every request, which the shape check cannot see (#19951).
6+
7+
`validateRlsPredicateEnforceability` judged a predicate's shape with every `current_user` value replaced by a placeholder, so a predicate whose refusal depends on a value passed `os validate` cleanly and enforced nothing. Its reference pass now reports two such classes as `error`, so **a previously green `os validate` can fail** on a stack that declares one of these predicates. Measured over this repository, 0 of the 126 authored `using` / `check` predicates (77 of them `current_user` predicates) change verdict.
8+
9+
**A `current_user` value of the wrong type for its position.** The kernel resolves `positions`, `org_user_ids` and `accessible_org_ids` as lists and `id`, `organization_id` and `email` as one value on every request, and `current_user` alone is the whole caller context. The compiler refuses a mismatch on every request, and the RLS compiler drops the policy: reads return no rows and `check` writes are refused 403.
10+
11+
- FROM silent TO `rls-predicate-unenforceable`: `record.f != current_user.org_user_ids` (any list key). Write `!(record.f in current_user.org_user_ids)`.
12+
- FROM silent TO `rls-predicate-unenforceable`: `record.f == current_user.positions` and `!(record.f == current_user.positions)`. Write `record.f in current_user.positions`, keeping any enclosing `!(...)`.
13+
- FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user.id` (any one-value key). Write `record.f == current_user.id`.
14+
- FROM silent TO `rls-predicate-unenforceable`: `record.f in current_user`, `record.f.startsWith(current_user)`, `.endsWith(current_user)` and `.contains(current_user)`. Name the key that holds the value, for example `record.f in current_user.org_user_ids` or `record.f.startsWith(current_user.email)`.
15+
- FROM silent TO `rls-predicate-unenforceable`: `record.f.startsWith(current_user.org_user_ids)` (a list handed to a string method). Write `record.f in current_user.org_user_ids`, or pass a one-value key.
16+
17+
**A `null` list member or `null` ordering bound.** The platform refuses both in every filter it is sent. The RLS layer does not run that check on its own filter, so the backend answers with its own semantics: on a SQL driver `record.f in [null]`, `!(record.f in ['a', null])` and `record.f > null` read no rows. Every analytics query over the object is refused.
18+
19+
- FROM silent TO `rls-predicate-unenforceable`: `record.f in ['a', null]` and `!(record.f in ['a', null])`. Write `(record.f in ['a'] || record.f == null)`, or drop the `null` member.
20+
- FROM silent TO `rls-predicate-unenforceable`: `record.f in [null]`. Write `record.f == null`.
21+
- FROM silent TO `rls-predicate-unenforceable`: `record.f > null`, `>= null`, `< null` and `<= null`. Write `record.f != null` or `record.f == null`, or compare against a real bound.
22+
23+
Each finding's hint carries the rewrite with the predicate's own field and key.
24+
25+
**Unchanged.** A comparison whose answer depends on which caller asks, such as `current_user.email == 'ops@acme.com'`, stays silent: it grants everything to the caller it names and is refused for everyone else, so no probe value can stand for it. The list literal (`record.f != ['a', 'b']`) and the bare root under `==` / `!=` (`record.f != current_user`) were already reported. `field == current_user.id`, `field in current_user.org_user_ids` and `field == null` stay clean.
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
fix(spec): a currency field's `precision` is its total digit count again — `precision: 18` on a fixed-USD field parses instead of being refused as a contradiction of the currency's two decimal places (#20011)
6+
7+
Clause-②: yes (widening) — one refusal is removed from `FieldSchema`, so the accepted set grows. Nothing that parsed before is refused now, no key is renamed or retired, and the output of every previously accepted field is byte-identical.
8+
9+
## What was wrong
10+
11+
`FieldSchema` compared a currency field's field-level `precision` with the ISO 4217 fraction digits of its fixed currency, and refused any difference. The key is declared `Total digits (non-negative integer)`, so a DECIMAL(18,2) USD amount writes `precision: 18`. That field was refused with "currency USD has 2 fraction digits; `precision: 18` contradicts it", and the refusal told the author to write `precision: 2`, which is a total-digit count of 2.
12+
13+
The check assumed the field-level key was the currency display width, because the Studio currency widget used to read it that way. That reading is gone. The ruled contract is that a currency amount's decimal places come from the currency and are not a field setting. No renderer reads the field-level `precision` as decimal places, and the SQL column does not read it at all.
14+
15+
## What it does now
16+
17+
- The field-level `precision` on a `currency` field is not compared with the currency. Any non-negative integer parses in every currency mode and is carried through unchanged.
18+
- `currencyConfig.precision` is a different key and is unchanged. Under `currencyMode: 'fixed'` it must still agree with the currency's fraction digits, and a contradiction is still refused at `currencyConfig.precision` with the same message.
19+
- `scale` on a `currency` field is still refused.
20+
21+
Nothing to migrate. Metadata that parsed before parses the same way. A currency field that had to drop `precision` or set it to the currency's fraction digits to get through validation can now declare its real total digit count.

0 commit comments

Comments
 (0)