Skip to content

Commit c135fb5

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-7496-submitbehavior-url-shape-wt
2 parents 11cf094 + 21888ab commit c135fb5

135 files changed

Lines changed: 9297 additions & 468 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: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
chore(spec): backfill nine ADR-0087 ledger entries the v17 stock was missing (#6350)
6+
7+
The #6148 completeness gate is deliberately **diff-only** — it judges what a PR
8+
adds, never the inventory, because auditing stock in CI would turn the repo red on
9+
adoption day and bill main's history to current authors (#6129). The cost of that
10+
choice is that every breaking changeset already in the v17 train was never compared
11+
against the ledger. This is the one-time manual reconciliation the maintainer ruled
12+
for, not a change to the gate: `check-adr-0087-registration.mjs` still judges diffs
13+
only, and nothing in CI starts auditing inventory.
14+
15+
Measured today over the stock: **274 declared-breaking changesets**, 106 in the
16+
residue (published break + a real FROM → TO prescription, so only `registered` or
17+
`already-registered` remain), **61 of them flagged** as never having touched a
18+
ledger file. Judging those 61 by hand found nine genuinely missing entries, now
19+
registered as D3 semantic migrations:
20+
21+
| entry | face | issue |
22+
| --- | --- | --- |
23+
| `runtime-httpserver-wrapper-retired` | the exported `HttpServer` delegating wrapper | #5122 |
24+
| `record-details-sections-object-form` | `RecordDetailsProps.sections` shape + `hideFields` | #5611 |
25+
| `data-driver-query-omit-object` | `IDataDriver`'s query parameter contract | #5181 |
26+
| `sort-node-direction-rejected` | `orderBy[].direction` → `order` | #4721 |
27+
| `tool-requires-confirmation-retired` | `tool.requiresConfirmation` | #3715 |
28+
| `export-axis-opt-in` | `allowExport` unset flips to deny | #3544 |
29+
| `apimethod-enum-shrink` | `enable.apiMethods` legacy values | #3543 |
30+
| `sharing-rule-recipient-reconcile` | sharing-rule `group` / `guest` / owner-type rules | #1878 |
31+
| `client-delete-result-success` | `DeleteDataResult.deleted` → `success` | #5638 |
32+
33+
Two of them had already shipped **half** a retirement: `tool.requiresConfirmation`
34+
carries a live `retiredKey()` tombstone in `ai/tool.zod.ts` and `SortNodeSchema`
35+
carries `aliases: { direction: 'order' }`, but neither had the ledger half. A
36+
tombstone is the proof the removal was declared; the ledger entry is what
37+
`spec-changes.json`, the upgrade guide and `os migrate meta` project to consumers,
38+
and a retirement needs both.
39+
40+
Each of the nine stock changesets now carries its `<!-- adr-0087: registered … -->`
41+
disposition marker, so the judgement is recorded where the next auditor reads
42+
rather than only in a PR body. `spec-changes.json` and
43+
`docs/protocol-upgrade-guide.md` are regenerated from the registry.
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---
2+
"@objectstack/service-analytics": minor
3+
---
4+
5+
fix(analytics)!: a `{ $field }` comparand is refused on both SQL-lowering doors instead of being BOUND as the comparison's value (#7598)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) This change retires NO key and adds none. `FieldReferenceSchema` stays declared in `packages/spec` exactly as it is, stays implemented by `@objectstack/formula`'s in-memory evaluator, and stays COMPILED by `driver-sql` / `driver-sqlite-wasm` under #5222 — `packages/spec` is untouched by this PR, no metadata schema gains or loses a key, and no authored or stored shape becomes unparseable. What moves is one COMPILER's posture at two doors of `@objectstack/service-analytics`: a shape that used to compile into a predicate binding the reference OBJECT as a value now refuses. There is therefore nothing for `objectstack migrate meta` to rewrite — the FROM shape is still valid metadata everywhere it was valid before, and rewriting it would be wrong, since the identical filter continues to execute on the ObjectQL engine path and on both SQL drivers. Nor is there a FROM/TO rule a ledger entry could state: the correct repair depends on which face the author's query routes to, which is a deployment fact rather than a metadata one. The channels that do reach an affected reader are this changeset's CHANGELOG text and the refusal message itself, which names the operator, the field, the referenced column, the faces that DO execute the shape, and why this compiler cannot — all shipped with this change. -->
8+
9+
**⚠️ Behaviour change.** A filter whose comparand is a field reference —
10+
`{ amount: { $gt: { $field: 'budget' } } }`, the shape
11+
`FieldReferenceSchema` declares and `compileCelToFilter` emits for a
12+
field-to-field comparison in a CEL permission / RLS rule — used to COMPILE on
13+
both of this package's doors. It now refuses: `INVALID_FILTER` / 400 on the
14+
analytics `where` door, `READ_SCOPE_COMPILE_FAILED` / 500 on the read-scope
15+
lowering (each door's existing envelope, unchanged).
16+
17+
#7598 was filed reading "these compilers still REFUSE `$field`". Measured on
18+
`origin/main` (`5823d593d`), nothing refused. For the six scalar comparison
19+
operators — exactly the ones #5222 taught `driver-sql` to compile into a
20+
same-table column-to-column comparison — the reference OBJECT went into the
21+
bind list:
22+
23+
| face | `{ amount: { $gt: { $field: 'budget' } } }` |
24+
|---|---|
25+
| `read-scope-sql` | `"person"."amount" > ?` · bound to `{"$field":"budget"}` |
26+
| `where` → `NativeSQLStrategy` | `WHERE amount > $1` · bound to the JSON TEXT `{"$field":"budget"}` |
27+
| `where` → `/analytics/sql` echo | `WHERE amount > $1` · bound to the reference OBJECT |
28+
| `where` → ObjectQL engine | reached `driver-sql`, which compiles it CORRECTLY since #5222 |
29+
30+
So the defect was a silent wrong answer, not a refusal: a syntactically perfect
31+
predicate comparing a column against a value no row can hold. Three of the four
32+
faces answered differently, and on the read-scope door the one answering wrongly
33+
is an administrator's RLS predicate. The gates assumed to be catching this
34+
(`isBindableComparand` / `isRenderableTextComparand`) had not drifted from
35+
`driver-sql` — they are simply never ASKED about that position, only about the
36+
LIKE family and `$in` / `$nin` / `$between` MEMBERS.
37+
38+
**What this does not do:** it does not bring the capability to these compilers.
39+
The four maintainer rulings that make a referenced column name safe in a SQL
40+
identifier position (same-table only, declared-only enumeration, tenant-isolation
41+
column forbidden on both sides, same comparison class) all turn on metadata
42+
`StrategyContext` does not expose — neither an object's declared field set nor its
43+
tenant-isolation column — so these compilers cannot enforce them, and shipping a
44+
port without them would open a comparison surface onto the tenant boundary.
45+
Implementing it here is a `packages/spec` contract question, left open on #7598.
46+
47+
Field-to-field RLS rules continue to work on the ObjectQL engine path, where the
48+
driver compiles them with the metadata it owns; they are now loudly refused,
49+
rather than silently mis-answered, on the raw-SQL analytics path.
50+
51+
Positions already refused before this change keep their exact wording — the LIKE
52+
family, `$in` / `$nin` members, and a bare `{ field: { $field: … } }` — because
53+
each of those refusals already CONVERGES with `driver-sql`'s own #5222 refusal
54+
arm. `minor` rather than `patch` follows #5234, the same class of change on the
55+
same two doors.

‎.changeset/apimethod-enum-shrink.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,5 @@ passing through Zod; the parse-time strip warning carries no object name).
7979
primitives = default-open). Read-only and deny-all whitelists are unchanged;
8080
the seven `[]` declarations are deliberately KEPT as defense-in-depth alongside
8181
`apiEnabled: false`.
82+
83+
<!-- adr-0087: registered apimethod-enum-shrink -->
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/plugin-hono-server': patch
3+
---
4+
5+
fix(hono-server): apply the baseline ADDITIVELY on `/auth/me/permissions` and `/me/apps` — the ADR-0090 D5 fallback cliff, one plane over (#7608)
6+
7+
Both permission resolutions in `current-user-endpoints.ts` applied the deployment
8+
baseline permission set(s) only in a **second** `resolvePermissionSets` call gated on
9+
`resolved.length === 0`. That is the fallback **cliff** D5 abolishes, verbatim:
10+
11+
> The fallback cliff is abolished. Today's semantics ("fallback applies only while the
12+
> user has *zero* explicit grants") mean the first real grant silently removes the user's
13+
> baseline. `everyone` is additive like any other position: baseline ∪ explicit, always.
14+
15+
`SecurityPlugin.resolvePermissionSetsForContext` — the **data** plane, one function call
16+
away — has pushed the baseline into `requested` and resolved once for as long as D5 has
17+
existed. These two endpoints had not, so the two planes disagreed the moment a member
18+
held any explicit grant at all.
19+
20+
**What a user saw.** A member who received their **first** position or permission-set
21+
grant kept the baseline on the data plane and lost it on the **UI** plane.
22+
`/auth/me/permissions` reported object/field access narrower than a read actually
23+
returns, and `/me/apps` dropped every app whose `requiredPermissions` or tab visibility
24+
came from the baseline. Measured on the fixture that ships with this change — a member of
25+
one org, a baseline granting two capabilities, one explicit grant adding a third —
26+
receiving that grant took the member from **2 apps to 1**. It now takes them from 2 to
27+
**3**: the two baseline apps are retained instead of traded away, and the same member
28+
regains one readable object, one readable field and two capabilities on
29+
`/auth/me/permissions`. The fail-direction was **closed** (the console hid what the API
30+
allowed), which is why it read as cosmetic for as long as it did.
31+
32+
The baseline names are now pushed into `requested` before the **single** resolution, in
33+
one shared `effectivePermissionSetNames` helper both handlers call — which retires the
34+
second `resolvePermissionSets` call outright rather than merely widening its guard: once
35+
the baseline is an input to the first call, a second call over a subset of those same
36+
names can add nothing. Unchanged: a member with zero grants still gets the baseline, a
37+
deployment declaring an empty baseline still resolves the caller's own names alone, and
38+
`/me/apps` still filters an app whose `requiredPermissions` nobody holds.
39+
40+
No `principalKind === 'agent'` branch, unlike the plugin's copy, and that is a property
41+
of this surface rather than an omission — these endpoints are reached only through the
42+
better-auth **session** resolver, which never marks a principal kind, so an agent has no
43+
session to present here.

‎.changeset/client-delete-result-success.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,5 @@ server suite never had: a real DELETE over HTTP whose body is read as
9696
`deleted.success` and whose key set is asserted literally — `z.object` strips
9797
unknown keys, so a passing parse alone cannot prove no stray `deleted` rode
9898
along.
99+
100+
<!-- adr-0087: registered client-delete-result-success -->

‎.changeset/data-driver-query-omit-object.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,5 @@ await driver.find('account', { where: { status: 'open' } });
2727
`QueryAST` 的 zod 形状(`data/query.zod.ts` 的 `BaseQuerySchema`)**没有动**:`object` 在引擎与 hook 那一层是被读的,改的只是驱动契约的参数类型。`expand` 条目里的 `object` 同样保留 —— 那里它命名的是**关联对象**,没有任何实参携带这个事实,不是冗余。
2828

2929
标 major 是因为这是**源码级破坏性**变更(调用点字面量),运行时行为零变化。注意 `check:api-surface` 只看得见新增的 `DriverQuery` 导出、看不见参数类型的收窄(它记录导出存在与否,不记录签名),所以这条迁移说明是该变更唯一的下游载体。
30+
31+
<!-- adr-0087: registered data-driver-query-omit-object -->
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(objectql,runtime): stop the platform's own stamps from failing spec validation — `/meta/diagnostics` reads clean again (#7561)
7+
8+
`GET /api/v1/meta/diagnostics` reported **94 of 94** registry entries INVALID —
9+
every entry, `sys_*` and `showcase_*` alike. The endpoint reports entries that
10+
fail their registered Zod schema, so at a 94/94 baseline it carried **no
11+
signal**: a genuinely broken object was indistinguishable from a healthy one,
12+
and any gate or dashboard built on it read permanently red.
13+
14+
Both error shapes behind the 94 were self-inflicted — the platform reporting
15+
defects about columns it wrote itself, on documents no author wrote or could
16+
fix.
17+
18+
**`fields.__search: Unrecognized key 'index'`.** `provisionSearchCompanion`
19+
stamped `index: true` on the hidden `__search` companion column. Field-level
20+
`index` was removed from `FieldSchema` in the 16.x line (#2377, ADR-0049)
21+
because a field-level index flag built no index, and `FieldSchema` is a
22+
`strictObject`, so the key was rejected by name. The companion is provisioned
23+
before the document is stored and `/meta` re-parses the served body, so the
24+
stamp badged `_diagnostics: { valid: false }` onto every object the platform
25+
provisions a companion for. This is the #6810 mechanism one field over
26+
(`applySystemFields` stamping `indexed` on `organization_id`), and the same
27+
retired key. The stamp is gone, along with the docblock claim that the column
28+
"IS `index`ed".
29+
30+
Unlike #6810 the index is **not** re-declared in the object's `indexes[]`, and
31+
that difference is measured rather than overlooked. #6810's predicate is
32+
`organization_id = ?` — equality, which a B-tree serves. This column's only
33+
reader is `buildSearchFilter`, which emits `{ __search: { $contains: term } }`
34+
— a leading-wildcard `LIKE '%term%'` no B-tree can answer — and `IndexSchema`
35+
spells nothing else (`name` / `fields` / `unique`; no trigram/GIN method).
36+
Declaring one would buy write amplification on every row for a read path that
37+
cannot use it. Search behaviour is unchanged either way: nothing read the flag.
38+
39+
**`config: expected record, received undefined`.** The datasource-visibility
40+
registration in `DefaultDatasourcePlugin` published the `default` row without
41+
`config`, which `DatasourceSchema` requires. It is now stamped `{}` —
42+
deliberately empty, not the host's real config, which carries connection
43+
credentials that would otherwise land on `GET /api/v1/meta/datasources` for
44+
every metadata reader. No information is lost versus the omitted key; only the
45+
spelling changes to the one the contract accepts. Fixed at the producer rather
46+
than by widening the spec: a real datasource document genuinely needs its
47+
config, so relaxing the schema would trade one honest verdict for a permanently
48+
weaker one.
49+
50+
**So it stops recurring.** Two pins land with the fix, because patching one key
51+
at a time is what turned #6810 into this card. A class pin walks every field
52+
the platform stamps — `applySystemFields` and `provisionSearchCompanion`,
53+
across every ownership / tenancy / `systemFields` branch — through
54+
`FieldSchema`, so the next retired-key stamp turns a suite red instead of
55+
poisoning diagnostics. A baseline pin asserts a realistically-built registry
56+
sweeps clean, naming both of this card's error shapes explicitly; the 94/94
57+
state survived undetected until a human read the endpoint by hand, because
58+
nothing asserted the baseline.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
**docs(spec): `fields` stops prescribing a dotted path no driver resolves (#7601)**
6+
7+
Six in-repo surfaces offered `fields: ['owner.name']` as the supported way to read
8+
one related column. No driver ever implemented it — measured on a real `SqlDriver`,
9+
a dotted projection is byte-identical to no projection at all, because Knex renders
10+
`"account"."name"` against a table that was never joined and the #3821 recovery
11+
ladder retries `select('*')`. Since #7532 those surfaces are additionally
12+
contradicted by a `400 INVALID_FIELD` at the ingress gate
13+
(`assertProjectionFieldsExist`). The migration tooling was the sharpest case: both
14+
protocol-17 upgrade prescriptions routed authors off `query.joins` and off the
15+
retired `{ field, fields, alias }` form directly into the refused spelling.
16+
17+
This aligns the declaration to the enforcement. The normative `fields` `.describe()`
18+
now names `expand` as the sanctioned mechanism for related data — its nested
19+
`QueryAST` both filters (`where`) and selects (`fields`) the related record's
20+
columns — and carries the sharp edge that was pinned but never documented: **the
21+
projection must retain the foreign-key column.** `fields: ['title']` with
22+
`expand: 'project_id'` resolves nothing, because the relation is carried by that
23+
key; adding `'project_id'` makes it work. Where the value is wanted on the queried
24+
object itself, the honest remedy is to denormalise it onto that object (a stored
25+
field, written when the source changes) — the same remedy the sort axis prescribes
26+
(#6924). Both retirement prescriptions and the two tombstone rejection messages now
27+
say the same thing, and the JSON Schema artifacts and reference docs regenerate from
28+
the source.
29+
30+
**No schema change.** `FieldNodeSchema` stays `z.string()`: the refusal of dotted
31+
projections is a *semantic* verdict, made at the ingress gate where the field map is
32+
available to judge against — not a *shape* check. Narrowing the type would duplicate
33+
that gate and refuse the registry-less internal callers the ingress deliberately
34+
tolerates. Every input that parsed before this change parses byte-identically after
35+
it, and the type/runtime pins that assert so are kept and renamed
36+
(`fieldNodeDottedNotNarrowed`) so they read as the non-narrowing guard they are
37+
rather than as an endorsement of a feature that does not exist.
38+
39+
Prose, prescriptions and generated artifacts only — no wire, stored-data or
40+
validation behaviour changes.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/driver-sql": minor
4+
---
5+
6+
fix(spec): lower equality triples with a `$field` comparand to `{ $eq: ref }` (#7597)
7+
8+
`parseFilterAST` lowered one authored intent two different ways depending only on
9+
how the operator was spelled:
10+
11+
| authored | lowered to | what it did |
12+
| :--- | :--- | :--- |
13+
| `['amount', '>', { $field: 'budget' }]` | `{ amount: { $gt: { $field: 'budget' } } }` | worked on both evaluation paths |
14+
| `['amount', '=', { $field: 'budget' }]` | `{ amount: { $field: 'budget' } }` | matched **nothing**, silently |
15+
16+
The four equality spellings (`=`, `==`, `equals`, `eq`) dropped the operator,
17+
because a LITERAL comparand's implicit-equality form is `{ field: value }` —
18+
correct for a literal, and for a field reference it produces a field spec whose
19+
only key is `$field`. Every consumer reads an all-`$` key set as an OPERATOR
20+
SPEC, and nothing implements an operator named `$field`: the in-memory evaluator
21+
(`@objectstack/formula`) dispatches it to its operator switch, finds no arm, and
22+
returns the fail-closed `false` — so the filter matched no record on the very
23+
path that produced it, with no error anywhere. On SQL push-down the same shape
24+
arrived as an unknown operator and was refused.
25+
26+
An equality triple whose comparand is a `FieldReferenceSchema` now lowers to the
27+
explicit `{ field: { $eq: ref } }` — the spelling both evaluation paths already
28+
implement (the memory evaluator resolves the reference; `driver-sql` compiles it
29+
to a column-to-column comparison, #5222). `['amount', '=', ref]` and
30+
`['amount', '>', ref]` are now the same kind of thing.
31+
32+
Unchanged, deliberately:
33+
34+
- **Literal comparands.** `['amount', '=', 5]` still lowers to `{ amount: 5 }`.
35+
The fix branches on the comparand being a field reference, never on the
36+
operator, and a `$field` carrying a non-string is not a field reference on any
37+
path — it keeps the literal lowering too.
38+
- **The in-memory evaluator's unknown-operator posture.** #6520 examined it and
39+
kept it; a hand-authored bare `{ amount: { $field: 'budget' } }`
40+
`FilterCondition` keeps exactly its current fate on every backend — fail-closed
41+
`false` in memory, and `driver-sql`'s actionable refusal naming `$eq` (#5222).
42+
Only what the ARRAY sugar produces has changed.
43+
44+
`@objectstack/driver-sql` gains `CROSS_FIELD_AUTHORED_CASES` — the conformance
45+
corpus's new AUTHORING arm, entering through the lowering sink instead of at the
46+
already-lowered object, run by both SQL drivers' cross-field suites. Its only
47+
other change is documentation.

0 commit comments

Comments
 (0)