Skip to content

Commit 0a60a2f

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20730-meta-packaged-base-resolver
2 parents e830f24 + 261c529 commit 0a60a2f

38 files changed

Lines changed: 2742 additions & 130 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/sdui-parser': minor
3+
---
4+
5+
`@objectstack/sdui-parser` now reads one base-prop list, ported from objectui's `SDUI_BASE_PROPS` at the console pin `db11afd4967c` (objectui#11008, #11044). Both `validateTree` and the generated JSX types (`generateDts`'s `SduiBaseProps`) are driven by it.
6+
7+
- On every node, whatever the component declares: `bind`, `hidden`, `visibleWhen`, `hiddenOn`, `testId` are newly accepted. They no longer draw `unknown-prop`, and the generated types accept them as attributes.
8+
- Only on a type whose registration declares no input of that name: `name`, `label`, `description`, `placeholder`, `data`, `ariaLabel` are newly accepted. A type that declares one keeps its declared type check and its declared attribute type; its generated interface `Omit`s that key from `SduiBaseProps`.
9+
10+
Effect for consumers: `os validate` stops warning `unknown-prop` on those keys, and a `.tsx` page that authors them now type-checks against `generateDts` output where it was a TypeScript error before. Measured on the tracked `sdui.manifest.json` (107 components), no component declares any of the five every-node keys, and every declared where-undeclared key is checked as before, so no diagnostic of error severity is removed for that manifest. The wider type surface is why this is a minor, not a patch.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
fix(cli): `os migrate meta` converts an object built with `ObjectSchema.create(…)` instead of stopping at load when the object carries a retired key
6+
7+
Clause-②: no
8+
9+
`os migrate meta` reads a config the current schema refuses, so it can rewrite the
10+
retired keys in it. It did that for artifacts built with a `define*` helper and for
11+
plain object literals. It did not do it for artifacts built with a factory such as
12+
`ObjectSchema.create(…)`, which validates when it is called. An object like this:
13+
14+
```ts
15+
ObjectSchema.create({
16+
name: 'ticket',
17+
fields: { title: { type: 'text' } },
18+
tenancy: { enabled: true, organizationField: 'organization_id' },
19+
})
20+
```
21+
22+
stopped `os migrate meta --from 17` at load with exit 1 and a raw JSON array of
23+
validation issues. The message in that array told the author to run
24+
`os migrate meta --from 17`.
25+
26+
The command now loads it, applies the conversion (here
27+
`object-tenancy-organization-field-removed`), and reports `schemaValid` for the
28+
migrated stack, exactly as it does for the same object written as a plain literal.
29+
This covers the five factories in `@objectstack/spec` that validate when called:
30+
`ObjectSchema.create` (`@objectstack/spec/data`) and `App.create`,
31+
`Dashboard.create`, `Report.create` and `Action.create` (`@objectstack/spec/ui`).
32+
The other `create` factories spec exports return their argument unchanged and
33+
never refused anything, so nothing changes for them.
34+
35+
A schema problem the migration cannot fix is still reported: it is listed among
36+
the refusals under the verdict, and `schemaValid` is `false`. A check that only
37+
the factory makes when it is called, such as `ObjectSchema.create` refusing a
38+
`managedBy: 'system-data'` object that grants no create, edit or delete, is not
39+
part of the stack schema. It is reported on the stderr line described below and
40+
does not change `schemaValid`, the same as `defineStack`'s own call-time checks.
41+
`os validate` still refuses it.
42+
43+
While the config loads, `os migrate meta` prints one stderr line for each
44+
artifact the current schema refused. A raw validation error on that line is now
45+
printed as a block, for example `ObjectSchema.create validation failed (1 issue):`
46+
followed by one `✗ path: message` line per issue, instead of a raw JSON array.
47+
This also applies to `define*` helpers that throw a raw validation error, such
48+
as `defineAgent`.
49+
50+
Nothing else changes. `os validate`, `os build` and every other command still
51+
refuse the retired key at load, with the same message. `ObjectSchema.create` and
52+
the other factories stay strict everywhere outside `os migrate meta`. The keys
53+
of the `--json` payload are unchanged, and a run whose migrated stack does not
54+
parse still exits 0.
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
"@objectstack/objectql": minor
3+
---
4+
5+
fix(objectql)!: a `groupBy` on a structured-JSON field is refused with `INVALID_FIELD` / 400 at the engine's `aggregate`, on every driver
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) a refusal of a grouping TARGET at the engine's aggregate door: a groupBy entry naming a declared json, composite, repeater, record, location, address or vector field. No authorable key, spelling, export or stored shape moves (the door module is internal; `@objectstack/objectql` exports nothing new and nothing less, and `EngineAggregateOptions` / `QuerySchema.groupBy` keep parsing the entry), and no stored row is read or rewritten. The grouping had no shared meaning to preserve (one merged group on the in-memory driver, one group per serialized document on SQLite, a 500 on PostgreSQL), and which scalar part of the document a caller meant to group on is not something a ledger entry can rewrite. The other categories are closed on facts: the package publishes (not `unpublished`); no ADR-0087 id covers a grouping target (not `registered` / `already-registered`); and the change is runtime behaviour, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
10+
11+
**BREAKING**: this narrows what `aggregate` accepts as a grouping target. A `groupBy` entry that names a declared field of the structured-JSON class (`json`, `composite`, `repeater`, `record`, `location`, `address`, `vector`) is refused by the engine before any driver is asked. Both entry spellings are judged, the field name and the `{ field }` object, a `dateGranularity` bucket included. It ships as `minor` under the launch-window convention for accept-set narrowings. No export or published type changes.
12+
13+
**What an author sees now.** `400 INVALID_FIELD`, naming the position (`groupBy[0]`, or `groupBy[0].field` for the object form), the field and its declared type, saying the query was not run, and naming the route inside the first 500 characters the REST door keeps: group by a field that stores one scalar value, storing the part of the document you group on in a field of its own. The thrown error carries `field`, `fields`, `object` and `param: 'groupBy'`.
14+
15+
**Why a refusal.** The drivers share no meaning for a JSON document as a group key. Measured through `POST /api/v1/data/:object/query` over three rows with different documents under the grouped field: the in-memory driver answered 200 with one group holding every row, SQLite answered 200 with one group per serialized document, and PostgreSQL answered 500 `DATABASE_ERROR`. A `vector` field split the same three ways, and a date bucket over a `json` field answered one `null` bucket on memory and SQLite and 500 on PostgreSQL. No producer that groups by a structured-JSON field was found (no dataset, cube, view grouping or `groupBy` in the example apps names one), so no meaning is defined for it here.
16+
17+
**Who is affected.** A caller of `engine.aggregate` or of the REST query door that grouped by such a field on the in-memory driver or on SQLite and read the merged or per-serialization groups as real ones. On PostgreSQL the same query was already a 500. The analytics service's aggregate path (a cube query the native-SQL strategy declines, such as a time dimension with a granularity, or any cube query on the in-memory driver) reaches the engine and answers this refusal too.
18+
19+
**Unchanged.** A `groupBy` on any other type (`text`, `number`, a `multiple: true` select, a file field), a structured-JSON field as an AGGREGATED column (`count`, `count_distinct`, `min`, `max`), and an undeclared name, which the REST door answers `INVALID_FIELD` as unknown before the engine is reached.
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/objectql': patch
4+
'@objectstack/plugin-security': minor
5+
---
6+
7+
feat(spec, objectql, plugin-security): one shared filter lowering, run once at the engine and RLS seams (ADR-0053 D-D1, amended)
8+
9+
Clause-②: yes
10+
11+
`@objectstack/spec/data` exports `lowerFilterCondition(filter, options?)` and its `FilterLoweringOptions` type. It is not exported from the package root entry. It is a pure `FilterCondition → FilterCondition` rewrite that applies three rules once:
12+
13+
- `$between` becomes `$gte` its minimum and `$lte` its maximum.
14+
- A `$lte` whose comparand is a bare `YYYY-MM-DD` day becomes `$lt` the next day, in the calendar-string domain. On the last supported day (`9999-12-31`) a lone `$lte` becomes `{ $null: false }`, and a `$between` keeps only its minimum.
15+
- The NULL-polarity guards the drivers already compile. A `$ne` of a value, a `$nin` or a `$notContains` holds for a row with no value. Every leaf of a `$not` operand is made total.
16+
17+
The rewrite is copy-on-write, idempotent and never refuses. A node it rewrites keeps its filter-subtree provenance mark. With `options.isDatetimeColumn` (a typed seam), the first two rules change only a declared `datetime` column. Without it they apply to every column.
18+
19+
As ADR-0053 D-D1 (amended 2026-09-30) requires, the seams now run it once, after the comparand doors and after filter-token resolution:
20+
21+
- **`@objectstack/objectql`** runs it on every filter position, typed by the object's declared fields. That covers `where` on `find`, `findOne`, `count`, `update` and `delete`, and `aggregate`'s `where`, `aggregations[i].filter` and `having`. `having` is typed by the aggregated row's columns, so `max` of a `datetime` field counts as a `datetime`. Drivers receive the lowered filter. A date macro such as `{today}` is resolved before the lowering reads it.
22+
- **`@objectstack/plugin-security`** runs it on every compiled RLS policy filter (`using` and `check`), right after the two comparand faces. `SecurityPlugin` now hands the compile seam the object's declared `datetime` columns (`RlsFieldGuard.datetime`). A guard without that set treats no column as `datetime`.
23+
24+
Row answers stay the same on every driver. Each driver keeps its own copy of these rules, and every copy gives the same answer on lowered input. One result changes. The engine evaluates `aggregate`'s `aggregations[i].filter` and `having` itself, and that evaluator now treats a row or group with no value the way every driver's `where` already does. It no longer counts such a row in a `$between` on a `datetime` column. It now keeps such a row under a `$not` over an ordering such as `$lt`.
25+
26+
Nothing is removed or renamed, and there is nothing to migrate.

