janela 0.11.0 → 0.13.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +42 -1
  3. data/README.md +50 -27
  4. data/UPGRADING.md +57 -1
  5. data/app/assets/javascripts/janela/chart_controller.js +6 -1
  6. data/app/assets/javascripts/janela/frame_controller.js +19 -9
  7. data/app/assets/stylesheets/janela.css +23 -1
  8. data/app/assets/stylesheets/vitral.css +21 -0
  9. data/app/controllers/janela/panes_controller.rb +2 -2
  10. data/app/controllers/janela/queries_controller.rb +3 -0
  11. data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
  12. data/app/helpers/janela/frames_helper.rb +12 -4
  13. data/app/models/janela/pane.rb +32 -1
  14. data/app/models/janela/query.rb +116 -4
  15. data/app/views/janela/panes/_form.html.erb +25 -0
  16. data/app/views/janela/queries/_query.html.erb +36 -14
  17. data/config/locales/en.yml +19 -0
  18. data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
  19. data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
  20. data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
  21. data/docs/agents.md +97 -0
  22. data/docs/composing.md +14 -14
  23. data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
  24. data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
  25. data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
  26. data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
  27. data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
  28. data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
  29. data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
  30. data/docs/decisions/052-how-the-project-is-run.md +93 -0
  31. data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
  32. data/docs/decisions/INDEX.md +27 -20
  33. data/docs/multi-tenancy.md +22 -4
  34. data/docs/roadmap.md +34 -35
  35. data/docs/theming.md +10 -4
  36. data/lib/janela/definition.rb +71 -4
  37. data/lib/janela/dimension.rb +7 -0
  38. data/lib/janela/doctor.rb +66 -8
  39. data/lib/janela/engine.rb +13 -1
  40. data/lib/janela/measure.rb +56 -7
  41. data/lib/janela/tools.rb +220 -0
  42. data/lib/janela/version.rb +1 -1
  43. data/lib/janela.rb +1 -0
  44. metadata +20 -9
