janela 0.5.0 → 0.7.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +33 -0
- data/README.md +61 -8
- data/UPGRADING.md +158 -0
- data/app/assets/javascripts/janela/frame_controller.js +38 -3
- data/app/controllers/janela/application_controller.rb +3 -2
- data/app/helpers/janela/frames_helper.rb +18 -5
- data/app/jobs/janela/snapshot_job.rb +47 -6
- data/app/models/janela/snapshot.rb +11 -2
- data/app/views/janela/frames/_frame.html.erb +1 -1
- data/app/views/janela/queries/show.html.erb +6 -1
- data/db/migrate/20260921000001_add_owner_to_janela_snapshots.rb +9 -0
- data/docs/decisions/009-snapshots.md +1 -1
- data/docs/decisions/029-a-panes-frame-is-identified-by-who-it-is.md +112 -0
- data/docs/decisions/030-a-panes-src-belongs-to-turbo.md +93 -0
- data/docs/decisions/031-a-subclass-inherits-the-dashboard.md +110 -0
- data/docs/decisions/032-janela-will-not-read-a-model-it-cannot-scope.md +156 -0
- data/docs/decisions/033-a-snapshot-is-told-who-owns-it.md +151 -0
- data/docs/decisions/034-janela-will-not-freeze-a-scope-the-host-has-not-named.md +238 -0
- data/docs/decisions/INDEX.md +12 -6
- data/docs/multi-tenancy.md +87 -18
- data/lib/janela/definition.rb +11 -1
- data/lib/janela/doctor.rb +58 -7
- data/lib/janela/model.rb +46 -10
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +43 -3
- metadata +24 -7
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-18
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 003, ADR 005, ADR 012, ADR 014, ADR 024
|
|
5
|
+
Superseded in part by: ADR 030
|
|
6
|
+
Triggers:
|
|
7
|
+
- changing how a pane's turbo frame is identified
|
|
8
|
+
- adding a parameter to a pane URL
|
|
9
|
+
- a host wanting a control over a pane's own settings
|
|
10
|
+
- a frame that does not update when it should
|
|
11
|
+
Topics: cross-filtering, panes, urls, host-integration
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 029: A Pane's Frame Is Identified by Who It Is, Not by What It Shows
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
Issue #42 found that pointing a pane's turbo frame at a URL differing only
|
|
19
|
+
in `limit`, `granularity` or `as` makes Turbo fetch the response and then
|
|
20
|
+
silently do nothing: no render, no `frame-missing`, no console error, the
|
|
21
|
+
frame left showing the old numbers forever. Measured in a browser, not
|
|
22
|
+
inferred.
|
|
23
|
+
|
|
24
|
+
The cause is that `Query.turbo_frame_id` builds the id out of the query
|
|
25
|
+
itself, renderer, granularity and limit included, so the response comes
|
|
26
|
+
back wearing a different id from the frame that asked for it. Turbo has
|
|
27
|
+
nothing to reconcile and gives up quietly.
|
|
28
|
+
|
|
29
|
+
**The project has already solved this once, in the half that does not have
|
|
30
|
+
the bug.** A pane that is a record uses `Pane#turbo_frame_id`, which is
|
|
31
|
+
`janela_pane_#{id}`, and the comment on it says exactly why:
|
|
32
|
+
|
|
33
|
+
> The DOM id is the row, not the query it runs: two rows in one frame may
|
|
34
|
+
> show the same measure by the same dimension, and a fingerprint of the
|
|
35
|
+
> query would give them the same turbo frame for Turbo to replace.
|
|
36
|
+
|
|
37
|
+
That is ADR 014's reasoning, and it is right. A record-backed pane can
|
|
38
|
+
change its renderer, its granularity and its limit all day and its frame
|
|
39
|
+
id never moves, because the id says who the pane is rather than what it
|
|
40
|
+
is currently showing.
|
|
41
|
+
|
|
42
|
+
The helper path has no row to point at, so it fingerprints the query
|
|
43
|
+
instead. The fingerprint is not arbitrary: without it, two `janela_pane`
|
|
44
|
+
calls for the same measure by the same dimension would collide, and one
|
|
45
|
+
would replace the other. So the fingerprint solves a real problem and
|
|
46
|
+
creates this one.
|
|
47
|
+
|
|
48
|
+
The reason this has not bitten the library itself is worth stating: a
|
|
49
|
+
click changes filters, and filters are deliberately not in the id, so
|
|
50
|
+
every navigation Janela performs keeps the id stable. It is only a host
|
|
51
|
+
reaching for the URL's other documented parameters that falls in, and
|
|
52
|
+
what it gets is stale numbers with no error, which is the worst failure
|
|
53
|
+
this library has (ADR 003).
|
|
54
|
+
|
|
55
|
+
## Decision
|
|
56
|
+
|
|
57
|
+
**`janela_pane` accepts an `id:`, and a pane given one is identified by
|
|
58
|
+
it rather than by a fingerprint of its query.**
|
|
59
|
+
|
|
60
|
+
1. The fingerprint stays the default. It is correct for the common case,
|
|
61
|
+
a page of unlike panes with no controls over them, and it is what
|
|
62
|
+
keeps two alike panes apart.
|
|
63
|
+
2. A host that wants to change a pane's settings in place names that
|
|
64
|
+
frame itself. The id is then stable by construction, because it comes
|
|
65
|
+
from the host rather than from the query, and Turbo reconciles
|
|
66
|
+
normally. This is the same move ADR 012 made for a record-backed pane,
|
|
67
|
+
made available to a pane that is not a record.
|
|
68
|
+
3. The response wears the id the frame asked for rather than one derived
|
|
69
|
+
again from the query. This needs no new surface at all: Turbo already
|
|
70
|
+
sends the requesting frame's id in the `Turbo-Frame` header, and
|
|
71
|
+
turbo-rails exposes it as `turbo_frame_request_id`. So a pane rendered
|
|
72
|
+
into a frame request answers to the frame that asked, and a pane
|
|
73
|
+
rendered any other way keeps deriving its own id as it does now. The
|
|
74
|
+
pane URL does not grow a parameter, which was the first thing this
|
|
75
|
+
decision reached for and did not need.
|
|
76
|
+
4. The failure is documented rather than left to be discovered. A host
|
|
77
|
+
that changes a pane's URL without naming its frame gets the silent
|
|
78
|
+
staleness described above, and the README says so where it describes
|
|
79
|
+
the pane URL's parameters.
|
|
80
|
+
|
|
81
|
+
## What this turns down
|
|
82
|
+
|
|
83
|
+
**Taking renderer, granularity and limit out of the fingerprint.** It
|
|
84
|
+
would fix #42 and reintroduce the collision ADR 014 avoided: the gallery
|
|
85
|
+
renders the same measure by the same dimension as a bar and as a line on
|
|
86
|
+
one page, and those two must not share a frame. The fingerprint is doing
|
|
87
|
+
real work.
|
|
88
|
+
|
|
89
|
+
**Leaving it to every host to fetch and swap the frame themselves**, which
|
|
90
|
+
is what the demo's gallery does today and what proved the diagnosis. It
|
|
91
|
+
works, and it asks each host to write the same Stimulus controller and to
|
|
92
|
+
know a thing about Turbo's id matching that nothing told them. ADR 001
|
|
93
|
+
says ship the load-bearing 5%: a frame that updates when its URL changes
|
|
94
|
+
is inside that, and a host reimplementing frame reconciliation is not.
|
|
95
|
+
(Superseded in part by ADR 030: a frame does not update when its URL
|
|
96
|
+
changes, because `src` is not a channel a host can speak through. What is
|
|
97
|
+
inside the 5% is a pane that goes where it is asked to, which is the same
|
|
98
|
+
thing said correctly.)
|
|
99
|
+
|
|
100
|
+
## Consequences
|
|
101
|
+
|
|
102
|
+
`janela_pane` grows one optional keyword argument, and a host that never
|
|
103
|
+
passes it sees no change at all. The demo's gallery control becomes
|
|
104
|
+
smaller, since the frame can be navigated rather than swapped by hand,
|
|
105
|
+
and that is the check on whether this decision is right: if the control
|
|
106
|
+
does not get simpler, the decision was wrong.
|
|
107
|
+
|
|
108
|
+
Two alike panes given the same `id` by a host would collide, which the
|
|
109
|
+
fingerprint prevented automatically. That is the cost of letting a host
|
|
110
|
+
name things, it is the same cost a host already carries for every DOM id
|
|
111
|
+
it writes, and the doctor is the place to notice it if it turns out to
|
|
112
|
+
happen.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-18
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 003, ADR 005, ADR 024, ADR 029
|
|
5
|
+
Supersedes: part of ADR 029
|
|
6
|
+
Triggers:
|
|
7
|
+
- a host changing what a pane shows from JavaScript
|
|
8
|
+
- writing to a turbo frame's src from anything but Turbo
|
|
9
|
+
- adding a data attribute the frame controller reads
|
|
10
|
+
- anything that would reintroduce reading intent out of the DOM
|
|
11
|
+
Topics: cross-filtering, panes, host-integration, javascript
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 030: A Pane's src Belongs to Turbo, So a Host Talks to the Frame
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
ADR 029 gave a host a way to name a pane's frame so it could be
|
|
19
|
+
reconfigured in place, and said that "a frame that updates when its URL
|
|
20
|
+
changes is inside" the load-bearing core. Building it showed that is not
|
|
21
|
+
true, and the reason is a decision this project already made.
|
|
22
|
+
|
|
23
|
+
#33 established that a frame's `src` does not describe what that frame
|
|
24
|
+
is showing. Turbo writes `src` back onto a frame when a response lands,
|
|
25
|
+
including a late one for a request that has since been superseded. So
|
|
26
|
+
`janela--frame` keeps its own record of what was asked for and reverts
|
|
27
|
+
anything that does not match it:
|
|
28
|
+
|
|
29
|
+
> The request for what Janela last asked for wins, and any other is
|
|
30
|
+
> reverted. What was asked for is the only honest rule (#33).
|
|
31
|
+
|
|
32
|
+
That rule is right, and its consequence was not drawn at the time: if
|
|
33
|
+
the controller's own record is the only trustworthy statement of intent,
|
|
34
|
+
then `src` is no longer a channel a host can speak through. A host that
|
|
35
|
+
follows ADR 029, names a pane and writes `frame.src`, has its request
|
|
36
|
+
aborted and the frame put back, with no error and the old numbers still
|
|
37
|
+
on screen. That is the failure #42 was about, reached by following the
|
|
38
|
+
instructions written to fix #42.
|
|
39
|
+
|
|
40
|
+
There is no way to tell a host's `src` write from Turbo's. Any attempt,
|
|
41
|
+
a mutation observer, a flag, a heuristic on the attribute, re-infers
|
|
42
|
+
intent from the DOM, which is exactly what #33 removed because it
|
|
43
|
+
produced wrong numbers.
|
|
44
|
+
|
|
45
|
+
And the record is not one attribute. A pane also carries the base URL
|
|
46
|
+
the controller rebuilds from on the next click, so a host writing the
|
|
47
|
+
record by hand has to write two attributes in the right order, and
|
|
48
|
+
reapply the frame's current filters itself. Only the frame controller
|
|
49
|
+
knows those filters. The demo's own gallery control got that wrong:
|
|
50
|
+
reconfigure a pane while a filter is active and it refetches unfiltered
|
|
51
|
+
while every pane beside it stays filtered (#43).
|
|
52
|
+
|
|
53
|
+
## Decision
|
|
54
|
+
|
|
55
|
+
**A host changes what a pane shows by asking `janela--frame`, and never
|
|
56
|
+
by writing `src` or a data attribute.**
|
|
57
|
+
|
|
58
|
+
The controller already does this for itself when the frame's filters
|
|
59
|
+
change. Making that reachable is extraction rather than new surface: one
|
|
60
|
+
way to ask a pane to go to a different query, which records what was
|
|
61
|
+
asked, reapplies the frame's filters and sets `src` in the order the
|
|
62
|
+
guard expects.
|
|
63
|
+
|
|
64
|
+
Three things follow.
|
|
65
|
+
|
|
66
|
+
1. **The data attributes are private.** `janelaAsked` and `janelaSrc` are
|
|
67
|
+
how the controller remembers, not an interface. A host that writes
|
|
68
|
+
them is relying on something that may change.
|
|
69
|
+
2. **A reconfigured pane stays cross-filtered.** Reapplying the frame's
|
|
70
|
+
current filters is part of repointing, not something a caller
|
|
71
|
+
remembers, because forgetting it shows numbers for the wrong filter
|
|
72
|
+
state and says nothing (ADR 003).
|
|
73
|
+
3. **`id:` is still necessary.** ADR 029 stands: without a stable frame
|
|
74
|
+
id there is nothing to repoint. It is necessary and it was not
|
|
75
|
+
sufficient, which is what this records.
|
|
76
|
+
|
|
77
|
+
## Consequences
|
|
78
|
+
|
|
79
|
+
ADR 029's claim that a frame updates when its URL changes is untrue as
|
|
80
|
+
written, and this supersedes that sentence rather than the decision. The
|
|
81
|
+
`id:` keyword and answering a frame request with the id Turbo sent are
|
|
82
|
+
both correct and stay.
|
|
83
|
+
|
|
84
|
+
A host reconfiguring a pane writes one call instead of three writes it
|
|
85
|
+
was never told about, and gets the filter behaviour right by default
|
|
86
|
+
rather than by knowing to. The demo's gallery control is the check, the
|
|
87
|
+
same way it was for ADR 029: it should get smaller again, and its filter
|
|
88
|
+
bug should disappear rather than be fixed separately.
|
|
89
|
+
|
|
90
|
+
The cost is one public method on a Stimulus controller, which is a
|
|
91
|
+
surface this project did not have before. It earns itself by removing a
|
|
92
|
+
class of silent wrongness rather than by adding capability, which is the
|
|
93
|
+
kind of addition ADR 001 leaves room for.
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-19
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 002, ADR 013, ADR 014, ADR 025, ADR 028
|
|
5
|
+
Triggers:
|
|
6
|
+
- subclassing a model that declares a janela block
|
|
7
|
+
- changing what is addressable over HTTP
|
|
8
|
+
- changing how the registry is populated
|
|
9
|
+
- adding to the Ransack allowlist a model gets from Janela
|
|
10
|
+
Topics: configuration, urls, security, scope
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
# ADR 031: A Subclass Inherits the Dashboard Its Parent Declared
|
|
14
|
+
|
|
15
|
+
## Context
|
|
16
|
+
|
|
17
|
+
`janela` stores its definition in a plain class instance variable, so a
|
|
18
|
+
subclass of a model that declares one gets `nil` from `.janela` and is
|
|
19
|
+
not in the registry (#11).
|
|
20
|
+
|
|
21
|
+
Reproducing it found something the issue did not say. The subclass does
|
|
22
|
+
inherit the Ransack allowlist, because
|
|
23
|
+
`define_janela_ransack_allowlist` defines singleton methods on the
|
|
24
|
+
parent and singleton methods inherit down the singleton class chain:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
subclass .janela: nil
|
|
28
|
+
subclass ransackable: ["status", "channel", "placed_on"]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
So a subclass is already half declared: filterable on the parent's
|
|
32
|
+
dimensions, with no definition behind it saying what those dimensions
|
|
33
|
+
are. Neither half was decided. One was written and the other happened.
|
|
34
|
+
|
|
35
|
+
The instinct is to walk the ancestors in `janela`, which fixes `.janela`
|
|
36
|
+
and does not fix the bug. What a host wants from an STI subclass is a
|
|
37
|
+
dashboard of it, and that needs the subclass registered:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
janela_pane PaidOrder, :revenue # src is /paid_orders/revenue
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
An inherited definition with no registration renders that pane and then
|
|
44
|
+
404s it. That is worse than today, because today the failure is loud at
|
|
45
|
+
the call site and that one is a dead pane on a page.
|
|
46
|
+
|
|
47
|
+
So the question is not whether the definition inherits. It is whether
|
|
48
|
+
being a subclass makes a model addressable, and `lib/janela.rb` states
|
|
49
|
+
the current answer plainly:
|
|
50
|
+
|
|
51
|
+
> Only models that declare a janela block are addressable over HTTP
|
|
52
|
+
|
|
53
|
+
## Decision
|
|
54
|
+
|
|
55
|
+
**A subclass inherits its parent's definition and is addressable on its
|
|
56
|
+
own route key. Declaring is what a family of classes does once.**
|
|
57
|
+
|
|
58
|
+
The argument that settles it is about data rather than URLs. An STI
|
|
59
|
+
subclass is a subset of its parent's rows. If the parent is addressable,
|
|
60
|
+
the subclass reveals no row the parent does not already total, and it is
|
|
61
|
+
read through the same host scope as everything else (ADR 014). So this
|
|
62
|
+
adds URL surface and no data surface, which is a much smaller thing than
|
|
63
|
+
the rule above makes it sound.
|
|
64
|
+
|
|
65
|
+
Three things follow.
|
|
66
|
+
|
|
67
|
+
1. **The halves agree.** The definition inherits, the registration
|
|
68
|
+
inherits, and the Ransack allowlist keeps inheriting as it already
|
|
69
|
+
does. A subclass is either a Janela model or it is not, rather than
|
|
70
|
+
being one in the part nobody chose.
|
|
71
|
+
2. **STI scoping is ActiveRecord's, not Janela's.** A definition whose
|
|
72
|
+
model is the subclass runs its query on that class and ActiveRecord
|
|
73
|
+
adds the `type` condition itself. Janela learns nothing about STI,
|
|
74
|
+
which is the right amount for it to know.
|
|
75
|
+
3. **A subclass may still declare its own block**, and then it has its
|
|
76
|
+
own definition rather than its parent's, which is how a subclass says
|
|
77
|
+
its dashboard is different.
|
|
78
|
+
|
|
79
|
+
Registration cannot happen where declaration happens, because the
|
|
80
|
+
subclass does not exist when the parent declares. It happens when the
|
|
81
|
+
subclass is created.
|
|
82
|
+
|
|
83
|
+
## What this turns down
|
|
84
|
+
|
|
85
|
+
**Inheriting nothing, and documenting that each subclass declares its
|
|
86
|
+
own.** It is the most conservative reading and it asks a host with five
|
|
87
|
+
STI types to retype the same measures and dimensions five times. ADR 002
|
|
88
|
+
spent Ransack's familiarity on the host deliberately; spending their
|
|
89
|
+
typing on a class hierarchy Rails already models is a worse trade.
|
|
90
|
+
|
|
91
|
+
**Inheriting the definition without registering.** Rejected above: a
|
|
92
|
+
helper that renders a pane which cannot load.
|
|
93
|
+
|
|
94
|
+
## Consequences
|
|
95
|
+
|
|
96
|
+
The honest cost is not security, it is noise. `Janela.definitions` feeds
|
|
97
|
+
the form that offers a choice of model when an analyst adds a pane
|
|
98
|
+
(ADR 012), and a host with a dozen STI types will see a dozen entries
|
|
99
|
+
where it expected one. That is a real cost and it is the thing to watch:
|
|
100
|
+
if it becomes the complaint, the answer is a way for a family to say
|
|
101
|
+
which of its classes are worth offering, and that is a later decision
|
|
102
|
+
rather than a setting invented now.
|
|
103
|
+
|
|
104
|
+
The check on whether this decision is right is what a host writes. If
|
|
105
|
+
adding a dashboard for an STI subclass still takes anything beyond
|
|
106
|
+
creating the class, the decision did not deliver.
|
|
107
|
+
|
|
108
|
+
The demo has no STI model, so proving this takes one: a `type` column on
|
|
109
|
+
a table and a subclass in the dummy. That is a fixture, and adding it is
|
|
110
|
+
part of the work rather than a reason to avoid it.
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-20
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 002, ADR 004, ADR 015, ADR 019, ADR 021, ADR 025
|
|
5
|
+
Triggers:
|
|
6
|
+
- deciding what Janela should do when a host has configured nothing
|
|
7
|
+
- adding a hook a host answers by defining a method
|
|
8
|
+
- a dashboard showing rows the person reading it should not see
|
|
9
|
+
- choosing a default for anything that decides what may be read
|
|
10
|
+
- writing a doctor check about authorisation
|
|
11
|
+
Topics: authorisation, tenancy, host-integration, security, configuration, releases
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 032: Janela Will Not Read a Model It Has Not Been Told How to Scope
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
`janela_scope` asks the host's controller for `policy_scope` and returns
|
|
19
|
+
`model.all` when there is none. ADR 002 called that authorisation being a
|
|
20
|
+
hook rather than a dependency, and ADR 004 recorded the gap in its
|
|
21
|
+
consequences as "tracked as a pre-public issue". The gem is public now,
|
|
22
|
+
so the condition that deferral was written under has passed.
|
|
23
|
+
|
|
24
|
+
What the fallback does, measured against the demo with `policy_scope`
|
|
25
|
+
removed from the host, which is the state an application using a
|
|
26
|
+
different authorisation library is already in:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
a pane the scope hides, with policy_scope: 404
|
|
30
|
+
the same pane, without: 200, showing $375.00
|
|
31
|
+
log lines mentioning scope, policy or janela: 0
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The request that should be a refusal becomes a number belonging to
|
|
35
|
+
somebody else, with nothing on the page or in the log to say so. Janela
|
|
36
|
+
treats a confidently wrong number as its worst failure, and this is the
|
|
37
|
+
purest form of it: not a pane that errors, a pane that lies.
|
|
38
|
+
|
|
39
|
+
Two facts narrow who this actually hits, and both were wrong in the
|
|
40
|
+
issue as filed.
|
|
41
|
+
|
|
42
|
+
**A host using Pundit is not affected, even one missing a policy.**
|
|
43
|
+
Pundit's `policy_scope` resolves through `policy_scope!`, which raises
|
|
44
|
+
`NotDefinedError`. Verified in Pundit 2.5.2: `Authorization#policy_scope`
|
|
45
|
+
calls `pundit_policy_scope`, which calls `pundit.policy_scope!`. A
|
|
46
|
+
forgetful Pundit host gets an exception, which is the correct outcome
|
|
47
|
+
and needs nothing from us. The exposure is a host that defines no
|
|
48
|
+
`policy_scope` at all.
|
|
49
|
+
|
|
50
|
+
**The two copies of the fallback do not agree with each other.** The
|
|
51
|
+
controller copy asks `self`, which is the controller; the helper copy
|
|
52
|
+
asks the view, which cannot see a private controller method. A host
|
|
53
|
+
following this project's own multi-tenancy guide, which writes
|
|
54
|
+
`policy_scope` as a private method with no `helper_method`, is scoped on
|
|
55
|
+
the engine's pages and unscoped on a frame embedded in its own. That is
|
|
56
|
+
a bug rather than a decision and is tracked separately; this ADR assumes
|
|
57
|
+
it is fixed and that both paths ask the controller.
|
|
58
|
+
|
|
59
|
+
Four options were considered.
|
|
60
|
+
|
|
61
|
+
**Log loudly.** Breaks nothing, and a log line in an application that is
|
|
62
|
+
already noisy is close to the silence it replaces. It also leaves the
|
|
63
|
+
wrong number on the screen, which is the part that matters.
|
|
64
|
+
|
|
65
|
+
**A configured scope resolver**, `Janela.scope = ->(model, controller)`.
|
|
66
|
+
Rejected on ADR 019, which found that a decision needing the current
|
|
67
|
+
user and tenant belongs inside a request rather than in a setting, and
|
|
68
|
+
on ADR 001, because the host's controller already answers this question
|
|
69
|
+
and a second way to answer it is a second thing to understand.
|
|
70
|
+
|
|
71
|
+
**A doctor check alone.** Rejected as insufficient rather than wrong.
|
|
72
|
+
The guide currently instructs a single tenant host to define nothing, so
|
|
73
|
+
a check flagging that would contradict the documentation a reader just
|
|
74
|
+
followed, which is the false alarm ADR 021 exists to prevent. It is also
|
|
75
|
+
advisory: it speaks at setup time and cannot stop a request.
|
|
76
|
+
|
|
77
|
+
**Raise.** Chosen, below.
|
|
78
|
+
|
|
79
|
+
The tension is real. `model.all` is the correct relation for a single
|
|
80
|
+
tenant application, and most applications are single tenant, so raising
|
|
81
|
+
asks something of hosts that were never wrong. Two things settle it.
|
|
82
|
+
This project has met the same shape twice and chosen the same way both
|
|
83
|
+
times: ADR 002 raises where Ransack silently dropped a filter, and ADR
|
|
84
|
+
025 raises where any predicate silently ran. And "single tenant" is not
|
|
85
|
+
the same claim as "every visitor may total every row of every model on a
|
|
86
|
+
dashboard". Today that second claim is made by omission. It should be
|
|
87
|
+
made on purpose or not at all.
|
|
88
|
+
|
|
89
|
+
## Decision
|
|
90
|
+
|
|
91
|
+
**Janela raises `Janela::Unscoped` when it would otherwise read a model
|
|
92
|
+
the host has not told it how to scope.** The fallback to `model.all` is
|
|
93
|
+
removed from both the controller and the helper.
|
|
94
|
+
|
|
95
|
+
A host says what may be read by answering the question Janela already
|
|
96
|
+
asks, in the place it already asks it:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
class ApplicationController < ActionController::Base
|
|
100
|
+
private
|
|
101
|
+
def policy_scope(model) = model.all
|
|
102
|
+
end
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
That line is not ceremony. It is the assertion that every visitor who
|
|
106
|
+
can reach a dashboard may read every row behind it, which is a sentence
|
|
107
|
+
a maintainer should have to write rather than inherit.
|
|
108
|
+
|
|
109
|
+
No new setting. ADR 019's pattern stands: Janela asks the host's
|
|
110
|
+
controller by duck typing and never by configuration.
|
|
111
|
+
|
|
112
|
+
**It raises at request time, not at boot.** Resolving
|
|
113
|
+
`Janela.parent_controller` during initialisation is a trap this project
|
|
114
|
+
has already paid for, and a boot raise would break a console or a
|
|
115
|
+
migration for a host part way through upgrading.
|
|
116
|
+
|
|
117
|
+
**It is not rescued into a pane.** A missing scope is not a data
|
|
118
|
+
condition, and dressing it as the sentence a 404 shows would hide the
|
|
119
|
+
thing the host has to fix.
|
|
120
|
+
|
|
121
|
+
**The doctor gains `unscoped-reads`, at error severity.** Unlike the
|
|
122
|
+
`unauthenticated-endpoints` check, which hedges because authentication
|
|
123
|
+
cannot be determined by reading, this one is exact: the parent
|
|
124
|
+
controller either responds to `policy_scope` or it does not.
|
|
125
|
+
|
|
126
|
+
## Consequences
|
|
127
|
+
|
|
128
|
+
- A host that defines no scope stops getting numbers and starts getting
|
|
129
|
+
an exception naming what to add. Loud, once, instead of quiet forever.
|
|
130
|
+
- Breaking, so it goes in a minor release with an `UPGRADING.md` section
|
|
131
|
+
and a `post_install_message` (ADR 015). A host using Pundit, or one
|
|
132
|
+
that followed the multi-tenancy guide, does nothing. A single tenant
|
|
133
|
+
host writes one method.
|
|
134
|
+
- ADR 004's consequence recording this as a pre-public issue is
|
|
135
|
+
superseded.
|
|
136
|
+
- **ADR 019 is narrowed rather than superseded.** It generalised taking
|
|
137
|
+
silence as an answer into a precedent and cited `policy_scope` as the
|
|
138
|
+
model for it. That precedent survives with a limit: silence is an
|
|
139
|
+
acceptable answer when the silent default is the narrow one, as a nil
|
|
140
|
+
owner is, and never when it is the widest one. A future hook that
|
|
141
|
+
defaults to reading everything is this decision again, not ADR 019.
|
|
142
|
+
- ADR 002's `on:` parameter keeps defaulting to `model.all`. That is a
|
|
143
|
+
call a maintainer writes in their own Ruby, where the scope is theirs
|
|
144
|
+
to choose and nothing is being decided on their behalf. The seam is
|
|
145
|
+
between what Janela chooses inside a request and what a host asks for
|
|
146
|
+
directly.
|
|
147
|
+
- The multi-tenancy guide's "define nothing" section, its summary table
|
|
148
|
+
and the README's description of the fallback all become wrong the
|
|
149
|
+
moment this lands, and change in the same commit.
|
|
150
|
+
- Duck typing still means a typo in the method name reads as absence.
|
|
151
|
+
Before this, that silently widened the scope; now it raises, so the
|
|
152
|
+
doctor check is a convenience rather than the only defence.
|
|
153
|
+
- What would change this decision: evidence that the raise fires for
|
|
154
|
+
hosts that had genuinely done nothing wrong and had no reasonable way
|
|
155
|
+
to know, in numbers rather than anecdote. A permissive default is not
|
|
156
|
+
coming back, but where the question is asked could.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
---
|
|
2
|
+
Date: 2026-09-21
|
|
3
|
+
Status: Accepted
|
|
4
|
+
Related: ADR 002, ADR 009, ADR 014, ADR 019, ADR 032
|
|
5
|
+
Triggers:
|
|
6
|
+
- adding an owner or a tenancy column to anything the engine stores
|
|
7
|
+
- creating a record outside a request, in a job, a task or a console
|
|
8
|
+
- scheduling a snapshot, or writing a job around Snapshot.take
|
|
9
|
+
- a snapshot the wrong audience can read
|
|
10
|
+
- extending ADR 019's controller hook to something new
|
|
11
|
+
Topics: snapshots, tenancy, authorisation, persistence, activejob, host-integration
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# ADR 033: A Snapshot Is Told Who Owns It, Because There Is No Request to Ask
|
|
15
|
+
|
|
16
|
+
## Context
|
|
17
|
+
|
|
18
|
+
`janela_snapshots` holds a name, an instant, the filters it was taken
|
|
19
|
+
under and the results. It has no owner column, so
|
|
20
|
+
`policy_scope(Janela::Snapshot)` has nothing to filter on. ADR 014 gave
|
|
21
|
+
a frame a nullable polymorphic owner for exactly this reason and said
|
|
22
|
+
Janela never interprets it. A snapshot never got the same.
|
|
23
|
+
|
|
24
|
+
The guide calls this the rough edge and offers three workarounds: keep
|
|
25
|
+
snapshots to one tenant, add a column of your own, or encode the tenant
|
|
26
|
+
in the filters and scope on the name. The demo does the first, crudely
|
|
27
|
+
and on purpose. None of them are a column a policy can filter on.
|
|
28
|
+
|
|
29
|
+
Measured against the demo's fixtures before deciding anything:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
every order: 375.0
|
|
33
|
+
one tenant's orders: 150.0
|
|
34
|
+
what Janela::SnapshotJob stored: 375.0
|
|
35
|
+
what take with an explicit on: 150.0
|
|
36
|
+
owner column on janela_snapshots: none
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Two separate problems sit in those five lines, and only the first is
|
|
40
|
+
this ADR's.
|
|
41
|
+
|
|
42
|
+
**A snapshot cannot be owned.** There is nowhere to put the answer, so a
|
|
43
|
+
multi tenant host cannot scope reads of one.
|
|
44
|
+
|
|
45
|
+
**Janela's own job chooses a scope.** `SnapshotJob` calls `take` with no
|
|
46
|
+
`on:`, which ADR 009 decided deliberately, because a job cannot receive
|
|
47
|
+
a relation. Whether that is wrong depends on where a host keeps its
|
|
48
|
+
tenancy: at the model layer, through acts_as_tenant or a default scope,
|
|
49
|
+
`model.all` is already the tenant's rows and the job is correct, which
|
|
50
|
+
is what the guide's own example means by "acts_as_tenant has already
|
|
51
|
+
scoped your own models". At the policy layer, through Pundit or
|
|
52
|
+
CanCanCan, it is every row and the host's scope never runs. Janela
|
|
53
|
+
cannot tell which it is in, so what it has is a default chosen by the
|
|
54
|
+
library where ADR 032 would have it stated by the host. Not solved
|
|
55
|
+
here: it is a different question with a different shape, and folding it
|
|
56
|
+
in would make this one about two things. Filed as #47 so it cannot be
|
|
57
|
+
lost.
|
|
58
|
+
|
|
59
|
+
The interesting part of the first problem is where the answer comes
|
|
60
|
+
from. ADR 019 settled that for a frame by asking the host's controller
|
|
61
|
+
for `janela_frame_owner`, and generalised it into a precedent. It does
|
|
62
|
+
not reach here. A frame is created in a request, by an analyst using
|
|
63
|
+
the engine's own form, so there is a controller to ask and a
|
|
64
|
+
`Current.tenant` to ask it about. A snapshot is never created in a
|
|
65
|
+
request: there is no create route, no controller action and no form,
|
|
66
|
+
only `Snapshot.take` called from a job, a rake task, a console or a
|
|
67
|
+
host's own code. Verified rather than assumed, by reading every call
|
|
68
|
+
site in the repository.
|
|
69
|
+
|
|
70
|
+
So the options were not "which hook" but "what does a method called
|
|
71
|
+
outside a request know".
|
|
72
|
+
|
|
73
|
+
**A controller hook anyway**, with `Snapshot.take` reaching for
|
|
74
|
+
`Current.something`. Rejected: it would work in a console and quietly
|
|
75
|
+
return nil in the job that is the whole point of the feature, which is
|
|
76
|
+
the silent-failure shape ADR 019 was written to avoid in the first
|
|
77
|
+
place.
|
|
78
|
+
|
|
79
|
+
**A setting**, `Janela.snapshot_owner = -> { ... }`. Rejected on ADR
|
|
80
|
+
019's own reasoning, which found that a judgement needing the current
|
|
81
|
+
tenant cannot live in a setting, and on ADR 001, since it is a second
|
|
82
|
+
way to answer a question an argument already answers.
|
|
83
|
+
|
|
84
|
+
**Inferring it from the panes' relations.** Rejected as impossible
|
|
85
|
+
rather than unwise: a relation does not name a tenant, and reading one
|
|
86
|
+
to guess would be Janela interpreting an owner, which ADR 014 forbids.
|
|
87
|
+
|
|
88
|
+
## Decision
|
|
89
|
+
|
|
90
|
+
**A snapshot's owner is an argument to `Snapshot.take`, because the
|
|
91
|
+
caller is the only thing that knows it.**
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Janela::Snapshot.take(name: "September 2026", owner: Current.account) do |take|
|
|
95
|
+
take.pane Order, :revenue, on: policy_scope(Order)
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
`janela_snapshots` gains nullable polymorphic `owner_type` and
|
|
100
|
+
`owner_id`, the same shape as a frame's, through the usual
|
|
101
|
+
`rails janela:install:migrations`. Janela assigns what it is handed and
|
|
102
|
+
reads nothing from it. Scoping stays entirely the host's policy, exactly
|
|
103
|
+
as ADR 014 said of a frame.
|
|
104
|
+
|
|
105
|
+
**`SnapshotJob` takes an `owner:` too.** ActiveJob serialises an
|
|
106
|
+
ActiveRecord object through its GlobalID, so unlike a relation an owner
|
|
107
|
+
crosses the queue boundary without anything new being invented.
|
|
108
|
+
|
|
109
|
+
**The owner is optional.** ADR 032 narrowed ADR 019's
|
|
110
|
+
silence-as-an-answer rule to permit it only where the silent default is
|
|
111
|
+
the narrow one, and named a nil owner as the example. A snapshot nobody
|
|
112
|
+
owns is invisible to a policy that filters on owner, which is the safe
|
|
113
|
+
direction to fail, and correct for a single tenant application where
|
|
114
|
+
nothing is filtering on it.
|
|
115
|
+
|
|
116
|
+
**This ADR does not extend ADR 019, it bounds it.** The rule is now
|
|
117
|
+
legible: the engine asks the host's controller when the engine is the
|
|
118
|
+
thing doing the creating inside a request, and takes an argument when
|
|
119
|
+
the host is. A future record written by Janela should be read against
|
|
120
|
+
which of those two it is, rather than reaching for the controller hook
|
|
121
|
+
because a frame does.
|
|
122
|
+
|
|
123
|
+
## Consequences
|
|
124
|
+
|
|
125
|
+
- A multi tenant host can scope snapshots with the same policy it
|
|
126
|
+
already writes for frames, and the guide loses its rough edge.
|
|
127
|
+
- A migration a host must run, so it goes in `UPGRADING.md` with the
|
|
128
|
+
install task and the fact that existing snapshots keep a nil owner
|
|
129
|
+
(ADR 015). Nothing breaks for a host that ignores it.
|
|
130
|
+
- `Snapshot.take`'s signature grows one keyword with a default, so every
|
|
131
|
+
existing call keeps working unchanged.
|
|
132
|
+
- **The shipped `SnapshotJob` still picks each model's default scope.**
|
|
133
|
+
Right for a host whose tenancy is in the model layer, silently wrong
|
|
134
|
+
for one whose tenancy is in its policies. Adding an owner sharpens
|
|
135
|
+
that rather than softening it: the snapshot is now scopable, so its
|
|
136
|
+
contents are the only unscoped thing left, and a host may reasonably
|
|
137
|
+
read the new column as a promise the numbers inside do not keep. This
|
|
138
|
+
wants its own decision and has one, #47; until it is settled the guide
|
|
139
|
+
has to keep saying plainly that a policy scoped host writes its own
|
|
140
|
+
job.
|
|
141
|
+
- Janela reads nothing from the owner, so a host that assigns one and
|
|
142
|
+
writes no policy has changed nothing. That is the frame's trap again,
|
|
143
|
+
and `frames_nobody_will_own` is the doctor check that catches the
|
|
144
|
+
frame version. The snapshot version is not the same shape, because the
|
|
145
|
+
caller assigns the owner in its own Ruby rather than the engine doing
|
|
146
|
+
it silently, so it is a thinner case for a check. Worth revisiting if
|
|
147
|
+
it bites.
|
|
148
|
+
- What would change this decision: a snapshot being created inside a
|
|
149
|
+
request, through an engine form the way a frame is. That would put a
|
|
150
|
+
controller back in the picture and make ADR 019's hook the consistent
|
|
151
|
+
answer, and this ADR should be read again rather than worked around.
|