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.
@@ -0,0 +1,238 @@
1
+ ---
2
+ Date: 2026-09-21
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 002, ADR 009, ADR 014, ADR 019, ADR 021, ADR 032, ADR 033
5
+ Triggers:
6
+ - scheduling a snapshot, or changing what Janela::SnapshotJob takes
7
+ - choosing a default for anything that decides what may be read outside a request
8
+ - a snapshot whose numbers disagree with the page it was published from
9
+ - adding a setting so a host can replace something the gem ships
10
+ - wondering whether the shipped snapshot job should exist at all
11
+ Topics: snapshots, tenancy, authorisation, activejob, host-integration, security, releases
12
+ ---
13
+
14
+ # ADR 034: Janela Will Not Freeze a Scope the Host Has Not Named
15
+
16
+ ## Context
17
+
18
+ `Janela::SnapshotJob` calls `Snapshot.take` with no `on:`, so every pane
19
+ it freezes reads the model's default scope. ADR 009 decided that
20
+ deliberately and documented it: a relation cannot be serialised into a
21
+ job, so "a tenanted host writes its own job around `Snapshot.take` and
22
+ passes `on:`".
23
+
24
+ Whether that is wrong depends on something Janela cannot see.
25
+
26
+ **Tenancy at the model layer**, through acts_as_tenant, a `default_scope`,
27
+ a per tenant connection or a schema. `model.all` is already the tenant's
28
+ rows. The shipped job is correct and nothing here applies. This is what
29
+ the multi tenancy guide's own example means when it writes
30
+ `else model.all # acts_as_tenant has already scoped your own models`.
31
+
32
+ **Tenancy at the policy layer**, through Pundit or CanCanCan. `model.all`
33
+ is every row, and the scope the host wrote never runs.
34
+
35
+ So this is not the flat bug the issue title says. It is a default that is
36
+ right for one common architecture and silently wrong for another, chosen
37
+ by the library where ADR 032 would have it stated by the host.
38
+
39
+ ### Measured
40
+
41
+ The demo does not scope `Order` by tenant: its `policy_scope` filters
42
+ Janela's own owned records and answers `model.all` for everything else,
43
+ which makes it a model layer host for its own data. The measurement below
44
+ was taken with that one method narrowed to what a policy layer host
45
+ writes, `when "Order" then model.where(customer: Current.tenant)`, and
46
+ nothing else changed.
47
+
48
+ ```
49
+ live pane, signed in as one tenant: $150.00
50
+ snapshot the shipped job froze for them: 375.0
51
+ that snapshot's owner column: that tenant
52
+ what they are then served from the snapshot: $375.00, answered 200
53
+ ```
54
+
55
+ The same application, at the same instant, shows one number on the page
56
+ and publishes another from the row it labelled as theirs. Nothing errors
57
+ and nothing in the log says so. ADR 032 called a pane that lies the worst
58
+ thing this library can do, and this is that pane with a URL and a
59
+ lifetime.
60
+
61
+ ### Why it is worth a decision rather than a patch
62
+
63
+ **It persists, and it publishes.** A live unscoped read is wrong once, on
64
+ a screen, to somebody already authenticated. A snapshot freezes the
65
+ number into a row and serves it at an address. ADR 009 names the
66
+ motivating case as "an external audience with no accounts", so the
67
+ failure mode is frozen cross tenant data published to people outside the
68
+ application altogether.
69
+
70
+ **ADR 033 made the promise louder.** Giving `janela_snapshots` an owner
71
+ invites a host to read the row as "this snapshot belongs to this tenant".
72
+ The row now says so. Its contents do not have to agree, and ADR 033 said
73
+ in as many words that adding the column sharpens this rather than
74
+ softening it.
75
+
76
+ ### What a fix has to get past
77
+
78
+ A relation cannot be serialised into a job. That constraint produced the
79
+ current behaviour and has not moved.
80
+
81
+ **Require `on:` on `Taking#pane`.** Makes the guess impossible, and
82
+ supersedes ADR 009's default for every caller, including the console and
83
+ rake callers for whom it was always right. ADR 032 explicitly left `on:`
84
+ alone on the grounds that it is a call a maintainer writes in their own
85
+ Ruby, where nothing is being decided on their behalf.
86
+
87
+ **A serialisable way to name a scope**, such as
88
+ `{ model: "orders", scope: "for_tenant" }`. Rejected twice over: it is a
89
+ query language in miniature, which ADR 001 and ADR 002 both push against,
90
+ and it is arbitrary class method invocation from queue arguments, so
91
+ whoever can enqueue a job can call anything.
92
+
93
+ **Derive the scope from the owner ADR 033 added.** Janela would have to
94
+ know how a host's models relate to a tenant, which means interpreting the
95
+ owner, which ADR 014 and ADR 033 both forbid.
96
+
97
+ **Detect which kind of host this is.** The obvious idea, and ADR 032 is
98
+ what kills it. After ADR 032 every host defines `policy_scope`, and a
99
+ model layer host and a single tenant host both define it as `model.all`,
100
+ so responding to it distinguishes nothing. There is no controller
101
+ instance in a job to ask in any case, and no current tenant for it to
102
+ answer about.
103
+
104
+ **A setting naming the host's own job**, `Janela.snapshot_job =
105
+ "TenantSnapshotJob"`. Rejected because it has nothing to indirect.
106
+ Verified by reading the engine: nothing in `app/` or `lib/` enqueues
107
+ `SnapshotJob`, there is no publish action and no create route, and ADR
108
+ 009 decided there would not be. The host's own scheduler is the only
109
+ caller and it already names a class, so the setting would be read by
110
+ nobody. It is the shape to reach for on the day Janela becomes the
111
+ caller, which ADR 033 already named as the thing that would reopen all of
112
+ this, and until then it fails the test ADR 021 and ADR 023 set for
113
+ whether a setting has earned itself.
114
+
115
+ **Remove `SnapshotJob`.** Not a straw man. ADR 009's own consequences say
116
+ "the shipped job is deliberately naive. If most hosts turn out to write
117
+ their own, the job should be removed rather than grown", and the
118
+ documentation has been sending tenanted hosts to write their own since
119
+ the day it shipped. It loses to two things. The evidence ADR 009 asked
120
+ for does not exist: there is one demo, not a population of hosts. And
121
+ removal costs model layer and single tenant hosts a real convenience to
122
+ solve a problem neither of them has.
123
+
124
+ **A doctor check alone.** Rejected for ADR 032's reason and one of its
125
+ own. It is advisory, so it speaks at setup time and cannot stop a job.
126
+ And it cannot tell the two architectures apart any better than the
127
+ library can, so it would either stay silent or cry wolf at every host,
128
+ which is the false alarm ADR 021 exists to prevent.
129
+
130
+ ## Decision
131
+
132
+ **The snapshot job asks one question, `scope_for(model)`, and refuses to
133
+ answer it on the host's behalf.**
134
+
135
+ Everything above is one question wearing two costumes: what rows does
136
+ this pane freeze? It becomes one method on the job, and the two
137
+ architectures answer it in the two ways each can answer honestly.
138
+
139
+ **A model layer or single tenant host answers in a symbol.** The shipped
140
+ `scope_for` reads a `scope:` argument and has no default:
141
+
142
+ ```ruby
143
+ Janela::SnapshotJob.perform_later(name: "September 2026", owner: account,
144
+ scope: :model_default,
145
+ panes: [ { "model" => "orders", "measure" => "revenue" } ])
146
+ ```
147
+
148
+ `:model_default` says each pane is taken over its model's default scope,
149
+ and that this is the host's claim rather than Janela's guess. It is the
150
+ same move as `private def policy_scope(model) = model.all`: not
151
+ ceremony, a sentence somebody should have to write rather than inherit.
152
+ Absent, `scope_for` raises `Janela::Unscoped`, the same class and the
153
+ same word as ADR 032, because it is the same refusal in the other half
154
+ of the library. A value Janela does not know raises `ArgumentError`,
155
+ plainly, because a job's `perform` is a method call and not a request.
156
+
157
+ **A policy layer host answers in Ruby, by subclassing.** A relation
158
+ cannot cross the queue, but a class name can, and the host's scheduler
159
+ already names one:
160
+
161
+ ```ruby
162
+ class TenantSnapshotJob < Janela::SnapshotJob
163
+ private def scope_for(model) = model.where(account: owner)
164
+ end
165
+ ```
166
+
167
+ They schedule that instead, and pass no `scope:`, because the method that
168
+ reads it is the one they replaced. `Janela::SnapshotJob` is a documented
169
+ base class rather than a leaf, and the serialisable `panes:` list, which
170
+ is the only genuinely reusable part of it and the part a host would
171
+ otherwise copy, keeps working. `perform` records what it was told and
172
+ exposes `owner`, `name` and `filters` as private readers, so a subclass
173
+ never has to override `perform` or reach into ActiveJob's `arguments`.
174
+
175
+ **This is a hook, and ADR 032 is why it is allowed to be one.** A
176
+ `scope_for` whose unanswered case returns `model.all` is exactly the
177
+ shape ADR 032 removed, and was rejected on that ground while the keyword
178
+ was a separate idea. Folding the two together inverts it: the default is
179
+ not the widest scope, it is a refusal. A host who has never heard of
180
+ `scope_for` does not get somebody else's numbers, they get an exception
181
+ naming both ways to answer.
182
+
183
+ **One question, so one place to answer it.** A symbol and a subclass are
184
+ not two competing APIs in ADR 001's sense. They are the same method,
185
+ answered by a caller who has nothing to add and by a caller who does.
186
+
187
+ **`Snapshot.take` and `Taking#pane` are untouched.** `on:` keeps
188
+ defaulting to `model.all`. ADR 032 drew that seam and it holds: the line
189
+ is between what Janela chooses inside its own code and what a host asks
190
+ for directly, and `take` is the host's own Ruby.
191
+
192
+ **No new doctor check.** A check earns itself by catching something quiet
193
+ (ADR 021), and after this there is nothing quiet left to catch: the job
194
+ either was answered or it raises. That is the opposite of the
195
+ `snapshots-nobody-will-see` case, where the failure was a row that simply
196
+ sat there.
197
+
198
+ **ADR 009 is narrowed, not superseded.** Everything it decided about what
199
+ a snapshot stores, how a stored pane is addressed and what static mode is
200
+ stands. Two sentences change. "The shipped job uses each model's default
201
+ scope" becomes "when told to", and "removed rather than grown" is
202
+ answered: it grows by one overridable method, which is how Rails ships a
203
+ default a host can replace.
204
+
205
+ ## Consequences
206
+
207
+ - A policy layer host's instruction stops being "write your own job" and
208
+ becomes "override one method", which is a sentence the multi tenancy
209
+ guide can end its rough edge section with instead of a link to an
210
+ issue.
211
+ - **Breaking, by one keyword**, so it goes in a minor release with an
212
+ `UPGRADING.md` section and a `post_install_message` (ADR 015).
213
+ - **A job already on the queue at upgrade time was serialised without
214
+ `scope:` and will raise when it performs.** A deployment hazard rather
215
+ than a code change, invisible from the diff, and it belongs in the
216
+ upgrade note beside the keyword: drain or re-enqueue before deploying,
217
+ or expect the retries.
218
+ - **`SnapshotJob`'s insides become public surface.** `scope_for` and the
219
+ readers beside it are a host's to override, so changing the shape of
220
+ the `panes:` loop is a breaking change from here on, where today it is
221
+ an internal detail. That is the real price of a base class over a leaf,
222
+ and it is paid knowingly: the surface is one method and three readers,
223
+ which is small enough to keep legible and small enough to fork past.
224
+ - The job stays naive on purpose. It does not become the place where
225
+ scoping is solved. It becomes the place where the host says who is
226
+ solving it.
227
+ - The guide's "what is still rough" section and the README's paragraph on
228
+ the job both change in the same commit that lands the code.
229
+ - A host who writes `scope: :model_default` without reading what it means
230
+ is no better off than before. True of ADR 032's one line too. The claim
231
+ is not that a keyword teaches, it is that a library should not make
232
+ this particular claim on a host's behalf.
233
+ - What would change this decision: Janela growing a caller of its own, a
234
+ publish button or a scheduled frame, at which point the class to
235
+ enqueue is Janela's question rather than the scheduler's and a setting
236
+ earns itself for the first time. ADR 033 named that same event as the
237
+ one that would reopen how a snapshot learns its owner, so the two
238
+ should be read together when it happens.
@@ -24,21 +24,21 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
24
24
  | **Open-source & host-decoupling** | 001, 022 |
