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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -1
- data/README.md +50 -27
- data/UPGRADING.md +57 -1
- data/app/assets/javascripts/janela/chart_controller.js +6 -1
- data/app/assets/javascripts/janela/frame_controller.js +19 -9
- data/app/assets/stylesheets/janela.css +23 -1
- data/app/assets/stylesheets/vitral.css +21 -0
- data/app/controllers/janela/panes_controller.rb +2 -2
- data/app/controllers/janela/queries_controller.rb +3 -0
- data/app/controllers/janela/snapshot_queries_controller.rb +3 -0
- data/app/helpers/janela/frames_helper.rb +12 -4
- data/app/models/janela/pane.rb +32 -1
- data/app/models/janela/query.rb +116 -4
- data/app/views/janela/panes/_form.html.erb +25 -0
- data/app/views/janela/queries/_query.html.erb +36 -14
- data/config/locales/en.yml +19 -0
- data/db/migrate/20260930000001_add_height_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000002_add_prominence_to_janela_panes.rb +7 -0
- data/db/migrate/20260930000003_add_companions_to_janela_panes.rb +8 -0
- data/docs/agents.md +97 -0
- data/docs/composing.md +14 -14
- data/docs/decisions/024-selecting-more-than-one-value.md +1 -1
- data/docs/decisions/038-a-ratio-is-a-measure-of-its-own.md +1 -1
- data/docs/decisions/047-a-charts-height-is-one-of-five-steps.md +169 -0
- data/docs/decisions/048-a-frame-can-be-told-to-refresh-and-janela-never-decides-when.md +154 -0
- data/docs/decisions/049-the-null-group-is-one-more-value-in-a-selection.md +143 -0
- data/docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md +124 -0
- data/docs/decisions/051-a-table-can-carry-companion-columns.md +160 -0
- data/docs/decisions/052-how-the-project-is-run.md +93 -0
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
- data/docs/decisions/INDEX.md +27 -20
- data/docs/multi-tenancy.md +22 -4
- data/docs/roadmap.md +34 -35
- data/docs/theming.md +10 -4
- data/lib/janela/definition.rb +71 -4
- data/lib/janela/dimension.rb +7 -0
- data/lib/janela/doctor.rb +66 -8
- data/lib/janela/engine.rb +13 -1
- data/lib/janela/measure.rb +56 -7
- data/lib/janela/tools.rb +220 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +1 -0
- 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.
|
data/docs/decisions/INDEX.md
CHANGED
|
@@ -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 |
|
|
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:
|
|
107
|
+
Next ADR: 054
|
data/docs/multi-tenancy.md
CHANGED
|
@@ -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
|
|
40
|
-
|
|
41
|
-
|
|
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
|