janela 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +36 -5
  4. data/UPGRADING.md +111 -0
  5. data/app/assets/javascripts/janela/frame_controller.js +15 -0
  6. data/app/assets/stylesheets/janela.css +29 -0
  7. data/app/controllers/janela/application_controller.rb +7 -0
  8. data/app/controllers/janela/panes_controller.rb +22 -7
  9. data/app/controllers/janela/queries_controller.rb +2 -1
  10. data/app/helpers/janela/frames_helper.rb +26 -7
  11. data/app/models/janela/frame.rb +20 -0
  12. data/app/models/janela/pane.rb +67 -4
  13. data/app/models/janela/query.rb +7 -2
  14. data/app/views/janela/frames/_content.html.erb +26 -0
  15. data/app/views/janela/frames/_frame.html.erb +9 -1
  16. data/app/views/janela/frames/_pane.html.erb +2 -2
  17. data/app/views/janela/panes/_content_form.html.erb +33 -0
  18. data/app/views/janela/panes/_row.html.erb +1 -1
  19. data/app/views/janela/panes/edit.html.erb +5 -1
  20. data/app/views/janela/panes/new.html.erb +6 -1
  21. data/app/views/janela/queries/_query.html.erb +15 -11
  22. data/config/locales/en.yml +5 -0
  23. data/db/migrate/20260924000001_add_content_to_janela_panes.rb +16 -0
  24. data/db/migrate/20260924000002_add_key_to_janela_frames.rb +10 -0
  25. data/docs/composing.md +269 -0
  26. data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +1 -0
  27. data/docs/decisions/035-a-check-does-what-janela-does-or-says-what-it-saw.md +262 -0
  28. data/docs/decisions/036-janela-publishes-what-a-theme-may-target.md +171 -0
  29. data/docs/decisions/037-what-1-0-means.md +172 -0
  30. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +194 -0
  31. data/docs/decisions/039-a-pane-can-hold-words-and-only-code-writes-markup.md +168 -0
  32. data/docs/decisions/040-a-host-can-fix-a-frames-filter.md +145 -0
  33. data/docs/decisions/041-a-host-finds-its-frame-by-owner-and-key.md +112 -0
  34. data/docs/decisions/042-a-charts-title-is-a-figcaption.md +174 -0
  35. data/docs/decisions/INDEX.md +26 -15
  36. data/docs/multi-tenancy.md +22 -0
  37. data/docs/naming.md +7 -0
  38. data/docs/roadmap.md +218 -0
  39. data/docs/theming.md +179 -0
  40. data/lib/janela/definition.rb +16 -1
  41. data/lib/janela/doctor.rb +121 -32
  42. data/lib/janela/model.rb +6 -1
  43. data/lib/janela/version.rb +1 -1
  44. data/lib/janela.rb +47 -3
  45. metadata +30 -15