25
25
  | **DSL & query layer** | 002, 006, 007, 020, 025 |
26
26
  | **Dependencies** | 002, 003, 004, 006, 017, 025 |
27
- | **Authorisation** | 002, 003, 004, 009, 017, 019, 022 |
27
+ | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034 |
28
28
  | **Performance & storage** | 007, 017, 025 |
29
29
  | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025 |
30
30
  | **Layouts & views** | 011, 012, 016, 018, 020, 027 |
31
31
  | **CSS & styling** | 016, 018, 023, 026, 027 |
32
- | **Frames, panes & persistence** | 012, 013, 014, 019 |
32
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033 |
33
33
  | **Naming rule** | 014, 023 |
34
34
  | **JavaScript delivery & charts** | 004, 006, 026 |
35
35
  | **Time dimensions** | 006, 025 |
36
36
  | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025 |
37
- | **Snapshots & publishing** | 009, 020, 028 |
37
+ | **Snapshots & publishing** | 009, 020, 028, 033, 034 |
38
38
  | **AI agents & guidance** | 010, 015, 021 |
39
- | **Releases & upgrades** | 015, 021 |
39
+ | **Releases & upgrades** | 015, 021, 032, 034 |
40
40
  | **Accessibility & keyboard** | 024 |
41
- | **Security** | 003, 025, 028 |
41
+ | **Security** | 003, 025, 028, 031, 032, 034 |
42
42
  | **Testing** | 003 |