‎.claude/skills/spec-property-retirement/SKILL.md‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ conversion,钉上 non-warn。十四个键里有一个是这样被证伪的 —
8383

8484
| Schema | 路线 | 机制 |
8585
|---|---|---|
86-
| **非 `.strict()`** | `retiredKey()` 墓碑 | `packages/spec/src/shared/retired-key.ts` 的 `retiredKey(guidance)` —— `z.never({ error: () => guidance }).optional()`。两个通道:`tsc`(输入类型 `never`)与 parse(处方本身,不是 "unrecognized key")。 |
87-
| **`.strict()`** | 删键 + guidance map | 从 shape 里删除;向该 schema 的 `*_RETIRED_KEY_GUIDANCE` 加条目,由 `strictObject()` 的 `guidance:` 槽消费(`shared/strict-object.ts`;整族一条走 `guidanceSets`)。样板 `ai/tool.zod.ts`,审计 `shared/alias-integrity.test.ts`。⛔ 别再手写 `$ZodErrorMap`。 |
86+
| **任何 shape**(strict 与否) | `retiredKey()` 墓碑 | `packages/spec/src/shared/retired-key.ts` 的 `retiredKey(guidance)` —— `z.never({ error: () => guidance }).optional()`。两个通道:`tsc`(输入类型 `never`)与 parse(处方本身)。strict 上裸删也响,但只报 "unrecognized key",两通道都丢。 |
87+
| **从未声明的拼写** | guidance map | 退役键的旧 alias、错层指针,墓碑无属性可换:向 `*_RETIRED_KEY_GUIDANCE` 加条目,由 `strictObject()` 的 `guidance:` 槽消费(整族走 `guidanceSets`)。样板 `data/mapping.zod.ts`,审计 `shared/alias-integrity.test.ts`。⛔ 别手写 `$ZodErrorMap`。 |
8888
| **没人 parse 它** | 都不用 | 没人能收到的处方是噪音。有意删掉 baseline 行并在 changeset 里写明 —— 先例 #3896 与 #4834(PR #4878),都在 kernel plugin-runtime 家族。家族删除后幸存的解释块在 `packages/spec/src/kernel/index.ts`(搜 `plugin-runtime.zod`)。 |
8989

