Skip to content

Commit 4b6459e

Browse files
committed
Merge origin/main into claude/issue-18177-bulk-action-param-strict
Resolves delete/modify conflicts on the five api-surface-declarations files this branch had modified (api.txt, data.txt, root.txt, system.txt, ui.txt) by taking main's deletion of the entire directory (commit 2277d1f, the #19024 revert). That mechanism is fully retired on main: no script regenerates or reads packages/spec/api-surface-declarations/ anymore, and the replacement mechanism (api-surface/*.json + api-surface-signatures.json) does not capture this PR's kind of change per its own documented scope (key-level narrowing inside a schema, not a factory-signature or export-kind change) — consistent with this PR never having touched those files. The actual carrier for this PR's surface change, authorable-surface/ui.json, merged and regenerated cleanly and still declares ui/BulkActionParam:dependsOn. Regenerated after the merge: packages/spec/authorable-surface/ (via gen:schema), content/docs/references/** (via gen:docs), docs/audits/2026-07-unknown-key-strictness-ledger.counts.md (via gen:strictness-ledger), and src/migrations/registry.ts (via gen:migration-registry) — all previously flagged by the merge driver as generated/not-text-merged. Co-Authored-By: Claude <noreply@anthropic.com>
2 parents 837234d + 2951c0f commit 4b6459e

648 files changed

Lines changed: 54643 additions & 244510 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: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
**BREAKING for action handlers** — `ActionEngineFacade.find` takes the engine's query ENVELOPE; the bare-filter parameter shape is withdrawn (#15124)
7+
8+
Clause-②: yes (narrowing)
9+
10+
`ctx.engine.find(object, query)` now takes `EngineQueryOptions` — the same
11+
options bag `IDataEngine.find` and ObjectQL's own `engine.find` take, named by
12+
identity rather than restated. **One platform, one query shape.**
13+
14+
### Migration — FROM → TO
15+
16+
| You wrote | Write instead |
17+
| --- | --- |
18+
| `ctx.engine.find('task', { status: 'open' })` | `ctx.engine.find('task', { where: { status: 'open' } })` |
19+
| `ctx.engine.find('task', { amount: { $gt: 100 } })` | `ctx.engine.find('task', { where: { amount: { $gt: 100 } } })` |
20+
| `ctx.engine.find('task', {})` | unchanged — an empty envelope is still the unfiltered read |
21+
22+
The rewrite is lossless and mechanical: the filter moves under `where`, verbatim.
23+
`tsc --noEmit` over your handlers finds every unmigrated call — see below.
24+
25+
### Why the shape was withdrawn rather than the bar closed
26+
27+
Until now this parameter was the `where` HALF of a query while every other
28+
`find` on the platform took the whole envelope, and the runtime wrapped what it
29+
was given. That made the most natural spelling the wrong one, silently: an
30+
author who passed the engine's own envelope reached the engine as
31+
`{ where: { where: … } }` — a filter on a field named `where` — which matches no
32+
row and resolves to `[]` with **no error at all**. A handler that made the
33+
mistake ran to completion over zero rows for as long as it shipped, and its own
34+
hand-written test double, written to the same belief, passed every assertion.
35+
Because an empty `{}` skipped the wrap, one unfiltered read kept working under
36+
either belief, so a dead handler looked partially alive.
37+
38+
Refusing `where` at the top level instead — intersecting the old parameter with
39+
`{ where?: never }` — was rejected: it asserts a vocabulary fact the spec
40+
declares nowhere, reserving the field name `where` across every customer's data
41+
model to buy one parameter's compile-time check. Aligning the parameter removes
42+
the ambiguity at its root and reserves nothing.
43+
44+
### What the new declaration refuses, measured
45+
46+
If your handler is typed with the published `ActionHandlerContext`, a bare filter
47+
no longer type-checks on **either** path you can reach it by:
48+
49+
- an object literal (`{ status: 'completed' }`) fails the excess-property check —
50+
a field name is not an envelope key;
51+
- a filter held in a `FilterCondition` variable fails **TS2559** — every envelope
52+
key is optional, so a bag of field names has no property in common with it.
53+
54+
The envelope's own keys are typed too: `where: 'a = b'`, `fields: 'id,subject'`
55+
and `limit: '50'` are each refused.
56+
57+
**If your handler is NOT typed with it** — a handler in an `objectstack.config.js`
58+
/ `.mjs`, one annotated with your own copy of the context type, or a `(ctx: any)`
59+
handler — nothing above reaches you, so the facade refuses the withdrawn shape at
60+
**runtime** instead, before the engine, with the same prescription:
61+
62+
```
63+
find('task') was given a key 'status' the query envelope does not carry.
64+
ctx.engine.find(object, query) takes the engine QUERY ENVELOPE, not a bare
65+
filter — move the filter under `where`: find(object, { where: { … } }).
66+
Envelope keys: context, cursor, distinct, expand, fields, limit, offset,
67+
orderBy, search, searchFields, top, where.
68+
```
69+
70+
⚠️ **That refusal matters most for a filter whose value is `null`.** The engine's
71+
own unknown-option check exempts a `null` value, because on an option bag a
72+
`null` is a withdrawal. On a filter it is the "rows with no X" idiom, so
73+
`{ deleted_at: null }` would have been dropped unexecuted and the read would have
74+
widened to **every row** — including the ones you were excluding — with no error
75+
at all. It is refused instead.
76+
77+
### What this opens
78+
79+
`fields`, `orderBy`, `limit`, `offset` and `expand` are reachable from an action
80+
handler for the first time — under the old parameter there was nowhere to carry
81+
them. A caller-supplied `context` is **ignored**: this facade is trusted and
82+
context-less by design, and the runtime stamps its own elevated
83+
`ExecutionContext` last. Do not write one — it reads as authorization and is
84+
none.
85+
86+
### Checking a migrated handler
87+
88+
Do not settle for "it still resolves". A handler that had been passing the
89+
envelope was returning `[]` on **every** call, so a suite written against the
90+
mistake passes and the row count is the only witness. Re-run each migrated
91+
handler against seeded data and assert it returns the rows its filter selects.
92+
93+
<!-- adr-0087: registered action-engine-facade-find-query-envelope -->

