Skip to content

Commit 1e08958

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-20595-objectql-citations
2 parents 8d64654 + 8dea55d commit 1e08958

33 files changed

Lines changed: 979 additions & 119 deletions
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
feat(spec)!: an analytics dataset dimension's and measure's `field` is a column reference — a SQL expression there is refused at parse, as it already is on the cube members a dataset compiles to (#21220)
6+
7+
Clause-②: yes (narrowing)
8+
9+
**BREAKING** — shipped as `minor` under the launch-window convention
10+
(`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by
11+
this banner, the `(narrowing)` arm above and the ADR-0087 disposition below,
12+
never by the level).
13+
14+
`DatasetDimensionSchema.field` and `DatasetMeasureSchema.field` — the `field` of
15+
every entry in an ADR-0021 dataset's `dimensions` and `measures` — admit a column
16+
reference only: a field of the dataset's object (`amount`), or a relationship path
17+
of bare identifiers ending in one (`account.amount`, `account.owner.region`); a
18+
measure also admits `'*'` for a count, and a count may still omit `field`. Any
19+
other value — an arithmetic, an aggregate, a `CASE`, a subquery, a function call,
20+
a quoted or `$`-prefixed spelling, a padded or empty string, a broken path — is
21+
refused at `dimensions.N.field` / `measures.N.field` with a prescription, and so
22+
is `'*'` on a dimension.
23+
24+
Why: the dataset layer was declared to take no raw SQL (ADR-0021 "zero raw SQL /
25+
zero raw expressions") and `field` was documented as a field or a relationship
26+
path, but it was a bare string and parsed anything. The analytics dataset door
27+
already refused an expression `field` on every query (`PERMISSION_DENIED` / 403,
28+
inline or saved), so such a dataset could be saved and never answered — declared,
29+
never enforced (ADR-0049). That door never judged an empty `field`: it skips one,
30+
which is how a `count` measure with `field: ''` kept counting rows on SQLite's
31+
native-SQL path (the D2 repair below). The accept set is the one the cube members a dataset
32+
compiles to already hold: the dataset compiler copies `field` into the member's
33+
`sql` verbatim, and both now read one shared declaration. `'*'` is refused on a
34+
dimension because grouping by every column is no axis — both analytics strategies
35+
answered such a dimension `500`. The rule is a `pattern` in the published JSON
36+
Schema too, so a document validated against `json-schema/**` is judged as the
37+
parse judges it.
38+
39+
## FROM → TO
40+
41+
```
42+
FROM defineDataset({ name: 'task_metrics', label: 'Task Metrics', object: 'task',
43+
dimensions: [{ name: 'priority', field: 'priority' }],
44+
measures: [
45+
{ name: 'task_count', aggregate: 'count', field: '' },
46+
{ name: 'done_points', aggregate: 'sum',
47+
field: "CASE WHEN status = 'done' THEN points ELSE 0 END" },
48+
] })
49+
-> parsed; the dataset door refused the expression on every query
50+
TO -> ZodError at measures.0.field and measures.1.field (invalid_format):
51+
`measures[].field` is a column reference: a field of the dataset's object …
52+
53+
defineDataset({ name: 'task_metrics', label: 'Task Metrics', object: 'task',
54+
dimensions: [{ name: 'priority', field: 'priority' }],
55+
measures: [
56+
{ name: 'task_count', aggregate: 'count' },
57+
{ name: 'done_points', aggregate: 'sum', field: 'points', filter: { status: 'done' } },
58+
] })
59+
```
60+
61+
A conditional count or sum is a measure with its own structured `filter`; a
62+
ratio, sum, difference or product of measures is `derived: { op, of: [...] }`
63+
over measures named in the same dataset. **Mind the scale:** a `derived` ratio is
64+
a 0–1 fraction, so an expression that multiplied by 100 returned percentage
65+
points — pair the ratio with a `%` numeral pattern. A dimension that bucketed a
66+
column with an expression has no expression form: group by the column itself, or
67+
keep the bucket as a field of the object and name that field.
68+
69+
**The one-line fix:** parse each dataset; every refusal at `…field` is one member
70+
to change — name the column, omit `field` on a plain count (never `field: ''`),
71+
or move the computation to a measure `filter` or a `derived` measure. The one
72+
mechanical case is done for you: `os migrate meta --from 17` lists, and every
73+
stored-row rehydration replays, the D2 conversion
74+
`dataset-count-measure-empty-field-removed`, which drops a `count` measure's empty
75+
`field` (it still counts rows). Nothing else has a mechanical rewrite.
76+
77+
**What an author who still writes it sees.** `DatasetSchema`, `defineStack({
78+
datasets })` (`STACK_SCHEMA_INVALID` / 422), the `dataset` write door and
79+
`POST /api/v1/analytics/dataset/query` (which parses every dataset it is handed,
80+
inline or saved, and now answers `400 VALIDATION_FAILED` at the path where it
81+
answered `403 PERMISSION_DENIED` before) refuse the member at its `field` path
82+
with the prescription. `tsc` does not: the key's type is still `string`.
83+
84+
## The retirement kit
85+
86+
- **Schema.** `ui/dataset.zod.ts` holds both keys to the pattern; the pattern is
87+
declared once, in the non-public `data/analytics-column-reference.ts`, and the
88+
cube layer's `CUBE_MEMBER_SQL` is that same `RegExp`. A dimension's pattern is
89+
the same column path without the `'*'` arm. A column reference parses
90+
byte-identically to before.
91+
- **ADR-0087.** D2 carries the one lossless repair: the conversion
92+
`dataset-count-measure-empty-field-removed` (`retiredFromLoadPath`, so an author
93+
is refused at parse while stored rows and `os migrate meta` replay it) drops a
94+
`count` measure's `field: ''`, which compiles to `COUNT(*)` without it. The D3
95+
entry `dataset-member-field-expression-refused`, linked to that conversion and
96+
with its step-18 rationale fragment, carries the rest — a non-count measure or a
97+
dimension with `''` and every expression have no mechanical rewrite into a
98+
column. No `RETIRED_KEYS_BY_MAJOR` row: no key left the shape, so the
99+
authorable-surface, api-surface and JSON-schema manifest ratchets are
100+
unchanged.
101+
- **Liveness.** The `dataset` ledger rows `dimensions.field` and
102+
`measures.field` stay `live`, re-verified, with the narrowing recorded.
103+
- **Docs.** The `ui/dataset` reference page is regenerated.
104+
- **Runtime.** Unchanged: the analytics dataset door's refusal stays as defence
105+
in depth for a dataset that reaches the service without meeting the parse — a
106+
row stored before this change, which the build probe hands over as read.
107+
108+
## Reach, measured
109+
110+
- This repository: no authored dataset carries a non-column `field` — the
111+
examples, `platform-objects`, the hand-written docs and the published skills
112+
were read. Two test fixtures that sent an expression `field` on purpose were
113+
re-pinned: the service door's test builds them unparsed, and the REST route's
114+
test now expects the route's `400`.
115+
- Studio's dataset inspector (objectui) seeds a new dimension or measure row with
116+
`field: ''`. A plain count saved that way parsed before; its query answered
117+
`500` on the ObjectQL path, while SQLite's native-SQL path accepted the
118+
`COUNT()` it compiled to. A row already stored that way is repaired on load by
119+
the D2 conversion above. A NEW save of that shape is refused at the save door
120+
with the prescription to omit the key, because the write path parses with the
121+
current schema and replays no conversion; the producer-side change is
122+
objectui's.
123+
- Out-of-repo authored datasets: NOT MEASURED.
124+
125+
<!-- adr-0087: registered dataset-member-field-expression-refused, dataset-count-measure-empty-field-removed -->

‎content/docs/data-modeling/analytics.mdx‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ export default defineStack({
8181

8282
- **No raw SQL, no hand-authored joins.** The author declares *which*
8383
relationships to include; the compiler derives the join from the object graph.
84+
- **`field` is a column reference.** A dimension's `field` names a field of the
85+
base object or a `relationship.field` path; a measure's may also be `'*'` (or
86+
be omitted) for a count. A SQL expression there is refused at parse: a
87+
conditional count or sum is a measure with its own `filter`, and a ratio is a
88+
`derived` measure.
8489
- **Metric certification** — a `certified` flag that marks a measure as a
8590
human-blessed governance checkpoint — is a design goal of ADR-0021 but is **not
8691
yet implemented**; `DatasetMeasureSchema` has no `certified` field today.

‎content/docs/references/ui/dataset.mdx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ const result = DatasetSchema.parse(data);
7272
| :--- | :--- | :--- | :--- |
7373
| **name** | `string` | ✅ | Dimension name — referenced by presentations |
7474
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
75-
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path |
75+
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
7676
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
7777
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
7878

@@ -83,7 +83,7 @@ const result = DatasetSchema.parse(data);
8383
| **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once |
8484
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
8585
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set |
86-
| **field** | `string` | optional | Aggregated field; optional for count(*) |
86+
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*"; optional for count(*). Never a SQL expression. |
8787
| **filter** | `any` | optional | |
8888
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
8989
| **currency** | `string` | optional | Display currency code (ISO 4217) |
@@ -108,7 +108,7 @@ const result = DatasetSchema.parse(data);
108108
| :--- | :--- | :--- | :--- |
109109
| **name** | `string` | ✅ | Dimension name — referenced by presentations |
110110
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
111-
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path |
111+
| **field** | `string` | ✅ | Base field, or `relationship[.relationship].field` path. A column reference, never a SQL expression. |
112112
| **type** | `Enum<'string' \| 'number' \| 'date' \| 'boolean' \| 'lookup'>` | optional | |
113113
| **dateGranularity** | `Enum<'day' \| 'week' \| 'month' \| 'quarter' \| 'year'>` | optional | |
114114

@@ -124,7 +124,7 @@ const result = DatasetSchema.parse(data);
124124
| **name** | `string` | ✅ | Measure name — e.g. "revenue"; defined once |
125125
| **label** | `string \| Record<string, string>` | optional | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time |
126126
| **aggregate** | `Enum<'count' \| 'sum' \| 'avg' \| 'min' \| 'max' \| 'count_distinct'>` | optional | Aggregation (sum/avg/count/...); omit when `derived` is set |
127-
| **field** | `string` | optional | Aggregated field; optional for count(*) |
127+
| **field** | `string` | optional | Aggregated field: a base field, a relationship path, or "*"; optional for count(*). Never a SQL expression. |
128128
| **filter** | `any` | optional | |
129129
| **format** | `string` | optional | Numeral pattern for a NUMERIC measure — grouping, decimals, percent; e.g. "0,0.00", "0.0%". An amount takes its symbol from `currency`, not from a "$" in the pattern. A DATE-valued measure never reads a date pattern: `"YYYY-MM-DD"` renders that arm's default face. A date or datetime value reads `format` as a display style — `short` or `relative`, honoured on both. |
130130
| **currency** | `string` | optional | Display currency code (ISO 4217) |

‎docs/adr/0061-record-search-architecture.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ The default contract is `$search: string` (plus existing `$filter`/scope). **Fie
5353
### D5 — Security: search is a thin layer over the gated `find()`
5454
Search runs through the **same query pipeline as `find()`** — RLS, field-level security, and row filters apply automatically; **no separate unguarded search path**. Results respect the **ADR-0045 materialization/visibility gate** (draft vs published). The `$searchFields` override is subset-validated (D1). **Secret / encrypted / PII fields are never searchable** (excluded from default, rejected from override). Public/anonymous search keeps the existing `publicPicker` model (projection + `maxResults` ≤ 50, no enumeration). LIKE wildcards are escaped (driver-sql already does).
5555

56+
> **Note (2026-10-01) — anonymous search retired.** The `publicPicker` model the sentence above keeps is retired by the maintainer's ruling E on [#21079](https://github.com/objectstack-ai/objectstack/issues/21079) ([comment 5933054144](https://github.com/objectstack-ai/objectstack/issues/21079#issuecomment-5933054144), 2026-10-01), which reverses the [#7467](https://github.com/objectstack-ai/objectstack/issues/7467) model (declare `publicPicker`). Anonymous public forms no longer take lookup, `master_detail` or `user` fields: the public-form resolve route leaves them off the anonymous rendering unconditionally, the anonymous record-search route `GET /forms/:slug/lookup/:field` is deleted, and `publicPicker` is a `retiredKey()` tombstone under ADR-0087 D2 (immediate retirement). So there is no public/anonymous record search; the rest of D5 stands. The retirement is [#21180](https://github.com/objectstack-ai/objectstack/issues/21180).
57+
5658
### D6 — Global search: fan-out now, unified `searchAll` is Tier 2
5759
Global search this phase = **client fan-out over the per-object Tier-1 resolver** (CommandPalette already fans out; it only needs per-object search to actually filter) — zero new server surface. The unified server `searchAll` with cross-object relevance is **Tier 2** (it needs a unified index to rank well). **Knowledge/vector stays a separate subsystem**; "blended" record+knowledge search is future and out of scope, but API-compatible.
5860

‎packages/plugins/plugin-dev/src/dev-plugin-security-enforcement-warning.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ async function boot(ctx: any, options: Record<string, unknown> = {}) {
118118
return plugin;
119119
}
120120

121-
describe('[#10036] the "nothing is enforced" warning must fire when SecurityPlugin.start() bailed', () => {
121+
describe('the "nothing is enforced" warning must fire when SecurityPlugin.start() bailed', () => {
122122
// ── The state the warning describes, constructed for real ───────────────
123123
//
124124
// SecurityPlugin registers `security.permissions` / `security.rls` /

‎packages/qa/dogfood/test/armed.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,9 +133,9 @@ export async function assertArmed(probes: readonly ArmingProbe[]): Promise<void>
133133
if (!Array.isArray(probes) || probes.length === 0) {
134134
throw new Error(
135135
'assertArmed(): the arming declaration is EMPTY, which asserts nothing. An empty ' +
136-
'declaration would certify every fixture that forgot to write one — the exact defect ' +
137-
'class this helper exists to close (#8074). Name at least one control, or do not call ' +
138-
'assertArmed at all and say in the fixture header why no precondition applies.',
136+
'declaration would certify every fixture that forgot to write one — the exact defect class this ' +
137+
'helper exists to close, a fixture that passes while the control it measures is not engaged. Name ' +
138+
'at least one control, or do not call assertArmed at all and say in the fixture header why no precondition applies.',
139139
);
140140
}
141141

@@ -159,7 +159,7 @@ export async function assertArmed(probes: readonly ArmingProbe[]): Promise<void>
159159

160160
if (disarmed.length > 0) {
161161
throw new Error(
162-
`[#8074] this fixture is DISARMED: ${disarmed.length} of ${probes.length} control(s) it ` +
162+
`assertArmed(): this fixture is DISARMED: ${disarmed.length} of ${probes.length} control(s) it ` +
163163
'measures are not engaged on the booted stack, so its assertions would pass without ' +
164164
'testing anything.\n\n' +
165165
`${disarmed.join('\n\n')}\n\n` +

‎packages/qa/dogfood/test/build-shaped-artifact.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ export function buildShapedArtifact(stack: Record<string, unknown>): BuildShaped
189189
`declares ${expected.length} (${expected.join(', ') || 'none'}). Either a callable was ` +
190190
`dropped, or \`lowerCallables\` grew a slot \`callableSlots\` in ` +
191191
`packages/qa/dogfood/test/build-shaped-artifact.ts does not know about — fix the walk, ` +
192-
`never the assertion (#6293).`,
192+
`never the assertion.`,
193193
);
194194
}
195195

@@ -222,7 +222,7 @@ export function buildShapedArtifact(stack: Record<string, unknown>): BuildShaped
222222
`without a sound — ${dropped.join(', ')}. An entry it does not recognise is deleted ` +
223223
`rather than handed to the schema, so the artifact would parse green carrying nothing ` +
224224
`where these were. Most likely the entry is a headless husk (an object with an effect ` +
225-
`and no handler), which is what a plain \`JSON.stringify\` of the stack leaves (#6293).`,
225+
`and no handler), which is what a plain \`JSON.stringify\` of the stack leaves.`,
226226
);
227227
}
228228
}
@@ -262,7 +262,7 @@ export function buildShapedArtifact(stack: Record<string, unknown>): BuildShaped
262262
if (leftover.length > 0) {
263263
throw new Error(
264264
`build-shaped artifact: ${leftover.length} value(s) are still functions after lowering and ` +
265-
`would be dropped by JSON.stringify without a sound: ${leftover.join(', ')} (#6293).`,
265+
`would be dropped by JSON.stringify without a sound: ${leftover.join(', ')}.`,
266266
);
267267
}
268268

‎packages/qa/dogfood/test/enterprise-organizations.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -181,7 +181,7 @@ export async function probeOrganizations(
181181
'problem — the package publishes no entry Node can load, and the importer\'s ' +
182182
'message below is the authority on what it has to publish'
183183
: `declare ${pkg} in ${root}'s own package.json and install it — being ` +
184-
'reachable as somebody else\'s transitive dependency is not enough (#4719)';
184+
'reachable as somebody else\'s transitive dependency is not enough';
185185
if (declared) {
186186
throw new Error(
187187
`${MULTI_ORG_ENV}=1 declares that ${pkg} (enterprise, ADR-0105 D12) is ` +

0 commit comments

Comments
 (0)