9090
永不从非 strict schema 上裸删一个键:zod 会静默剥掉它,你只是用一个静默 no-op 换了
@@ -99,18 +99,15 @@ liveness 门禁走的是 **schema 的 shape**,逐个属性去
9999
| 路线 | 键还在被走的 shape 里? | 它的台账条目 |
100100
|---|---|---|
101101
| `retiredKey()` 墓碑 | **在**(`z.never()` 是属性) | **保留** —— `status: "dead"`、一个 `verifiedAt`、一条 `note` 写明 REMOVED + 条目为何还在 |
102-
| strict 删除 | 不在 | **删除**,连同 CLI advisory-lint 的预期 |
102+
| 删键(无墓碑) | 不在 | **删除**,连同 CLI advisory-lint 的预期 |
103103

104104
现在两个方向都会红 CI,搞反了两边都很响:
105105

106106
- 删掉**墓碑**键的行,报 **UNCLASSIFIED**(#3896 清扫一次 14 个 —— 本节就是防它);
107-
- 留着 **strict 删除**键的行,报 **ORPHAN** 行。
107+
- 留着**无墓碑删除**的键的行,报 **ORPHAN** 行。
108108

109-
orphan 这条腿是新的(`packages/spec/scripts/liveness/orphans.mts`)。它落地之前这个方向从不失败
110-
—— 门禁走 schema 再查行,键已离开 shape 的行根本不会被问到,原地腐烂。report 的
111-
`aria`/`performance` 行就这样比它们的键多活了一整个 release,靠有人恰好读到那个文件
112-
才手工删掉。你撞上 orphan 报错而属性确实还可编写时,要修的是 **walk**,不是行:
113-
walk 看不见的属性就是 ratchet 管不到的属性。
109+
orphan 这条腿住 `packages/spec/scripts/liveness/orphans.mts`,来历见其头注。你撞上 orphan 报错而属性
110+
确实还可编写时,要修的是 **walk**,不是行:walk 看不见的属性就是 ratchet 管不到的属性。
114111

115112
墓碑条目的 note 模板(house style 原文,如 `liveness/action.json`):
116113

@@ -204,18 +201,21 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都
204201
(`flow.nodes[].outputSchema`),那也是 upgrade guide 打印的。多键 conversion
205202
仍用恰好 `' / '` 连接子句(tool 清扫以来的 house style)。下游不再有任何东西
206203
从它解析归属 —— 那个职责移给了上面的条目。
207-
- [ ] **`retiredFromLoadPath: true`** —— 退役恒真,但管辖权只有 authoring 漏斗
204+
- [ ] **`retiredFromLoadPath: true` 与 `retiredAfter: 'x.y.z'`** —— 后者必填(缺则 `tsc` 拒),新退役填
205+
`packages/spec/package.json` 的当前版本标签(`retired-after.census.test.ts` 逐值钉;artifact 门据它
206+
逐条开窗)。前者退役恒真,但管辖权只有 authoring 漏斗
208207
`normalizeStackInput`;三处 data-at-rest seam 以 `includeRetired: true` 故意重放退役
209208
条目,它**一处也拦不住**:`applyConversionsToStoredItem`(钉死)、automation
210209
engine 的 flow rehydration、`applyArtifactForwardConversions`。对*改名*它意味着
211210
「没有 alias 窗口,故意的」;对**默认值翻转**,只有确知输入早于翻转的 seam 才可重
212211
放,其余按 id 退订 `excludeConversionIds` —— `app-hidden-to-unpublished` 在 artifact
213-
门即如此。上一版样例栽在这:它教「只有 migrate meta 能应用翻转」,而 boot 时照样
214-
应用,该 conversion 已撤(`packages/spec/CHANGELOG.md`)。
215-
- [ ] **一步 D3 链**,在 `packages/spec/src/migrations/registry.ts` —— 把 id 加进
216-
`MIGRATIONS_BY_MAJOR[N].conversionIds`,扩写该步的 `rationale`。
217-
`conversion.toMajor` **必须等于**该步的 major。⚠ 没有东西直接断言「每个
218-
conversion 都接进了某一步」,拼错的 id 在 replay 时被**静默跳过**;
212+
门即如此。
213+
- [ ] **一步 D3 链**,在 `packages/spec/src/migrations/registry.ts`;`conversion.toMajor` **必须等于**该步的
214+
major。**18 步**:conversion 只进 `conversions/registry.ts` 的 `MAJOR_18_CONVERSIONS`,照其头注按
215+
标识符排序插入,本步 `conversionIds` 由它派生;`rationale` 只加一个 `STEP18_RATIONALE` 片段,
216+
按其头注插在你 D3 semantic id 的排序位;尾部追加被两处头注点名的 merge 测试拒收。
217+
**更早的步**:id 加进 `MIGRATIONS_BY_MAJOR[N].conversionIds`,扩写该步 `rationale`。⚠ 没有东西
218+
直接断言「每个 conversion 都接进了某一步」,拼错的 id 在 replay 时被**静默跳过**;
219219
chain-replay 测试抓得到它,只因为没接线的 fixture 永远到不了自己的 `after`。
220220
所以把那个测试的失败读作「没接线」,不是「transform 坏了」。
221221
- [ ] **fixture 必须不相交 —— 两重。** 每个 fixture 都被整张表 replay,必须恰好等于
@@ -239,7 +239,7 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都
239239

240240
从上往下做;每一行背后都有一个门。
241241

242-
- [ ] **Schema** —— 墓碑或 strict 删除(§2),外加 schema 内注释:删了什么、真正生
242+
- [ ] **Schema** —— 墓碑或无墓碑删键(§2),外加 schema 内注释:删了什么、真正生
243243
效的机制是什么。
244244
- [ ] **孤儿值 schema** —— 一个键的 `XxxConfigSchema` 没有别的消费者就随它一起走
245245
(`PerformanceConfigSchema`、`AIKnowledgeSchema`、`ToolCategorySchema`)。没有
@@ -254,7 +254,7 @@ conversion 是消费者跟的;D3 条目是升级方的 agent 读的。三个都
254254
要手改)。
255255
- [ ] **生成 baseline** —— `pnpm --filter @objectstack/spec gen:schema` 会动
256256
`authorable-surface/<category>.json`(墓碑 → 一条新的 `… [RETIRED]` 行;
257-
strict 删除 → 该行**消失**,这是门 (a) 的绊线,所以同一个 PR 里有意删掉它)与
257+
无墓碑删键 → 该行**消失**,这是门 (a) 的绊线,所以同一个 PR 里有意删掉它)与
258258
`json-schema.manifest/<category>.json`。#5837 起两者都按 category 分片 —— 门
259259
禁把整个目录读成一个集合,退役流程不变;变的只是那一行住在哪个文件。然后
260260
`gen:spec-changes`、`gen:upgrade-guide`、`gen:api-surface`、`gen:docs`。

