Amends 0108 and 0213
(the settings surface: sections, the Product and Node scopes, per-field scopes, approval, client exposure,
rules, and ConnectionRef<C> in place of @ConnectionRef), and
0294 (the Resource, Widget, Document,
Settings and Flag rows of decision 16, and the slot formula of decision 20). The panel’s other decisions in
ADR 0164, 0110, 0270, 0271 and 0279 are changed by this ADR as described below; each of those pages carries a note in this change (STA-414).Context
A resource page is a list, a record view and a form, and a dashboard is a set of tiles. Three things are wrong with the current shape. A table’s columns are checked against the model, while the list query returns a view, so a column can pass gen and render nothing. Sort and filter have no server contract, so a sort on an unindexed field is accepted. A key performance indicator is three loose field names with no previous period and no target, and the query’s cache repeats the refresh list. This ADR fixes the resource, widget and settings surfaces on the server contract, so that the REST, MCP and panel readers see the same numbers. The panel’s data comes from the query (ADR 0305 rule 31), and the panel only presents it.Decision
Resources: the table is a server contract (R-1 to R-8).-
A table’s keys are wire paths:
Path<Row>, whereRowis the item type of the list query’s output. Gen records each resource’s row in aListRowsmap (RowOf<M>), because TypeScript cannot infer the row from the list query once the model is written. Where the row is the model,Resource<Order>({ ... })is enough. Where the row differs from the model, the explicit formResource<Order, OrderView>({ ... })names the row too. Until gen writes the map, keys are checked against the model, and a list query that returns another row is a compile error. The infolist and relation keys come from the get output, and form keys come from the create or update input. Dotted paths are typed, for example"totals.totalMinor". -
Gen compiles each resource table into a closed
TableInputfor its list query. Sort is an enum of the sortable keys, filter is a typed object of the declared filters, and search exists only when the model is@Searchable.CrudService.table(input, { where })applies it, so no raw field name reaches SQL. A sortable or filterable key needs an index (@Indexor@Searchable.filters). A list query is bound to at most one resource table. -
Every button runs an Action class. A placed action’s default places follow its input:
.in(...)narrows the places..bulk()adds the Bulk bar to a record action, and fans out with the policy per record. Derived operations place themselves: create in the List header and the empty state, update as Edit on the row and View, delete on the row overflow, the View header and the Bulk bar. -
A placed action’s permission is its key’s permission (
order.cancelbecomesorders.order.cancel). The module’sPermissionsclass must have that verb. Pages follow their operations: List and navigation need the list query’s permission, and View the get query’s. Export needs theexportverb, and import theimportverb with each row’s action permission. The client hides a button the actor may not use, from one batched abilities read per page (the ids and the row-placed actions). The run still decides on every call, and a bulk run groups its refusals by code throughDecision.deny(code). -
Builders are pure. Gen runs each builder method once at gen time and records the tree in
stackra.manifest.jsonunderui.resources. A builder that reads anything but its argument fails gen (builder_impure). Conditions are a closed set (field,record,page,can,all,any,not), built through a typed callback, so no executable code reaches the web or native client. -
Every name, such as a section, a view, a column or an empty state, has a derived label key
<module>::resources.<model>.<part>.<name>. No name takes a label option.stackra gen --fix-langadds missing keys. -
The members of a resource are
overridemethods that return builders:table,views,form,infolist,relationsandactions.bulkActionsandtoastsare retired intoactions. The actionuxoption is retired (ADR 0305 rule 29). - Builder calls inside a member are not declarations, so they are not counted by ADR 0305’s one-declaration rule.
- Saved views are panel-owned and tenant-scoped. A personal view belongs to one user, and a tenant-shared view is
created by a holder of
panel.view.share. A view stores only declared keys, and.renamed()migrates stored views. The URL carries the view, the filters, the sort and the page size, but never free-text search. - Import is a placement over an Action,
a.import({ mode, match }). Every row goes through the full action pipeline, and failed rows are written to a file the user can download. Export excludes@Classifiedfields above Internal, unless the column opts in under an L2 verb. Export is queued, offers CSV and XLSX, and its link is valid for 24 hours and access-logged. - Forms take conditional fields from union branches, a
T | nullobject as a switch, and a closed set of.when()conditions for UX-only logic. Relation managers are a builder,r.table(Query, columns), over same-module data. The discriminant is spelledscope!: typeof RefundScope.Full. - Counts are estimated by default (
Count.Estimated), and a filtered count is shown capped as “10,000+”. A navigation badge names a view. Bulk selection is capped at 10,000 records; up to 100 records run inline, and more run as a job. - Not in v3.1: grouping, summaries, reordering and custom column renderers. The need for one of them is met by a widget in a slot. Reopening one needs a resource that cannot express its need that way, and the owner decides.
-
A widget is
Widget<Query>({ slot, size, order, params, ranges, range, refreshOn, refreshEvery, roles }). Itsdisplay(d: Display<OutputOf<Query>>)method is abstract, so every path is checked by TypeScript and by gen.rolesaffects presentation only. A widget never changes what the query returns. -
A key performance indicator binds to a field of the query output typed as a metric class:
MoneyMetric,CountMetric,NumberMetric,PercentMetricorDurationMetric. The type carries the value, the previous period, the series and the target, so format, unit, currency, trend, sparkline and target come from the type. The display adds only what the type cannot know: the direction that is better, threshold tones, the icon and a drill-down. -
The query computes the current and previous values, the buckets, the ratios and the targets through
CrudService.metrics(), in one statement. The panel presents them and computes nothing. Money KPIs are in the tenant’s reporting currency, from checkout-time snapshots, and never mix currencies. The metric shapes live in the core entry of@stackra/panel. -
refreshOnis derived from the query’scache.invalidateOn. A widget writes its ownrefreshOnonly for an uncached query, and an explicit event that the query does not invalidate on fails gen (refresh_without_invalidation). -
The slots are typed places, not hooks. Each slot has a place type that limits its displays and sizes:
Slots.Dashboard.Headeris a Band (Kpi, KpiGroup and Progress, one row);Slots.Dashboard.Mainis a Grid of 12 columns, with sizes Small 3, Medium 6, Large 9 and Full 12;Slots.<Module>.<Model>.List.Headerand.View.Headerare Bands;.List.Footerand.View.Footerare Grids;.View.Asideand.Edit.Asideare Asides, whose width the aside sets;.View.Tabsholds Tabs, one per widget, labelled with the widget title;Slots.Shell.Bannerholds Banners, at most two shown, full width above the page;Slots.Shell.Checklistholds Checks, in the checklist launcher.
Bind.record(path)is allowed only in a View slot, andBind.tableFilter()only in a List slot. - Placements replace each other by layer (ADR 0306 rules 7 to 8). Layers 4 and 5, the tenant and user copies, are data. The experience service validates them when they are saved and again when they are read.
-
A dashboard is a Document of kind Dashboard. It places widget classes only, and each tile has a key, a permission, a refresh rule and a library entry. Inline query blocks stay in Page documents. A dashboard has one range picker, with the ranges
TimeRange.Today,TimeRange.Last7DaysandTimeRange.ThisMonth, and its filters are the range and the scope node. A personal arrangement is an overlay on the base, not a copy. - A banner and a checklist item are widgets with the display kinds Banner and Check, placed in the shell slots. A resource does not list its widgets, documents or settings, because each of those has its own owner and permission. Quick create is opt-in per resource.
- One
<module>.settings.tsfile declares a settings class,extends Settings({ scopes, navigation }). A field typed as a schema is a section, and sections nest one level deep (settings_too_deep). Each field carries@Setting({ scopes, lockable, approval, client }), which narrows the scopes for that field. The scopes are Platform, Product, Tenant, Node and User. There is no Market scope, because a Market is a pricing entity (ADR 0244).rules(r)is a closed set. - A setting is never a secret. A credential is a
ConnectionRef<C>(ADR 0308), and gen fails withsecret_in_settingson a secret or a credential-shaped field in a settings tree. Client exposure is opt-in withclient: true. Renames are made with@Renamed, and a type change is made through the settings migrator. - A setting is read with
SettingsReader#get(OrdersSettings). Gen generates the settings page from the classes, with a Features section derived from the flags. - A feature flag keeps the kind name
Flag(.flag.ts, in@stackra/feature-flags). Its row gainsscopes, which default to Platform and Tenant. A flag is not an entitlement (ADR 0213 decision 7).
Consequences
- A table column that does not exist in the list output fails at gen, not at render, and a sort on an unindexed key fails before it ships.
- Permissions are derived from keys, so a missing verb is a gen error, and not a button that appears for nobody.
- Dashboards and settings become data that the panel reads. A tenant can rearrange tiles without a code change.
- The template’s current table and bulk declarations, and its inline dashboard query blocks, convert in the template pass (STA-344), and the skill that teaches them is rewritten by the lead (STA-345).
- The panel package grows: metric shapes, time ranges,
Display, the drill and bind sources, and the slot place types all live in@stackra/panel. The count and the metrics method live in@stackra/database.
Enforcement
- planned — STA-340: the resource and widget gen checks listed in the Decision (
column_key_unknown,sort_not_indexed,filter_not_indexed,list_query_shared,table_row_mismatch,action_permission_missing,builder_impure,widget_display_type_mismatch,metric_path_not_metric,refresh_without_invalidation,slot_unknown,placement_duplicate,dashboard_block_not_widget,settings_too_deep,secret_in_settings, and the rest of their families). - planned — STA-339: the slot table and the generated
#stackra/slotsmodule, and the layer-4 and layer-5 validation in the experience service. - planned — the panel’s batched abilities read and the
Decision.deny(code)grouping, which are part of the panel build.