43
43
 
44
44
  ## Chronological
@@ -73,7 +73,13 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
73
73
  | 026 | A Renderer Is the Seam, and HTML Comes First | 2026-09-18 | Accepted |
74
74
  | 027 | The Gallery Is a Host Page, Built from the Gem's Helpers | 2026-09-18 | Accepted |
75
75
  | 028 | The Predicate List ADR 025 Named Was Not Quite Right | 2026-09-18 | Accepted |
76
+ | 029 | A Pane's Frame Is Identified by Who It Is, Not by What It Shows | 2026-09-18 | Accepted |
77
+ | 030 | A Pane's src Belongs to Turbo, So a Host Talks to the Frame | 2026-09-18 | Accepted |
78
+ | 031 | A Subclass Inherits the Dashboard Its Parent Declared | 2026-09-19 | Accepted |
79
+ | 032 | Janela Will Not Read a Model It Cannot Scope | 2026-09-20 | Accepted |
80
+ | 033 | A Snapshot Is Told Who Owns It | 2026-09-21 | Accepted |
81
+ | 034 | Janela Will Not Freeze a Scope the Host Has Not Named | 2026-09-21 | Accepted |
76
82
 
77
83
  ## Next number
78
84
 
79
- Next ADR: 029
85
+ Next ADR: 035
@@ -6,15 +6,22 @@ engine asks your application two questions and does what it is told.
6
6
 
