janela 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -1
  3. data/README.md +203 -21
  4. data/UPGRADING.md +136 -0
  5. data/app/assets/javascripts/janela/chart_controller.js +16 -8
  6. data/app/assets/javascripts/janela/{dashboard_controller.js → frame_controller.js} +11 -4
  7. data/app/assets/stylesheets/janela.css +163 -0
  8. data/app/controllers/janela/application_controller.rb +45 -0
  9. data/app/controllers/janela/frames_controller.rb +57 -0
  10. data/app/controllers/janela/panes_controller.rb +66 -13
  11. data/app/controllers/janela/queries_controller.rb +17 -0
  12. data/app/controllers/janela/{snapshot_panes_controller.rb → snapshot_queries_controller.rb} +7 -5
  13. data/app/helpers/janela/{dashboard_helper.rb → frames_helper.rb} +25 -9
  14. data/app/models/janela/frame.rb +28 -0
  15. data/app/models/janela/pane.rb +107 -85
  16. data/app/models/janela/query.rb +149 -0
  17. data/app/models/janela/snapshot.rb +6 -6
  18. data/app/views/janela/frames/_card.html.erb +7 -0
  19. data/app/views/janela/frames/_form.html.erb +23 -0
  20. data/app/views/janela/frames/_frame.html.erb +4 -0
  21. data/app/views/janela/frames/_pane.html.erb +12 -0
  22. data/app/views/janela/frames/edit.html.erb +25 -0
  23. data/app/views/janela/frames/index.html.erb +15 -0
  24. data/app/views/janela/frames/new.html.erb +5 -0
  25. data/app/views/janela/frames/show.html.erb +9 -0
  26. data/app/views/janela/panes/_form.html.erb +58 -0
  27. data/app/views/janela/panes/_row.html.erb +12 -0
  28. data/app/views/janela/panes/edit.html.erb +5 -0
  29. data/app/views/janela/panes/new.html.erb +22 -0
  30. data/app/views/janela/panes/show.html.erb +3 -44
  31. data/app/views/janela/queries/_query.html.erb +47 -0
  32. data/app/views/janela/queries/show.html.erb +4 -0
  33. data/app/views/janela/shared/_errors.html.erb +7 -0
  34. data/app/views/layouts/janela/application.html.erb +26 -0
  35. data/config/importmap.rb +1 -1
  36. data/config/locales/en.yml +64 -0
  37. data/config/routes.rb +15 -2
  38. data/db/migrate/20260916000001_create_janela_frames.rb +13 -0
  39. data/db/migrate/20260916000002_create_janela_panes.rb +22 -0
  40. data/docs/decisions/001-built-to-be-forked.md +4 -0
  41. data/docs/decisions/005-pane-urls-and-mount-path.md +4 -1
  42. data/docs/decisions/009-snapshots.md +5 -2
  43. data/docs/decisions/010-agent-guidance-ships-the-agent-waits.md +134 -0
  44. data/docs/decisions/011-panes-do-not-render-in-the-host-layout.md +81 -0
  45. data/docs/decisions/012-frames-and-panes-are-data.md +166 -0
  46. data/docs/decisions/013-naming-and-addressing-frames.md +119 -0
  47. data/docs/decisions/014-corrections-before-frames-are-built.md +222 -0
  48. data/docs/decisions/015-how-breaking-change-is-communicated.md +114 -0
  49. data/docs/decisions/016-the-styling-vocabulary.md +100 -0
  50. data/docs/decisions/017-janela-owns-no-data-store.md +113 -0
  51. data/docs/decisions/018-a-table-is-the-universal-renderer.md +75 -0
  52. data/docs/decisions/019-a-created-frame-asks-the-host-who-owns-it.md +79 -0
  53. data/docs/decisions/020-formatting-belongs-to-the-measure.md +93 -0
  54. data/docs/decisions/021-a-check-has-a-name-a-host-can-silence.md +84 -0
  55. data/docs/decisions/INDEX.md +24 -5
  56. data/docs/multi-tenancy.md +175 -0
  57. data/lib/janela/definition.rb +23 -9
  58. data/lib/janela/dimension.rb +5 -1
  59. data/lib/janela/doctor.rb +219 -0
  60. data/lib/janela/engine.rb +15 -2
  61. data/lib/janela/measure.rb +68 -4
  62. data/lib/janela/version.rb +1 -1
  63. data/lib/janela.rb +24 -1
  64. data/lib/tasks/janela.rake +6 -0
  65. metadata +57 -4