@@ -0,0 +1,160 @@
1
+ ---
2
+ Date: 2026-09-30
3
+ Status: Accepted
4
+ Related: ADR 002, ADR 005, ADR 007, ADR 009, ADR 020, ADR 025, ADR 029, ADR 032, ADR 037, ADR 038, ADR 047
5
+ Triggers:
6
+ - a table pane that needs a second number or a second fact beside its label
7
+ - a rate whose sample size decides whether to trust it
8
+ - adding a column to janela_panes, or a parameter to the pane URL
9
+ - changing the shape of what a query returns
10
+ - reading a column that is not the pane's dimension or measure
11
+ Topics: dsl, query layer, measures, tables, snapshots, public-api, performance
12
+ ---
13
+
14
+ # ADR 051: A Table Can Carry Companion Columns, and a Fact Is Shown Only When It Is Shared
15
+
16
+ ## Context
17
+
18
+ #34, raised from a real install: a table pane renders exactly two cells per
19
+ row, the label and the value, and that runs out the moment the label needs
20
+ context to be read. A top ten of words by times right, "quero 21", is really
21
+ asking which words *and of what kind*. The other half is worth more: a
22
+ "worst" list by error rate cannot say whether 75% came from four reviews or
23
+ forty, which is exactly what decides whether to act on it. #27's ratio makes
24
+ that case more common, not less.
25
+
26
+ Investigated against the demo's fixtures, and the way the issue proposes to
27
+ resolve "the attribute on the same row" does not survive contact with data.
28
+
29
+ **Nothing in the code can carry a second number today.** A table is a
30
+ `<caption>` and a `<tbody>` of two cells with no header row, and
31
+ `Definition#query` returns a hash of label to value from which the table, a
32
+ chart's arrays, a ring's slices and a stored snapshot are all built. This is
33
+ not a view change.
34
+
35
+ **A dimension attribute has three obvious implementations and two are wrong.**
36
+ Measured, with customer names and their regions, and with statuses and regions
37
+ (`paid` orders come from two regions):
38
+
39
+ | Approach | by customer, region (one each) | by status, region (`paid` has two) |
40
+ | --- | --- | --- |
41
+ | group by both | correct | `paid` appears twice: the two dimension grouping the issue rejected |
42
+ | `MAX(attr)` | correct | `paid` reads `EU`, silently: an arbitrary value shown as fact |
43
+ | `CASE WHEN MIN(attr) = MAX(attr) THEN MIN(attr) END` | correct | `paid` is blank, the others right |
44
+
45
+ A bare column is not an option on a strict database. The third form is one
46
+ expression, works on SQLite, PostgreSQL and MySQL, and is the only one that
47
+ cannot be silently wrong: a value that differs across the rows of a group is
48
+ not shown as though it were one thing.
49
+
50
+ **A second measure is cheap.** A second grouped calculation merged on the group
51
+ key, or one `pluck`, gives `{"paid" => [1.0, 2], ...}` for a rate and the row
52
+ count behind it. Ordering and the limit belong to the primary measure and are
53
+ untouched: the extras are fetched for the keys already chosen.
54
+
55
+ ### What was considered
56
+
57
+ **Group by both.** Rejected, above: it multiplies the rows whenever the
58
+ attribute is not determined by the label, which is not a case a declaration can
59
+ rule out ahead of the data.
60
+
61
+ **`MAX` or `MIN`.** Rejected: an arbitrary value presented as fact is this
62
+ library's worst failure, a wrong number that looks right.
63
+
64
+ **A second dimension in `by:`.** Rejected: it answers a different question and
65
+ multiplies the rows, which the issue itself said.
66
+
67
+ **A concatenated generated column, or a SQL fragment option.** Rejected: it puts
68
+ presentation in the schema, gives up filtering on either part, and a SQL string
69
+ in a declaration is the second query language ADR 002 refused.
70
+
71
+ **Calling it `attributes`.** Rejected as a name: `attributes` is an
72
+ `ActiveRecord::Base` method on a `Pane` row, and a column of that name would
73
+ shadow it.
74
+
75
+ ## Decision
76
+
77
+ **A table pane may carry up to three companions: a measure or a dimension of the
78
+ same model, drawn as a column beside its label. A measure is its own
79
+ aggregate, formatted as it declares, and a dimension is shown only when every
80
+ row of the group shares one value.**
81
+
82
+ ```erb
83
+ <%= janela_pane Order, :expedited_rate, by: :customer, as: :table, companions: [:orders, :region] %>
84
+ ```
85
+
86
+ ```
87
+ Customer Expedited rate Orders Region
88
+ Acme 75.0% 4 APAC
89
+ Globex 50.0% 40 EU
90
+ ```
91
+
92
+ **What may be named.** A companion is a declared measure or a declared
93
+ dimension of the pane's model, by the name the model declared it under, and
94
+ nothing else: no column, no expression, no string that becomes SQL (ADR 025,
95
+ ADR 032). The pane's own measure and dimension cannot be repeated, and a time
96
+ dimension cannot be a companion, since a bucket is not a fact about a label.
97
+ A time pane may carry measures. An unknown name is a `BadRequest` that names
98
+ the pane's declared measures and dimensions, as an unknown filter does.
99
+
100
+ **A measure companion** is a second grouped calculation merged on the group key,
101
+ one query each, formatted by its own declaration (ADR 020, ADR 038) so a
102
+ ratio beside a count reads `75.0%` beside `4`.
103
+
104
+ **A dimension companion** is `CASE WHEN MIN(col) = MAX(col) THEN MIN(col) END` in
105
+ one query for all of a pane's dimension companions. A group whose rows disagree
106
+ shows nothing, not a guess. A group whose rows are all null shows nothing too.
107
+
108
+ **Nothing about the primary changes.** The pane's measure orders and limits it
109
+ (ADR 007), the label is the click target, filters and cross-filtering read as
110
+ they always did, and the returned hash is still label to value. The
111
+ companions are a separate answer keyed by label, so a chart, a ring and a
112
+ stored snapshot are built exactly as before.
113
+
114
+ **Only a table draws them.** A chart, a ring and a single value ignore
115
+ companions, so a pane switched between renderers keeps them (ADR 047, ADR 050).
116
+
117
+ **A table with companions has a header row.** Three unlabelled columns are
118
+ unreadable. The `<thead>` carries the label's dimension name and each
119
+ companion's name, and a table with no companions is exactly what it is now: no
120
+ header row, nothing added.
121
+
122
+ **Bounded at three, because each is a query.** ADR 025 bounds what one query
123
+ may ask for; this bounds how many a pane may run. Four is a refusal that says
124
+ so.
125
+
126
+ **One vocabulary in three places,** as height does:
127
+
128
+ - `Janela::Pane#companions`, a JSON array of names, validated against the
129
+ model's declared measures and dimensions when the row is saved (ADR 043
130
+ validates a default filter the same way), and a multiple select on the pane
131
+ form.
132
+ - `janela_pane` and `janela_snapshot_pane` take `companions:`.
133
+ - The pane URL carries `companions[]=orders&companions[]=region`, because the
134
+ server draws the pane again from that URL on every cross-filter.
135
+
136
+ It is not part of the pane's identity (ADR 029).
137
+
138
+ ## Consequences
139
+
140
+ - A rate can sit beside the count behind it, which is the more valuable half of
141
+ the issue, and a label can sit beside the fact that gives it its meaning.
142
+ - **A dimension companion can be blank where the data disagrees.** That is the
143
+ price of never being wrong, and it is visible: a blank cell in a column that
144
+ should be full is a prompt to look, where a guessed value is not.
145
+ - **A stored snapshot shows the base table only.** ADR 009 freezes the result
146
+ a pane had; the companions are not part of what is stored, so a snapshot pane
147
+ with companions renders as if it had none. Storing them is a separate change
148
+ to the stored shape and is left until someone needs a frozen comparison.
149
+ - **Up to three more queries per table pane.** The rate at which a page asks
150
+ is the host's (ADR 048); the count each pane makes is bounded here.
151
+ - **A migration for stored frames:** `janela_panes` gains a nullable
152
+ `companions` JSON column, additive, taken with `height` and `prominence`
153
+ in one migration step for a host using stored frames.
154
+ - **The pane URL grammar grows by an array parameter and the theming contract by
155
+ a header row and the classes on the extra cells,** named in the build and
156
+ documented in `docs/theming.md`. Both are surface ADR 037 freezes, which is why
157
+ this belongs before 1.0.
158
+ - What would change this decision: a companion that needs to be a *filter*, not
159
+ only a column, which is a different feature; or a frozen comparison that
160
+ needs the companions stored, which changes ADR 009's shape.
@@ -0,0 +1,93 @@
1
+ ---
2
+ Date: 2026-09-30
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 010, ADR 015, ADR 025, ADR 032, ADR 037
5
+ Triggers:
6
+ - deciding who may cut a release, or how a release is approved
7
+ - a vulnerability report, or where one should go
8
+ - what a contributor may expect in reply to an issue or a pull request
9
+ - changing the release workflow, its environment, or how the gem is published
10
+ - adding a process file to the repository root
11
+ Topics: vision, releases, security, open-source, planning
12
+ ---
13
+
14
+ # ADR 052: Two People Cut Releases, Reports Go Private, and Support Is Best Effort
15
+
16
+ ## Context
17
+
18
+ #6: the repository had a licence and a code of conduct and nothing else about how
19
+ it is run. A project meant to be used and forked by other people needs four things
20
+ written down before it is announced: how contributions are accepted, who can cut a
21
+ release, where a vulnerability is reported privately, and what support to expect.
22
+ It is also the last item on the 1.0 list that is a decision and not code
23
+ (ADR 037).
24
+
25
+ Three facts were established from the repository before deciding.
26
+
27
+ **A tag push publishes.** `release.yml` runs on any `v*` tag and publishes to
28
+ RubyGems through trusted publishing, and a published version can never be reused.
29
+ Four accounts have admin on the repository, so any of the four could publish a
30
+ release by pushing a tag. There was no branch or tag protection.
31
+
32
+ **A vulnerability had nowhere private to go.** GitHub private vulnerability
33
+ reporting was disabled, so the only route was a public issue. The doctor exists to
34
+ catch a host showing numbers nobody should see (ADR 035), and a project that asks
35
+ hosts to take that seriously has to take a report of its own seriously.
36
+
37
+ **A tag ruleset cannot name a person.** GitHub rulesets accept a role, a team or an
38
+ app as an exception, not a user. Restricting `v*` tags would have meant either
39
+ giving up on naming Jay and Vanessa or creating a team for two people. An
40
+ environment with required reviewers can name individuals, and a workflow job
41
+ attached to it waits for one of them.
42
+
43
+ ## Decision
44
+
45
+ **Jay Killeen and Vanessa Soares cut releases, and the workflow enforces it.**
46
+ `release.yml` runs its publish job in a protected `release` environment whose
47
+ required reviewers are those two and which accepts runs only from a `v*` tag.
48
+ Anyone with repository access can push a tag, and nothing publishes until one of
49
+ the two approves. Self approval is allowed, because a maintainer releasing alone is
50
+ the ordinary case and two-person sign off is not the aim: the aim is that a
51
+ publish is a deliberate act by a named person.
52
+
53
+ **A vulnerability is reported through GitHub's private advisory form.** It is
54
+ enabled, `SECURITY.md` points at it, and it says what counts: reads outside the
55
+ host's scope, filters that widen or burden, markup or SQL from typed text, and a
56
+ doctor that says safe when it is not. It says plainly that authentication belongs
57
+ to the host (ADR 032). No email address is published, so there is no inbox to
58
+ watch, and the maintainers are notified by GitHub.
59
+
60
+ **Support is best effort, with no promised response time.** Issues and pull
61
+ requests are read when the maintainers can. There is no support contract, and
62
+ before 1.0 the surface may move with an upgrade note each time. This is written in
63
+ `CONTRIBUTING.md` and the README rather than left to be inferred, because a promise
64
+ that cannot be kept (a triage cadence) is worse than a limit that is stated.
65
+
66
+ **Contributions follow what ADR 001 and ADR 015 already say,** written down in one
67
+ place. A pull request is reviewed on the merits of the diff whoever wrote it; a
68
+ change that touches the public surface starts as an issue and, if it is a design
69
+ choice, an ADR; a change a host can see updates the changelog and, if they must act,
70
+ the upgrade steps. The steps for a release are in `RELEASING.md`, and the same
71
+ steps stay in the agent skill that carries them out.
72
+
73
+ ## Consequences
74
+
75
+ - A report of a vulnerability is private by default, and a release cannot be
76
+ published by an accident or by a person who is not named.
77
+ - **Every release now needs an approval click** in the Actions tab after the tag is
78
+ pushed. A workflow started by hand has to be started from the tag, since the
79
+ environment refuses a branch.
80
+ - **The RubyGems trusted publisher is not restricted to the environment.** Its
81
+ configuration on rubygems.org names the repository and workflow, and can name an
82
+ environment as well. Doing so would make the gate binding on RubyGems' side and
83
+ not only on GitHub's. That is a setting only an owner of the gem can change, and
84
+ it is left as a follow-up for one of the two maintainers.
85
+ - **Branch protection is not added.** The issue did not ask for it, `main` has one
86
+ author of nearly every commit, and requiring reviews would stop the maintainers
87
+ merging their own work. It can be added if the number of contributors grows.
88
+ - The four admin accounts are not changed. Who has repository access is a matter
89
+ for the owners, and the release gate holds regardless.
90
+ - What would change this decision: a third maintainer, which is a change to the
91
+ reviewers list and a line in `RELEASING.md`; or an incident that shows the gate
92
+ is friction without benefit, which is an argument to remove the environment and
93
+ keep the named-releasers convention.
@@ -0,0 +1,187 @@
1
+ ---
2
+ Date: 2026-10-02
3
+ Status: Accepted
4
+ Related: ADR 001, ADR 010, ADR 012, ADR 014, ADR 017, ADR 019, ADR 025, ADR 032, ADR 033, ADR 034, ADR 037, ADR 041
5
+ Triggers:
6
+ - proposing an MCP server, an agent tool or any machine facing surface for Janela
7
+ - an agent needs to read or arrange a frame without a browser
8
+ - adding a dependency on an MCP library
9
+ - deciding whether Janela should detect, register or install anything in a host's own tooling
10
+ - running a Janela query outside a controller, from a tool, a job or a console
11
+ - changing what an agent may do to a pane
12
+ Topics: ai, agents, mcp, tools, authorisation, scope, host-integration, frames, dependencies, 1.0
13
+ ---
14
+
15
+ # ADR 053: An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None
16
+
17
+ ## Context
18
+
19
+ Janela dashboards are meant to be composed by agents, and an agent in a
20
+ host application has no way in. Frames and panes are rows (ADR 012), so
21
+ the only route is `Janela::Frame` and `Janela::Pane` written from a
22
+ console. A host that already runs an MCP server writes its own wrappers
23
+ by hand, and a host without one gets nothing. #73 asks for an MCP
24
+ surface, and ADR 010 named it as "one ADR away" and as one of the two
25
+ conditions for shipping Jan.
26
+
27
+ The issue proposed that Janela detect a host's MCP server and offer to
28
+ install tools into it. Four facts, measured on the demo before this was
29
+ written, change what that should mean.
30
+
31
+ **Reading a pane outside a controller reads everything.** The refusal in
32
+ ADR 032 lives in `Janela::ApplicationController`, where `policy_scope`
33
+ is asked. `Query#result` underneath takes an optional `on:` relation,
34
+ and without it reads the model's default scope. With two orders in the
35
+ demo, `pane.query.result` returned both statuses, and
36
+ `pane.query.result(on: Order.where(status: "paid"))` returned one. A tool
37
+ that called the first form would show an agent, and so a person, rows
38
+ the host never scoped, with no error. This is the same shape as ADR 034:
39
+ a thing that runs outside a request and has to be told what may be read.
40
+
41
+ **The host's scope is an answer given at the point of asking.** ADR 019
42
+ found that a decision needing the current user and tenant belongs inside
43
+ a request rather than in a setting, and ADR 032 rejected a global
44
+ `Janela.scope = ->(model, controller)` for it. An MCP call is not a
45
+ request to Janela's controllers. Who is asking arrives through whatever
46
+ authentication the host's own MCP server has, which Janela does not own
47
+ and cannot see.
48
+
49
+ **A pane row already refuses what it should.** A pane naming a measure or
50
+ model that is not declared is invalid, and says so in words an agent can
51
+ act on: `Measure "nonsense" is not a measure of Order` and `Model
52
+ "no_such_model" is not a janela model`. The tools do not need a
53
+ validation layer of their own.
54
+
55
+ **Tools can be built from data.** The official Ruby SDK, `mcp` 1.6.1,
56
+ builds a tool from a name, a description, a JSON schema and a block
57
+ (`MCP::Tool.define`), and hands the caller's context to the block as
58
+ `server_context`. Other libraries take the same four things. A tool
59
+ definition is therefore plain data plus a call, and none of it needs to
60
+ live in an MCP library to be written once.
61
+
62
+ One more fact corrects the issue. #73 describes the install task as "in
63
+ the same spirit as `rails janela:install:skill`". That task does not
64
+ exist. ADR 010 records the skill, the install task and the agent
65
+ definition as not implemented, and `docs/skills/` is absent. This would
66
+ be the first install task Janela has, not the second.
67
+
68
+ ### What was considered
69
+
70
+ **Janela serves MCP itself**, as an engine endpoint. Rejected. A served
71
+ endpoint needs authentication, a notion of who is calling and a way to
72
+ answer ADR 032's question for them, and Janela has none of the three: its
73
+ controllers are exactly as authenticated as the host's (ADR 010). Adding
74
+ them is the subsystem ADR 001 refused, and getting scope wrong shows one
75
+ tenant's numbers to another.
76
+
77
+ **Detect the host's MCP server and install into it**, as #73 proposed.
78
+ Rejected for the reason ADR 034 rejected detecting the kind of host: the
79
+ signal does not distinguish anything. A host's MCP setup is a gem, an
80
+ endpoint or a hand-written server, and the one thing Janela can reliably
81
+ know is none of those. It would also be Janela editing a host's tooling,
82
+ which ADR 010 says it never does.
83
+
84
+ **A `janela:install:mcp` task that writes tool files into the host.**
85
+ Rejected. Once copied the files are the host's, they drift from the
86
+ gem's public API, and the doctor would have to grow a check for each
87
+ release to say so. A host that wants the shape reads it in the guide and
88
+ owns the ten lines.
89
+
90
+ **A global scope setting**, so the tools need no argument. Rejected on
91
+ ADR 019 and ADR 032, above.
92
+
93
+ **`mcp` as a dependency of the gem.** Rejected. It is the right library
94
+ today and is not the only one a host may use, so a hard dependency would
95
+ make Janela's choice the host's. It stays out of the gemspec, the way the
96
+ demo's markdown renderer does, and the adapter below is optional.
97
+
98
+ **Writing and reading in one step.** Rejected as the shipped shape, not
99
+ as an idea. The write tools change records a person arranged, so they are
100
+ off until asked for.
101
+
102
+ ## Decision
103
+
104
+ **Janela ships its agent tools as plain Ruby that the host builds with a
105
+ scope and registers wherever it likes. Janela registers nothing,
106
+ detects nothing and serves nothing.**
107
+
108
+ ```ruby
109
+ tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) }, write: true)
110
+
111
+ tools.all # name, description, input_schema, read_only
112
+ tools.call("add_pane", frame_id: 3, model: "orders", measure: "revenue")
113
+ ```
114
+
115
+ **`scope:` is required and has no default.** It is a callable that takes
116
+ a model class and returns a relation: the same answer `policy_scope`
117
+ gives. Leaving it out raises `Janela::Unscoped` when the tools are built,
118
+ not when one is called, which is ADR 032's refusal moved to the place
119
+ this surface begins. Every read the tools make passes the relation it
120
+ returns as `on:`, and frames and panes are read through the same callable
121
+ (`scope.call(Janela::Frame)`). It is an argument and not a setting,
122
+ because it is built where the host knows who is asking, as ADR 034's
123
+ `scope_for` is.
124
+
125
+ **Read tools first, and writes only when asked for.** The 1.0 set:
126
+
127
+ | Tool | Does |
128
+ | --- | --- |
129
+ | `describe_vocabulary` | The models, measures, dimensions, renderers and granularities `Janela.definitions` declares, so an agent offers only what can be asked |
130
+ | `list_frames` | Id, name, owner and pane count of the frames the scope returns |
131
+ | `get_frame` | One frame with its panes in position order |
132
+ | `read_pane` | A pane's values, through the host's scope, with `q[...]` filters bounded as ADR 025 bounds them |
133
+ | `add_pane`, `update_pane`, `remove_pane`, `move_pane` | Only with `write: true` |
134
+
135
+ Inputs are the pane's own attributes and the README's own names. There is
136
+ no agent only vocabulary (ADR 010). A rejected row returns the pane's
137
+ validation messages unchanged. A frame is found or made by the host with
138
+ `Frame.for` (ADR 041) or by an analyst on the engine's pages, so there is
139
+ no `create_frame` in 1.0.
140
+
141
+ **A tool definition is data, and an adapter is a few lines the host
142
+ owns.** `Janela::Tools#all` carries what any library needs. The guide
143
+ shows the mapping for the official `mcp` SDK, and a host using another
144
+ writes the same few lines. Janela adds no MCP library to its gemspec.
145
+
146
+ **Snapshots are not in 1.0.** A snapshot persists and publishes, so it
147
+ needs an owner (ADR 033) and a scope the caller has named (ADR 034). That
148
+ is two more decisions an agent should not be making by default, and it
149
+ waits for a host that wants it.
150
+
151
+ **Jan still does not ship.** ADR 010's second condition, that an MCP
152
+ surface exists, is met by this. Its first reason, that a skill loaded
153
+ agent already does the work, has not been tested, because the skill was
154
+ never built. The skill ships first and teaches these tools. An agent
155
+ definition is revisited after a host has used both, and ADR 010 is not
156
+ superseded here.
157
+
158
+ ## Consequences
159
+
160
+ - **It ships as 0.13.0, with its own short soak.** This adds public
161
+ surface, and ADR 037 says anything that changes the surface resets the
162
+ soak clock in #71. The earliest 1.0 tag moves by the length of that
163
+ soak. The rule for what 1.0 then contains is set now, before anyone has
164
+ seen the result: if a real host has exercised the write tools and found
165
+ nothing wrong, 1.0 ships with them. If no host has used them by the end
166
+ of the soak, 1.0 ships read only and the write tools wait for 1.1.
167
+ Freezing four schemas that nobody has tried is what a soak is for
168
+ avoiding.
169
+ - **The tool names and input schemas join the contract at 1.0**, so the
170
+ set is small on purpose and every name is a verb and a README noun. A
171
+ tool added after 1.0 is a minor release. A tool changed is a major one.
172
+ - **A host does the registering.** That is a few lines in the place its
173
+ MCP server already lives, and the guide says where. A host with no MCP
174
+ server gets nothing from this, which is the honest answer: Janela does
175
+ not run servers.
176
+ - **The first test is the measured failure.** A tool built with a scope
177
+ that returns `Order.none` reads no values, and tools built without a
178
+ scope raise. Both belong in the suite before the code.
179
+ - **No doctor check.** Construction raises, so there is nothing for the
180
+ doctor to see that the raise does not say (ADR 035).
181
+ - **The pane `order` option (#75) reaches agents for free**, since the
182
+ write tools take a pane's attributes. It does not change this ADR.
183
+ - **What would change this decision:** two hosts writing the same
184
+ adapter by hand, which would earn a shipped one for that library; or a
185
+ host with no MCP server that needs one, which would be the day Janela
186
+ is asked to run something and has to answer ADR 032's question for
187
+ itself.
@@ -20,29 +20,29 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
20
20
 
21
21
  | Topic | ADRs |
22
22
  |-------|------|
23
- | **Vision, scope, forkability** | 001, 010, 012, 037 |
24
- | **Open-source & host-decoupling** | 001, 022, 036, 041 |
25
- | **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044 |
26
- | **Dependencies** | 002, 003, 004, 006, 017, 025 |
27
- | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040 |
28
- | **Performance & storage** | 007, 017, 025 |
23
+ | **Vision, scope, forkability** | 001, 010, 012, 037, 052 |
24
+ | **Open-source & host-decoupling** | 001, 022, 036, 041, 052 |
25
+ | **DSL & query layer** | 002, 006, 007, 020, 025, 038, 044, 049, 051 |
26
+ | **Dependencies** | 002, 003, 004, 006, 017, 025, 053 |
27
+ | **Authorisation** | 002, 003, 004, 009, 017, 019, 022, 032, 033, 034, 035, 039, 040, 048, 053 |
28
+ | **Performance & storage** | 007, 017, 025, 048, 051 |
29
29
  | **Ordering & formatting** | 007, 020, 038 |
30
- | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045 |
31
- | **Layouts & views** | 011, 012, 016, 018, 020, 027, 039 |
32
- | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046 |
33
- | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044 |
30
+ | **Cross-filtering & Hotwire** | 003, 004, 005, 008, 024, 025, 040, 043, 044, 045, 048, 049 |
31
+ | **Layouts & views** | 011, 012, 016, 018, 020, 027, 039, 047, 050 |
32
+ | **CSS & styling** | 016, 018, 023, 026, 027, 036, 042, 046, 047, 050 |
33
+ | **Frames, panes & persistence** | 012, 013, 014, 019, 029, 030, 033, 039, 040, 041, 043, 044, 047, 048, 050, 051, 053 |
34
34
  | **Naming rule** | 014, 023, 036 |
35
- | **JavaScript delivery & charts** | 004, 006, 026, 042, 046 |
35
+ | **JavaScript delivery & charts** | 004, 006, 026, 042, 046, 047 |
36
36
  | **Time dimensions** | 006, 025, 045 |
37
- | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045 |
38
- | **Snapshots & publishing** | 009, 020, 028, 033, 034 |
39
- | **AI agents & guidance** | 010, 015, 021 |
37
+ | **Routes, URLs & naming** | 005, 007, 008, 009, 011, 013, 022, 024, 025, 040, 041, 045, 047, 050, 051 |
38
+ | **Snapshots & publishing** | 009, 020, 028, 033, 034, 051 |
39
+ | **AI agents & guidance** | 010, 015, 021, 053 |
40
40
  | **The doctor & checks** | 021, 025, 032, 033, 035 |
41
- | **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042 |
42
- | **Accessibility & keyboard** | 024, 042, 045, 046 |
43
- | **Security** | 003, 025, 028, 031, 032, 034, 035, 044 |
41
+ | **Releases & upgrades** | 015, 021, 032, 034, 035, 036, 037, 042, 052 |
42
+ | **Accessibility & keyboard** | 024, 042, 045, 046, 049 |
43
+ | **Security** | 003, 025, 028, 031, 032, 034, 035, 044, 052 |
44
44
  | **Testing** | 003 |
45
- | **Roadmap & planning** | 001, 037, 045, 046 |
45
+ | **Roadmap & planning** | 001, 037, 045, 046, 052, 053 |
46
46
 
47
47
  ## Chronological
48
48
 
@@ -85,7 +85,7 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
85
85
  | 035 | A Check Does What Janela Does, or It Says What It Saw | 2026-09-22 | Accepted |
86
86
  | 036 | Janela Publishes What a Theme May Target, and Vitral Is Only One | 2026-09-22 | Accepted |
87
87
  | 037 | 1.0 Means the Surface Stops Moving, Not That Janela Is Finished | 2026-09-23 | Accepted |
88
- | 038 | A Ratio Is a Measure of Its Own, Stored as a Fraction and Read as a Percentage | 2026-09-24 | Proposed |
88
+ | 038 | A Ratio Is a Measure of Its Own, Stored as a Fraction and Read as a Percentage | 2026-09-24 | Accepted |
89
89
  | 039 | A Pane Can Hold Words, and Only Code Writes Markup | 2026-09-24 | Accepted |
90
90
  | 040 | A Host Can Fix a Frame's Filter, and No Click Removes It | 2026-09-24 | Accepted |
91
91
  | 041 | A Host Finds Its Frame by Owner and Key | 2026-09-24 | Accepted |
@@ -94,7 +94,14 @@ them when in doubt: `grep -l "Triggers:.*fork" docs/decisions/*.md`.
94
94
  | 044 | A Categorical Dimension Can Say What It Excludes, Not Only What It Includes | 2026-09-29 | Accepted |
95
95
  | 045 | Clicking a Time Bucket Filters the Frame to Its Range | 2026-09-29 | Accepted |
96
96
  | 046 | A Ring Is Server Drawn SVG, and a Palette Is Eight Fixed Colours | 2026-09-29 | Accepted |
97
+ | 047 | A Chart's Height Is One of Five Steps, and Unset Changes Nothing | 2026-09-30 | Accepted |
98
+ | 048 | A Frame Can Be Told to Refresh, and Janela Never Decides When | 2026-09-30 | Proposed |
99
+ | 049 | The Null Group Is One More Value in a Selection | 2026-09-30 | Accepted |
100
+ | 050 | A Single Value's Prominence Is One of Three Steps, and Unset Changes Nothing | 2026-09-30 | Accepted |
101
+ | 051 | A Table Can Carry Companion Columns, and a Fact Is Shown Only When It Is Shared | 2026-09-30 | Accepted |
102
+ | 052 | Two People Cut Releases, Reports Go Private, and Support Is Best Effort | 2026-09-30 | Accepted |
103
+ | 053 | An Agent Reaches Janela Through Tools the Host Scopes, and Janela Registers None | 2026-10-02 | Accepted |
97
104
 
98
105
  ## Next number
99
106
 
100
- Next ADR: 047
107
+ Next ADR: 054
@@ -36,9 +36,10 @@ without asking first:
36
36
  - A snapshot, read through `policy_scope(Janela::Snapshot)`.
37
37
 
38
38
  A pane rendered inline in your own page runs in your request, so the
39
- same scope applies there as in the engine's controllers. Nothing is
40
- calculated in a background context where the current tenant would have
41
- gone missing.
39
+ same scope applies there as in the engine's controllers, so a live pane
40
+ is never calculated where the current tenant would have gone missing. A
41
+ scheduled snapshot is the one thing that runs in the background, and it
42
+ is told whose it is (see Snapshots below).
42
43
 
43
44
  ## With Pundit
44
45
 
@@ -69,6 +70,23 @@ class ApplicationController < ActionController::Base
69
70
  end
70
71
  ```
71
72
 
73
+ A snapshot is read through its own scope as well. Without a policy for it
74
+ Pundit raises, and a hand rolled `policy_scope` that does not name it
75
+ would hand every tenant every snapshot, so write the second one too:
76
+
77
+ ```ruby
78
+ # app/policies/janela/snapshot_policy.rb
79
+ module Janela
80
+ class SnapshotPolicy < ApplicationPolicy
81
+ class Scope < ApplicationPolicy::Scope
82
+ def resolve
83
+ scope.where(owner: Current.account)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ ```
89
+
72
90
  Your own models keep the policies they already have. Janela calls
73
91
  `policy_scope(Order)` and gets whatever `OrderPolicy::Scope` returns.
74
92
 
@@ -85,7 +103,7 @@ class ApplicationController < ActionController::Base
85
103
  private
86
104
  def policy_scope(model)
87
105
  case model.name
88
- when "Janela::Frame" then model.where(owner: ActsAsTenant.current_tenant)
106
+ when "Janela::Frame", "Janela::Snapshot" then model.where(owner: ActsAsTenant.current_tenant)
89
107
  else model.all # acts_as_tenant has already scoped your own models
90
108
  end
91
109
  end