7
7
  | Question | How your application answers | If it says nothing |
8
8
  | --- | --- | --- |
9
- | What may this request read? | `policy_scope(model)` on the controller Janela inherits | Everything: `model.all` |
9
+ | What may this request read? | `policy_scope(model)` on the controller Janela inherits | Nothing: it raises `Janela::Unscoped` |
10
10
  | What owns a frame being created? | `janela_frame_owner` on the same controller | Nothing: a nil owner |
11
+ | What owns a snapshot being taken? | `owner:`, an argument to `Snapshot.take` | Nothing: a nil owner |
12
+ | What rows does a scheduled snapshot freeze? | `scope:` on `SnapshotJob`, or `scope_for` in a subclass of it | Nothing: it raises `Janela::Unscoped` |
11
13
 
12
- Both are ordinary methods on your `ApplicationController`, found by
13
- duck typing. Pundit defines the first for you. Anything else, you
14
- define in about five lines. ADR 019 has the reasoning for asking
14
+ The first two are ordinary methods on your `ApplicationController`,
15
+ found by duck typing. Pundit defines the first for you. Anything else,
16
+ you define in about five lines. ADR 019 has the reasoning for asking
15
17
  rather than being configured: ownership is per-request state, and a
16
18
  setting cannot hold it.
17
19
 
