Skip to content

Commit 698fae3

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-6483-adr0005-override-rollback
2 parents bab8901 + 6595262 commit 698fae3

15 files changed

Lines changed: 1120 additions & 49 deletions
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
---
2+
"@objectstack/driver-sql": minor
3+
"@objectstack/driver-turso": minor
4+
"@objectstack/driver-sqlite-wasm": minor
5+
"@objectstack/spec": minor
6+
"@objectstack/objectql": patch
7+
---
8+
9+
feat(drivers,spec)!: `GroupByNode.alias` is honoured by the SQL faces — one aggregate, one column key (#6401)
10+
11+
`GroupByNodeSchema` has declared `alias` ("Alias for the projected group
12+
value", defaulting to `field`) for as long as the structured `groupBy` entry has
13+
existed. Exactly one execution path read it. The result: the SAME query came
14+
back with a different result-column key depending on which path the engine
15+
happened to take.
16+
17+
```ts
18+
groupBy: [{ field: 'closed_at', dateGranularity: 'month', alias: 'qtr' }]
19+
```
20+
21+
- pushed down to a driver ⇒ rows keyed **`closed_at`**
22+
- run through the in-memory fallback ⇒ rows keyed **`qtr`**
23+
24+
And the choice between them is `engine.ts`'s
25+
`allStructuredSupported && !tzRequiresInMemory` — a driver capability bit and a
26+
`timezone`, neither of which the caller can see. That is the multi-face
27+
consistency invariant broken in its quietest form: both answers are valid rows,
28+
so nothing throws and nothing looks wrong.
29+
30+
**Resolved to ENFORCE**, and the leg was chosen by measurement rather than
31+
taste. ADR-0049 splits on whether the feature already exists: a *dangling*
32+
promise is removed, a *live* one with a missing gate is enforced. `alias` is
33+
live — three consumers read it and change behaviour
34+
(`in-memory-aggregation.ts`, `MemoryDriver.performAggregation`, and
35+
`chartAggregateCategoryKey`), and the publish gate *compels* it:
36+
`validate-react-page-props.ts` errors `REACT_CHART_AXIS_UNKNOWN` unless a
37+
chart's category axis is bound to `alias ?? field`, telling the author in so
38+
many words to "bind it to" the alias. A key the build gate makes you write is
39+
not a dangling promise. The count of real non-test producers is **zero**, which
40+
is what makes enforcing safe rather than what argues against it: no shipped
41+
payload changes its result keys.
42+
43+
**What changed, on every SQL face at once** — a fix landing on one and not its
44+
twin is the #6203 shape, and `TursoDriver` picks its face from `url`:
45+
46+
- **`driver-sql`** — both limbs of the structured `groupBy` branch project
47+
`alias ?? field`: the date-bucket limb aliases the bucket expression to it,
48+
and the plain limb emits `?? as ??` (only when the name actually moves — an
49+
alias equal to the field emits no self-rename). `presentedOutput` is now keyed
50+
by the OUTPUT column, matching how the aggregation branch beside it has always
51+
worked; an aliased group value went unpresented before.
52+
- **`driver-turso` REMOTE** — the same projection, `"field" AS "alias"`. The
53+
alias reaches the statement as a quoted identifier and is therefore held to
54+
`assertSafeIdentifier`, exactly like `field`.
55+
- **`driver-sqlite-wasm`** — inherits `SqlDriver`'s compiler; covered by its own
56+
conformance suite rather than by assumption.
57+
58+
**GROUP BY still keys on the FIELD** on every face. Only the projection is
59+
renamed, so the buckets are unchanged. This is deliberate and pinned: SQLite
60+
resolves output names in `GROUP BY`, so a face that grouped by the alias would
61+
look correct here and diverge on a dialect that does not.
62+
63+
`having` needed no change and now means one thing: it is applied over the
64+
aggregated row's own columns, so a filter on a group projection references the
65+
alias on every path — previously the alias on one path and the field on the
66+
other.
67+
68+
**Conformance.** `AGGREGATION_CASES` (#6409) gains a `groupByAlias` axis and two
69+
cases. Their VALUES are an existing case verbatim — only the key moves — so they
70+
can fail only on the key, which is the point: every wrong answer in this area is
71+
a valid query returning plausible rows. `objectql`'s in-memory fallback is now
72+
**enrolled** as a fourth face, answering #6409's open question ②: it is the face
73+
the SQL three were converged onto, so the new behaviour would otherwise be
74+
pinned against nothing, and reaching it needs no engine at all —
75+
`applyInMemoryAggregation` is a pure function of rows and an AST.
76+
77+
**Reverse verification**, predicted before running. Reverting the in-memory face
78+
to `g.field`: only the two alias cases move and only ONE fails — the degenerate
79+
`alias === field` case stays green, which is why both are in the table.
80+
Reverting the harness to read `c.groupBy` instead of `c.groupByAlias ?? c.groupBy`
81+
— the copied-neighbour mistake: everything passes on an unmodified face, a false
82+
GREEN, which is the failure mode that would have made the axis vacuous.
83+
84+
**Frozen drivers (#5499), measured from source, not flipped.** `driver-memory`
85+
already returned `{ field, alias: node.alias ?? node.field }` and projects under
86+
the alias — it had independently reached the enforce answer, so it needed no
87+
alignment. `driver-mongodb` is a recorded DEBT row and the defect is wider than
88+
`alias`: `buildAggregationPipeline` types `groupBy` as `string[]` and builds
89+
`groupId[field] = '$' + field`, so a structured node — aliased or not — becomes
90+
the literal key `"[object Object]"`. It cannot take a structured `GroupByNode`
91+
at all; `mongodb-driver.ts` passes `(query as any).groupBy`, which is why `tsc`
92+
never saw it. Tracked on #6814.
93+
94+
**Compatibility.** A caller who writes `alias` and reads the result under
95+
`field` on a pushdown path will now find the value under `alias` — which is what
96+
the key has always meant on the fallback path, and what the chart gate already
97+
required. Callers who never write `alias` are unaffected: the emitted SQL is
98+
byte-identical.
99+
100+
<!-- adr-0087: not-required (no-migration-prescription) Nothing is retired: `GroupByNodeSchema.alias` keeps its declaration, its spelling and its type — it starts being HONOURED by three faces that parsed and ignored it. There is no tombstone to write and no authored metadata to rewrite, so there is no mechanical transform a migration could prescribe: every stack that validated before validates after, unchanged. The behaviour change is in the RESULT of a runtime query (a result-column key moves from `field` to `alias` on the pushdown path, converging on what the in-memory path and the chart publish gate already required), which the ledger has no channel for and no upgrader could apply a codemod to. The bang is on the changeset because callers who read that column by the field name must move, and the measured non-test producer count for the key is zero. -->
101+

.github/workflows/lint.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -878,6 +878,33 @@ jobs:
878878
- name: Check SKILL.md compatibility declarations match the workspace majors
879879
run: pnpm check:skill-compatibility
880880

881+
# The fourth gate over the .claude/ file tree, and the first to read
882+
# .claude/agents/ at all (#6803). An agent definition that declares no `model:`
883+
# INHERITS the dispatching session's model, so the role's tier is set by whoever
884+
# dispatched it and when — a property of the caller's ambient state rather than
885+
# of the role. Measured cost: four devs dispatched from one smaller-model session
886+
# all died on the same shared quota wall, three leaving uncommitted and wholly
887+
# ungated work in their worktrees (#6686). The failure is batched, and invisible
888+
# to the dispatcher, whose pre-dispatch checks never ask what model the batch runs.
889+
#
890+
# The caller-side half of this rule already existed and did not hold: pm-dispatch
891+
# SKILL.md §5 has said 'pass `model: "opus"` on every dev dispatch' since before
892+
# that incident, and a seat that had read it still dispatched twelve agents in a
893+
# row without passing it. That is why the assertion is over the DEFINITION, which
894+
# holds regardless of caller compliance, rather than more prose at the call site.
895+
#
896+
# It asserts PRESENCE, never which tier — the tier is maintainer policy (#6803
897+
# puts changing it out of scope), and a policy change should not have to edit a
898+
# gate to land. `model: inherit` stays legal for a role that genuinely follows its
899+
# caller, but only with a written justification the script re-checks against the
900+
# live file, so a deliberate inherit is a recorded decision and a silent one is red.
901+
#
902+
# Same job and same reasons as its neighbours: no paths filter and required, so it
903+
# cannot go dormant on exactly the PR that breaks it — and a `.claude/**` filter
904+
# would blind it to the PR that adds a new agent definition anywhere else.
905+
- name: Check every agent definition declares a model
906+
run: pnpm check:agent-model-declared
907+
881908
- name: Check the react-blocks contract is in sync with the spec
882909
run: pnpm --filter @objectstack/spec check:react-blocks
883910

0 commit comments

Comments
 (0)