@@ -0,0 +1,100 @@
1
+ ---
2
+ Date: 2026-09-16
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 012, ADR 014
5
+ Triggers:
6
+ - adding or renaming a CSS class the engine renders
7
+ - adding a layout value a person can edit
8
+ - changing the spacing scale or the grid
9
+ - a host asking how to restyle Janela
10
+ Topics: css, layout, styling, public-api, security
11
+ ---
12
+
13
+ # ADR 016: The Styling Vocabulary
14
+
15
+ ## Context
16
+
17
+ ADR 012 made a frame's layout data: how many columns, what gap, how
18
+ many columns a pane spans. ADR 014 confirmed the vocabulary is CSS
19
+ Grid's rather than a new glossary. Two things still had to be decided
20
+ before any of it is rendered.
21
+
22
+ The first is safety. These values are edited by an analyst, and a
23
+ length like `1rem` interpolated into a style attribute is injectable:
24
+ `1rem; position: fixed; top: 0` is valid CSS and escaping does not
25
+ help inside an attribute. The field is harmless today only because
26
+ nobody can type into it yet, which changes in build 4.
27
+
28
+ The second is that Janela ships no CSS at all, so a table pane renders
29
+ with no padding and a label runs into its number, reading as `false55`
30
+ (issue #15). A grid means nothing without CSS, so the stylesheet stops
31
+ being optional the moment frames exist.
32
+
33
+ Tailwind is the obvious model, and the part worth taking is not
34
+ utility classes but the reason they work: the scale is **finite and
35
+ indexed rather than measured**, so an inconsistent design is not
36
+ expressible.
37
+
38
+ ## Decision
39
+
40
+ **Layout values are small integers, and the gem supplies the units.**
41
+ `columns` 1 to 12, `gap` 0 to 8, `span` 1 to 12. An integer cannot
42
+ inject anything, so the field is safe by construction rather than by a
43
+ regular expression somebody has to keep trusting.
44
+
45
+ **An integer selects a class; it is never interpolated into a style
46
+ attribute.** The engine renders class names and the shipped stylesheet
47
+ defines them:
48
+
49
+ ```css
50
+ .janela-frame { display: grid; }
51
+ .janela-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
52
+ .janela-gap-4 { gap: calc(var(--janela-space) * 4); }
53
+ .janela-span-2 { grid-column: span 2; }
54
+ ```
55
+
56
+ Around thirty rules, enumerated for every value the validations
57
+ permit. Nothing an analyst supplies reaches CSS: the integer only
58
+ chooses a rule that was already written.
59
+
60
+ **One naming rule: `janela-{property}-{scale}`.** `janela-cols-3`,
61
+ `janela-gap-4`, `janela-span-2`. Learn it once and the rest is
62
+ predictable, which is doing more work in Tailwind's success than any
63
+ individual class.
64
+
65
+ **One base unit is the whole spacing theme.** `--janela-space`,
66
+ defaulting to `0.25rem`. A host sets it once and the entire scale
67
+ moves, without touching data or overriding rules.
68
+
69
+ **Responsive behaviour is in the stylesheet, not in the data.** The
70
+ grid collapses to a single column below a narrow breakpoint. An
71
+ analyst chooses a column count, not a set of breakpoints, and a
72
+ dashboard that is unreadable on a phone is not a choice worth
73
+ offering. Per breakpoint control, if ever needed, is a separate
74
+ decision.
75
+
76
+ **No arbitrary value escape hatch.** Tailwind offers `gap-[17px]`
77
+ because a developer occasionally has to defy the design system. Here
78
+ the editor is an analyst, the constraint is the point, and an escape
79
+ hatch reintroduces exactly the injection surface these integers close.
80
+
81
+ **The class names are public API.** A host restyles Janela by
82
+ overriding `.janela-pane`, `.janela-gap-4` and the rest, or by setting
83
+ `--janela-space`, so renaming one is a breaking change and belongs in
84
+ `UPGRADING.md` like any other (ADR 015). The stylesheet ships as
85
+ `app/assets/stylesheets/janela.css` and a host includes it, rather
86
+ than the engine injecting it into a layout it does not own.
87
+
88
+ ## Consequences
89
+
90
+ - Issue #15 is answered: the gem ships CSS, so a pane is legible on
91
+ install, and a host overrides rather than writes from scratch.
92
+ - An analyst cannot express `1.375rem` of gap. Intended: the realistic
93
+ choices are none, small, medium and large, and a finite scale is why
94
+ a dashboard built by several people still looks like one thing.
95
+ - The enumerated rules must stay in step with the validation ranges.
96
+ A value that validates but has no class renders unstyled and
97
+ silently, so the test suite asserts the two agree.
98
+ - Janela now owns a stylesheet, which is a surface it has to maintain
99
+ and a thing hosts will want to argue with. Custom properties are
100
+ the pressure valve: retheme without forking.
@@ -0,0 +1,113 @@
1
+ ---
2
+ Date: 2026-09-16
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 002, ADR 004, ADR 007, ADR 009
5
+ Triggers:
6
+ - proposing a data store, warehouse, cache or copy of a host's data
7
+ - a pane or a frame being measurably slow on a real host
8
+ - adding a database adapter or a second connection
9
+ - anything that would query outside ActiveRecord and therefore outside the host's scope
10
+ Topics: performance, columnar, storage, authorisation, scope, dependencies
11
+ ---
12
+
13
+ # ADR 017: Janela Owns No Data Store
14
+
15
+ ## Context
16
+
17
+ Commercial BI tools mostly answer performance by taking a copy: extract
18
+ the host's data into a columnar store the vendor owns, and query that.
19
+ Columnar storage is genuinely the right shape for what Janela does, a
20
+ `SUM` or a `COUNT` grouped by one dimension over many rows, and the
21
+ motivation for keeping that store local rather than in someone else's
22
+ cloud is the same instinct that made this project worth starting.
23
+
24
+ The Postgres ecosystem has caught up in a way that matters here.
25
+ [`pg_duckdb` reached 1.0](https://motherduck.com/blog/pg-duckdb-release/)
26
+ with over a million downloads by its own account,
27
+ [`pg_ducklake` became production ready in January 2026](https://github.com/duckdb/pg_duckdb),
28
+ and Citus has had a columnar access method for years. Each of these
29
+ puts a vectorised columnar engine **inside** Postgres rather than
30
+ beside it, which is the distinction this ADR turns on. Read those
31
+ before reopening the question: they are the reason it is worth
32
+ reopening at all, and they are also the reason it does not need to be
33
+ reopened yet.
34
+
35
+ ADR 001 already declined a separate data warehouse and ETL. This ADR
36
+ records why that holds even now that a local columnar store is
37
+ practical, and what Janela does instead.
38
+
39
+ ## Decision
40
+
41
+ **Janela never holds a copy of a host's data.** No warehouse, no
42
+ extract, no second connection, no adapter of its own.
43
+
44
+ The reason is not performance, it is authorisation. Everything safe
45
+ about this gem rests on `on: policy_scope(Order)`, an ActiveRecord
46
+ relation carrying the host's Pundit scope and whatever tenancy it
47
+ uses (ADR 002, ADR 004). A copy has no relation, no policy and no
48
+ tenant filter, so a copy means reimplementing row level security
49
+ against it. That is the subsystem ADR 001 refused, and getting it
50
+ subtly wrong shows one tenant's numbers to another, which is a far
51
+ worse failure than a slow pane.
52
+
53
+ **Janela stays fast by generating SQL a columnar engine can
54
+ accelerate, and by getting out of the way.** A host that needs
55
+ analytical speed converts the table it reports on, with
56
+ [`pg_duckdb`](https://github.com/duckdb/pg_duckdb), `pg_ducklake`,
57
+ Citus columnar or whatever its own database offers. The
58
+ table stays addressable by ActiveRecord, so Pundit, tenancy and every
59
+ part of Janela's query path keep working, and **Janela gets faster
60
+ with no change to the gem at all**. That is a page of documentation
61
+ rather than an architecture, and it is the right division: the host
62
+ owns its storage, the gem owns the question.
63
+
64
+ It follows that the gem's obligation is to emit plain, grouped,
65
+ aggregate SQL and not to outsmart the planner. ADR 007's ordering and
66
+ limit are the whole of the performance surface Janela owns.
67
+
68
+ **Snapshots are already the useful part of a warehouse.** ADR 009
69
+ persists frozen results with no extract, no staleness question and no
70
+ authorisation problem, because a snapshot was taken *under* a scope
71
+ and stores only what that scope returned. Anything that wants
72
+ precomputed aggregates should reach for a snapshot before it reaches
73
+ for a store.
74
+
75
+ **No measured problem exists.** The demo runs on 600 rows, the
76
+ commercial host's largest reported table on about 1,100, the personal
77
+ host's on four. Nothing is slow, and ADR 001 is explicit about not
78
+ building for hypothetical futures.
79
+
80
+ **What would change this.** A pane, measured on a real host, that is
81
+ too slow to use after its table has been made columnar and after
82
+ ADR 007's limit has been applied. At that point the question is
83
+ whether Janela should read a host-owned materialised view or
84
+ continuous aggregate, which is still the host's data under the host's
85
+ scope, and not whether Janela should own a store. That distinction is
86
+ the thing this ADR is really protecting.
87
+
88
+ ## Consequences
89
+
90
+ - A host with genuinely large tables must do something about its own
91
+ storage, and the gem can only document the options rather than solve
92
+ it. That is a real limitation and it is the correct one: the
93
+ alternative is a copy whose access control Janela cannot honestly
94
+ guarantee.
95
+ - The README gains a short performance section naming the columnar
96
+ extensions and pointing at snapshots. It must be honest that the
97
+ gem has not been tested against a large columnar table, because it
98
+ has not.
99
+ - Refusing a second connection also refuses a whole category of
100
+ feature: querying anything that is not an ActiveRecord model in the
101
+ host. Reporting across two applications, or over a CSV, or over a
102
+ warehouse the business already has, is out of scope for the same
103
+ reason.
104
+ - [`activerecord-duckdb`](https://rubygems.org/gems/activerecord-duckdb)
105
+ is at 0.1.0 and describes itself as incomplete, so even the tempting
106
+ version of this would be built on something immature today. That is
107
+ a timing observation rather than a reason, and it will stop being
108
+ true, which is why it is recorded as an observation.
109
+ - This is the third time a scope question has been settled by asking
110
+ where authorisation lives (ADR 002's `on:`, ADR 012's persisted
111
+ panes, now this). It is worth naming as the project's actual test:
112
+ **if a feature cannot be expressed as a scoped ActiveRecord
113
+ relation, it is probably not Janela's to build.**
@@ -0,0 +1,75 @@
1
+ ---
2
+ Date: 2026-09-16
3
+ Status: Accepted
4
+ Related: ADR 009, ADR 011, ADR 013, ADR 016
5
+ Triggers:
6
+ - rendering a pane where the chart runtime may be absent
7
+ - adding a renderer, or changing what a renderer means
8
+ - a pane rendering as something other than its row says
9
+ - the engine serving a page of its own
10
+ Topics: rendering, renderers, charts, progressive-enhancement, engines
11
+ ---
12
+
13
+ # ADR 018: A Table Is the Universal Renderer
14
+
15
+ ## Context
16
+
17
+ ADR 013 promised a host could install the gem and navigate its own
18
+ dashboards the same day, which is why the engine serves an index and a
19
+ frame page of its own. ADR 011 established that those pages render in
20
+ the engine's minimal layout, which loads none of the host's assets
21
+ because the engine cannot know their names or bundler. ADR 014 then
22
+ had a frame render its panes inline so that the page is correct before
23
+ any JavaScript.
24
+
25
+ Correct, but not complete. A chart pane is a `canvas` the Stimulus
26
+ chart controller fills in, and the engine's own pages deliberately
27
+ load no Stimulus and no Chart.js. So a frame with two chart panes
28
+ served by the engine shows two empty holes the height of a chart. The
29
+ claim that those pages are a correct static dashboard was not true.
30
+
31
+ The engine could not fix this by loading a chart runtime: emitting a
32
+ working module graph needs the host's importmap or bundle, which is
33
+ exactly what ADR 011 said the engine cannot assume.
34
+
35
+ ## Decision
36
+
37
+ **Where no chart runtime exists, a chart pane renders as a table.**
38
+
39
+ The engine's own frame page passes `charts: false` to `janela_frame`,
40
+ and a pane whose renderer is `bar` or `line` renders its table instead.
41
+ A host's own page renders charts normally, because a host that
42
+ registered the Stimulus controllers has the runtime.
43
+
44
+ This is not JavaScript detection and it is not a fallback that guesses.
45
+ The engine's pages deterministically have no chart renderer, so they
46
+ deterministically use the one that needs nothing.
47
+
48
+ **A renderer is a viewing choice, not part of the pane.** ADR 009
49
+ already decided this when it made a snapshot store results rather than
50
+ markup, and this follows from it: the row records that a pane is best
51
+ seen as a bar chart, and a surface that cannot draw one shows the same
52
+ numbers another way. Nothing about the data changes.
53
+
54
+ **A table is the renderer that always works**, needing no JavaScript,
55
+ no canvas and no measurement, which is why it is the one to fall back
56
+ to rather than an error or an empty space.
57
+
58
+ ## Consequences
59
+
60
+ - The engine's own pages are now what ADR 013 promised: a host mounts
61
+ the engine and reads a real dashboard the same day, with no
62
+ JavaScript wiring at all.
63
+ - The same frame can look different in two places: a bar chart on a
64
+ host's page and a table on the engine's. That is the cost, it will
65
+ surprise someone, and the README says so plainly rather than leaving
66
+ them to discover it.
67
+ - Cross-filtering is still absent from the engine's pages, because
68
+ that genuinely needs Stimulus. They are for reading, and a host that
69
+ wants interaction renders the frame in its own page, which is one
70
+ helper call.
71
+ - `charts:` is a rendering option rather than a stored column, so no
72
+ migration and nothing for an analyst to get wrong.
73
+ - If the engine ever gains a way to know a host's asset setup, this
74
+ decision is worth revisiting, because the better answer is a chart
75
+ that draws everywhere.
@@ -0,0 +1,79 @@
1
+ ---
2
+ Date: 2026-09-16
3
+ Status: Accepted
4
+ Related: ADR 002, ADR 004, ADR 012, ADR 014
5
+ Triggers:
6
+ - creating a frame or any other record on a host's behalf
7
+ - adding a configuration setting to Janela
8
+ - a host needing to influence what the engine writes
9
+ - a record being invisible to the scope that was meant to find it
10
+ Topics: authorisation, tenancy, persistence, host-integration, configuration
11
+ ---
12
+
13
+ # ADR 019: A Created Frame Asks the Host Who Owns It
14
+
15
+ ## Context
16
+
17
+ ADR 014 gave a frame a nullable polymorphic owner purely so a host's
18
+ Pundit scope has a column to filter on, and said Janela never
19
+ interprets it. That was right while frames were only created in a
20
+ console or a seed, where whoever created one could set it.
21
+
22
+ Build 4 lets an analyst create a frame through the engine's own form,
23
+ and the gap becomes a fault: Janela writes the row, so Janela decides
24
+ the owner, and it has nothing to decide with. A frame saved with no
25
+ owner is hidden by the very scope meant to find it, so the analyst
26
+ creates a dashboard and it disappears. That failure has already
27
+ happened twice in this project by other routes, once in a test and
28
+ once on the live demo, which is how confidently it can be predicted
29
+ here.
30
+
31
+ Three ways out. A configuration setting naming the owner. The engine
32
+ not creating frames at all, leaving it to each host, which defeats the
33
+ purpose of build 4 since the analyst is the person who cannot deploy.
34
+ Or asking the host at the moment of writing.
35
+
36
+ ## Decision
37
+
38
+ **If the host's controller responds to `janela_frame_owner`, the
39
+ engine assigns its return value as the owner of a frame it creates.**
40
+
41
+ ```ruby
42
+ class ApplicationController < ActionController::Base
43
+ private
44
+ def janela_frame_owner
45
+ Current.account
46
+ end
47
+ end
48
+ ```
49
+
50
+ A host that defines nothing gets a nil owner, which is correct for a
51
+ single tenant application and is what the demo did before it had
52
+ tenancy at all.
53
+
54
+ This is the pattern `policy_scope` already established (ADR 004):
55
+ Janela asks the host's controller a question by duck typing, and takes
56
+ silence as an answer rather than requiring configuration. It keeps
57
+ Janela's settings at one, `parent_controller`, and it puts the
58
+ decision in the only place that can make it, inside a request where
59
+ the current user and tenant exist.
60
+
61
+ **Janela still never interprets the owner.** It assigns what it is
62
+ handed and reads nothing from it. Scoping remains entirely the host's
63
+ policy, exactly as ADR 014 said.
64
+
65
+ ## Consequences
66
+
67
+ - An analyst can create a dashboard and see it, which is the whole
68
+ point of build 4 and was impossible without this.
69
+ - A multi tenant host that forgets to define the method gets frames
70
+ nobody can see. That is a quiet failure, so it is what the doctor
71
+ task should notice: a host with a `policy_scope` that filters frames
72
+ by owner, but no `janela_frame_owner`, is misconfigured. Worth
73
+ adding to ADR 015's checks.
74
+ - Duck typing means a typo in the method name fails silently, which is
75
+ the cost of not having configuration. The doctor check above is the
76
+ mitigation.
77
+ - The same question will arrive for any other record Janela writes on
78
+ a host's behalf. The answer is this pattern rather than a second
79
+ setting, and this ADR is the precedent to cite.
@@ -0,0 +1,93 @@
1
+ ---
2
+ Date: 2026-09-16
3
+ Status: Accepted
4
+ Related: ADR 002, ADR 009, ADR 012, ADR 018
5
+ Triggers:
6
+ - a number rendering with more precision than it means
7
+ - adding an option to a pane, a frame or a pane URL
8
+ - units, currency or percentages
9
+ - a chart and a table showing the same number differently
10
+ Topics: dsl, rendering, charts, snapshots, configuration
11
+ ---
12
+
13
+ # ADR 020: Formatting Belongs to the Measure
14
+
15
+ ## Context
16
+
17
+ An averaged measure rendered as `928.8767833333333`. Tables ran
18
+ values through `number_with_delimiter`, which handles thousands and
19
+ nothing else, and a chart tooltip showed whatever the database
20
+ returned. Nothing anywhere said how many decimal places a number
21
+ means, or that it is money, or a percentage.
22
+
23
+ There are four layers that could say, and each was a real candidate.
24
+
25
+ The **pane** is where an analyst works, so it is the tempting answer:
26
+ this chart in thousands, that one to the cent. But a pane is a
27
+ database row and a form field, so every knob there is a migration, a
28
+ validation and another decision put to someone who should not have to
29
+ know that `amount` is a decimal with a scale of two. Worse, the same
30
+ measure on two frames could disagree about itself.
31
+
32
+ The **pane URL** would make it ad hoc, `?precision=0`. That widens the
33
+ grammar ADR 005 fixed, and it lets anyone holding a link change what a
34
+ number appears to say. Precision is not a question a reader asks.
35
+
36
+ The **frame** is the wrong shape. One frame holds panes from different
37
+ models, so a single setting would be right for the currency and wrong
38
+ for the count sitting next to it.
39
+
40
+ The **measure** is the only one of the four that knows what the number
41
+ *is*. The other three know where it appears.
42
+
43
+ ## Decision
44
+
45
+ **A measure declares its own format, and every renderer asks the
46
+ measure.**
47
+
48
+ ```ruby
49
+ janela do
50
+ measure :revenue, sum: :amount, prefix: "$"
51
+ measure :pass_rate, average: :score, precision: 1, suffix: "%"
52
+ measure :orders, count: true
53
+ end
54
+ ```
55
+
56
+ **Almost nothing needs declaring, because the schema already knows.**
57
+ Precision is the measure's own if it declares one, otherwise the
58
+ column's: counting rows has no decimal places, a `decimal(10, 2)`
59
+ column has two, summing an integer column stays whole. Only where
60
+ neither says anything, such as averaging an integer, does it fall back
61
+ to two places. Thousands are delimited with the host's own locale.
62
+
63
+ `prefix` and `suffix` carry the unit, which no amount of rounding can
64
+ say. They are two plain strings rather than a currency vocabulary,
65
+ because a string is the thing a forker can read and replace in a
66
+ minute.
67
+
68
+ **Formatting is rendering, never rounding.** The number itself reaches
69
+ a snapshot, an order clause and a comparison at full precision. A
70
+ stored pane is data (ADR 009), so a snapshot taken last month reads
71
+ back under a format declared today.
72
+
73
+ **One formatter serves every renderer.** A table cell, a single value
74
+ and a chart tooltip all show the string the measure produced, and the
75
+ chart is handed those strings rather than formatting a second time in
76
+ JavaScript. The chart still plots raw numbers, because an axis is a
77
+ scale and not a label.
78
+
79
+ ## Consequences
80
+
81
+ - The common measures read correctly with nothing declared, which is
82
+ the test this had to pass: money as money, counts as counts.
83
+ - A host changes how every one of its numbers reads by editing its
84
+ model, not by touching a dashboard, a URL or a template.
85
+ - A measure that wants two formats is two measures. That is a real
86
+ limit, and it is the same answer ADR 002 gives to every other
87
+ variation: declare what you mean and name it.
88
+ - The chart controller now takes a `formatted` value alongside
89
+ `values`. A host that renders its own chart from Janela's data has
90
+ the strings available and is not obliged to use them.
91
+ - If per pane formatting ever proves necessary, the measure's format
92
+ is the default it would override, not something to be undone first.
93
+ This is the layer to add it to, and the reason to think twice.
@@ -0,0 +1,84 @@
1
+ ---
2
+ Date: 2026-09-16
3
+ Status: Accepted
4
+ Related: ADR 010, ADR 015, ADR 019
5
+ Triggers:
6
+ - adding a check to janela:doctor
7
+ - adding a setting to Janela
8
+ - a warning that is a false alarm for some applications
9
+ - running the doctor in CI
10
+ Topics: configuration, tooling, upgrades, agent-guidance
11
+ ---
12
+
13
+ # ADR 021: A Check Has a Name, and a Host Can Silence It
14
+
15
+ ## Context
16
+
17
+ `janela:doctor` reports what a host still has to do, and one of its
18
+ checks cannot be certain. Authentication is whatever the host does, so
19
+ the check reads the controller's filters and says what it sees. For an
20
+ application that authenticates another way, that warning is wrong
21
+ every single run.
22
+
23
+ A warning that is always wrong is worse than no warning. People learn
24
+ to skip the output, and the next finding, the true one, goes unread
25
+ with it. In CI it is worse again, because the only way to keep a green
26
+ build is to stop running the task.
27
+
28
+ The pattern here is settled elsewhere. Django's system check framework
29
+ gives every check an identifier and lets a project list the ones to
30
+ silence. Homebrew, Flutter, npm, Bundler and RubyGems all ship a
31
+ doctor of some shape, and the mature ones can be told to be quiet
32
+ about a thing the maintainer has already judged.
33
+
34
+ Janela had neither half: findings were prose with no stable handle, so
35
+ there was nothing to name in a conversation, a commit message or a
36
+ setting.
37
+
38
+ ## Decision
39
+
40
+ **Every finding carries the name of the check that produced it**, in
41
+ the output and on the finding itself:
42
+
43
+ ```
44
+ WARNING (unauthenticated-endpoints): no authentication filter found on ApplicationController
45
+ ```
46
+
47
+ The name is the check's own method name, dasherised by the runner
48
+ rather than written out per check, so a code and the thing it names
49
+ cannot drift apart. Words rather than numbers: there are seven checks,
50
+ not seven hundred, and `unmigrated-tables` needs no lookup table the
51
+ way `janela.E004` would.
52
+
53
+ **A host silences a check by that name**:
54
+
55
+ ```ruby
56
+ # config/initializers/janela.rb
57
+ Janela.silenced_checks = %w[unauthenticated-endpoints]
58
+ ```
59
+
60
+ **A silenced check is named every run**, in one line at the end, even
61
+ when there is nothing else to report. A silence nobody remembers is
62
+ how a real finding goes unread, which is the problem this is meant to
63
+ solve rather than reproduce.
64
+
65
+ This is Janela's second setting, and deliberately so. ADR 019 turned
66
+ down configuration for what owns a frame because ownership is
67
+ per-request state that a setting cannot hold. Silencing is the
68
+ opposite: a judgement made once, about the application as a whole,
69
+ that does not change between requests. That is what configuration is
70
+ for, and the distinction is the rule to apply next time rather than a
71
+ preference about this one.
72
+
73
+ ## Consequences
74
+
75
+ - The doctor can run in CI on an application it cannot fully
76
+ understand, which is the only way it gets run at all.
77
+ - Silencing an error passes the task. That is the point and it is a
78
+ loaded gun, which is why the silence is printed every run.
79
+ - A check's name is now public. Renaming a check method renames what a
80
+ host silences, so a rename is a breaking change and goes in
81
+ UPGRADING.md like any other (ADR 015).
82
+ - Agents get a stable handle too. "Silence unmigrated-tables" is an
83
+ instruction that can be given and followed without quoting a
84
+ sentence of prose (ADR 010).
@@ -20,16 +20,23 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
20
20
 
21
21
  | Topic | ADRs |
22
22
  |-------|------|
23
- | **Vision, scope, forkability** | 001 |
23
+ | **Vision, scope, forkability** | 001, 010, 012 |
24
24
  | **Open-source & host-decoupling** | 001 |
25
25
  | **DSL & query layer** | 002, 006, 007 |
26
- | **Dependencies** | 002, 003, 004, 006 |
27
- | **Authorisation** | 002, 003, 004, 009 |
26
+ | **Dependencies** | 002, 003, 004, 006, 017 |
27
+ | **Authorisation** | 002, 003, 004, 009, 017, 019 |
28
+ | **Performance & storage** | 007, 017 |
28
29
  | **Cross-filtering & Hotwire** | 003, 004, 005, 008 |
30
+ | **Layouts & views** | 011, 012, 016, 018 |
31
+ | **CSS & styling** | 016, 018 |
32
+ | **Frames, panes & persistence** | 012, 013, 014, 019 |
33
+ | **Naming rule** | 014 |
29
34
  | **JavaScript delivery & charts** | 004, 006 |
30
35
  | **Time dimensions** | 006 |
31
- | **Routes, URLs & naming** | 005, 007, 008, 009 |
36
+ | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013 |
32
37
  | **Snapshots & publishing** | 009 |
38
+ | **AI agents & guidance** | 010, 015 |
39
+ | **Releases & upgrades** | 015 |
33
40
  | **Security** | 003 |
34
41
  | **Testing** | 003 |
35
42
 
@@ -46,7 +53,19 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
46
53
  | 007 | Ordering and Limits | 2026-09-15 | Accepted |
47
54
  | 008 | Dashboard Filters in the Page URL | 2026-09-15 | Accepted |
48
55
  | 009 | Snapshots | 2026-09-15 | Accepted |
56
+ | 010 | Agent Guidance Ships, the Agent Waits | 2026-09-15 | Accepted |
57
+ | 011 | Panes Do Not Render in the Host Layout | 2026-09-15 | Accepted |
58
+ | 012 | Frames and Panes Are Data | 2026-09-15 | Accepted |
59
+ | 013 | Naming and Addressing Frames | 2026-09-15 | Accepted |
60
+ | 014 | Corrections Before Frames Are Built | 2026-09-15 | Accepted |
61
+ | 015 | How Breaking Change Is Communicated | 2026-09-16 | Accepted |
62
+ | 016 | The Styling Vocabulary | 2026-09-16 | Accepted |
63
+ | 017 | Janela Owns No Data Store | 2026-09-16 | Accepted |
64
+ | 018 | A Table Is the Universal Renderer | 2026-09-16 | Accepted |
65
+ | 019 | A Created Frame Asks the Host Who Owns It | 2026-09-16 | Accepted |
66
+ | 020 | Formatting Belongs to the Measure | 2026-09-16 | Accepted |
67
+ | 021 | A Check Has a Name, and a Host Can Silence It | 2026-09-16 | Accepted |
49
68
 
50
69
  ## Next number
51
70
 
52
- Next ADR: 010
71
+ Next ADR: 022