20
+ The third is an argument rather than a method for the reason ADR 033
21
+ gives: a snapshot is never taken in a request. It is taken in a job, a
22
+ task or a console, where there is no controller to ask and no current
23
+ tenant to ask about, so the caller passes what it already knows.
24
+
18
25
  ## What goes through your scope
19
26
 
20
27
  Everything. There is no path through the engine that reads a record
@@ -104,9 +111,28 @@ class and returns a relation is the whole contract.
104
111
 
105
112
  ## With one tenant
106
113
 
107
- Define nothing. `policy_scope` is absent, every query runs over
108
- `model.all`, and a frame is created with a nil owner because nothing
109
- is filtering on one.
114
+ Write the answer anyway, once:
115
+
116
+ ```ruby
117
+ class ApplicationController < ActionController::Base
118
+ private
119
+ def policy_scope(model) = model.all
120
+ end
121
+ ```
122
+
123
+ Janela will not guess this one. Every other question here takes silence
124
+ as an answer, because the silent answer is the narrow one: no owner, no
125
+ theme, no tenancy. This question's permissive answer is the widest thing
126
+ a library can assume, so it is the one you have to say out loud (ADR
127
+ 032).
128
+
129
+ The line is not ceremony either. It asserts that everyone who can reach
130
+ a dashboard may read every row behind it, and "one tenant" and "no rows
131
+ worth hiding from staff" are not the same claim. If the second is not
132
+ true here, return something narrower.
133
+
134
+ A frame is still created with a nil owner, because nothing is filtering
135
+ on one.
110
136
 
111
137
  ## What owns a frame
112
138
 
@@ -123,20 +149,63 @@ save failed silently. `bin/rails janela:doctor` reports this for you:
123
149
  it asks your policy for a scope over frames and looks for an owner in
124
150
  what comes back.
125
151
 
126
- ## Snapshots are the rough edge
152
+ ## Snapshots
153
+
154
+ A snapshot carries the same polymorphic owner a frame does, so your
155
+ policy has the same column to filter on. You pass it when you take one:
156
+
157
+ ```ruby
158
+ Janela::Snapshot.take(name: "September 2026", owner: ActsAsTenant.current_tenant) do |take|
159
+ take.pane Order, :revenue, on: policy_scope(Order)
160
+ end
161
+ ```
162
+
163
+ ```ruby
164
+ def policy_scope(model)
165
+ case model.name
166
+ when "Janela::Frame", "Janela::Snapshot" then model.where(owner: ActsAsTenant.current_tenant)
167
+ else model.all
168
+ end
169
+ end
170
+ ```
171
+
172
+ `Janela::SnapshotJob` takes `owner:` as well, since ActiveJob carries a
173
+ record across the queue through its GlobalID.
174
+
175
+ ## What a scheduled snapshot freezes
176
+
177
+ The owner says who a snapshot belongs to. What the numbers inside it
178
+ cover is a separate question, and the job asks you rather than guessing.
127
179
 