‎.changeset/16045-spec-declaration-text-snapshots.md‎

Lines changed: 0 additions & 18 deletions
This file was deleted.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
fix(spec)!: `CronSchedule.timezone` is judged by the `iana_time_zone` membership predicate (#16292)
6+
7+
**BREAKING** — an accept-set narrowing on a published authoring key.
8+
`CronScheduleSchema.timezone` was a bare `z.string().optional().default('UTC')`, so
9+
`defineJob` and `JobSchema.parse` took `timezone: 'UTC+8'` at authoring and build time
10+
and said nothing. It is now judged by `isValueDomainMember('iana_time_zone', …)` — the
11+
predicate `@objectstack/spec/shared` already exports, and the same judge the four
12+
`valueDomain: 'iana_time_zone'` columns (`sys_business_unit.timezone`,
13+
`sys_organization.timezone`, `sys_job.timezone`, `sys_report_schedule.timezone`) are
14+
written against. Shipped as `minor` under the repo's launch-window convention for
15+
accept-set narrowings.
16+
17+
No job that ran yesterday stops running. The value was already carried unchanged to
18+
`CronJobAdapter.schedule`, where croner — constructed with a callback — throws on a
19+
non-member and `AppPlugin` records a per-job `FAILED TO SCHEDULE` at `error` level plus
20+
a `jobScheduleFailuresTotal` increment: the job was declared and never ran. What moves
21+
is WHEN its author is told, from the first environment that boots to `defineJob` /
22+
`os build`. So a stack whose job carries a zone the platform cannot honour now stops
23+
building instead of booting-and-not-running.
24+
25+
Membership is the `Intl.DateTimeFormat` probe rather than a checked-in list, so the
26+
accepted set is the host's own tz database — deliberately, and identically to those four
27+
columns, the settings door and `resolveAuthzContext`. It is what every `Intl`-based
28+
consumer downstream accepts, so the parse-time answer and the schedule-time answer
29+
cannot disagree on one host. `UTC`, the key's own declared default, is a member on every
30+
conforming runtime, so an omitted key is untouched.
31+
32+
`interval` and `once` schedules carry no zone and are unaffected. The boundary type
33+
`JobSchedule.timezone` on `@objectstack/spec/contracts` is a third, separate door and is
34+
deliberately left out of this change.
35+
36+
Clause-②: yes (narrowing)
37+
38+
<!-- adr-0087: not-required (no-migration-prescription) No key, export, config field or stored shape is added, removed, renamed or re-spelled, and no metadata document has to be rewritten into a different one. The narrowing is value-level and undecidable in the upgrade direction: an offset spelling such as `UTC+8` names no zone at all, so nothing can derive whether its author meant `Asia/Shanghai`, `Asia/Singapore` or `Australia/Perth` — the answer is a fact about the deployment, never about the refused string. `objectstack migrate meta` therefore has nothing mechanical it could apply, and a ledger row would carry an empty mapping. The sibling column-tier narrowing of the same concept is already recorded as `platform-timezone-columns-iana-domain-refused`; this authoring-tier door is a different door and is deliberately not filed under that id. -->
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/lint': minor
4+
---
5+
6+
**BREAKING** — a dataset-bound dashboard widget's `chartConfig` carries appearance only: `type`, `xAxis`, `yAxis` and `series` are refused by name, each refusal naming the dataset selection the intent belongs in.
7+
8+
Clause-②: yes (narrowing)
9+
10+
`DashboardWidgetSchema.dataset` is REQUIRED, so **every** dashboard widget is dataset-bound, and ADR-0021 already made the dataset the owner of the chart's structure: it decides which series exist and which column each one reads. `chartConfig` nonetheless declared `type` / `xAxis` / `yAxis` / `series`, and the two answers met with no rule between them. That was not merely inert. An authored `yAxis[].field` was a live MEMBERSHIP channel — the renderer synthesised a series from the authored axes when the chart declared none — so one authored axis could silently re-point a dataset-bound series at a different column while the chart still drew, which reads as a true statement about the data. Maintainer ruling 2026-09-12, decision batch #121 item 1, verbatim 「同意」, on options C+D together: state the ownership split in the protocol AND refuse the four keys by name.
11+
12+
## FROM → TO
13+
14+
| you wrote inside `chartConfig` (17.4 and earlier) | write instead |
15+
| --- | --- |
16+
| `type: 'line'` | `type: 'line'` on the WIDGET, beside `dataset` — the widget's own `type` is the chart family and it always won; nothing on this face ever read the chart config's |
17+
| `xAxis: { field: 'stage' }` | `dimensions: ['stage']` on the widget — the dataset dimension the category axis plots |
18+
| `yAxis: [{ field: 'amount' }]` | `values: ['amount']` on the widget — the dataset measures, one entry per mark. A second axis is a second measure, not a second axis declaration |
19+
| `series: [{ name: 'amount' }]` | `values` (plus a second `dimensions` entry to split) — series membership follows the selection; an entry naming a measure outside it was already being ignored |
20+
21+
**The one-line fix:** delete the four keys; the widget's `type` and its `dimensions` / `values` are the chart's structure.
22+
23+
`os migrate meta --from 17` lists the mechanical edits for existing sources; apply them by hand.
24+
25+
## What is NOT retired
26+
27+
The keys stay authorable on `ChartConfigSchema` itself, and that is the half a blanket refusal would have broken. A react-tier `<ObjectChart data={…} />` binds inline rows with no dataset behind them, so its axes are the author's and are unchanged — `react-blocks.ts` still publishes all four in that block's `dataProps`. `ReportChartSchema` keeps its own `xAxis` / `yAxis`, narrowed to its bound dataset's dimension and measure names. The refusal lives on a new per-carrier `DashboardWidgetChartConfigSchema` (`ChartConfigSchema.extend(…)`, the `ReportChartSchema` spelling) precisely so it cannot reach those two.
28+
29+
## What this costs, stated rather than discovered
30+
31+
`xAxis` / `yAxis` / `series` carried presentation alongside the binding — axis titles, number formats, bounds, grid lines, log scale, and per-series labels, colours, stacking and mark types. Refusing the keys takes the presentation with the binding: a dataset-bound chart takes those from the dataset's own dimension and measure declarations, and `colors` on the chart config remains the palette channel. **The combo chart a dataset-bound widget could author through `series[].type` has no authoring channel on this face any more.** That capability loss is ruled, not incidental — the option that kept it was on the table and was not taken.
32+
33+
## Accept-set movement, both directions
34+
35+
Narrowing, on a dataset-bound widget: the four keys move from accepted to refused. **And one widening, which is forced by the ruling rather than chosen:** `ChartConfigSchema.type` is REQUIRED, so before this change a `chartConfig` without a `type` was refused as incomplete. Refusing `type` while keeping the bag authorable for appearance — which ruling item 1 requires in as many words — means absence must now be legal. So `chartConfig: { title: 'Revenue' }` on a dashboard widget moves from refused to accepted. That is why the declaration reads `yes (narrowing)` rather than `no`.
36+
37+
## The retirement kit
38+
39+
- **Four `retiredKey()` tombstones on the widget carrier**, registered as `ui/DashboardWidgetChartConfig:type` / `:xAxis` / `:yAxis` / `:series` under protocol 18. `tsc` types each key `never`, so every authoring site in a consumer's tree fails to compile before anything runs, and a value that reaches a parse raises the prescription rather than a bare unrecognized-key report.
40+
- **The ADR-0087 pair.** The D2 conversion `dashboard-widget-chart-config-structure-removed` strips the four keys from stored dashboard widgets (dashboards only — reports and the react tier keep theirs); the D3 semantic entry `dashboard-widget-chart-config-structure-refused` carries the judgement, because moving what the keys MEANT into the dataset selection needs facts the widget does not hold — an authored axis field can name a dataset dimension the widget never selected.
41+
- **The liveness rows stay and are regraded `dead`**, the `retiredKey` route's discipline: the tombstone keeps the key in the walked shape, so the row remains and records why. Three of them were graded `live` on their presentation half on 2026-09-12 and that measurement is recorded as overridden, not withdrawn.
42+
- **`chart-config-missing` is withdrawn from `@objectstack/lint`.** It advised a `combo` widget with no `chartConfig` to declare `chartConfig: { series: [{ name, type }] }` — metadata the schema now refuses — and after the ruling there is nothing a `combo` author can do about the finding. The rule ID stays exported, so an existing `suppressWarnings: ['chart-config-missing']` entry keeps parsing. `chart-field-unknown` still fires on a legacy document and its hints now say delete-and-migrate instead of describing what the keys used to carry.
43+
44+
## What an operator with a STORED dashboard sees
45+
46+
A `sys_metadata` `dashboard` row written before this release can carry any of the four. Nothing breaks at read: the conversion replays on rehydration and strips them, so the row is served canonical, and `os migrate meta --stored --apply` rewrites the rows. ⚠️ The strip is the mechanical half only. A widget whose authored axes AGREED with its selection renders identically afterwards — that is the expected case. A widget that renders differently was relying on the membership channel this removes, which is the case the ruling was made about.
47+
48+
<!-- adr-0087: registered dashboard-widget-chart-config-structure-removed, dashboard-widget-chart-config-structure-refused -->
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
Gallery, kanban and timeline view configs declare an author-settable row ceiling.
6+
7+
`GalleryConfigSchema`, `KanbanConfigSchema` and `TimelineConfigSchema` each gain a
8+
`limit` member — a positive integer, default **100** — saying how many records the
9+
view fetches. The default is APPLIED by the schema rather than only described, and
10+
the key's own text states the other half of the contract: when the ceiling applies,
11+
the renderer must show a visible truncation signal, because a bounded view that
12+
looks complete is worse than an unbounded one. `DEFAULT_VIEW_ROW_LIMIT` is exported
13+
so a consumer reads that number instead of re-declaring it.
14+
15+
The knob belongs in the protocol because two renderers already cap by author choice
16+
off keys the protocol never declared: objectui's kanban board fetches
17+
`$top: schema.limit ?? DEFAULT_KANBAN_LIMIT` with `limit` declared in
18+
`@object-ui/types` alone, its timeline does the same off a component props
19+
interface, and its gallery caps not at all. `limit` is the name those consumers
20+
already read, so this declaration absorbs the consumer-local keys instead of
21+
introducing a second spelling of one concept.
22+
23+
Nothing is removed, renamed or narrowed, and no document that parsed before is
24+
refused now. Two things to know when upgrading:
25+
26+
- a parsed gallery / kanban / timeline config carries `limit: 100` where the author
27+
wrote no ceiling, so code that compares a parsed config against a literal object
28+
sees the new member;
29+
- `KanbanConfigParsed` is now declared (ADR-0122) because that schema has two shapes
30+
for the first time; `KanbanConfig` is unchanged and remains the author state.
31+
32+
The non-grid four — gantt, calendar, map and tree — are deliberately untouched:
33+
their rows stay bounded by a platform ceiling the renderer owns, because a gantt's
34+
range, a map's camera fit and a tree's parent chain are computed over the whole set.
35+
36+
Clause-②: yes (widening)

0 commit comments

Comments
 (0)