omen 0.3.1 → 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 +42 -0
- data/INSTRUCTIONS.md +225 -0
- data/README.md +86 -122
- data/app/models/omen/conversation.rb +5 -3
- data/app/models/omen/executed.rb +2 -2
- data/app/models/omen/instructions.md +26 -9
- data/app/models/omen/instructions.rb +23 -27
- 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/distance.rb +1 -1
- data/lib/omen/grants.rb +58 -6
- data/lib/omen/inquirer.rb +36 -20
- data/lib/omen/narrowing.rb +87 -0
- data/lib/omen/renamed.rb +17 -0
- data/lib/omen/time_zone.rb +57 -0
- data/lib/omen/version.rb +1 -1
- data/lib/omen.rb +19 -1
- data/lib/tasks/omen.rake +16 -2
- metadata +17 -2
- data/lib/omen/eastern.rb +0 -40
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,48 @@ 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
|
+
|
|
39
|
+
## 0.4.0 - 2026-08-24
|
|
40
|
+
|
|
41
|
+
* [Breaking change] Prefix every database function this gem creates, and drop the names they
|
|
42
|
+
had: `eastern` is now `omen_time_zone`, `miles_between` is `omen_miles_between`. A statement
|
|
43
|
+
stored before this names the old ones, so re-running one fails rather than answering from a
|
|
44
|
+
function nothing maintains -- an answer already drawn is unaffected, since its rows are stored
|
|
45
|
+
rather than recomputed
|
|
46
|
+
* [Feature] Create `omen_today()` and tell Claude to build every relative window on it, so a
|
|
47
|
+
statement kept and run again answers "last month" for the month it is run in rather than the
|
|
48
|
+
month it was written in. `STABLE`, and a date rather than a timestamp
|
|
49
|
+
* [Feature] Ask for a note that says a sliding window the way the statement says it, and then
|
|
50
|
+
what it comes to today, so a stored note does not stop being true on the second run
|
|
51
|
+
|
|
10
52
|
## 0.3.1 - 2026-08-24
|
|
11
53
|
|
|
12
54
|
* [Fix] Stop asserting the role attributes only a superuser may set, since `NOSUPERUSER`,
|
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,157 +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
|
-
never crosses a breaking change.
|
|
26
|
-
|
|
27
|
-
## Requirements
|
|
28
|
-
|
|
29
|
-
**PostgreSQL only**
|
|
30
|
-
|
|
31
|
-
<details>
|
|
32
|
-
<summary>This is not a gap waiting to be filled. </summary>
|
|
33
|
-
Two of the guarantees Omen makes are
|
|
34
|
-
Postgres features with no equivalent elsewhere: it identifies an encrypted column by the table OID
|
|
35
|
-
and column number Postgres reports for each result column, which is what stops an alias or an
|
|
36
|
-
expression from laundering one; and it narrows privileges for the statement it runs with
|
|
37
|
-
`SET LOCAL ROLE`, which reverts when the transaction ends. MySQL has `SET ROLE` but nothing
|
|
38
|
-
transaction-scoped, so a raised exception would leave a pooled connection holding the role. Omen
|
|
39
|
-
raises at boot on any other adapter rather than running with a weaker promise.
|
|
40
|
-
</details>
|
|
41
|
-
|
|
42
|
-
**db/schema.rb in Rails**
|
|
43
|
-
|
|
44
|
-
<details>
|
|
45
|
-
<summary>Only the :ruby schema is supported. </summary>
|
|
46
|
-
The schema is what Claude is shown, so an app
|
|
47
|
-
on `db/structure.sql` cannot use Omen. Checked at boot and raised on, because copying a
|
|
48
|
-
`structure.sql` to that path fails silently and with teeth: the strip regexes read the Ruby DSL,
|
|
49
|
-
so they match nothing, Omen's own tables stay in the prompt, and Claude is shown the log of every
|
|
50
|
-
question ever asked.
|
|
51
|
-
</details>
|
|
52
|
-
|
|
53
|
-
**Two database functions**
|
|
54
|
-
|
|
55
|
-
<details>
|
|
56
|
-
<summary>A timestamp and a distance are read through a function, never an expression. </summary>
|
|
57
|
-
`db:omen:grant` creates both. `eastern()` hands a stored timestamp back in the zone the company
|
|
58
|
-
works in, so every date in the prompt means the same whole days, and
|
|
59
|
-
`miles_between(lat1, lng1, lat2, lng2)` answers a great-circle distance in miles. The prompt
|
|
60
|
-
names each and forbids writing either by hand: a conversion assembled per query drifts, and a
|
|
61
|
-
great-circle expression runs to a dozen nested calls that a reply balances by hand and gets
|
|
62
|
-
wrong. Both are `LANGUAGE sql IMMUTABLE` and executable by anyone, so neither needs a grant.
|
|
63
|
-
An app in another zone renames the first; an app whose tables carry no coordinates simply never
|
|
64
|
-
calls the second. Neither can be a migration: Rails' `:ruby` schema format dumps no functions,
|
|
65
|
-
so `db:schema:load` would drop one a migration had made.
|
|
66
|
-
</details>
|
|
67
|
-
|
|
68
|
-
## Configuration
|
|
69
|
-
|
|
70
|
-
Installing by adding to your Gemfile and running three commands in your terminal:
|
|
24
|
+
Then four commands:
|
|
71
25
|
|
|
72
26
|
```sh
|
|
73
|
-
bin/rails g omen:install
|
|
74
|
-
bin/rails db:migrate
|
|
75
|
-
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
|
|
76
31
|
```
|
|
77
32
|
|
|
78
|
-
`
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
```ruby
|
|
82
|
-
granted = Rake::Task['db:omen:grant']
|
|
83
|
-
|
|
84
|
-
%w[ db:create db:prepare db:reset db:test:prepare ].each do |name|
|
|
85
|
-
Rake::Task[name].enhance do
|
|
86
|
-
granted.reenable
|
|
87
|
-
granted.invoke
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
```
|
|
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.
|
|
91
35
|
|
|
92
|
-
|
|
36
|
+
## What your app has to have first
|
|
93
37
|
|
|
94
|
-
|
|
95
|
-
on the record class, with the `reading` entry logging in as a Postgres role granted `SELECT`
|
|
96
|
-
and nothing else. Omen raises rather than falling back to a role that could write, which is
|
|
97
|
-
the point. Creating that role is the app's own business — Omen has no name for it, and
|
|
98
|
-
discovers it when granting.
|
|
99
|
-
- **Active Record Encryption keys.** Without them an encrypted column reads back as the
|
|
100
|
-
placeholder rather than as the value, quietly.
|
|
101
|
-
- **An `ApplicationJob`.** A reading is answered outside the request, and the job descends
|
|
102
|
-
from the app's own base class.
|
|
103
|
-
- **A `db/schema.rb`.** It is the prompt, so a reading cannot happen before the first
|
|
104
|
-
`db:migrate` has dumped one.
|
|
38
|
+
None of it is Omen's to create, and each is checked rather than assumed:
|
|
105
39
|
|
|
106
|
-
|
|
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.
|
|
107
48
|
|
|
108
|
-
|
|
109
|
-
writes it with each line commented out, as the list of what there is to say.
|
|
49
|
+
## What you get
|
|
110
50
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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.
|
|
115
56
|
|
|
116
|
-
##
|
|
57
|
+
## Two kinds of reading
|
|
117
58
|
|
|
118
|
-
`
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
121
63
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
end
|
|
126
|
-
```
|
|
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.
|
|
127
67
|
|
|
128
|
-
|
|
129
|
-
follow-up is `reading.ask '...'`. Each question is answered in a job, and the answer carries the
|
|
130
|
-
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
|
|
131
69
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
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:
|
|
135
72
|
|
|
136
73
|
```ruby
|
|
137
|
-
|
|
138
|
-
|
|
74
|
+
class Consult < Omen::Reading
|
|
75
|
+
belongs_to :provider
|
|
139
76
|
|
|
140
|
-
|
|
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/
|
|
141
83
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
usually does — the role is made by hand and the revocation with it:
|
|
145
|
-
|
|
146
|
-
```sql
|
|
147
|
-
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
|
|
148
86
|
```
|
|
149
87
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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.
|
|
155
119
|
|
|
156
120
|
## License
|
|
157
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.
|
|
@@ -31,19 +31,32 @@ have made it, and switching quote style to avoid an apostrophe is how you make i
|
|
|
31
31
|
|
|
32
32
|
## Reading the schema
|
|
33
33
|
|
|
34
|
-
Timestamps are stored in UTC, and `%{
|
|
34
|
+
Timestamps are stored in UTC, and `%{zone_fn}()` is the one way to read one: it hands the same
|
|
35
35
|
moment back in the zone the company works in. Wrap every timestamp you touch in it -- in a
|
|
36
36
|
`WHERE`, in an `ORDER BY`, in a `GROUP BY`, and in a column you return -- so a question about a
|
|
37
|
-
day, a week or a month means whole days here: `date_trunc('day', %{
|
|
37
|
+
day, a week or a month means whole days here: `date_trunc('day', %{zone_fn}(created_at))`. Never
|
|
38
38
|
write a conversion of your own.
|
|
39
39
|
|
|
40
|
-
Today is %{today}
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
Today is %{today}, and `%{today_fn}()` is that same day asked of the database. Use it for every
|
|
41
|
+
window a question describes in relation to now:
|
|
42
|
+
|
|
43
|
+
```sql
|
|
44
|
+
WHERE %{zone_fn}(created_at) >= date_trunc('month', %{today_fn}()) - interval '1 month'
|
|
45
|
+
AND %{zone_fn}(created_at) < date_trunc('month', %{today_fn}())
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Write it that way rather than working the dates out and putting them in, because a statement is
|
|
49
|
+
kept and run again: one carrying `'2026-08-01'` answers a question nobody asked the second time,
|
|
50
|
+
where one carrying `%{today_fn}()` still answers "last month" whenever it is run. A window a
|
|
51
|
+
question names outright -- "in July 2026", "since the 3rd" -- is not relative to now, and keeps
|
|
52
|
+
its literal dates.
|
|
53
|
+
|
|
54
|
+
Never `now()`, `current_date` or `current_timestamp`: those are the machine's clock in the
|
|
55
|
+
machine's zone. `%{today_fn}()` is the company's day, and it is a date, so it needs no truncating.
|
|
43
56
|
|
|
44
57
|
Where a table carries coordinates, the distance between two points in miles is
|
|
45
|
-
`%{
|
|
46
|
-
`WHERE %{
|
|
58
|
+
`%{miles_fn}(lat1, lng1, lat2, lng2)`, so a radius reads
|
|
59
|
+
`WHERE %{miles_fn}(l.lat, l.lng, u.lat, u.lng) <= 2`. Never write the trigonometry yourself: a
|
|
47
60
|
great-circle expression built by hand runs to a dozen nested calls, and one bracket out of place
|
|
48
61
|
either refuses the statement or, worse, measures something else and says nothing about it.
|
|
49
62
|
|
|
@@ -100,7 +113,11 @@ into `note`. Do not guess. A question with no date range, on data that spans yea
|
|
|
100
113
|
one of those.
|
|
101
114
|
|
|
102
115
|
Otherwise keep the note to a sentence or two: what the query returns, and any assumption you
|
|
103
|
-
made.
|
|
116
|
+
made. This is somebody reading quickly, not a report.
|
|
117
|
+
|
|
118
|
+
Say a sliding window the way the statement says it, and then what it comes to today: "in the
|
|
119
|
+
previous calendar month (July 2026)". A note reading "in July 2026" alone stops being true the
|
|
120
|
+
moment the statement is run again.
|
|
104
121
|
|
|
105
122
|
## The schema
|
|
106
123
|
|