128
- `janela_snapshots` has no owner column. A snapshot is a name, an
129
- instant, the filters it was taken under and the results. So a multi
130
- tenant application has to scope it by something else:
180
+ A model's default scope is already your tenant's rows if your tenancy is
181
+ enforced on the models, through acts_as_tenant, a `default_scope`, a
182
+ connection or a schema. Say so:
183
+
184
+ ```ruby
185
+ Janela::SnapshotJob.perform_later(name: "September 2026", owner: tenant,
186
+ scope: :model_default, panes: [ ... ])
187
+ ```
188
+
189
+ If your scoping lives in your policies, `model.all` is every row, and no
190
+ symbol can carry the relation you want across a queue. Answer in Ruby.
191
+ Subclass the job, override its one question, and schedule yours:
192
+
193
+ ```ruby
194
+ class TenantSnapshotJob < Janela::SnapshotJob
195
+ private def scope_for(model) = model.where(account: owner)
196
+ end
197
+ ```
131
198
 
132
- - Keep snapshots to one tenant, or to whoever publishes.
133
- - Add your own column with a migration on `janela_snapshots` and scope
134
- on that.
135
- - Encode the tenant in the filters a snapshot is taken under, and
136
- scope on the stored name.
199
+ `name`, `owner` and `filters` are readable inside `scope_for`, so a
200
+ subclass never has to override `perform` or read ActiveJob's arguments.
201
+ Override `scope_for` and the `scope:` argument is not consulted: the
202
+ method that reads it is the one you replaced.
137
203
 
138
- None of those is as clean as a frame's owner. Giving a snapshot the
139
- same polymorphic owner is [issue #32](https://github.com/retail-tasker/janela/issues/32).
204
+ Answer neither way and the job raises `Janela::Unscoped` instead of
205
+ freezing a scope nobody chose. That matters more here than on a live
206
+ page: a wrong pane is wrong once, on a screen, to somebody already
207
+ signed in, while a wrong snapshot is frozen into a row, labelled with an
208
+ owner and served at an address (ADR 034).
140
209
 
141
210
  ## Proving your wiring
142
211
 
@@ -6,10 +6,20 @@ module Janela
6
6
 
7
7
  attr_reader :model, :measures, :dimensions
8
8
 
9
- def initialize(model)
9
+ def initialize(model, &block)
10
10
  @model = model
11
11
  @measures = {}
12
12
  @dimensions = {}
13
+ @block = block
14
+ instance_eval(&block) if block
15
+ end
16
+
17
+ # The same declaration read against another model, which is how a subclass
18
+ # inherits a dashboard: its measures and dimensions are its parent's, and
19
+ # the queries they run are its own, because ActiveRecord adds the type
20
+ # condition to a relation on the subclass (ADR 031).
21
+ def for(model)
22
+ self.class.new(model, &@block)
13
23
  end
14
24
 
15
25
  def measure(name, **aggregate)
data/lib/janela/doctor.rb CHANGED
@@ -10,8 +10,9 @@ module Janela
10
10
  # Run in this order, and each one names the finding it produces: a host
11
11
  # silences a check by that name (ADR 021).
12
12
  CHECKS = %i[stale_identifiers unmounted_engine unmigrated_tables unregistered_controllers
13
- through_dimensions_without_an_allowlist frames_nobody_will_own
14
- unauthenticated_endpoints hardcoded_disallowed_predicates].freeze
13
+ through_dimensions_without_an_allowlist unscoped_reads frames_nobody_will_own
14
+ snapshots_nobody_will_see unauthenticated_endpoints
15
+ hardcoded_disallowed_predicates].freeze
15
16
 
16
17
  # Identifiers a previous version of Janela used, and what replaced them.