‎AGENTS.md‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1077,11 +1077,12 @@ Both non-handshake shapes, and how to classify and probe your own:
10771077
spec key, an export, a config field), the changeset body must state the FROM → TO mapping and the one-line fix —
10781078
this text ships to consumers as `CHANGELOG.md` inside the npm package and is what an upgrading agent greps after the
10791079
tombstone error. Removing an authorable spec key also requires a tombstone so the rejection itself carries the
1080-
prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on a non-strict schema, or an entry in
1081-
the relevant `UNKNOWN_KEY_GUIDANCE` / `*_RETIRED_KEY_GUIDANCE` map (see `object.zod.ts`, `ai/tool.zod.ts`) when the
1082-
schema is `.strict()`. The changeset is one of fourteen surfaces a retirement touches — follow the
1083-
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note the two routes
1084-
imply **opposite** liveness-ledger dispositions.
1080+
prescription — `retiredKey()` (`packages/spec/src/shared/retired-key.ts`) on the schema whether or not it is
1081+
`.strict()`, and an entry in the shape's `*_RETIRED_KEY_GUIDANCE` map (see `data/mapping.zod.ts`) only for a
1082+
spelling the shape never declared, such as the retired key's old alias, where a tombstone has no property to
1083+
replace. The changeset is one of fourteen surfaces a retirement touches — follow the
1084+
`spec-property-retirement` skill (`.claude/skills/`) rather than reconstructing the kit, and note that a tombstone
1085+
keeps its liveness-ledger row while a key deleted without one loses it.
10851086
**A breaking changeset must also state its ADR-0087 disposition, in writing** — exactly one marker in the changeset
10861087
body, which also carries the PR's `Clause-②` line: `pnpm check:adr-0087-registration` reads the arm there. ⛔ The
10871088
categories are NOT copied here — the gate prints the full set when it fails.

0 commit comments

Comments
 (0)