omen 0.4.0 → 0.5.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 +29 -0
- data/INSTRUCTIONS.md +225 -0
- data/README.md +86 -128
- data/app/models/omen/conversation.rb +5 -3
- data/app/models/omen/executed.rb +2 -2
- data/app/models/omen/instructions.md +2 -2
- data/app/models/omen/instructions.rb +15 -24
- data/app/models/omen/narrowed.rb +35 -0
- data/app/models/omen/query.rb +6 -2
- data/app/models/omen/reading.rb +4 -1
- data/app/models/omen/refusal.rb +43 -0
- data/app/models/omen/reply.rb +20 -0
- data/app/models/omen/role.rb +14 -2
- data/app/models/omen/schema.rb +25 -4
- data/db/migrate/20260912000000_add_type_to_omen_readings.rb +6 -0
- data/lib/generators/omen/install/install_generator.rb +1 -1
- data/lib/generators/omen/pages/USAGE +25 -0
- data/lib/generators/omen/pages/pages_generator.rb +70 -0
- data/lib/generators/omen/pages/templates/_form.html.erb.tt +8 -0
- data/lib/generators/omen/pages/templates/controller.rb.tt +54 -0
- data/lib/generators/omen/pages/templates/index.html.erb.tt +12 -0
- data/lib/generators/omen/pages/templates/model.rb.tt +7 -0
- data/lib/generators/omen/pages/templates/show.html.erb.tt +45 -0
- data/lib/omen/bound.rb +26 -0
- data/lib/omen/grants.rb +56 -5
- data/lib/omen/inquirer.rb +36 -20
- data/lib/omen/narrowing.rb +87 -0
- data/lib/omen/version.rb +1 -1
- data/lib/omen.rb +17 -0
- data/lib/tasks/omen.rake +16 -2
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 38d16f1a8a34b75c8ec0c68256c468a1964433fd7700c3d70cac77a442d112a5
|
|
4
|
+
data.tar.gz: de2689fb094379753948c4e7d8a98446b92e7870ea52ec26448f4e9dae19cc04
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0d53e0fffd2be50f0ed33c9def0f59df816dc6de58be56b9ad0a4397a0d9f4ad5f778c6b046938b85f0cad3e31b32346ee0e395084a29938116d1aa4b553b486
|
|
7
|
+
data.tar.gz: 410c8befd10808099f1d07969b2197a6212b8a26c3a064788f60d5e828a37d4b9b254a32709171c84e35479183a30f7c6c55b90d8ec922704ee174b9afbf8629
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,35 @@ For more information about changelogs, check [Keep a Changelog](http://keepachan
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## 0.5.0 - 2026-09-11
|
|
11
|
+
|
|
12
|
+
* [Breaking change] `omen_readings` carries a `type` column, so a host's two kinds of reading
|
|
13
|
+
are two classes rather than two names for the same rows. Without it the class that loaded a
|
|
14
|
+
row decided what it could read: a customer's own reading, loaded as the host's admin one, ran
|
|
15
|
+
its statement as the wider role. Rows written before this migration have no type and come
|
|
16
|
+
back as `Omen::Reading`, so a host with subclasses backfills its own -- one `UPDATE` per kind
|
|
17
|
+
* [Feature] Narrow a reading to one owner's rows. A subclass declares what its audience may
|
|
18
|
+
read -- `narrows 'the_role', by: :provider_id, own: { 'bookings' => 'provider_id = %{owner}' },
|
|
19
|
+
whole: %w[ zips ]` -- and `db:omen:grant` writes the role, the column grants and the policies.
|
|
20
|
+
The rows are held by Postgres, so every statement Claude can write resolves through them
|
|
21
|
+
* [Feature] Turning row level security on takes nothing away from whoever could already read: a
|
|
22
|
+
permissive policy for everybody goes on beside a RESTRICTIVE one for the narrowed role, which
|
|
23
|
+
is ANDed with it and applies to nobody else -- a role made after the fact included
|
|
24
|
+
* [Feature] Grant the membership a narrowed role needs `WITH INHERIT FALSE`, so a role that may
|
|
25
|
+
enter it is not itself held back by its policies. Postgres 16 and later
|
|
26
|
+
* [Feature] `db:omen:widen` takes every narrowing back off, and `db:omen:narrowed` says which
|
|
27
|
+
roles each restrictive policy really holds back, membership included, and exits non-zero on
|
|
28
|
+
one nobody asked for
|
|
29
|
+
* [Feature] Show Claude the schema the role may actually read: a table none of whose columns it
|
|
30
|
+
holds `SELECT` on goes, and so does a column a column-level grant left out, so the prompt
|
|
31
|
+
follows the grants rather than restating them
|
|
32
|
+
* [Fix] Revoke before granting a role the columns of a table, since a grant only ever adds: a
|
|
33
|
+
column dropped from the list used to stay readable by whoever was granted it last time
|
|
34
|
+
* [Breaking change] `Omen::Query.new` and `Omen::Conversation.new` take the reading they answer,
|
|
35
|
+
and `Omen::Instructions.block` and `Omen::Schema.new` take what it says they may read
|
|
36
|
+
* [Breaking change] The shape a reply is held to is `Omen::Reply::SHAPE`, and the config that
|
|
37
|
+
sends it `Omen::Reply.output_config`, both moved off `Omen::Instructions`
|
|
38
|
+
|
|
10
39
|
## 0.4.0 - 2026-08-24
|
|
11
40
|
|
|
12
41
|
* [Breaking change] Prefix every database function this gem creates, and drop the names they
|
data/INSTRUCTIONS.md
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
# Omen, in full
|
|
2
|
+
|
|
3
|
+
Everything there is to say about installing Omen and building on it. The [README](README.md) is
|
|
4
|
+
the short way in; this is the reasoning underneath it, and the place to come when a default
|
|
5
|
+
turns out not to suit.
|
|
6
|
+
|
|
7
|
+
You ask Claude a complex question about data stored by your Rails app.
|
|
8
|
+
Claude answers with the SQL. Rails runs it.
|
|
9
|
+
|
|
10
|
+
Omen is an engine that you too can use. You just need a Rails app running on PostgreSQL.
|
|
11
|
+
Omen provides the models and the logic to talk to Claude; to parse what it says; to run read-only statements.
|
|
12
|
+
|
|
13
|
+
Your data never travels. Claude is shown the schema and writes one `SELECT`; Rails runs it and
|
|
14
|
+
draws the answer (including encrypted attributes) for whoever asked. Nothing that statement returned is ever sent back.
|
|
15
|
+
|
|
16
|
+
## How to install
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
gem install omen
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Or, in a `Gemfile`, pinned to the current minor while this is still below 1.0:
|
|
23
|
+
|
|
24
|
+
```ruby
|
|
25
|
+
gem 'omen', '~> 0.4.0'
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Omen follows [Semantic Versioning](https://semver.org) from 1.0 onwards. Until then a release may
|
|
29
|
+
break whatever it likes, so the pin stops short of the next minor: `~> 0.4.0` takes every fix in
|
|
30
|
+
0.4 and nothing beyond it.
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
**PostgreSQL only**
|
|
35
|
+
|
|
36
|
+
<details>
|
|
37
|
+
<summary>This is not a gap waiting to be filled. </summary>
|
|
38
|
+
Two of the guarantees Omen makes are
|
|
39
|
+
Postgres features with no equivalent elsewhere: it identifies an encrypted column by the table OID
|
|
40
|
+
and column number Postgres reports for each result column, which is what stops an alias or an
|
|
41
|
+
expression from laundering one; and it narrows privileges for the statement it runs with
|
|
42
|
+
`SET LOCAL ROLE`, which reverts when the transaction ends. MySQL has `SET ROLE` but nothing
|
|
43
|
+
transaction-scoped, so a raised exception would leave a pooled connection holding the role. Omen
|
|
44
|
+
raises at boot on any other adapter rather than running with a weaker promise.
|
|
45
|
+
</details>
|
|
46
|
+
|
|
47
|
+
**db/schema.rb in Rails**
|
|
48
|
+
|
|
49
|
+
<details>
|
|
50
|
+
<summary>Only the :ruby schema is supported. </summary>
|
|
51
|
+
The schema is what Claude is shown, so an app
|
|
52
|
+
on `db/structure.sql` cannot use Omen. Checked at boot and raised on, because copying a
|
|
53
|
+
`structure.sql` to that path fails silently and with teeth: the strip regexes read the Ruby DSL,
|
|
54
|
+
so they match nothing, Omen's own tables stay in the prompt, and Claude is shown the log of every
|
|
55
|
+
question ever asked.
|
|
56
|
+
</details>
|
|
57
|
+
|
|
58
|
+
**Three database functions, all prefixed**
|
|
59
|
+
|
|
60
|
+
<details>
|
|
61
|
+
<summary>A timestamp, a day and a distance go through a function, never an expression. </summary>
|
|
62
|
+
`db:omen:grant` creates all three, each named `omen_` so that none of them can take a name an
|
|
63
|
+
app wanted for itself -- `today` especially. `omen_time_zone()` hands a stored timestamp back in
|
|
64
|
+
the zone the company works in, so every date means the same whole days. `omen_today()` answers
|
|
65
|
+
what day it is there, and the prompt builds every relative window on it, so a statement that is
|
|
66
|
+
kept and run again answers "last month" for the month it is run in rather than the month it was
|
|
67
|
+
written in. `omen_miles_between(lat1, lng1, lat2, lng2)` answers a great-circle distance in
|
|
68
|
+
miles. The prompt names each and forbids writing any of them by hand: a conversion assembled per
|
|
69
|
+
query drifts, and a great-circle expression runs to a dozen nested calls that a reply balances
|
|
70
|
+
by hand and gets wrong. The first and last are `IMMUTABLE`; `omen_today()` is `STABLE`, because
|
|
71
|
+
it reads the clock and an immutable function of the clock may be folded to a constant -- which
|
|
72
|
+
is exactly the sliding this one exists to keep. All are executable by anyone, so none needs a
|
|
73
|
+
grant. An app in another zone renames the first two; an app whose tables carry no coordinates
|
|
74
|
+
never calls the last. None can be a migration: Rails' `:ruby` schema format dumps no functions,
|
|
75
|
+
so `db:schema:load` would drop one a migration had made.
|
|
76
|
+
</details>
|
|
77
|
+
|
|
78
|
+
## Configuration
|
|
79
|
+
|
|
80
|
+
Installing by adding to your Gemfile and running three commands in your terminal:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
bin/rails g omen:install # adds the migrations + an initializer you can delete
|
|
84
|
+
bin/rails db:migrate # creates the tables `omen_readings`, `omen_questions`, `omen_answers`
|
|
85
|
+
bin/rails db:omen:grant # set the read-only role statements run as
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`db:omen:grant` is worth running from the tasks that build a database, so a fresh one is never
|
|
89
|
+
missing the role. In `lib/tasks` of the host:
|
|
90
|
+
|
|
91
|
+
```ruby
|
|
92
|
+
granted = Rake::Task['db:omen:grant']
|
|
93
|
+
|
|
94
|
+
%w[ db:create db:prepare db:reset db:test:prepare ].each do |name|
|
|
95
|
+
Rake::Task[name].enhance do
|
|
96
|
+
granted.reenable
|
|
97
|
+
granted.invoke
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### Requirements
|
|
103
|
+
|
|
104
|
+
- **A read-only connection role.** `connects_to database: { writing: :primary, reading: :reader }`
|
|
105
|
+
on the record class, with the `reading` entry logging in as a Postgres role granted `SELECT`
|
|
106
|
+
and nothing else. Omen raises rather than falling back to a role that could write, which is
|
|
107
|
+
the point. Creating that role is the app's own business — Omen has no name for it, and
|
|
108
|
+
discovers it when granting.
|
|
109
|
+
- **Active Record Encryption keys.** Without them an encrypted column reads back as the
|
|
110
|
+
placeholder rather than as the value, quietly.
|
|
111
|
+
- **An `ApplicationJob`.** A reading is answered outside the request, and the job descends
|
|
112
|
+
from the app's own base class.
|
|
113
|
+
- **A `db/schema.rb`.** It is the prompt, so a reading cannot happen before the first
|
|
114
|
+
`db:migrate` has dumped one.
|
|
115
|
+
|
|
116
|
+
### The options
|
|
117
|
+
|
|
118
|
+
Every setting has a default, so the initializer is optional. `rails generate omen:install`
|
|
119
|
+
writes it with each line commented out, as the list of what there is to say.
|
|
120
|
+
|
|
121
|
+
| Setting | Default |
|
|
122
|
+
|---|---|
|
|
123
|
+
| `narrow_role` | `'omen_inquirer'` |
|
|
124
|
+
| `notes` | none, so the prompt says nothing about this app beyond its schema |
|
|
125
|
+
|
|
126
|
+
## What a host builds on top
|
|
127
|
+
|
|
128
|
+
`Omen::Reading` has a `type` column nowhere, so a subclass is a transparent second name for the
|
|
129
|
+
same rows: `Inquiry.all` carries no type condition, and `to_partial_path` becomes
|
|
130
|
+
`inquiries/inquiry`.
|
|
131
|
+
|
|
132
|
+
```ruby
|
|
133
|
+
class Inquiry < Omen::Reading
|
|
134
|
+
belongs_to :agent
|
|
135
|
+
end
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
`Omen::Reading.create! question: 'Where are the homes we serve?'` is the whole of asking; a
|
|
139
|
+
follow-up is `reading.ask '...'`. Each question is answered in a job, and the answer carries the
|
|
140
|
+
statement Claude wrote, the rows it found, and which header of theirs held an encrypted column.
|
|
141
|
+
|
|
142
|
+
Two things a subclass cannot reach, because the gem's own class is what a job loads:
|
|
143
|
+
`broadcasts_refreshes`, and anything else that has to be declared on `Omen::Reading` itself. One
|
|
144
|
+
line in the host does it, and `rails g omen:pages` below writes that line where there is a Turbo
|
|
145
|
+
to broadcast over:
|
|
146
|
+
|
|
147
|
+
```ruby
|
|
148
|
+
ActiveSupport.on_load(:omen_reading) { broadcasts_refreshes }
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## Putting it on a screen
|
|
152
|
+
|
|
153
|
+
Omen ships no controllers, no views and no routes, and there is no engine to mount: what a
|
|
154
|
+
reading looks like belongs to the app that installs it. What it takes to *draw* one is the gem's
|
|
155
|
+
own business, though, and a fourth command hands that over:
|
|
156
|
+
|
|
157
|
+
```sh
|
|
158
|
+
bin/rails g omen:pages
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
create app/models/inquiry.rb
|
|
163
|
+
create app/controllers/inquiries_controller.rb
|
|
164
|
+
create app/views/inquiries/index.html.erb
|
|
165
|
+
create app/views/inquiries/show.html.erb
|
|
166
|
+
create app/views/inquiries/_form.html.erb
|
|
167
|
+
route resources :inquiries
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
`bin/rails s`, then `/inquiries`. Type a question and the answer arrives under it, with the
|
|
171
|
+
statement Claude wrote and the rows it found. Every one of those files lands in the host and is
|
|
172
|
+
the host's from then on -- rename them, restyle them, throw them away. Pass a name to be called
|
|
173
|
+
something else: `bin/rails g omen:pages Consultation` writes `Consultation`,
|
|
174
|
+
`ConsultationsController` and `app/views/consultations`.
|
|
175
|
+
|
|
176
|
+
The generator exists because four of the things those files get right are things nobody should
|
|
177
|
+
have to learn before their first question:
|
|
178
|
+
|
|
179
|
+
- **The controller descends from `ApplicationController`**, so whatever guards the rest of the
|
|
180
|
+
app guards the one page that will run SQL across all of it. This is the reason the pages are
|
|
181
|
+
generated into the host rather than mounted from the gem: a mounted engine inherits none of
|
|
182
|
+
the app's `before_action`, and forgetting to say so publishes an ask-anything console.
|
|
183
|
+
- **There is no `new` and no `edit`.** Opening a reading is asking its first question, so
|
|
184
|
+
`create` is the only way in and a blank one has no page; and a reading is never edited, so
|
|
185
|
+
`update` calls `ask` and the thread grows by one. `question` is an attribute, not a column --
|
|
186
|
+
an `edit` form would rewrite something nothing reads back.
|
|
187
|
+
- **The rows come from `answer.shown`, never `answer.result`.** The second is what Postgres
|
|
188
|
+
handed over; the first is that read back through Active Record Encryption, with the columns
|
|
189
|
+
Claude asked to be drawn joined. An answer with no `sql` is Claude asking something back, and
|
|
190
|
+
`note` is where it says so.
|
|
191
|
+
- **The refresh is subscribed to as `Omen::Reading`.** Where the host has Turbo, `show` gets
|
|
192
|
+
`turbo_stream_from @inquiry.becomes(Omen::Reading)` and a `config/initializers/omen_broadcasts.rb`
|
|
193
|
+
is written beside it declaring `broadcasts_refreshes`. Both name the gem's class and not
|
|
194
|
+
`Inquiry`, because a question reaches its reading through the association: what a job is
|
|
195
|
+
handed, and so what it broadcasts as, is always `Omen::Reading`. A stream named after the
|
|
196
|
+
subclass hears nothing. Where there is no Turbo neither line is written, and a reload is what
|
|
197
|
+
shows an answer.
|
|
198
|
+
|
|
199
|
+
What the generator cannot do for you is the part above: `ANTHROPIC_API_KEY` has to be
|
|
200
|
+
resolvable, `db:omen:grant` has to have run, and `config/database.yml` has to have the read-only
|
|
201
|
+
entry that `connects_to` names. The two are missed differently, which is why the `show` page
|
|
202
|
+
draws both -- a reading that could not reach Claude at all is left `failed`, with the class of
|
|
203
|
+
what went wrong in the log and nothing in the page, since a message from that far down may quote
|
|
204
|
+
a row; a reading whose statement had no role to run as is answered, with
|
|
205
|
+
`Omen::Role::MISCONFIGURED` in `answer.error`.
|
|
206
|
+
|
|
207
|
+
## After the first deploy
|
|
208
|
+
|
|
209
|
+
The narrow role is granted `SELECT` on every table and then refused Omen's own three, which can
|
|
210
|
+
only happen once those tables exist. On a database that forbids `CREATE ROLE` — a managed one
|
|
211
|
+
usually does — the role is made by hand and the revocation with it:
|
|
212
|
+
|
|
213
|
+
```sql
|
|
214
|
+
REVOKE SELECT ON omen_readings, omen_questions, omen_answers FROM omen_inquirer;
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
A managed database also refuses `ALTER ROLE ... NOSUPERUSER`, since only a superuser may say it.
|
|
218
|
+
Omen skips that statement and carries on rather than stopping, then reads the role back and says
|
|
219
|
+
so if it holds `SUPERUSER`, `BYPASSRLS` or `REPLICATION` -- which a role it created never does.
|
|
220
|
+
|
|
221
|
+
A missed table there means Claude is shown the log of every question ever asked.
|
|
222
|
+
|
|
223
|
+
## License
|
|
224
|
+
|
|
225
|
+
MIT, see [LICENSE.txt](LICENSE.txt).
|
data/README.md
CHANGED
|
@@ -1,163 +1,121 @@
|
|
|
1
1
|
# Omen
|
|
2
2
|
|
|
3
|
-
You ask Claude a complex question about data
|
|
3
|
+
You ask Claude a complex question about the data your Rails app already holds.
|
|
4
4
|
Claude answers with the SQL. Rails runs it.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
<!-- The demo goes here. Drop the recording in as demo.gif at the root of the repo and
|
|
7
|
+
uncomment the line below:
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+

|
|
10
|
+
-->
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
Your data never travels. Claude is shown the schema and writes one `SELECT`; Rails runs it
|
|
13
|
+
read-only and draws the answer -- encrypted columns included -- for whoever asked. Nothing that
|
|
14
|
+
statement returned is ever sent back.
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
gem install omen
|
|
16
|
-
```
|
|
16
|
+
## Running it
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
In your `Gemfile`, pinned to the current minor while this is still below 1.0:
|
|
19
19
|
|
|
20
20
|
```ruby
|
|
21
|
-
gem 'omen', '~> 0.
|
|
21
|
+
gem 'omen', '~> 0.5.0'
|
|
22
22
|
```
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
break whatever it likes, so the pin stops short of the next minor: `~> 0.4.0` takes every fix in
|
|
26
|
-
0.4 and nothing beyond it.
|
|
27
|
-
|
|
28
|
-
## Requirements
|
|
29
|
-
|
|
30
|
-
**PostgreSQL only**
|
|
31
|
-
|
|
32
|
-
<details>
|
|
33
|
-
<summary>This is not a gap waiting to be filled. </summary>
|
|
34
|
-
Two of the guarantees Omen makes are
|
|
35
|
-
Postgres features with no equivalent elsewhere: it identifies an encrypted column by the table OID
|
|
36
|
-
and column number Postgres reports for each result column, which is what stops an alias or an
|
|
37
|
-
expression from laundering one; and it narrows privileges for the statement it runs with
|
|
38
|
-
`SET LOCAL ROLE`, which reverts when the transaction ends. MySQL has `SET ROLE` but nothing
|
|
39
|
-
transaction-scoped, so a raised exception would leave a pooled connection holding the role. Omen
|
|
40
|
-
raises at boot on any other adapter rather than running with a weaker promise.
|
|
41
|
-
</details>
|
|
42
|
-
|
|
43
|
-
**db/schema.rb in Rails**
|
|
44
|
-
|
|
45
|
-
<details>
|
|
46
|
-
<summary>Only the :ruby schema is supported. </summary>
|
|
47
|
-
The schema is what Claude is shown, so an app
|
|
48
|
-
on `db/structure.sql` cannot use Omen. Checked at boot and raised on, because copying a
|
|
49
|
-
`structure.sql` to that path fails silently and with teeth: the strip regexes read the Ruby DSL,
|
|
50
|
-
so they match nothing, Omen's own tables stay in the prompt, and Claude is shown the log of every
|
|
51
|
-
question ever asked.
|
|
52
|
-
</details>
|
|
53
|
-
|
|
54
|
-
**Three database functions, all prefixed**
|
|
55
|
-
|
|
56
|
-
<details>
|
|
57
|
-
<summary>A timestamp, a day and a distance go through a function, never an expression. </summary>
|
|
58
|
-
`db:omen:grant` creates all three, each named `omen_` so that none of them can take a name an
|
|
59
|
-
app wanted for itself -- `today` especially. `omen_time_zone()` hands a stored timestamp back in
|
|
60
|
-
the zone the company works in, so every date means the same whole days. `omen_today()` answers
|
|
61
|
-
what day it is there, and the prompt builds every relative window on it, so a statement that is
|
|
62
|
-
kept and run again answers "last month" for the month it is run in rather than the month it was
|
|
63
|
-
written in. `omen_miles_between(lat1, lng1, lat2, lng2)` answers a great-circle distance in
|
|
64
|
-
miles. The prompt names each and forbids writing any of them by hand: a conversion assembled per
|
|
65
|
-
query drifts, and a great-circle expression runs to a dozen nested calls that a reply balances
|
|
66
|
-
by hand and gets wrong. The first and last are `IMMUTABLE`; `omen_today()` is `STABLE`, because
|
|
67
|
-
it reads the clock and an immutable function of the clock may be folded to a constant -- which
|
|
68
|
-
is exactly the sliding this one exists to keep. All are executable by anyone, so none needs a
|
|
69
|
-
grant. An app in another zone renames the first two; an app whose tables carry no coordinates
|
|
70
|
-
never calls the last. None can be a migration: Rails' `:ruby` schema format dumps no functions,
|
|
71
|
-
so `db:schema:load` would drop one a migration had made.
|
|
72
|
-
</details>
|
|
73
|
-
|
|
74
|
-
## Configuration
|
|
75
|
-
|
|
76
|
-
Installing by adding to your Gemfile and running three commands in your terminal:
|
|
24
|
+
Then four commands:
|
|
77
25
|
|
|
78
26
|
```sh
|
|
79
|
-
bin/rails g omen:install
|
|
80
|
-
bin/rails db:migrate
|
|
81
|
-
bin/rails db:omen:grant
|
|
27
|
+
bin/rails g omen:install # the migrations and an initializer you may delete
|
|
28
|
+
bin/rails db:migrate # omen_readings, omen_questions, omen_answers
|
|
29
|
+
bin/rails db:omen:grant # the read-only role a statement runs as, and three functions
|
|
30
|
+
bin/rails g omen:pages # a model, a controller, three views and a route, all yours
|
|
82
31
|
```
|
|
83
32
|
|
|
84
|
-
`
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
```ruby
|
|
88
|
-
granted = Rake::Task['db:omen:grant']
|
|
89
|
-
|
|
90
|
-
%w[ db:create db:prepare db:reset db:test:prepare ].each do |name|
|
|
91
|
-
Rake::Task[name].enhance do
|
|
92
|
-
granted.reenable
|
|
93
|
-
granted.invoke
|
|
94
|
-
end
|
|
95
|
-
end
|
|
96
|
-
```
|
|
33
|
+
`bin/rails s`, then `/inquiries`. Type a question and the answer arrives under it, with the
|
|
34
|
+
statement Claude wrote and the rows it found.
|
|
97
35
|
|
|
98
|
-
|
|
36
|
+
## What your app has to have first
|
|
99
37
|
|
|
100
|
-
|
|
101
|
-
on the record class, with the `reading` entry logging in as a Postgres role granted `SELECT`
|
|
102
|
-
and nothing else. Omen raises rather than falling back to a role that could write, which is
|
|
103
|
-
the point. Creating that role is the app's own business — Omen has no name for it, and
|
|
104
|
-
discovers it when granting.
|
|
105
|
-
- **Active Record Encryption keys.** Without them an encrypted column reads back as the
|
|
106
|
-
placeholder rather than as the value, quietly.
|
|
107
|
-
- **An `ApplicationJob`.** A reading is answered outside the request, and the job descends
|
|
108
|
-
from the app's own base class.
|
|
109
|
-
- **A `db/schema.rb`.** It is the prompt, so a reading cannot happen before the first
|
|
110
|
-
`db:migrate` has dumped one.
|
|
38
|
+
None of it is Omen's to create, and each is checked rather than assumed:
|
|
111
39
|
|
|
112
|
-
|
|
40
|
+
- **PostgreSQL**, and a `db/schema.rb` rather than a `structure.sql`. Both are raised on at boot.
|
|
41
|
+
- **A read-only connection role**: `connects_to database: { writing: :primary, reading: :reader }`
|
|
42
|
+
on `ApplicationRecord`, where `reader` logs in as a Postgres role granted `SELECT` and nothing
|
|
43
|
+
else. Omen raises rather than falling back to a role that could write, which is the point.
|
|
44
|
+
- **`ANTHROPIC_API_KEY`**, or a key named in the initializer.
|
|
45
|
+
- **Active Record Encryption keys**, without which an encrypted column reads back as a
|
|
46
|
+
placeholder rather than as its value, quietly.
|
|
47
|
+
- **An `ApplicationJob`**, since a reading is answered outside the request.
|
|
113
48
|
|
|
114
|
-
|
|
115
|
-
writes it with each line commented out, as the list of what there is to say.
|
|
49
|
+
## What you get
|
|
116
50
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
51
|
+
`Omen::Reading.create! question: 'Where are the homes we serve?'` is the whole of asking, and
|
|
52
|
+
`reading.ask '...'` is a follow-up. Each question is answered in a job, and the answer carries
|
|
53
|
+
the statement Claude wrote, the rows it found, and which of their headers held an encrypted
|
|
54
|
+
column. `rails g omen:pages` writes the pages that draw all of that, into your app, for you to
|
|
55
|
+
keep or replace.
|
|
121
56
|
|
|
122
|
-
##
|
|
57
|
+
## Two kinds of reading
|
|
123
58
|
|
|
124
|
-
`
|
|
125
|
-
|
|
126
|
-
|
|
59
|
+
`omen_readings` carries a `type`, so a subclass of `Omen::Reading` is a kind of reading rather
|
|
60
|
+
than a second name for every row: `Inquiry.count` counts inquiries, and a row answers as what it
|
|
61
|
+
was written as, whatever class asks for it. That last part is what keeps a narrowing honest --
|
|
62
|
+
what a reading may read is the row's own to say, not the caller's.
|
|
127
63
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
end
|
|
132
|
-
```
|
|
64
|
+
Upgrading an app that already has readings: they were written without a type, so they come back
|
|
65
|
+
as `Omen::Reading` until you say what they were. One `UPDATE omen_readings SET type = 'Inquiry'`
|
|
66
|
+
per kind, in a migration of your own.
|
|
133
67
|
|
|
134
|
-
|
|
135
|
-
follow-up is `reading.ask '...'`. Each question is answered in a job, and the answer carries the
|
|
136
|
-
statement Claude wrote, the rows it found, and which header of theirs held an encrypted column.
|
|
68
|
+
## Narrowing a reading to one owner's rows
|
|
137
69
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
line in the host does it:
|
|
70
|
+
A reading answers with whatever its role may read, so an app that lets a customer ask about
|
|
71
|
+
their own data says so on the reading, and `db:omen:grant` writes it into the database:
|
|
141
72
|
|
|
142
73
|
```ruby
|
|
143
|
-
|
|
144
|
-
|
|
74
|
+
class Consult < Omen::Reading
|
|
75
|
+
belongs_to :provider
|
|
145
76
|
|
|
146
|
-
|
|
77
|
+
narrows 'provider_inquirer', by: :provider_id,
|
|
78
|
+
own: {
|
|
79
|
+
'bookings' => 'provider_id = %{owner}',
|
|
80
|
+
'locations' => 'EXISTS (SELECT 1 FROM bookings WHERE bookings.location_id = locations.id)',
|
|
81
|
+
},
|
|
82
|
+
whole: %w[ states zips ], except: /_count\z/
|
|
147
83
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
usually does — the role is made by hand and the revocation with it:
|
|
151
|
-
|
|
152
|
-
```sql
|
|
153
|
-
REVOKE SELECT ON omen_readings, omen_questions, omen_answers FROM omen_inquirer;
|
|
84
|
+
def notes = Rails.root.join('config/provider_notes.md').read
|
|
85
|
+
end
|
|
154
86
|
```
|
|
155
87
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
88
|
+
`own` is every table it reads rows of, and what makes a row its own -- `%{owner}` stands for
|
|
89
|
+
whoever the reading names, and a table reaching through another needs no owner of its own, since
|
|
90
|
+
the policy on the table it reaches through has already run. `whole` is read entire, because
|
|
91
|
+
nothing in those tables is anybody's; `except` is the columns of those it may not read, a counter
|
|
92
|
+
over everybody being the case it exists for. A credential is refused everywhere, by name, without
|
|
93
|
+
being asked for.
|
|
94
|
+
|
|
95
|
+
What that buys is that it does not matter what SQL Claude writes -- a join, a `WITH`, a `UNION`,
|
|
96
|
+
a subquery on a table it was never shown -- every path resolves through the policy, and rows
|
|
97
|
+
outside it do not exist for that role. The reading's own row says who it is: the setting is `SET
|
|
98
|
+
LOCAL` inside the transaction the statement runs in, and gone with it. A reading that names
|
|
99
|
+
nobody reads nothing.
|
|
100
|
+
|
|
101
|
+
### What it does not take away
|
|
102
|
+
|
|
103
|
+
Row level security binds every role but a table's owner, so turning it on could empty a table for
|
|
104
|
+
everybody else. It does not: each table gets a permissive policy for everybody beside the
|
|
105
|
+
`RESTRICTIVE` one for the narrowed role, which is ANDed with it and applies to nobody else -- a
|
|
106
|
+
role created afterwards included. The membership that lets a role enter the narrowed one is
|
|
107
|
+
granted `WITH INHERIT FALSE` (Postgres 16 and later) so that entering it is not the same as being
|
|
108
|
+
held back by it.
|
|
109
|
+
|
|
110
|
+
`db:omen:narrowed` says which roles each restrictive policy really holds back, membership and all,
|
|
111
|
+
and exits non-zero on one nobody asked for -- worth running in CI, since an over-narrowed role
|
|
112
|
+
reads empty rather than raising. `db:omen:widen` takes it all back off.
|
|
113
|
+
|
|
114
|
+
## Everything else
|
|
115
|
+
|
|
116
|
+
[INSTRUCTIONS.md](INSTRUCTIONS.md) has the reasoning: why Postgres and no other adapter, what
|
|
117
|
+
`db:omen:grant` creates and what to do on a managed database that forbids it, every setting and
|
|
118
|
+
its default, what the generated pages get right and why, and what a host can build on top.
|
|
161
119
|
|
|
162
120
|
## License
|
|
163
121
|
|
|
@@ -15,16 +15,18 @@ class Omen::Conversation
|
|
|
15
15
|
|
|
16
16
|
# @param questions [ActiveRecord::Relation] the questions asked so far, oldest first, up to
|
|
17
17
|
# and including the one being answered.
|
|
18
|
-
|
|
18
|
+
# @param reading [Omen::Reading] the one they were asked of, which says what it may read.
|
|
19
|
+
def initialize(questions, reading)
|
|
19
20
|
@questions = questions
|
|
21
|
+
@reading = reading
|
|
20
22
|
end
|
|
21
23
|
|
|
22
24
|
# No tools are sent, by design: a tool_result block would be rows travelling back to Claude.
|
|
23
25
|
# @return [Hash] the blocks Claude answered with, why it stopped, and what it cost.
|
|
24
26
|
def advance
|
|
25
27
|
said = client.messages.create model: Omen.config.claude_model, max_tokens: MAX_TOKENS,
|
|
26
|
-
system_: Omen::Instructions.block, messages: messages,
|
|
27
|
-
output_config: Omen::
|
|
28
|
+
system_: Omen::Instructions.block(@reading), messages: messages,
|
|
29
|
+
output_config: Omen::Reply.output_config
|
|
28
30
|
|
|
29
31
|
{ content: said.content.map { |block| block.to_h.deep_stringify_keys },
|
|
30
32
|
stop_reason: said.stop_reason, input_usage: said.usage.input_tokens,
|
data/app/models/omen/executed.rb
CHANGED
|
@@ -18,7 +18,7 @@ private
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def answer(question)
|
|
21
|
-
reply = Omen::Conversation.new(asked_up_to
|
|
21
|
+
reply = Omen::Conversation.new(asked_up_to(question), self).advance
|
|
22
22
|
increment! :input_usage, reply[:input_usage]
|
|
23
23
|
increment! :output_usage, reply[:output_usage]
|
|
24
24
|
execute question.create_answer!(**reply)
|
|
@@ -29,7 +29,7 @@ private
|
|
|
29
29
|
def execute(answered)
|
|
30
30
|
return if answered.sql.blank?
|
|
31
31
|
|
|
32
|
-
answered.update! Omen::Query.new(answered.sql).answer
|
|
32
|
+
answered.update! Omen::Query.new(answered.sql, self).answer
|
|
33
33
|
rescue Omen::Role::Unavailable => error
|
|
34
34
|
answered.update! error: error.message
|
|
35
35
|
rescue StandardError => error
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
You answer questions about the data an app holds, for the
|
|
1
|
+
You answer questions about the data an app holds, for the person who asked.
|
|
2
2
|
|
|
3
3
|
You answer by writing **one PostgreSQL SELECT** against the schema at the end of this message,
|
|
4
4
|
and a short note about it. Nothing else runs.
|
|
@@ -113,7 +113,7 @@ into `note`. Do not guess. A question with no date range, on data that spans yea
|
|
|
113
113
|
one of those.
|
|
114
114
|
|
|
115
115
|
Otherwise keep the note to a sentence or two: what the query returns, and any assumption you
|
|
116
|
-
made.
|
|
116
|
+
made. This is somebody reading quickly, not a report.
|
|
117
117
|
|
|
118
118
|
Say a sliding window the way the statement says it, and then what it comes to today: "in the
|
|
119
119
|
previous calendar month (July 2026)". A note reading "in July 2026" alone stops being true the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Everything Claude is told before a question: the prose, the schema
|
|
1
|
+
# Everything Claude is told before a question: the prose, and the schema it may write against.
|
|
2
2
|
class Omen::Instructions
|
|
3
3
|
# The prose, kept beside this class so it reads as prose rather than as a string.
|
|
4
4
|
PROSE = File.expand_path 'instructions.md', __dir__
|
|
@@ -13,28 +13,16 @@ class Omen::Instructions
|
|
|
13
13
|
# The database function a distance in miles is measured with, created by the same task.
|
|
14
14
|
MILES = 'omen_miles_between'
|
|
15
15
|
|
|
16
|
-
# The one shape a reply may take: both keys required, and no others admitted.
|
|
17
|
-
ANSWER = {
|
|
18
|
-
type: 'object', additionalProperties: false, required: %w[ sql note combine ],
|
|
19
|
-
properties: {
|
|
20
|
-
sql: { type: 'string',
|
|
21
|
-
description: 'The one PostgreSQL SELECT that answers the question, or ' \
|
|
22
|
-
'empty to ask something first.', },
|
|
23
|
-
note: { type: 'string',
|
|
24
|
-
description: 'A sentence or two: what the query returns and any ' \
|
|
25
|
-
'assumption made. If sql is empty, the question you ' \
|
|
26
|
-
'need answered first.', },
|
|
27
|
-
combine: Omen::Combination::SCHEMA,
|
|
28
|
-
},
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
# @return [Hash] what a reply is constrained to, so that it always parses.
|
|
32
|
-
def self.output_config = { format_: { type: :json_schema, schema: ANSWER } }
|
|
33
|
-
|
|
34
16
|
# Cached for an hour: the schema is thousands of tokens, and refining sends it again.
|
|
17
|
+
# @param reading [Omen::Reading] the one being answered, which says what it may read.
|
|
35
18
|
# @return [Array<Hash>] the one system block of a request.
|
|
36
|
-
def self.block
|
|
37
|
-
[ { type: 'text', text: new.text, cache_control: { type: 'ephemeral', ttl: '1h' } } ]
|
|
19
|
+
def self.block(reading)
|
|
20
|
+
[ { type: 'text', text: new(reading).text, cache_control: { type: 'ephemeral', ttl: '1h' } } ]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param reading [Omen::Reading] the one being answered, which says what it may read.
|
|
24
|
+
def initialize(reading)
|
|
25
|
+
@reading = reading
|
|
38
26
|
end
|
|
39
27
|
|
|
40
28
|
# Today's date is said out loud because "last month" is Claude's to resolve, and it has no clock.
|
|
@@ -43,17 +31,20 @@ class Omen::Instructions
|
|
|
43
31
|
format File.read(PROSE), today: Date.current.to_fs(:long), zone_fn: TIME_ZONE,
|
|
44
32
|
today_fn: TODAY, miles_fn: MILES,
|
|
45
33
|
schema: schema, types: types, readable: readable, refused: refused,
|
|
46
|
-
notes:
|
|
34
|
+
notes: @reading.notes
|
|
47
35
|
end
|
|
48
36
|
|
|
49
37
|
private
|
|
50
38
|
|
|
51
|
-
def schema = Omen::Schema.new.text
|
|
39
|
+
def schema = Omen::Schema.new(@reading.runs_as).text
|
|
52
40
|
|
|
41
|
+
# This gem's own tables are told apart by a type column too, and are hidden from the schema,
|
|
42
|
+
# so naming their subclasses here would say the one thing the schema is careful not to.
|
|
53
43
|
def types
|
|
54
44
|
Rails.application.eager_load!
|
|
55
45
|
Omen.config.record.descendants.select(&:finder_needs_type_condition?)
|
|
56
|
-
.group_by(&:table_name).
|
|
46
|
+
.group_by(&:table_name).except(*Omen.tables).sort
|
|
47
|
+
.map { |table, kinds| "- `#{table}`: #{named kinds}" }.join "\n"
|
|
57
48
|
end
|
|
58
49
|
|
|
59
50
|
def named(kinds) = kinds.map(&:name).sort.map { |name| "`#{name}`" }.join ', '
|