17
18
  RENAMED = {
@@ -181,6 +182,28 @@ module Janela
181
182
  end
182
183
  end
183
184
 
185
+ # A host that has defined no policy_scope has not said what may be read,
186
+ # and since ADR 032 every dashboard raises rather than answering with
187
+ # every row. Reported so it is found here rather than by a visitor.
188
+ #
189
+ # Where unauthenticated_endpoints has to hedge, because what counts as
190
+ # authentication cannot be determined by reading, this one is exact: the
191
+ # method is defined or it is not, and that is the whole contract.
192
+ def unscoped_reads
193
+ parent = Janela.parent_controller.safe_constantize
194
+ return unless parent
195
+ return if parent.private_method_defined?(:policy_scope) || parent.method_defined?(:policy_scope)
196
+
197
+ Finding.new(severity: :error,
198
+ summary: "#{parent} defines no policy_scope, so every pane will raise",
199
+ detail: " Janela asks your controller what may be read and refuses to guess.\n" \
200
+ " Define it on #{parent}:\n" \
201
+ " private def policy_scope(model) = model.all\n" \
202
+ " That line says every visitor may read every row of every model on a\n" \
203
+ " dashboard. If that is not true of this application, return something\n" \
204
+ " narrower; docs/multi-tenancy.md has the wiring for the usual libraries.")
205
+ end
206
+
184
207
  # A host whose policy filters frames by owner, but which never tells
185
208
  # Janela what owns a new one, creates frames its own scope then hides.
186
209
  # The failure is silent, and a typo in the method name looks the same as
@@ -189,7 +212,7 @@ module Janela
189
212
  parent = Janela.parent_controller.safe_constantize
190
213
  return unless parent&.private_method_defined?(:policy_scope) || parent&.method_defined?(:policy_scope)
191
214
  return if parent.private_method_defined?(:janela_frame_owner) || parent.method_defined?(:janela_frame_owner)
192
- return unless Janela::Frame.table_exists? && scope_filters_frames_by_owner?(parent)
215
+ return unless Janela::Frame.table_exists? && scope_filters_by_owner?(parent, Janela::Frame)
193
216
 
194
217
  Finding.new(severity: :error,
195
218
  summary: "#{parent} scopes frames by owner but defines no janela_frame_owner",
@@ -202,11 +225,39 @@ module Janela
202
225
  nil # no database or no policy to ask; nothing can be concluded
203
226
  end
204
227
 
228
+ # A snapshot with no owner is stored and unreachable to a policy that
229
+ # filters on one: the row is there, a link to it is a 404, and nothing
230
+ # says why. Most often these were taken before the column existed, which
231
+ # is what an upgrade produces.
232
+ #
233
+ # ADR 033 judged this a thinner case than the frame's, because a caller
234
+ # assigns a snapshot's owner in its own Ruby rather than the engine doing
235
+ # it silently, and said it was worth revisiting if it bit. It bit four
236
+ # times in this repository's tests and once on the live demo within an
237
+ # afternoon of the column landing (#49).
238
+ def snapshots_nobody_will_see
239
+ parent = Janela.parent_controller.safe_constantize
240
+ return unless parent && Janela::Snapshot.table_exists?
241
+ return unless scope_filters_by_owner?(parent, Janela::Snapshot)
242
+
243
+ unowned = Janela::Snapshot.where(owner_id: nil).count
244
+ return if unowned.zero?
245
+
246
+ Finding.new(severity: :warning,
247
+ summary: "#{unowned} #{'snapshot'.pluralize(unowned)} with no owner, which your policy filters on",
248
+ detail: " Your policy narrows snapshots by owner, so one with none is stored and\n" \
249
+ " unreachable: a link to it answers 404 and nothing says why. Usually these\n" \
250
+ " were taken before the owner column existed. Assign an owner to them, or\n" \
251
+ " delete them, and pass owner: to Janela::Snapshot.take from now on.")
252
+ rescue StandardError
253
+ nil # no database or no policy to ask; nothing can be concluded
254
+ end
255
+
205
256
  # Asking the policy rather than reading its source: a scope that narrows
206
- # frames is one that will hide an unowned one.
207
- def scope_filters_frames_by_owner?(parent)
208
- scope = parent.allocate.send(:policy_scope, Janela::Frame)
209
- scope.to_sql.include?("owner")
257
+ # an owned record is one that will hide an unowned one. Shared, because
258
+ # a frame and a snapshot are the same question asked of two tables.
259
+ def scope_filters_by_owner?(parent, model)
260
+ parent.allocate.send(:policy_scope, model).to_sql.include?("owner")
210
261
  rescue StandardError
211
262
  false
212
263
  end
data/lib/janela/model.rb CHANGED
@@ -1,25 +1,61 @@
1
1
  module Janela
2
2
  module Model
3
+ # Dimensions are the only things Janela filters on, so they are the
4
+ # Ransack allowlist. This asks for the definition when it is called rather
5
+ # than closing over one, so a subclass answers with the definition it
6
+ # reports, whether that is its parent's or one it declared itself. The two
7
+ # halves cannot then disagree, which is what they did before ADR 031: a
8
+ # subclass inherited this list and reported no definition behind it.
9
+ module RansackAllowlist
10
+ def ransackable_attributes(_auth_object = nil)
11
+ janela.ransackable_attributes
12
+ end
13
+
14
+ def ransackable_associations(_auth_object = nil)
15
+ janela.ransackable_associations
16
+ end
17
+ end
18
+
3
19
  def janela(&block)
4
- return @janela_definition unless block
20
+ return @janela_definition ||= inherited_janela_definition unless block
5
21
 
6
- @janela_definition = Definition.new(self)
7
- @janela_definition.instance_eval(&block)
22
+ @janela_definition = Definition.new(self, &block)
8
23
  define_janela_ransack_allowlist
9
24
  Janela.register(self)
10
25
  @janela_definition
11
26
  end
12
27
 
28
+ # A subclass cannot be registered where its parent declares, because it
29
+ # does not exist yet, so it registers as it is created (ADR 031). An
30
+ # anonymous class has no route key to be addressed by; naming it is the
31
+ # host's move and declaring on it is the host's other one.
32
+ def inherited(subclass)
33
+ super
34
+ Janela.register_subclass(subclass) if subclass.name && janela
35
+ end
36
+
13
37
  private
14
- # Dimensions are the only things Janela filters on, so they are the
15
- # Ransack allowlist. A model that already declares its own allowlist
16
- # keeps it.
38
+ # What a subclass inherits is the declaration, not the definition
39
+ # object. A definition holds the model it queries, so a subclass handed
40
+ # its parent's would report the right dashboard and then total the
41
+ # parent's rows behind it.
42
+ def inherited_janela_definition
43
+ superclass.janela&.for(self) if superclass.respond_to?(:janela)
44
+ end
45
+
46
+ # A model that already answers for itself keeps its answer, whether it
47
+ # said so here or on a class above. Ransack's own default lives on
48
+ # ActiveRecord::Base, so anything nearer than that was somebody's
49
+ # decision and is not ours to replace.
17
50
  def define_janela_ransack_allowlist
18
- return if singleton_class.method_defined?(:ransackable_attributes, false)
51
+ return if janela_ransack_allowlist_answered?
52
+
53
+ extend RansackAllowlist
54
+ end
19
55
 
20
- definition = @janela_definition
21
- define_singleton_method(:ransackable_attributes) { |_auth_object = nil| definition.ransackable_attributes }
22
- define_singleton_method(:ransackable_associations) { |_auth_object = nil| definition.ransackable_associations }
56
+ def janela_ransack_allowlist_answered?
57
+ owner = singleton_class.instance_method(:ransackable_attributes).owner
58
+ !ActiveRecord::Base.singleton_class.ancestors.include?(owner)
23
59
  end
24
60
  end
25
61
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end