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 +4 -4
- data/CHANGELOG.md +18 -0
- data/README.md +27 -8
- data/UPGRADING.md +104 -0
- data/app/controllers/janela/application_controller.rb +3 -2
- data/app/helpers/janela/frames_helper.rb +4 -2
- data/app/jobs/janela/snapshot_job.rb +47 -6
- data/app/models/janela/snapshot.rb +11 -2
- data/db/migrate/20260921000001_add_owner_to_janela_snapshots.rb +9 -0
- data/docs/decisions/009-snapshots.md +1 -1
- 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 +9 -6
- data/docs/multi-tenancy.md +87 -18
- data/lib/janela/doctor.rb +58 -7
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +27 -0
- metadata +20 -8
|
@@ -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.
|
|
@@ -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.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -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, 029, 030 |
|
|
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, 031 |
|
|
41
|
+
| **Security** | 003, 025, 028, 031, 032, 034 |
|
|
42
42
|
| **Testing** | 003 |
|
|
43
43
|
|
|
44
44
|
## Chronological
|
|
@@ -76,7 +76,10 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
|
|
|
76
76
|
| 029 | A Pane's Frame Is Identified by Who It Is, Not by What It Shows | 2026-09-18 | Accepted |
|
|
77
77
|
| 030 | A Pane's src Belongs to Turbo, So a Host Talks to the Frame | 2026-09-18 | Accepted |
|
|
78
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 |
|
|
79
82
|
|
|
80
83
|
## Next number
|
|
81
84
|
|
|
82
|
-
Next ADR:
|
|
85
|
+
Next ADR: 035
|
data/docs/multi-tenancy.md
CHANGED
|
@@ -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 |
|
|
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
|
-
|
|
13
|
-
duck typing. Pundit defines the first for you. Anything else,
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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
|
|
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? &&
|
|
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
|
-
#
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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/version.rb
CHANGED