@@ -0,0 +1,194 @@
1
+ ---
2
+ Date: 2026-09-24
3
+ Status: Proposed
4
+ Related: ADR 001, ADR 002, ADR 007, ADR 020, ADR 025
5
+ Triggers:
6
+ - adding a measure kind, or a sixth aggregate to the DSL
7
+ - deciding how a measure's value is scaled or formatted
8
+ - changing how a grouped pane is ordered
9
+ - a host wanting a rate or a percentage over a boolean column
10
+ - wondering whether a measure option belongs on the measure or on a pane
11
+ Topics: DSL, query layer, measures, formatting, ordering
12
+ ---
13
+
14
+ # ADR 038: A Ratio Is a Measure of Its Own, Stored as a Fraction and Read as a Percentage
15
+
16
+ ## Context
17
+
18
+ A boolean column is how an application stores a yes or no fact, and
19
+ averaging one is how a person asks what share of rows are yes. Janela
20
+ refuses that today, correctly, and the refusal leaves nowhere to go
21
+ (#20, #27).
22
+
23
+ Four things were measured against the demo on `c6922da`, Rails 8.1.3.1
24
+ and SQLite, with `orders.expedited` as a real boolean column.
25
+
26
+ **The cast is total, not partial.** `Order.average(:expedited)` returns
27
+ `true` where the true ratio is `0.333`, and returns `true` again where
28
+ the true ratio is `0.0`, because Rails' boolean cast does not read a
29
+ float `0.0` as false. There is no value of the ratio that renders as
30
+ anything other than `true`. #20 described this as a cast losing
31
+ information; it loses all of it.
32
+
33
+ **The existing machinery already carries the fix.**
34
+ `relation.average(Arel.sql("CASE WHEN ... THEN 1.0 ELSE 0.0 END"))`
35
+ answers `0.3333333333333333` as a Float ungrouped, and grouped it
36
+ answers a hash per bucket. Nothing new is needed for grouping,
37
+ gap filling, cross filtering or snapshots, because the ratio goes
38
+ through the same `Measure#apply` every other measure goes through.
39
+
40
+ **Ordering breaks, which #27 did not mention.** `Definition` orders a
41
+ grouped query with `order(Arel.sql("#{measure.sql_alias} DESC"))`, and
42
+ `sql_alias` is `"#{aggregate}_#{column}"`, the alias ADR 007 names.
43
+ There is no such alias for an expression: the string becomes
44
+ `average_CASE WHEN orders.expedited THEN 1.0 ELSE 0.0 END DESC` and
45
+ raises `ActiveRecord::StatementInvalid`. Ordering by the expression
46
+ itself works and sorts correctly.
47
+
48
+ **The null claim in #27 is backwards.** On one true, one false and one
49
+ null row:
50
+
51
+ | expression | result |
52
+ | --- | --- |
53
+ | `AVG(CASE WHEN f THEN 1.0 ELSE 0.0 END)`, null as false | 0.333 |
54
+ | the same with a `WHEN f IS NULL THEN NULL` arm | 0.5 |
55
+ | `AVG(f)`, plain | 0.5 |
56
+
57
+ #27 says excluding null "differs from `AVG` in SQL". Excluding null is
58
+ exactly what `AVG` does. The naive `CASE` is the form that differs, and
59
+ it counts every unknown as a failure, which is this library's worst
60
+ failure mode: a wrong number that looks like a right one.
61
+
62
+ ### Fraction or percentage, and where that choice lives
63
+
64
+ The formatter does not scale. `format(0.6667)` with `suffix: "%"`
65
+ renders `"0.7%"`, wrong by a hundred; `format(66.67)` renders
66
+ `"66.7%"`. ADR 020's own example, `measure :pass_rate, average: :score,
67
+ precision: 1, suffix: "%"`, only reads correctly if the value is
68
+ already on a nought to a hundred scale.
69
+
70
+ Three positions were considered.
71
+
72
+ **Storing nought to a hundred** was rejected. ADR 020 holds that the
73
+ number itself reaches a snapshot, an order clause and a comparison at
74
+ full precision. Storing a presentation scale puts a display choice into
75
+ a frozen snapshot and into an `ORDER BY`, where it is no longer a
76
+ display choice at all. A ratio is a proportion, and a proportion is
77
+ nought to one.
78
+
79
+ **Leaving the scale to each pane** was rejected, and ADR 020 had
80
+ already rejected it: `janela_panes` carries `model`, `measure`,
81
+ `dimension`, `renderer`, `granularity`, `limit` and `position`, and no
82
+ formatting columns, deliberately. Two panes of one measure could
83
+ otherwise freeze different numbers into different snapshots and serve
84
+ both as fact. ADR 020's consequences say per pane formatting, if it
85
+ ever arrives, overrides the measure's format rather than replacing the
86
+ idea, and calls that the reason to think twice.
87
+
88
+ **A fraction with an opt in percentage**, such as `percent: true` or a
89
+ remembered `suffix: "%"`, was considered and is the option this
90
+ decision narrows rather than takes. A proportion shown to a person is a
91
+ percentage; making that a flag means every host that declares a rate
92
+ also remembers a second thing, and a host that forgets gets `0.31` on a
93
+ dashboard where `31.2%` was meant. ADR 001 prefers one obvious way over
94
+ a knob.
95
+
96
+ ## Decision
97
+
98
+ **A ratio is a measure kind of its own. It computes a fraction, and it
99
+ renders as a percentage.**
100
+
101
+ ```ruby
102
+ janela do
103
+ measure :expedited_rate, ratio: :expedited # a boolean column
104
+ end
105
+ ```
106
+
107
+ **The kind sits beside the five aggregates rather than inside them.**
108
+ ADR 002 says a measure takes exactly one aggregate of `sum`, `count`,
109
+ `average`, `minimum`, `maximum`. `ratio:` is a sixth option in that
110
+ position and not a sixth aggregate, because it names a column and an
111
+ intent rather than an SQL function. `Measure::AGGREGATES` is unchanged
112
+ and the error a host already sees when it declares two of them is
113
+ unchanged.
114
+
115
+ **It requires a boolean column, which is the mirror of the refusal it
116
+ answers.** `average:` rejects a boolean column and names `ratio:`;
117
+ `ratio:` rejects anything that is not one. Both look the column up
118
+ through `model.type_for_attribute`, so the expression is built from a
119
+ column the model has confirmed, never from a string that arrived over
120
+ HTTP. ADR 025 bounds what a filter may ask for; this is the same
121
+ principle one layer down.
122
+
123
+ **Null is excluded, so a ratio agrees with `AVG`.**
124
+
125
+ ```sql
126
+ AVG(CASE WHEN col IS NULL THEN NULL WHEN col THEN 1.0 ELSE 0.0 END)
127
+ ```
128
+
129
+ An unknown is not a failure. A host that wants unknowns counted as
130
+ failures says so in its own schema with a `NOT NULL` default, which is
131
+ a decision about the data rather than about the dashboard.
132
+
133
+ **A condition form is not included.** `ratio: { status: "paid" }` was
134
+ considered and rejected: it is a second query language growing inside
135
+ the measure, it needs the same bounding ADR 025 gives filters, and the
136
+ case it serves is already served. Where a column is not already a yes
137
+ or no fact, the split is the honest answer and cross filters better,
138
+ which is what #20's refusal says and what ADR 002 says to every other
139
+ variation. The boolean case earns its own kind precisely because the
140
+ split cannot answer it: "the share that passed, over time" is one
141
+ series, and a dimension gives two.
142
+
143
+ **The stored number is the fraction. The rendered string is the
144
+ percentage.** The value that reaches a snapshot, an `ORDER BY`, a chart
145
+ axis and a comparison is `0.3119`. The string a table cell, a single
146
+ value and a chart tooltip show is `31.2%`. This follows ADR 020 rather
147
+ than bending it: that decision forbids transforming the number, and a
148
+ ratio's number is never transformed. Only the string is.
149
+
150
+ Precision defaults to one decimal place for a ratio rather than ADR
151
+ 020's fallback of two, because a percentage carries two more significant
152
+ figures than the fraction it came from and `31.19%` is noise. A measure
153
+ may still declare its own.
154
+
155
+ **Declaring `prefix:` or `suffix:` on a ratio raises.** The kind already
156
+ says what unit it is, and a host that writes `suffix: "%"` out of habit
157
+ would otherwise render `31.2%%`. Raising names the conflict rather than
158
+ guessing which was meant, which is what this library does everywhere
159
+ else it is given two answers.
160
+
161
+ **A measure answers what it is ordered by, rather than what its column
162
+ alias is.** `Measure#sql_alias` becomes `Measure#order_by`: for an
163
+ aggregate it returns the alias ActiveRecord already gives, unchanged,
164
+ and for a ratio it returns the `AVG(CASE ...)` expression. ADR 007's
165
+ decision is untouched, since a category pane is still ordered by its
166
+ measure, largest first, with no way to turn it off. Only the mechanism
167
+ it named has to widen, because that ADR assumed every measure is an
168
+ aggregate with an alias and a ratio is not.
169
+
170
+ ## Consequences
171
+
172
+ - A host with a boolean column declares one line and gets a rate that
173
+ cross filters, gap fills, snapshots and orders like any other measure.
174
+ - `rails janela:doctor` has nothing to add. The declaration either
175
+ raises at boot or is correct, so there is no silent state for a check
176
+ to find.
177
+ - **A measure that wants the fraction rather than the percentage cannot
178
+ have it.** That is the cost of refusing the flag, and it is a real
179
+ one: a host wanting `0.31` on a dashboard has to use `average:` over
180
+ a numeric column of its own. If that turns up in practice, the answer
181
+ is a format on the measure, not on the pane, and ADR 020 already says
182
+ which layer that is.
183
+ - `Measure#sql_alias` is gone. It is internal rather than documented
184
+ surface, with one call site in the gem, but a fork that reached for it
185
+ will not find it, so it needs a line in `CHANGELOG.md` under Changed
186
+ rather than an `UPGRADING.md` step (ADR 015): nothing a host declares
187
+ changes.
188
+ - #20's error message names `ratio:`, which closes the loop the refusal
189
+ opened. That string is what a host actually meets, so it is part of
190
+ the work rather than a nicety.
191
+ - What would change this decision: a host that needs a rate over
192
+ something that is not a boolean column and for which the split is
193
+ genuinely wrong. That is the case the condition form was rejected for,
194
+ and one real report of it is better evidence than the argument above.
@@ -0,0 +1,168 @@
1
+ ---
2
+ Date: 2026-09-24
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 009, ADR 012, ADR 016, ADR 036, ADR 037
5
+ Triggers:
6
+ - putting a heading, a paragraph, a link, an icon or an image on a stored frame
7
+ - adding a kind of pane that is not a query
8
+ - letting an analyst's text reach a page
9
+ - storing HTML, Markdown or a template in a row
10
+ - deciding whether a frame draws its own name
11
+ Topics: frames, panes, persistence, layout, authorisation, html
12
+ ---
13
+
14
+ # ADR 039: A Pane Can Hold Words, and Only Code Writes Markup
15
+
16
+ ## Context
17
+
18
+ ADR 012 made a dashboard data so that its author, the analyst, can
19
+ change it without a deploy. It gave the analyst one thing to arrange:
20
+ a pane that names a measure the model declared. That was the whole
21
+ vocabulary, and it is why a stored frame is safe to hand over. A row
22
+ cannot invent a query, reach a model nobody exposed, or put anything on
23
+ the page that a developer did not write.
24
+
25
+ A real dashboard is more than query results. The first one a host asked
26
+ about had, above its numbers, a heading with an icon, a count of done
27
+ out of total, a percentage, a "view all" link and a progress bar. A
28
+ block form frame can have all of it, because the page around the panes
29
+ is the host's own ERB (docs/composing.md). A stored frame can have none
30
+ of it. `janela/frames/_frame` renders `frame.panes` and nothing else,
31
+ and `Janela::Pane` requires a `measure` and validates it against a
32
+ `janela` block, so there is no row a heading could be. The frame's
33
+ `name` is never drawn either. The analyst who owns the dashboard can
34
+ choose every number on it and cannot label them.
35
+
36
+ So the question is not whether a stored frame should hold content. It
37
+ has to, or the frame the analyst owns is always the unlabelled half of
38
+ the page. The question is what an analyst may put in a row without
39
+ breaking the property ADR 012 was built on.
40
+
41
+ ### What was considered
42
+
43
+ **Stored HTML**, a column of markup rendered as it is, was rejected.
44
+ It is the one thing ADR 012 exists to prevent: an analyst's row would
45
+ put arbitrary markup on a page that every other reader of that frame
46
+ loads, which is stored cross site scripting with a dashboard for a
47
+ delivery mechanism. Running it through Rails' `sanitize` narrows the
48
+ tags but not the problem. The allowlist becomes a security boundary
49
+ Janela has to maintain, and what survives it, links anywhere and
50
+ styling that imitates the host's own chrome, is still content a
51
+ developer did not write appearing as if they had.
52
+
53
+ **Markdown** was rejected for the same reason one step removed. It
54
+ compiles to HTML, it needs a parser as a dependency (ADR 001 is spare
55
+ with those), and a link in it can point anywhere. What an analyst
56
+ actually needs from it, a heading and a paragraph, is two fields.
57
+
58
+ **A sandboxed iframe**, `<iframe srcdoc sandbox="allow-scripts">`, was
59
+ considered because an application built on this gem already renders
60
+ agent-authored slides that way, and it works: the sandbox gives the
61
+ content an opaque origin, so it cannot read the page or its cookies.
62
+ It was rejected for a public gem. The surface is large, the
63
+ guarantees depend on attributes a host can loosen, and an iframe does
64
+ not size to its content, so it cannot sit in a CSS grid beside a pane
65
+ without fixed heights, which is exactly what #24 is trying to remove
66
+ for charts. It stays available to any host that wants it, in its own
67
+ code, around a frame.
68
+
69
+ **Drawing the frame's name as a heading** was rejected as the whole
70
+ answer. It would change every existing frame's appearance on upgrade,
71
+ it gives one heading per frame when a dashboard often wants one per
72
+ row, and it still leaves no icon, no paragraph and no link.
73
+
74
+ **A separate table of content rows** beside `janela_panes` was
75
+ rejected because position is the frame's single ordering. Two tables
76
+ would need a shared position sequence across them, and ADR 012 kept
77
+ one level between a frame and what it shows on purpose.
78
+
79
+ ## Decision
80
+
81
+ **A pane can hold words instead of a query. An analyst writes text
82
+ into a row and Janela escapes it; anything that needs markup is a
83
+ partial the host wrote in code, which the analyst places by name.**
84
+
85
+ This is ADR 012's division of labour applied to content: developers
86
+ define what can be shown, analysts arrange it and write the words.
87
+
88
+ **A pane gains a `kind`.** `query` is today's pane and the default, so
89
+ every existing row is unchanged. Two more kinds:
90
+
91
+ - **`text`**: a `heading`, a `body` and an optional `link`, each a
92
+ plain string. Janela renders the heading as a heading, the body as
93
+ paragraphs split on blank lines, and the link as an anchor, and
94
+ escapes all three. No markup an analyst types reaches the page as
95
+ markup.
96
+ - **`partial`**: a `partial` name, plus the same `heading`, `body` and
97
+ `link` passed to it as locals. The partial is the host's, under
98
+ `app/views/janela_content/`, so the host writes the icon, the image,
99
+ the layout and the look, and the analyst writes the words that go in
100
+ it. A name must match `/\A[a-z0-9_]+\z/` and a partial must exist in
101
+ that directory, so a row cannot reach any other template. There is
102
+ no registry and no setting: the directory is the allowlist, the way
103
+ a controller's views directory already is. It sits outside
104
+ `app/views/janela/` on purpose. A host view at an engine's path
105
+ replaces the engine's own, and `janela/panes/` already holds the
106
+ engine's `_form` and `_row`, so a content partial named `form` there
107
+ would silently replace the pane editing form.
108
+
109
+ ```ruby
110
+ frame.panes.create!(kind: "text", heading: "Refunds are excluded",
111
+ body: "Figures are in the store's own currency.")
112
+ frame.panes.create!(kind: "partial", partial: "overview_heading",
113
+ heading: "Project overview", link: "/cards", span: 3)
114
+ ```
115
+
116
+ A `text` or `partial` pane has no `model` or `measure`. Those columns
117
+ become nullable, and a validation requires them for `query` and
118
+ refuses them for the other two, so the database cannot hold a
119
+ half-query.
120
+
121
+ **A link is a path, not a URL.** `link` must start with `/`, and not
122
+ `//`, so an analyst can point readers at another page of the same
123
+ application and cannot send them to another site. A host that wants an
124
+ external link writes it in a partial.
125
+
126
+ **Images and icons are the host's.** Janela stores no files and ships
127
+ no icon set (ADR 036). A heading with an icon is a partial that draws
128
+ the icon beside the heading it is given. An image is a partial that
129
+ reads it from wherever the host already keeps images. Janela does not
130
+ take a dependency on Active Storage to do something a host's own
131
+ partial already can.
132
+
133
+ **A content pane sits in the grid like any other.** It spans columns,
134
+ it is ordered by `position`, it is drawn as a `janela-pane`, and under
135
+ vitral it is a pane of glass. It does not take part in cross filtering:
136
+ it has no query, so the frame controller has no `src` to rewrite, and a
137
+ content pane renders inline rather than inside a turbo frame.
138
+
139
+ ## Consequences
140
+
141
+ - An analyst can label a stored frame, explain it and link out of it
142
+ without a deploy, which is the half of ADR 012 that was missing.
143
+ - The property ADR 012 stated plainly still holds, extended: a row can
144
+ name only a declared measure, an escaped string, a same-site path, or
145
+ a partial a developer wrote. Nothing an analyst types is markup.
146
+ - `janela_panes` gains `kind`, `heading`, `body`, `link` and `partial`,
147
+ and `model` and `measure` become nullable. That is a migration a host
148
+ has to install, so it needs an `UPGRADING.md` entry (ADR 015). The
149
+ engine's own forms need a way to add each kind.
150
+ - The pane partial becomes a branch on `kind`. `_pane.html.erb` is the
151
+ smallest file this touches and should stay that way: one partial per
152
+ kind under `janela/frames/`, chosen by name.
153
+ - Snapshots store results, not HTML (ADR 009). A snapshot is a list of
154
+ pane results with no frame behind it, so a content pane has no result
155
+ to store and is not in one. Words that explain a signed off figure
156
+ have to live in whatever page shows the snapshot. If a snapshot ever
157
+ needs to carry the text that sat beside its numbers on the day, that
158
+ is a separate decision.
159
+ - The pane class names a theme may target (ADR 036) gain one for the
160
+ content kinds, `janela-content`, and it goes into docs/theming.md in
161
+ the same change.
162
+ - Not in 1.0 (ADR 037). It adds to the public surface rather than
163
+ changing it, so it does not block a stable release and can land
164
+ before or after one.
165
+ - What would change this decision: a host that genuinely needs an
166
+ analyst to author markup, with a real reason a partial cannot serve.
167
+ The sandboxed iframe is the fallback that case would reopen, and this
168
+ record says why it was set aside.
@@ -0,0 +1,145 @@
1
+ ---
2
+ Date: 2026-09-24
3
+ Status: Accepted
4
+ Related: ADR 003, ADR 008, ADR 012, ADR 024, ADR 025, ADR 030, ADR 032, ADR 034
5
+ Triggers:
6
+ - showing a frame on a record's own page, filtered to that record
7
+ - reusing one frame across many records
8
+ - carrying a host's filter in q[...] and finding it cleared
9
+ - deciding whether a filter is a view choice or an authorisation boundary
10
+ - changing how a pane URL is built, or what clear() and toggle() touch
11
+ Topics: frames, filters, cross-filtering, urls, authorisation
12
+ ---
13
+
14
+ # ADR 040: A Host Can Fix a Frame's Filter, and No Click Removes It
15
+
16
+ ## Context
17
+
18
+ A host wants the same dashboard on every record's page, narrowed to
19
+ that record: a frame of the work in a queue, shown at the top of each
20
+ queue's page. Since ADR 012 a frame is a row an analyst edits, so the
21
+ panes are the same everywhere and only the record changes. Janela has
22
+ no way to say "this frame, for this record" (#57).
23
+
24
+ The only filter Janela knows is the one in the page URL, `q[...]`
25
+ (ADR 008). So a host redirects the record's page to carry one,
26
+ `/queues/42?q[queue_id_eq]=42`, and the panes read it. Measured on the
27
+ demo with `q[status_eq]=paid` standing in for the host's filter, it
28
+ does not hold:
29
+
30
+ - Opened with the filter, the revenue pane read $469,097.85 and every
31
+ pane's `src` carried `q[status_eq]=paid`.
32
+ - **Clear filters**, the button a dashboard shows, took it off. Revenue
33
+ read $664,639.01, the whole table, and the page URL lost the
34
+ parameter.
35
+ - **Escape**, with focus inside the frame, did the same.
36
+ - A click on a `status` value would replace it, because `toggle()`
37
+ clears every predicate on the dimension it is changing (ADR 024).
38
+
39
+ None of this is a bug in the frame controller. `q[...]` is Janela's by
40
+ design: `stripFilters` removes every `q[` key before writing the
41
+ current selection, and `clear()` empties the frame's filters, because
42
+ the selection is the reader's to change. A host's filter placed there
43
+ becomes the reader's to change too. On a record's page the reader then
44
+ sees every record their scope allows under that record's heading.
45
+ Nothing leaks, since `policy_scope` still bounds every pane (ADR 032).
46
+ The numbers are wrong, and they look right.
47
+
48
+ ### What was considered
49
+
50
+ **Making `clear()` leave the host's keys alone** was rejected. The
51
+ controller cannot tell a host's `q[queue_id_eq]` from a reader's link
52
+ that carries the same key, and ADR 008 means a shared link carries
53
+ exactly that. Any rule for telling them apart would be a second
54
+ convention inside one parameter.
55
+
56
+ **The host's `policy_scope`** was rejected. It answers who may see
57
+ which rows, and it runs in the engine's pane requests, which know
58
+ nothing about the page that holds the frame. Putting a page's record
59
+ into it would make authorisation depend on which page a pane was
60
+ loaded from, and ADR 032's checks would be reasoning about a scope that
61
+ changes per page.
62
+
63
+ **Storing the filter on the frame**, a `filters` column on
64
+ `janela_frames`, was rejected as the answer to this case. The case is
65
+ one frame reused across many records, so the filter belongs to the
66
+ render, not to the row. A frame that should always be narrowed the
67
+ same way is a smaller, different case, and it can be built on this
68
+ later.
69
+
70
+ **Signing the filter**, carrying it as a `MessageVerifier` token so a
71
+ reader cannot edit it, was rejected. It would imply a security
72
+ guarantee this is not. A reader who edits the fixed filter out of a
73
+ pane URL sees rows `policy_scope` already lets them see, which they
74
+ could see on any other dashboard. Signing adds a key to rotate and a
75
+ failure mode to explain, to protect nothing that authorisation does
76
+ not already protect.
77
+
78
+ ## Decision
79
+
80
+ **A host passes a filter when it renders a frame, and Janela applies it
81
+ to every pane beneath the reader's selection, where no click, clear or
82
+ link can remove it.**
83
+
84
+ ```erb
85
+ <%= janela_frame @frame, where: { queue_id_eq: @queue.id } %>
86
+
87
+ <%= janela_frame where: { queue_id_eq: @queue.id } do %>
88
+ <%= janela_pane Card, :count, by: :status %>
89
+ <% end %>
90
+ ```
91
+
92
+ `where:` is the name `Definition#query` already uses for the same
93
+ thing, Ransack conditions narrowing a relation, so a host learns one
94
+ word for it.
95
+
96
+ **It travels in the pane's base URL under its own key, `where[...]`.**
97
+ The helper writes it into each pane's `data-janela-src`, the base the
98
+ frame controller builds every request from, and into the first
99
+ render's `src`. The controller never reads or writes `where[...]`:
100
+ `stripFilters` removes only `q[` keys, `clear()` empties only the
101
+ frame's filters, and `toggle()` changes only `q[`. So the fixed filter
102
+ survives every click without the controller learning anything about
103
+ it. It is not written into the page URL, which already says what the
104
+ record is.
105
+
106
+ **The server applies it first and separately.** The pane endpoints
107
+ read `params[:where]` and `params[:q]` and narrow the relation twice,
108
+ fixed filter first, so the reader's selection can only narrow further.
109
+ The same key in both is two conditions ANDed, never one replacing the
110
+ other. The fixed filter goes through `Definition#filter`, so it is
111
+ bounded exactly as `q[...]` is: only declared dimensions, only the
112
+ predicates ADR 025 allows for each kind, and no more than 1000 values.
113
+ A host cannot fix a filter on a column it has not declared, which is
114
+ the same rule a reader already meets.
115
+
116
+ **It is a view filter, not an authorisation boundary, and the
117
+ documentation says so in those words.** It is visible in every pane's
118
+ URL, and a reader can remove it by editing one. What they reach by
119
+ doing that is what `policy_scope` already allows. A host that needs a
120
+ reader not to see other records writes that in its scope, where it
121
+ applies to every pane on every page.
122
+
123
+ ## Consequences
124
+
125
+ - One frame serves every record's page. An analyst edits it once, and
126
+ the host needs no column pointing at a frame per record.
127
+ - A host that carries its filter in `q[...]` today, by redirect, moves
128
+ it to `where:` and drops the redirect. Nothing breaks if it does not
129
+ move; the old way keeps its old weakness. The guide for this goes in
130
+ docs/composing.md.
131
+ - The pane URL shape gains `where[...]`. ADR 037 names the pane URL as
132
+ part of the surface that stops moving at 1.0, so this is better
133
+ decided before 1.0 than after, and it is additive: every existing URL
134
+ means what it meant.
135
+ - A snapshot is taken with the filters its caller passes (ADR 009,
136
+ ADR 034), not from a rendered page. A host snapshotting a record's
137
+ frame passes the fixed filter in `filters:`, and nothing does that
138
+ for it. Worth a line in the snapshot documentation, not a mechanism.
139
+ - The dimension the host fixes should not also be offered as a
140
+ clickable pane. Clicking it would AND a second condition on the same
141
+ column, and any value but the fixed one returns nothing. That is
142
+ correct and confusing, so the guide says not to.
143
+ - What would change this decision: a host that needs the fixed filter
144
+ hidden from the reader for a reason `policy_scope` cannot express.
145
+ That would reopen signing, and this record says why it was left out.
@@ -0,0 +1,112 @@
1
+ ---
2
+ Date: 2026-09-24
3
+ Status: Accepted
4
+ Related: ADR 012, ADR 013, ADR 014, ADR 019, ADR 040
5
+ Triggers:
6
+ - giving a record of the host's its own frame
7
+ - finding a frame from code without storing its id
8
+ - an owner that has more than one frame
9
+ - adding a slug, a handle or any second identifier to a frame
10
+ Topics: frames, persistence, naming, host-integration
11
+ ---
12
+
13
+ # ADR 041: A Host Finds Its Frame by Owner and Key
14
+
15
+ ## Context
16
+
17
+ A host that shows a frame on one of its own pages has to find that
18
+ frame from code. ADR 012 made frames rows an analyst edits, and ADR 040
19
+ made one frame usable across many records, so the host needs a way to
20
+ say "the frame for this" without a deploy creating it and without
21
+ remembering an id.
22
+
23
+ The owner looked like the answer. It is a nullable polymorphic
24
+ reference that Janela never reads (ADR 014, ADR 019), so a host can
25
+ point it at the record the frame is for, and
26
+ `Janela::Frame.find_or_create_by!(owner: queue)` is a frame's `dom_id`:
27
+ two columns, nothing added to the host's schema.
28
+
29
+ It holds for exactly one frame per owner, and owners rarely have one. A
30
+ tenant owns every frame an analyst makes from the engine's New button,
31
+ through `janela_frame_owner`, and a host wants frames of its own for
32
+ particular pages on top of those. `find_by(owner: account)` then returns
33
+ whichever row the database gives first. On a real installation it
34
+ returned the wrong frame: the first the tenant had ever made, not the
35
+ one the page was for (#59).
36
+
37
+ ### What was considered
38
+
39
+ **`name` as the key** was rejected. It is the analyst's to rename, so a
40
+ host looking a frame up by name loses it the day someone edits the
41
+ title.
42
+
43
+ **A column in the host's table** pointing at the frame was rejected as
44
+ the answer the owner was meant to save a host from. It needs a
45
+ migration on every table whose records want a frame, and a second one
46
+ for each extra frame per record.
47
+
48
+ **A slug**, which ADR 013 turned down, is not what is being asked for,
49
+ and the difference is the reason this is allowed. ADR 013 rejected a
50
+ slug as a way to *address* a frame: in a URL a person reads, derived
51
+ from the name, needing uniqueness rules, reserved words and regeneration
52
+ on rename, and a second lookup path to keep working. What a host needs
53
+ is none of those. It is an identifier the host writes in its own code,
54
+ never in a URL, never derived from the name, never shown to an analyst,
55
+ and never changed once set.
56
+
57
+ **Janela choosing the frame for a record**, a registry mapping host
58
+ classes to frames, was rejected as configuration for something one line
59
+ of the host's code already says.
60
+
61
+ ## Decision
62
+
63
+ **A frame may carry a `key` the host sets. A frame is found by its
64
+ owner and its key together, and each owner has at most one frame per
65
+ key.**
66
+
67
+ ```ruby
68
+ Janela::Frame.for(account, :overview) # the tenant's overview
69
+ Janela::Frame.for(queue, :analytics) # this queue's frame
70
+ Janela::Frame.for(queue, :analytics) { |frame| frame.name = "#{queue.name} analytics" }
71
+ ```
72
+
73
+ `for` finds the frame with that owner and key, or creates it. The block
74
+ runs only when it creates one, to set what the host wants a new frame to
75
+ start as; a new frame is named after its key unless the block says
76
+ otherwise. The owner may be `nil` for a host with one tenant.
77
+
78
+ **The key is the host's and nobody else's.** Janela reads it only in
79
+ `for`. It is not in any route and not in the engine's forms, so an
80
+ analyst can rename, regrid and recompose a keyed frame without being
81
+ able to detach it from the page that finds it. A frame without a key,
82
+ which is every frame an analyst makes, is unchanged.
83
+
84
+ **Uniqueness is per owner, in the database.** A unique index on owner
85
+ and key, so two requests creating the same frame at once end with one
86
+ row, and `for` retries the find when the insert loses that race. Frames
87
+ with no key are not constrained by it.
88
+
89
+ `key` is a short lowercase string matching `/\A[a-z0-9_]+\z/`, the
90
+ shape of the symbols a host will pass, so `:analytics` and `"analytics"`
91
+ are the same key and nothing that looks like a URL or a name gets in.
92
+
93
+ ## Consequences
94
+
95
+ - A host gives any record any number of frames with no column of its
96
+ own: owner and key are the `dom_id`, and the key is the suffix
97
+ `dom_id(queue, :analytics)` would carry.
98
+ - `janela_frames` gains `key` and a unique index, so a migration and an
99
+ `UPGRADING.md` entry (ADR 015). Existing frames keep a nil key.
100
+ - The owner now does two jobs for a host that uses `for`: tenancy for
101
+ the policy scope, and which record a frame belongs to. ADR 019 said
102
+ Janela never reads the owner; `for` queries by it, and only there. A
103
+ host whose owner is a record rather than its tenant has to make its
104
+ policy scope see frames owned by its own records, which the
105
+ multi-tenancy guide shows.
106
+ - A frame deleted from the engine's pages is created again, empty, the
107
+ next time its page is visited. That is the right answer for a frame a
108
+ page depends on, and it is worth a line in the README so it is not a
109
+ surprise.
110
+ - What would change this decision: a host needing to find a frame by
111
+ something that is not stable in its own code. That is the addressing
112
+ problem ADR 013 decided, and it would be decided there again.