Reserved number: 0470, assigned to batch stackra-380 (STA-1478) for the data-layer wave 1 round of
2026-10-28.
Context
ADR 0163 promises a module@Report query compiled to
SQL and @Report widgets. Nothing was built, and v3 replaces decorators with kinds declared by their base class
(0294), so the accepted text names a form the
programming model forbids. ADR 0270
decision 10 and ADR 0271 say there is no report builder and “no tenant SQL or pivot engine”.
Two ADRs also disagree on grouping: 0271 decision 1 puts group-by and aggregates in a saved view, while
0307 rule 13 leaves grouping and
summaries out of v3.1 tables (0359
decision 14 already reopened it without a design). Today the panel’s groupRows groups and totals in memory over the
rows loaded, so subtotals on a paged list are wrong for the full set. The owner answered on 2026-10-28 (data-layer
decision 13): reports v2 yes, group-by belongs to views, drop 0307 rule 13. ADR
0154 still holds: a tenant never writes SQL or code.
Decision
- A report is a
Reportkind, not a decorator.class OrderSales extends Report<Order>({ key, dimensions, measures, defaultFilter, permissions })lives in a module’sreports/group and in the@stackra/reportspackage (data category). Its source is a model, aProjectionor anIndexertarget (0471). Gen records each report instackra.manifest.jsonunderreportsand emits one generatedQueryper report, so REST, MCP and the panel read the same contract.@Reportis retired. - Dimensions and measures are a closed, typed vocabulary.
- A dimension is a declared field path of the source, optionally across a
Link<T>relation, with an optional time grain (hour, day, week, month, quarter, year) bucketed in the scope’s time zone. - A measure is one of
count,countDistinct,sum,avg,min,max, or a formula over other measures of the same report from a closed grammar (arithmetic and ratio only). Each measure names its metric class (MoneyMetric, …), and a money measure keeps the mixed-currency guard. - Nothing outside the declaration can be grouped, filtered or measured.
- A dimension is a declared field path of the source, optionally across a
- Grouping, totals and pivots run on the server. A
ReportRequest(dimensions, measures, filters, sort, limit, cursor, pivot axes) is compiled byReportCompilerinto one parameterised statement through the ORM query builder. It appliesvisibleFilterand row-level security, resolves every path throughcolumnOf, runs on the read replica by default, and carries a statement timeout and a row cap. TheReportResultholds groups, subtotals, grand totals and a pivot matrix. This is a pivot over declared dimensions and measures, not a pivot engine over arbitrary tables, and it adds no raw SQL at runtime (0160). - Group-by and aggregates belong to views (0271 decision 1). A saved view’s grouping, sub-grouping and totals are
served by decision 3, never computed in the browser over a loaded page. ADR 0307 rule 13 no longer excludes
grouping and summaries; reordering and custom column renderers stay out as that rule says.
groupRowsstays only for an inline relation table that is fully loaded. A view may target aReportkey as well as a resource. - Report definitions are data. A user may save a
ReportDefinition: a report key, the chosen dimensions and measures, filters, sort, pivot axes and a display (table, pivot or a registered chart type). It is stored in thereportsmodule, shared personally, by role or for the whole scope under thereports.definition.sharepermission, and validated against the manifest on every save and every run; a definition naming an undeclared key is refused. The editor over definitions is a document kind on@stackra/builderor inline editing on the view. It never accepts SQL, code or an expression outside the closed grammar, so 0154 stands. - Columns carry visibility. A dimension or measure may carry a permission and a data class (0341). The compiler drops or masks what the caller may not see, and the validator refuses a shared definition that would expose it to its audience.
- Exports and schedules run as the reader. A report or view export is the queued export of 0163 decision 4, and a scheduled report runs as its subscriber, permissions re-checked at run time.
- One engine now. The compiler targets Postgres. A
ReportEngineport may put other engines behind configuration later; adding one is decided by the warehouse and analytics batches, not by this ADR. 0163 decision 5 is not changed here.
Consequences
- A module ships its reports as code and a scope builds its own over them, without either writing SQL.
- Subtotals on a paged list become correct, and the panel can draw grouping without the page-limited helper.
- The two
Aggregateenums merge into the database one, extended; the panel enum goes. @stackra/reportsis a new package; gen gains areportsmanifest section and a query emitter per report.- A renamed or removed dimension or measure breaks the definitions and views that name it; the validator must report them, as 0271 already requires for fields.
- Reports run on the replica, so a report may trail a write by the replica’s lag.
Enforcement
planned — gen refuses aReport whose dimension or measure names an undeclared path or a measure outside the closed
set (stackra-389); ReportCompiler tests assert one statement, visibleFilter and row-level security applied, the
timeout, the row cap and the mixed-currency guard (stackra-390); the definition validator refuses undeclared keys and
shares that expose a restricted column (stackra-395, stackra-396). Nothing checks this today.
Supersedes and sources
Supersedes: none. Amends 0163 (decisions 1 and 2: the@Report decorator), 0270
(decision 10), 0271 (the consequence “no report builder”) and
0307 (rule 13, grouping and
summaries); each carries a note dated 2026-10-28. Sources: owner answers to the data-layer questionnaire, 2026-10-28
(decision 13); notes handover/gap-analysis/data-layer/reporting.md (gaps 1 to 8, 11, 15); Linear STA-1478.