janela 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fd31f19d1b100c312826e8077aefc291873c0cc153410aa9c53199dcdf5ba18
4
- data.tar.gz: ec551cd6c640de00e6ba13266ae2920945f68ad4a576c344e157830c9442563d
3
+ metadata.gz: 91a8da061f836f0c93ed3c6005f704fc8c5830ea2fb8e73f2f77c96f2107c467
4
+ data.tar.gz: ace46d3c7c08ea5423de350f14dcd17e6a303528f62f2a3c60b66b9bf2cc88bf
5
5
  SHA512:
6
- metadata.gz: 692add30c86d3633d3144b6fe1fbb138246762e974f618430db2f290f1980a91bb55441104b2a1d183e67e6e16ca3ffd75aaab8d8e0156d77444950bc388dc21
7
- data.tar.gz: 794ac1f1352ee3e30357d8ef2ff7bd2d0692895f932b3aec5d3acc5df55ab21e592397f111df855a120c547eea271273807e870d988a602a49c6575987ddaa9a
6
+ metadata.gz: ce044bb0f401c9541a69952212b22492c94d3d68a7a7ce05af8e568b0a77c16adf41b8153291cbc981c80ad2343a16122989890c00ce6feeb5ee3200605ebc82
7
+ data.tar.gz: 1244329924141334f04b3d4f4ac0f15834862d110d0577f5a3739ff2085549a790d76691941df362080a1b470de6927773a3a4cd0cda963f62cb3ca31e0640a4
data/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.7.0] - 2026-09-22
9
+
10
+ ### Added
11
+
12
+ - `rails janela:doctor` reports snapshots stored with no owner when your policy filters snapshots by owner, as `snapshots-nobody-will-see`, a warning naming how many. Such a snapshot is stored and unreachable: the row is there, a link to it answers 404, and nothing says why. Usually they are snapshots taken before the owner column existed, which is what an upgrade produces. ADR 033 judged a check here a thinner case than the frame's, because a caller assigns a snapshot's owner in its own Ruby, and said it was worth revisiting if it bit: it bit four times in this repository's own tests and once on the live demo within an afternoon of the column landing (#49).
13
+ - A snapshot carries an owner, the same nullable polymorphic one a frame has, so a multi tenant application's policy has the same column to filter a snapshot on that it already has for a frame. `Janela::Snapshot.take(name:, owner:)` assigns it and `Janela::SnapshotJob` carries it across the queue through its GlobalID. Janela reads nothing from it, exactly as with a frame. It is an argument rather than a method on your controller because a snapshot is never taken in a request: there is no create route and no form, only `take` called from a job, a task or a console, where a hook reaching for the current tenant would work in a console and return nil in the job that is the point of the feature. Needs `rails janela:install:migrations` and a migrate; existing snapshots keep a nil owner and nothing breaks if you ignore it. The owner says who a snapshot belongs to and nothing about the numbers inside it, which is the separate question the `SnapshotJob` entry above answers (ADR 033, #32).
14
+
15
+ ### Changed
16
+
17
+ - **Breaking.** `Janela::SnapshotJob` will not freeze a scope you have not named. It took every pane over the model's default scope, which ADR 009 chose deliberately because a relation cannot be serialised into a job. That is your tenant's rows if your tenancy is enforced on your models, and every row of every model if your scoping lives in your policies, and nothing reachable from a job can tell which application it is in: measured against a policy scoped host, the same application showed $150.00 on the live pane and published $375.00 from the snapshot beside it, under that tenant's own name, answered 200 with nothing in the log. A live unscoped read is wrong once, on a screen, to somebody already signed in; a snapshot freezes it into a row and serves it at an address to the external audience snapshots exist for. The job now asks one question, `scope_for(model)`, and refuses to answer it for you. A host whose tenancy is on its models, or who has one tenant, passes `scope: :model_default` and carries on. A host whose scoping is in its policies answers in Ruby, where a relation is still a relation, by subclassing the job and overriding `scope_for`, with `name`, `owner` and `filters` readable beside it so nothing has to override `perform`; that replaces the old advice to write a job around `Snapshot.take` from scratch. Neither answer raises `Janela::Unscoped`, the same refusal a request gets. `Snapshot.take` and `take.pane`'s `on:` are unchanged, because those are calls you write in your own Ruby. Note before deploying: a job already on the queue was serialised without `scope:` and will raise when it performs, which no diff will show you (ADR 034, #47).
18
+
19
+ - **Breaking.** Janela refuses to read a model it has not been told how to scope. `janela_scope` asked the host's controller for `policy_scope` and fell back to `model.all` when there was none, so an application using a different authorisation library, or none at all, got every row of every model on a dashboard with nothing said: a request that should have been a refusal answered 200 carrying numbers its reader may have had no right to, and no line mentioning scope reached the log. It now raises `Janela::Unscoped`, naming the method to define and the class to define it on. An application with nothing to hide answers once, `private def policy_scope(model) = model.all`, which is a sentence worth writing rather than inheriting by omission: "one tenant" and "everyone may read every row" are not the same claim. A host using Pundit is unaffected, including one missing a policy, because Pundit already raises on that. `rails janela:doctor` reports the absence as `unscoped-reads` at error severity, and unlike `unauthenticated-endpoints` it does not have to hedge, since the method is either defined or it is not (ADR 032, #7).
20
+
21
+ ### Fixed
22
+
23
+ - A frame rendered in a host's own page read every row when `policy_scope` was a private controller method, which is the shape `docs/multi-tenancy.md` teaches and the shape Pundit's own has. Janela asked the view whether the host had defined a scope, where its own controllers ask the controller, and a view cannot see a private controller method: the same application was scoped on Janela's pages and silently unscoped on its own, with no error and nothing in the log. A host using Pundit was unaffected, because `Pundit::Helper` separately defines a view side copy. If you embed `janela_frame` or `janela_pane` in your own views and your scope narrows what a pane counts, those numbers were too high and are now correct (#46).
24
+
8
25
  ## [0.6.0] - 2026-09-20
9
26
 
10
27
  ### Added
@@ -152,6 +169,7 @@ First alpha, installed from GitHub for testing in a single host application.
152
169
  - Only models that declare a `janela` block are addressable over HTTP.
153
170
  - ADRs 001 to 004 in `docs/decisions/`, shipped inside the gem.
154
171
 
172
+ [0.7.0]: https://github.com/retail-tasker/janela/releases/tag/v0.7.0
155
173
  [0.6.0]: https://github.com/retail-tasker/janela/releases/tag/v0.6.0
156
174
  [0.5.0]: https://github.com/retail-tasker/janela/releases/tag/v0.5.0
157
175
  [0.4.1]: https://github.com/retail-tasker/janela/releases/tag/v0.4.1
data/README.md CHANGED
@@ -27,7 +27,7 @@ Janela is an alpha on [rubygems.org](https://rubygems.org/gems/janela). It has t
27
27
 
28
28
  ```ruby
29
29
  # Gemfile
30
- gem "janela", "~> 0.6"
30
+ gem "janela", "~> 0.7"
31
31
  ```
32
32
 
33
33
  ```ruby
@@ -348,13 +348,15 @@ The model is its route key (`orders`, `sales_orders`), then the measure, then op
348
348
  A snapshot freezes the results of several panes at one instant, under one set of filters, so an audience sees exactly what was signed off while the live dashboard stays editable. Results are stored, not HTML; a stored pane can still be drawn as a table or a chart. It needs the same migrations frames do.
349
349
 
350
350
  ```ruby
351
- Janela::Snapshot.take(name: "September 2026", filters: { status_eq: "paid" }) do |take|
351
+ Janela::Snapshot.take(name: "September 2026", owner: Current.account, filters: { status_eq: "paid" }) do |take|
352
352
  take.pane Order, :revenue, on: policy_scope(Order)
353
353
  take.pane Order, :revenue, by: :status, on: policy_scope(Order)
354
354
  take.pane Order, :revenue, by: :placed_on, granularity: :week
355
355
  end
356
356
  ```
357
357
 
358
+ `owner:` is optional and Janela reads nothing from it: it is there so your policy has the same column to filter a snapshot on that it has for a frame. It is an argument rather than a controller hook because a snapshot is never taken in a request, so there is nothing to ask (ADR 033).
359
+
358
360
  Render a stored pane the same way you render a live one:
359
361
 
360
362
  ```erb
@@ -390,7 +392,22 @@ Do not point Janela at your **application** layout. Janela is an isolated engine
390
392
 
391
393
  Stored panes are static by nature: no filter buttons, charts ignore clicks, and the URL says *as of*: `/dashboards/snapshots/42/orders/revenue/status`. Request filters are ignored because the snapshot's were fixed when it was taken.
392
394
 
393
- `Janela::SnapshotJob.perform_later(name:, panes: [{ "model" => "orders", "measure" => "revenue", "by" => "status" }])` takes one from serialisable arguments so you can schedule it with whatever runs your jobs. The job uses each model's default scope; if you scope by tenant, write your own job around `Snapshot.take` and pass `on:`.
395
+ `Janela::SnapshotJob` takes one from serialisable arguments so you can schedule it with whatever runs your jobs, carrying the owner across the queue through its GlobalID. It asks you one question and will not answer it for you: what rows does each pane freeze?
396
+
397
+ ```ruby
398
+ Janela::SnapshotJob.perform_later(name: "September 2026", owner: Current.account, scope: :model_default,
399
+ panes: [ { "model" => "orders", "measure" => "revenue", "by" => "status" } ])
400
+ ```
401
+
402
+ `scope: :model_default` says each pane is taken over its model's default scope. That is already your tenant's rows if your tenancy is enforced on the models themselves, through acts_as_tenant, a `default_scope`, a connection or a schema. If your scoping lives in your policies instead, it is every row of every model, and no symbol can carry the relation you want, so answer in Ruby and schedule your own job:
403
+
404
+ ```ruby
405
+ class TenantSnapshotJob < Janela::SnapshotJob
406
+ private def scope_for(model) = model.where(account: owner)
407
+ end
408
+ ```
409
+
410
+ `scope_for` is the whole extension point. Override it and the `scope:` argument is not consulted, because the method that reads it is the one you replaced; `name`, `owner` and `filters` are readable beside it, so you never have to override `perform`. Pass neither and the job raises `Janela::Unscoped` rather than freezing a scope nobody chose, which is the same refusal `policy_scope` gets in a request (ADR 034).
394
411
 
395
412
  Who may see a snapshot is your decision. Stored panes go through the same controllers as live ones, so your authentication applies; an external audience gets a page you build over `janela_snapshot_pane` behind whatever share tokens you already trust. ADR 009 has the reasoning.
396
413
 
@@ -411,7 +428,7 @@ It is *prepended* so it runs before any filter on your `ApplicationController` t
411
428
 
412
429
  Your own route helpers work in there. Janela is an isolated engine, so a bare `new_session_path` would normally resolve against Janela's routes and raise, and this bites any host code that generates a URL while inside the engine: an authentication concern, a `rescue_from` that redirects, an `after_action`. Janela forwards the route helpers it does not define itself to your application, so they behave as they do everywhere else (ADR 022). Two things to know. A name Janela also uses means Janela's in here, and `main_app.frames_path` says yours. And `url_for(@record)` resolves polymorphically with no name to forward, so that one still needs `main_app.`.
413
430
 
414
- Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls `policy_scope(model)` if your `ApplicationController` defines it, and falls back to `model.all` otherwise. Every model you put on a dashboard needs a policy with a `Scope`, and so do `Janela::Frame` and `Janela::Snapshot`: frames, pane rows and stored panes are all read through the scope, never around it. `test/dummy/app/controllers/application_controller.rb` is the smallest honest example of the wiring.
431
+ Scoping is automatic when you use Pundit: `Janela::ApplicationController` calls `policy_scope(model)` if your `ApplicationController` defines it, and raises `Janela::Unscoped` if it does not, rather than reading everything on the strength of an omission (ADR 032). An application with nothing to hide answers `def policy_scope(model) = model.all` once and is done; `bin/rails janela:doctor` reports the absence as `unscoped-reads` before a visitor finds it. Every model you put on a dashboard needs a policy with a `Scope`, and so do `Janela::Frame` and `Janela::Snapshot`: frames, pane rows and stored panes are all read through the scope, never around it. `test/dummy/app/controllers/application_controller.rb` is the smallest honest example of the wiring.
415
432
 
416
433
  **Multi tenancy** has its own guide: [docs/multi-tenancy.md](docs/multi-tenancy.md). It covers what goes through your scope, worked wiring for Pundit, acts_as_tenant and CanCanCan, what owns a frame the analyst creates, and the one rough edge, which is that a snapshot has no owner column yet.
417
434
 
@@ -444,9 +461,11 @@ bin/rails janela:doctor
444
461
  Reads your application and lists what still needs doing: identifiers left over
445
462
  from an earlier version, Stimulus controllers you have not registered, tables
446
463
  you have not migrated, a `through:` dimension whose associated model does not
447
- allowlist the attribute, a policy that scopes frames by an owner you never
448
- supply, and whether the engine is mounted and authenticated. It exits non-zero
449
- when it finds an error, so it works in CI. It only reads and reports.
464
+ allowlist the attribute, a controller that defines no `policy_scope` at all, a
465
+ policy that scopes frames by an owner you never supply, snapshots stored with
466
+ no owner under a policy that filters on one, and whether the engine is mounted
467
+ and authenticated. It exits non-zero when it finds an error, so it works in
468
+ CI. It only reads and reports.
450
469
 
451
470
  Every finding names the check that produced it:
452
471
 
@@ -484,7 +503,7 @@ Deliberately out of scope: natural-language query, a separate data warehouse, a
484
503
 
485
504
  ## Status
486
505
 
487
- **v0.6.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests. Not yet built: a visual editor, drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
506
+ **v0.7.0 alpha.** The measures/dimensions DSL, time dimensions, cross-filtering with multi-selection, bar and line charts, pane URLs, shareable dashboard URLs, snapshots, database-backed frames, STI subclasses, the engine's own pages for reading and editing them and the optional vitral theme work and are covered by unit and real-browser tests. Not yet built: a visual editor, drill-down on time panes, other chart types. Open work is in [GitHub Issues](https://github.com/retail-tasker/janela/issues).
488
507
 
489
508
  ## Development
490
509
 
data/UPGRADING.md CHANGED
@@ -12,6 +12,110 @@ bin/rails janela:doctor
12
12
 
13
13
  It reads your application and lists what still needs changing.
14
14
 
15
+ ## 0.6.0 to 0.7.0
16
+
17
+ Janela has stopped guessing what may be read, in the two places it used
18
+ to default to every row: a pane in a request (ADR 032) and a scheduled
19
+ snapshot (ADR 034). Three steps, and most applications have already
20
+ taken the first.
21
+
22
+ **1. Say what may be read, if you have not.**
23
+
24
+ If your `ApplicationController` defines `policy_scope`, nothing changes.
25
+ If you use Pundit, nothing changes. If neither is true, every dashboard,
26
+ pane and inline frame now raises `Janela::Unscoped` where it previously
27
+ totalled every row:
28
+
29
+ ```ruby
30
+ class ApplicationController < ActionController::Base
31
+ + private
32
+ + def policy_scope(model) = model.all
33
+ end
34
+ ```
35
+
36
+ That line is an assertion, not a formality: it says every visitor who can
37
+ reach a dashboard may read every row of every model on it. "One tenant"
38
+ and "nothing here is worth hiding from staff" are different claims, and
39
+ only the second one licenses `model.all`. If it is not true of your
40
+ application, return something narrower.
41
+ `docs/multi-tenancy.md` has the wiring for acts_as_tenant, CanCanCan and
42
+ the rest.
43
+
44
+ Find it before a visitor does:
45
+
46
+ ```bash
47
+ bin/rails janela:doctor
48
+ ```
49
+
50
+ `unscoped-reads` reports the absence as an error and prints the line to
51
+ add.
52
+
53
+ **2. Take the snapshot owner migration, if you use snapshots.**
54
+
55
+ `janela_snapshots` gains a nullable polymorphic owner so your policy can
56
+ filter a snapshot the way it already filters a frame:
57
+
58
+ ```bash
59
+ bin/rails janela:install:migrations
60
+ bin/rails db:migrate
61
+ ```
62
+
63
+ Existing snapshots keep a nil owner and keep working. Pass `owner:` when
64
+ you take new ones, and add `Janela::Snapshot` to whatever your policy
65
+ already does for `Janela::Frame`:
66
+
67
+ ```ruby
68
+ - Janela::Snapshot.take(name: "September") { |take| ... }
69
+ + Janela::Snapshot.take(name: "September", owner: Current.account) { |take| ... }
70
+ ```
71
+
72
+ The owner says who a snapshot belongs to. What the numbers inside it
73
+ cover is the next step.
74
+
75
+ **3. Tell `Janela::SnapshotJob` what rows to freeze, if you schedule
76
+ snapshots.**
77
+
78
+ The job used to take every pane over the model's default scope, which is
79
+ your tenant's rows if your tenancy is enforced on your models and every
80
+ row if it lives in your policies. Janela cannot tell which application it
81
+ is in, so it has stopped choosing (ADR 034). It now raises
82
+ `Janela::Unscoped` unless you answer.
83
+
84
+ If your tenancy is on your models, or you have one tenant, say so:
85
+
86
+ ```ruby
87
+ - Janela::SnapshotJob.perform_later(name: "September", panes: [...])
88
+ + Janela::SnapshotJob.perform_later(name: "September", scope: :model_default, panes: [...])
89
+ ```
90
+
91
+ If your scoping lives in your policies, `model.all` is every row and no
92
+ symbol can carry the relation you want. Answer in Ruby instead, and
93
+ schedule your own job:
94
+
95
+ ```ruby
96
+ class TenantSnapshotJob < Janela::SnapshotJob
97
+ private def scope_for(model) = model.where(account: owner)
98
+ end
99
+ ```
100
+
101
+ Override `scope_for` and the `scope:` argument is not consulted, because
102
+ the method that reads it is the one you replaced. `name`, `owner` and
103
+ `filters` are readable beside it, so there is no need to override
104
+ `perform`. This replaces the old advice to write a job around
105
+ `Snapshot.take` from scratch, which still works and is now one method
106
+ longer than it needs to be.
107
+
108
+ **Drain the queue, or expect the retries.** A `SnapshotJob` enqueued
109
+ before you deploy was serialised without `scope:` and will raise when it
110
+ performs. Nothing in the diff shows you this. Let the queue empty before
111
+ deploying, or re-enqueue what fails afterwards.
112
+
113
+ **What did not change.** `Order.janela.query(:revenue)` called from your
114
+ own Ruby still runs over `Order.all`, because you wrote that call and the
115
+ scope was yours to choose. Only what Janela decides on your behalf inside
116
+ a request has stopped guessing. Frames, pane rows and snapshots are read
117
+ through the same scope they always were.
118
+
15
119
  ## 0.5.0 to 0.6.0
16
120
 
17
121
  How single table inheritance is handled changed (ADR 031). Nothing here
@@ -27,9 +27,10 @@ module Janela
27
27
 
28
28
  # Pundit defines policy_scope on the host's ApplicationController, which
29
29
  # this inherits from, so authorisation applies without Janela depending
30
- # on Pundit or being configured.
30
+ # on Pundit or being configured. A host that defines nothing is refused
31
+ # rather than answered with every row (ADR 032).
31
32
  def janela_scope(model)
32
- respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
33
+ Janela.scope(self, model)
33
34
  end
34
35
 
35
36
  # A host that scopes frames by owner would hide a frame created without
@@ -51,9 +51,11 @@ module Janela
51
51
  end
52
52
 
53
53
  # A frame rendered inline runs its queries in the host's own request, so
54
- # the same Pundit scope the engine's controllers apply is applied here.
54
+ # the same scope the engine's controllers apply is applied here. The
55
+ # controller is asked, not the view, and the question itself lives in
56
+ # one place so the two can never disagree again (#46, ADR 032).
55
57
  def janela_scope(model)
56
- respond_to?(:policy_scope, true) ? policy_scope(model) : model.all
58
+ Janela.scope(controller, model)
57
59
  end
58
60
 
59
61
  def janela_page_filters
@@ -1,18 +1,59 @@
1
1
  module Janela
2
2
  # Takes a snapshot from serialisable arguments so a host can schedule it
3
- # with whatever runs its jobs. Each pane uses its model's default scope; a
4
- # host that scopes by tenant writes its own job around Snapshot.take and
5
- # passes on: (ADR 009).
3
+ # with whatever runs its jobs.
4
+ #
5
+ # What rows each pane freezes is the host's answer rather than Janela's. A
6
+ # host whose tenancy is enforced on its models says so with
7
+ # scope: :model_default; a host whose tenancy lives in its policies
8
+ # subclasses this and overrides scope_for, because a class name crosses the
9
+ # queue where a relation cannot (ADR 034).
6
10
  class SnapshotJob < ActiveJob::Base
7
- def perform(name:, panes:, filters: {})
8
- Snapshot.take(name: name, filters: filters) do |take|
11
+ # ActiveJob cannot serialise a relation, which is why on: is not an
12
+ # argument here, but it serialises a record through its GlobalID, so an
13
+ # owner crosses the queue boundary without anything new (ADR 033).
14
+ def perform(name:, panes:, owner: nil, filters: {}, scope: nil)
15
+ @name, @owner, @filters, @scope = name, owner, filters, scope&.to_sym
16
+
17
+ Snapshot.take(name: name, owner: owner, filters: filters) do |take|
9
18
  panes.each do |pane|
10
19
  pane = pane.to_h.stringify_keys
11
20
  model = Janela.definition!(pane.fetch("model")).model
12
- take.pane(model, pane.fetch("measure").to_sym,
21
+ take.pane(model, pane.fetch("measure").to_sym, on: scope_for(model),
13
22
  by: pane["by"]&.to_sym, granularity: pane["granularity"], limit: pane["limit"])
14
23
  end
15
24
  end
16
25
  end
26
+
27
+ private
28
+ # What the job was told, so a subclass answering scope_for never has to
29
+ # override perform or reach into ActiveJob's arguments to find it.
30
+ attr_reader :name, :owner, :filters
31
+
32
+ # The one question this job asks, and the one it will not answer on a
33
+ # host's behalf: what rows does a pane freeze? A model's default scope
34
+ # is the tenant's rows when tenancy is enforced on the models, and every
35
+ # row when it lives in a policy, and nothing reachable from a job can
36
+ # tell which application this is. Unanswered it refuses, because the
37
+ # wrong answer here is frozen into a row and published (ADR 034).
38
+ def scope_for(model)
39
+ raise Unscoped, unanswered if @scope.nil?
40
+ raise ArgumentError, "#{@scope.inspect} is not a scope Janela knows. Pass :model_default, " \
41
+ "or override scope_for in a subclass of Janela::SnapshotJob." unless @scope == :model_default
42
+
43
+ model.all
44
+ end
45
+
46
+ def unanswered
47
+ "Janela::SnapshotJob has not been told what rows to freeze. If your tenancy is enforced " \
48
+ "on your models, a model's default scope is already the rows you mean, and saying so is " \
49
+ "the whole of it:\n\n" \
50
+ " Janela::SnapshotJob.perform_later(name: \"September\", scope: :model_default, panes: [...])\n\n" \
51
+ "If your scoping lives in your policies instead, that is every row of every model. " \
52
+ "Answer in Ruby, where a relation is still a relation, and schedule your own job:\n\n" \
53
+ " class TenantSnapshotJob < Janela::SnapshotJob\n" \
54
+ " private def scope_for(model) = model.where(account: owner)\n" \
55
+ " end\n\n" \
56
+ "docs/multi-tenancy.md has the wiring."
57
+ end
17
58
  end
18
59
  end
@@ -4,15 +4,24 @@ module Janela
4
4
  # live dashboard stays editable (ADR 009). Results are stored, not HTML; the
5
5
  # renderer is chosen by whoever shows a stored pane.
6
6
  class Snapshot < ActiveRecord::Base
7
+ # Janela sets what it is handed and reads nothing from it. The column
8
+ # exists so a multi tenant host's policy has something to filter a
9
+ # snapshot on, the same as a frame's (ADR 033).
10
+ belongs_to :owner, polymorphic: true, optional: true
11
+
7
12
  attribute :filters, default: -> { {} }
8
13
  attribute :panes, default: -> { [] }
9
14
 
10
15
  validates :name, :taken_at, presence: true
11
16
 
12
- def self.take(name:, filters: {}, taken_at: Time.current)
17
+ # The owner is an argument rather than something asked of a controller,
18
+ # because a snapshot is never taken in a request: this runs in a job, a
19
+ # task, a console or a host's own code, and the caller is the only thing
20
+ # that knows the answer (ADR 033).
21
+ def self.take(name:, owner: nil, filters: {}, taken_at: Time.current)
13
22
  taking = Taking.new(filters.to_h.stringify_keys)
14
23
  yield taking
15
- create!(name: name, taken_at: taken_at, filters: taking.filters, panes: taking.panes)
24
+ create!(name: name, owner: owner, taken_at: taken_at, filters: taking.filters, panes: taking.panes)
16
25
  end
17
26
 
18
27
  def stored_result(query)
@@ -0,0 +1,9 @@
1
+ class AddOwnerToJanelaSnapshots < ActiveRecord::Migration[8.0]
2
+ def change
3
+ # Janela never reads the owner. It is here so a host's Pundit Scope has
4
+ # something to filter a snapshot on, the same as a frame's (ADR 033).
5
+ # Nullable, because a snapshot nobody owns is invisible to a policy that
6
+ # filters on one, which is the safe direction to fail.
7
+ add_reference :janela_snapshots, :owner, polymorphic: true, null: true
8
+ end
9
+ end
@@ -2,7 +2,7 @@
2
2
  Date: 2026-09-15
3
3
  Status: Accepted
4
4
  Related: ADR 001, ADR 002, ADR 005, ADR 008, ADR 012
5
- Superseded in part by: ADR 012
5
+ Superseded in part by: ADR 012, ADR 033, ADR 034
6
6
  Triggers:
7
7
  - publishing a dashboard or pane for an audience that must not see live data or slicers
8
8
  - adding a database table, migration or model to the engine
@@ -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.