Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
370c296
Merge branch 'egor/dev-2101-mode-b-as-a-sql-compatible-grammar-custom…
ZmeiGorynych Oct 21, 2026
8f72e07
Plan the dev-2119 OpenSpec change: row-level expressions in every col…
ZmeiGorynych Oct 21, 2026
3892d2d
Preserve declared joins on saved query-backed models
mattfaltyn Oct 21, 2026
45cacec
Record query-backed join validation results
mattfaltyn Oct 21, 2026
fe16f88
Merge branch 'egor/dev-2101-mode-b-as-a-sql-compatible-grammar-custom…
ZmeiGorynych Oct 22, 2026
5066a82
dev-2119 tasks: DEV-2101 merged in, unit suite green
ZmeiGorynych Oct 22, 2026
7dd5f59
Add the failing test suite for row-level expressions in every column …
ZmeiGorynych Oct 22, 2026
f88cd80
Treat a repeated stored join in any extension as that edge
ZmeiGorynych Oct 22, 2026
e6be7f7
Specify extension-join reuse of stored edges
ZmeiGorynych Oct 22, 2026
57e6a8b
Skip the DuckDB httpfs notebook when the CDN file changes mid-read
ZmeiGorynych Oct 22, 2026
535efd3
Archive fix-query-backed-declared-joins OpenSpec change
ZmeiGorynych Oct 22, 2026
54beea3
Merge pull request #508 from mattfaltyn/fix/query-backed-declared-joins
ZmeiGorynych Oct 22, 2026
2717651
chore: bump version to 1.4.1
ZmeiGorynych Oct 22, 2026
516539d
Merge pull request #510 from MotleyAI/egor/bump-version-1.4.1
ZmeiGorynych Oct 22, 2026
a9209a9
Rename TimeTruncKey.column to source; living-architecture 0.3.3
ZmeiGorynych Oct 23, 2026
4c128ef
TimeTruncKey.source is any row-level key; bucket consumers read the k…
ZmeiGorynych Oct 23, 2026
ef8ee5e
Time dimensions as Mode-B text: expression buckets, names, per-leaf r…
ZmeiGorynych Oct 23, 2026
a64490a
Row-level expressions as partition keys, ranking keys, ranked values …
ZmeiGorynych Oct 23, 2026
afc20ab
Default ranking key stays producer-local (D8); query-wide reading def…
ZmeiGorynych Oct 23, 2026
91b7e33
Retarget tests pinning the object dump, gran(col) wording and lifted …
ZmeiGorynych Oct 23, 2026
08c38a2
semantics.arc42: a column is the one-leaf row-level expression; an ex…
ZmeiGorynych Oct 23, 2026
d1b33c5
Source-model prefix strip removes only a leading qualifier, never an …
ZmeiGorynych Oct 23, 2026
919fde4
Merge branch 'origin/egor/dev-2101-mode-b-as-a-sql-compatible-grammar…
ZmeiGorynych Oct 23, 2026
47fbbcf
Merge branch 'origin/main' into egor/dev-2119-row-level-expressions-i…
ZmeiGorynych Oct 23, 2026
e070563
Unnamed expression time dimensions join the construction-time name-un…
ZmeiGorynych Oct 23, 2026
7cf299d
Literal {var} placeholders in a time-dimension expression substitute …
ZmeiGorynych Oct 23, 2026
d2b4f86
Equivalent time-dimension spellings that share their name and agree i…
ZmeiGorynych Oct 23, 2026
5a4e2d4
Split the time-dimension name identity and the ranked derived-column …
ZmeiGorynych Oct 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
276 changes: 0 additions & 276 deletions .basedpyright/baseline.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ Make changes with `/la:pr` in [Claude Code](https://docs.claude.com/en/docs/clau
**Install the `la` plugin** at the version pinned in `pyproject.toml`, from inside Claude Code:

```text
/plugin marketplace add MotleyAI/living-architecture#v0.3.1
/plugin marketplace add MotleyAI/living-architecture#v0.3.3
/plugin install la@living-architecture
```

Expand Down
11 changes: 9 additions & 2 deletions architecture/semantics.arc42.md
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,16 @@ is the coercion from coarser to finer.
13. **Positions**: every query expression is a field or a measure; dimensions
and measures return the value, filters mask on it, order sorts by it — the
expression evaluates identically in every position, compiled by
construction as a hidden field/measure where not projected.
construction as a hidden field/measure where not projected. A column
reference is the one-leaf row-level expression: every position that takes
a column takes any row-level expression of its type, homed by Axiom 2.2.
[enforced: test:tests/test_dev1865_value_parity.py]
[enforced: test:tests/test_dev1865_stratification.py]
[enforced: test:tests/test_dev1865_order.py]
[enforced: test:tests/test_dev1976_dimension_values.py]
[enforced: test:tests/test_dev2119_expression_time_dimensions.py]
[enforced: test:tests/test_dev2119_partition_keys.py]
[enforced: test:tests/test_dev2119_ranked_expressions.py]
14. **Filters**: a field-typed filter masks population rows before any
aggregation; a measure-typed filter masks result cells after all values
are computed, never changing a surviving cell's values; valid-as-both
Expand All @@ -243,13 +248,15 @@ is the coercion from coarser to finer.
[enforced: test:tests/test_dev1935_boolean_lowering.py]
15. **Time spine**: the spine is the dataset of all instants, keyed by its
instant. A dataset's axis — its declared default time column, else its
sole one — joins it to-one; a dataset reaches the spine by its nearest
sole one — joins it to-one; an expression is never an axis, so a spine
dimension over one is a type error; a dataset reaches the spine by its nearest
axis on a to-one chain, equal distances failing loudly, and a path may
begin or end at the spine but never cross it. The spine has no countable
rows. A query with spine dimensions has population `spine × P`, P
inferred per Axiom 12 from its other dimensions and field filters, and
must bound the spine from below.
[enforced: test:tests/test_dev2015_time_spine.py]
[enforced: test:tests/test_dev2119_time_spine.py]

## 3. Laws

Expand Down
6 changes: 5 additions & 1 deletion docs/concepts/formulas.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ An attached value — an aggregate or a grained transform
*parameter* of a row-level aggregation
(`weighted_avg(amount, weight=sum(amount, partition_by=region))`): it is
attached into the input relation, so each row is weighted by its cell's value.
A column-valued parameter also takes a row-level expression, positionally or by name
(`weighted_avg(price, weight=quantity * 2)`, `corr(amount, quantity + 1)`).

**Gates.** Per-column `allowed_aggregations` / primary-key / type-default
gates apply to *columns*, not expressions — `sum(price * quantity)` succeeds
Expand Down Expand Up @@ -177,7 +179,9 @@ share-of-parent shape (`SUM(revenue) OVER (PARTITION BY region)`):
sums to 1.0 within each region. `partition_by` takes one query dimension, a list
(`partition_by=[region, channel]`), a dotted path (`partition_by=customers.region`),
or `[]` for the grand total (share of total). A time dimension partitions by its
truncated bucket. The coarser aggregate is computed over the rows passing the
truncated bucket. A key may also be a row-level expression (`partition_by=upper(city)`) or a
time dimension's name: an expression equal to a query computed dimension or time dimension
is that dimension. The coarser aggregate is computed over the rows passing the
query's row-level filters — `having`-phase filters and pagination change which
rows you see, never the parent total. NULL-valued partition dimensions keep their
group.
Expand Down
4 changes: 3 additions & 1 deletion docs/concepts/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,7 @@ columns:
- {name: balance, sql: balance, type: number}
```

`last(balance, updated_at)` gives the most recent balance per group; `first(balance, updated_at)` the earliest. When grouped by month, each month returns the latest (or earliest) record's balance in that month. If no time column is specified, ordering resolves via: query's `main_time_dimension` → first time/date dimension in the query → first time dimension in filters → model's `default_time_dimension`. Whichever column wins is an input of the aggregation: if it, or a derived definition it names, crosses a join hop that is not provably to-one from the aggregation's root, the query fails with the input-safety error exactly as an explicit ranking argument would.
`last(balance, updated_at)` gives the most recent balance per group; `first(balance, updated_at)` the earliest. When grouped by month, each month returns the latest (or earliest) record's balance in that month. If no time column is specified, ordering resolves via: query's `main_time_dimension` → first time/date dimension in the query → first time dimension in filters → model's `default_time_dimension`. Whichever column wins is an input of the aggregation: if it, or a derived definition it names, crosses a join hop that is not provably to-one from the aggregation's root, the query fails with the input-safety error exactly as an explicit ranking argument would. The value and the ranking key may each be a row-level expression (`first(amount - customers.discount)`, `last(balance, coalesce(updated_at, created_at))`), and a computed dimension typed DATE / TIMESTAMP counts as a time/date dimension in that order.

Not to be confused with the [`last()` formula function](formulas.md#first-and-last-functions) — a window-function transform that broadcasts a value across all rows. Same name, different layer.

Expand Down Expand Up @@ -453,6 +453,8 @@ Or use the saved result as a model in another query:

A consumed query-backed model is spliced into the consuming query as stages whose names stay private to it, and one that references itself, directly or through other query-backed models, is rejected.

Joins declared on a saved query-backed model, keyed on its output columns, stay usable wherever the model is consumed; the backing source model's joins are not inherited.

### Variables in model SQL

`{variable}` substitution is not limited to query-level filters — the same mechanism injects caller-supplied values into a model's **raw-SQL (Mode A) surfaces**. This is the primitive for parameterizing hand-written model SQL (for example, representing a Cube `FILTER_PARAMS` cube as an `sql`-mode model whose filter bounds arrive as variables).
Expand Down
4 changes: 3 additions & 1 deletion docs/concepts/queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,8 @@ Emits roughly `SELECT orders.status, orders.amount FROM orders WHERE orders.amou

A time dimension with a required granularity and an optional date range. Supports an optional `label` for human-readable output. To use a time column without truncation, add it as a regular dimension instead. The column may also be given under the key `column` as an alias of `dimension`; the object always serializes with `dimension`.

`dimension` may be any row-level expression typed DATE or TIMESTAMP (`"coalesce(shipped_at, created_at)"`, `"CAST(created_txt AS DATE)"`, also as `month(coalesce(shipped_at, created_at))`), and an optional `name` sets its result key and lets `main_time_dimension` and `partition_by=` refer to it (an unnamed expression is named like a computed dimension).

```json
{
"dimension": "created_at",
Expand Down Expand Up @@ -451,7 +453,7 @@ Use one of:

Filters support `{variable_name}` placeholders, substituted from the query's `variables` dict. This keeps filter templates reusable and avoids string concatenation in client code.

The same placeholders work in measure formulas, computed-dimension expressions, `order` expressions and `date_range` bounds (`"measures": ["sum(amount) * {k} / 100"]`), and an unnamed entry is named from its template (`amount_sum_k_100`), so result keys never depend on the values; `{? ... ?}` blocks and placeholders in a time dimension's granularity or column are rejected.
The same placeholders work in measure formulas, computed- and time-dimension expressions, `order` expressions and `date_range` bounds (`"measures": ["sum(amount) * {k} / 100"]`), and an unnamed entry is named from its template (`amount_sum_k_100`), so result keys never depend on the values; `{? ... ?}` blocks and a placeholder naming a time dimension's granularity or standing for its whole dimension is rejected.

```json
{
Expand Down
2 changes: 2 additions & 0 deletions docs/concepts/time.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,8 @@ The population is `time_spine × P` (P the population of the remaining dimension

A stage of a multi-stage query, or a saved query, can be a spine query too; later stages then see one row per bucket.

An expression is never an axis: a time dimension whose expression reads `time_spine.timestamp` (`time_spine.timestamp + INTERVAL '1 day'`) is a typed error that names the remedy of bucketing the column itself, while any other expression time dimension is an ordinary dimension of P.

## Custom granularities

A datasource's `granularities` list defines named buckets `{name, base, multiple, origin}` — e.g. `{"name": "fiscal_year", "base": "month", "multiple": 12, "origin": "2024-04-01"}` or `{"name": "quarter_hour", "base": "minute", "multiple": 15}` — usable wherever a built-in granularity is (time dimensions, `fiscal_year(order_date)`, `Column.granularity`, transform units), each bucket starting at `origin + k × multiple × base`.
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 16
version: 17
name: customers
sql_table: customers
source_kind: table
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 16
version: 17
name: items
sql_table: items
source_kind: table
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 16
version: 17
name: orders
sql_table: orders
source_kind: table
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 16
version: 17
name: products
sql_table: products
source_kind: table
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 16
version: 17
name: stores
sql_table: stores
source_kind: table
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 16
version: 17
name: supplies
sql_table: supplies
source_kind: table
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: 16
version: 17
name: tweets
sql_table: tweets
source_kind: table
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-21
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
## Context

The public save path validates and persists joins after populating a query-backed model's output columns. Execution strips those joins from its placeholder, then creates a flat synthetic model from the final StageSchema. Product-hop support can turn the missing edge into a successful uncorrelated filter rather than a refusal.

## Decisions

1. Repair the saved-relation boundary. Preserve only the query-backed model's own declared joins; never inherit its backing source's join graph.
2. Reuse `model_from_stage_schema` to carry optional declared joins for saved final relation names in source, extension and sibling/target paths. Keep StageSchema unchanged. Validate joins against the saved relation's real identity so a legal target named `_stage` is not mistaken for a self-join.
3. Select ModelScope for a materialized saved relation by its identity in the query-backed model map, including relations with incoming reverse edges and no outgoing joins. Ordinary/private query stages retain flat StageSchema binding.
4. Keep demand-based splicing. Merely declaring or reaching an unread query-backed model must not plan it or manufacture a cycle.
5. Reuse the existing logical join-key and extension semantics. Test distinct/named extension joins. An extension join equal to one of its base model's stored joins in all but metadata and pair order is that stored edge, applied once, for every model kind; the runtime overlay and the schema-drift stage graph share this one rule. A join differing in target, name, join type, cardinality or pairs stays a separate edge.

## Validation

Record failing segmented-total tests before the implementation. Execute the repaired paths on SQLite and DuckDB using renamed physical/logical keys. Check root and target consumption, aggregate attribution, extension parity, private-stage scopes and cycles. Run the required unit, lint, type and architecture gates and the documented CI integration selection, then inspect current-head CI and review results.

## Risks

An incomplete root-only fix leaves target/sibling reads broken. Passing saved relationships to ordinary stages breaks stage isolation. Eager traversal of retained edges can introduce false cycles. Preserve those distinctions and use existing fixtures and regression suites.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
## Why

A saved query-backed model's declared joins disappear when its final stage becomes a consuming relation. A customer-segment filter consequently returns all-customer revenue (150 instead of 120), because the missing relationship is treated as a product hop. The older outbound traversal gap is tracked as DEV-1918; this change fixes its wrong-result consequence reported in GitHub issue #507.

## What Changes

- Retain the saved relation's own joins through query-backed placeholder and relation materialization.
- Bind saved relations as model scopes, keeping ordinary query-stage outputs flat.
- Add executable SQLite/DuckDB tests for filtered totals, traversal and aggregate attribution.
- Preserve existing demand splicing, private stage scopes, cycle errors and extension behavior.
- Treat an extension join repeating a stored join of its base as that edge, for every model kind.

## Capabilities

### Modified Capabilities

- `queries/query-backed-inline`: consuming a saved relation retains its own declared relationships.
- `models/join-traversal`: an extension join repeating a stored edge is that edge.

## Impact

A focused engine/IR fix with regression tests and a clarification in the models guide. No new dependency, persisted schema, public request field or normative architecture change.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## ADDED Requirements

### Requirement: An extension join repeating a stored edge is that edge

A query-level model extension's join that equals one of its base model's stored joins in every field except `description`, `meta` and pair order SHALL be that stored edge, applied once, for every model kind; an extension join differing in target, name, join type, cardinality or join pairs SHALL remain a separate edge.

#### Scenario: Repeating a stored join in an extension
- **WHEN** an extension over a table-backed model repeats a stored join and a query reads through it
- **THEN** the query resolves over that single edge instead of failing as an ambiguous hop

#### Scenario: A differing extension join stays parallel
- **WHEN** an extension adds an unnamed join to the same target with different join pairs
- **THEN** a bare hop to that target fails closed as ambiguous
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
## ADDED Requirements

### Requirement: Saved relation relationships survive splicing

A stored query-backed model consumed as a relation SHALL retain that model's own declared joins, with keys resolved against its final-stage output columns. Those relationships SHALL remain available when the relation is a source, a join target or an extended source. Ordinary query-stage outputs SHALL remain flat and SHALL NOT inherit joins from their backing source.

#### Scenario: Joined segment filter on a saved relation
- **WHEN** a saved customer-total relation declares a customer-key join and a query filters to gold customers
- **THEN** only the related gold customers contribute, with the same values as an equivalent relationship declared solely on a query extension, on SQLite and DuckDB

#### Scenario: Joined dimensions and aggregate attribution
- **WHEN** a query reads a dimension or aggregate through a saved relation's declared join
- **THEN** the relationship determines the rows and attribution instead of being treated as an unrelated dataset

#### Scenario: A saved relation consumed through another model
- **WHEN** another model joins a saved query-backed relation and a query traverses onward through that relation's own join
- **THEN** the declared relationships remain available with logical key spelling and correct values

#### Scenario: Extending a saved relation
- **WHEN** a query extension adds a distinct named join to a saved relation
- **THEN** both its stored joins and the extension's joins remain available

#### Scenario: Repeating an existing saved relationship
- **WHEN** an extension over a saved query-backed relation repeats an exactly identical declared join
- **THEN** that edge is applied once, without introducing an ambiguous parallel path

#### Scenario: Legal join targets do not collide with temporary identities
- **WHEN** a saved relation declares a join to a legal model named `_stage`
- **THEN** both unjoined and joined consumers execute without a false self-join error

#### Scenario: Ordinary stage isolation
- **WHEN** an ordinary query stage is consumed by a later stage
- **THEN** only its projected output schema is visible and its backing source's joins are not inherited
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
## 1. Plan and reproduce

- [x] 1.1 Refresh current upstream and public issue/PR history; report the wrong-result behavior in #507, distinguishing the known DEV-1918 traversal gap.
- [x] 1.2 Review the relation-boundary design independently, preserving flat ordinary stages and demand splicing without a normative architecture edit.
- [x] 1.3 Record failing SQLite/DuckDB segmented-total regressions before production changes.

## 2. Implement and document

- [x] 2.1 Preserve declared joins through saved-relation placeholder, root/extension and sibling/target materialization.
- [x] 2.2 Verify logical-key traversal, aggregate attribution, extension compatibility, ordinary-stage isolation and cycle behavior.
- [x] 2.3 Clarify saved-relation joins in the user models guide.
- [x] 2.4 Treat an extension join repeating a stored join as that edge for every model kind, in the runtime overlay and the schema-drift stage graph.

## 3. Validate and review

- [x] 3.1 Pass the focused regressions and related query-backed scope/cycle tests.
- [x] 3.2 Run the full documented unit selection, retest environment/timing failures, and pass lint, type and architecture gates.
- [x] 3.3 Independently review the implementation and verify the final tree.

Validation note: the local Python 3.14 unit run reported 31,377 passed, 99 skipped, 7 xfailed and three failures. All three passed isolated retries; the Poetry PATH was corrected for the affected subprocess test, and the timing-sensitive failure also reproduced on unchanged upstream. The documented integration selection passed with 656 passed, 36 skipped and 1 xfailed. CI validates the full suites on Python 3.11 and 3.14.
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-21
Loading
Loading