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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dd517ec9c5893a92f285613782d245ef9573bb83c8ef78378510f49a208626fc
4
- data.tar.gz: 5d5f97f73bd6cf0b423f9264426522aee942df7ab4fb622e3442b75493da29c0
3
+ metadata.gz: 38d16f1a8a34b75c8ec0c68256c468a1964433fd7700c3d70cac77a442d112a5
4
+ data.tar.gz: de2689fb094379753948c4e7d8a98446b92e7870ea52ec26448f4e9dae19cc04
5
5
  SHA512:
6
- metadata.gz: f383e42b3c7e9fdd06f7c69c6ef7fd8563cb55f47db66583ee07c656b56bfabe6217972c63c89596db5407a96af4e8080f71e3561e7ee188a507c74d9ff28404
7
- data.tar.gz: a8894e28c75abeeb62cd139ead7bf0abca83593a49c89bae50273dbe178016ef6cf915179a07355b4b7af38f4f17c06efc2e57f597e949637bf5f7528fbce8a7
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 stored by your Rails app.
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
- Omen is an engine that you too can use. You just need a Rails app running on PostgreSQL.
7
- Omen provides the models and the logic to talk to Claude; to parse what it says; to run read-only statements.
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
- Your data never travels. Claude is shown the schema and writes one `SELECT`; Rails runs it and
10
- draws the answer (including encrypted attributes) for whoever asked. Nothing that statement returned is ever sent back.
9
+ ![Asking Omen a question inside a Rails app](demo.gif)
10
+ -->
11
11
 
12
- ## How to install
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
- ```sh
15
- gem install omen
16
- ```
16
+ ## Running it
17
17
 
18
- Or, in a `Gemfile`, pinned to the current minor while this is still below 1.0:
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.4.0'
21
+ gem 'omen', '~> 0.5.0'
22
22
  ```
23
23
 
24
- Omen follows [Semantic Versioning](https://semver.org) from 1.0 onwards. Until then a release may
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 # adds three migrations + an initializer you can delete
80
- bin/rails db:migrate # creates the tables `omen_readings`, `omen_questions`, `omen_answers`
81
- bin/rails db:omen:grant # set the read-only role statements run as
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
- `db:omen:grant` is worth running from the tasks that build a database, so a fresh one is never
85
- missing the role. In `lib/tasks` of the host:
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
- ### Requirements
36
+ ## What your app has to have first
99
37
 
100
- - **A read-only connection role.** `connects_to database: { writing: :primary, reading: :reader }`
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
- ### The options
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
- Every setting has a default, so the initializer is optional. `rails generate omen:install`
115
- writes it with each line commented out, as the list of what there is to say.
49
+ ## What you get
116
50
 
117
- | Setting | Default |
118
- |---|---|
119
- | `narrow_role` | `'omen_inquirer'` |
120
- | `notes` | none, so the prompt says nothing about this app beyond its schema |
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
- ## What a host builds on top
57
+ ## Two kinds of reading
123
58
 
124
- `Omen::Reading` has a `type` column nowhere, so a subclass is a transparent second name for the
125
- same rows: `Inquiry.all` carries no type condition, and `to_partial_path` becomes
126
- `inquiries/inquiry`.
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
- ```ruby
129
- class Inquiry < Omen::Reading
130
- belongs_to :agent
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
- `Omen::Reading.create! question: 'Where are the homes we serve?'` is the whole of asking; a
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
- Two things a subclass cannot reach, because the gem's own class is what a job loads:
139
- `broadcasts_refreshes`, and anything else that has to be declared on `Omen::Reading` itself. One
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
- ActiveSupport.on_load(:omen_reading) { broadcasts_refreshes }
144
- ```
74
+ class Consult < Omen::Reading
75
+ belongs_to :provider
145
76
 
146
- ## After the first deploy
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
- The narrow role is granted `SELECT` on every table and then refused Omen's own three, which can
149
- only happen once those tables exist. On a database that forbids `CREATE ROLE` — a managed one
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
- A managed database also refuses `ALTER ROLE ... NOSUPERUSER`, since only a superuser may say it.
157
- Omen skips that statement and carries on rather than stopping, then reads the role back and says
158
- so if it holds `SUPERUSER`, `BYPASSRLS` or `REPLICATION` -- which a role it created never does.
159
-
160
- A missed table there means Claude is shown the log of every question ever asked.
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
- def initialize(questions)
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::Instructions.output_config
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,
@@ -18,7 +18,7 @@ private
18
18
  end
19
19
 
20
20
  def answer(question)
21
- reply = Omen::Conversation.new(asked_up_to question).advance
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 staff who run it.
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. These are colleagues reading quickly, not a report.
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, and the shape of a reply.
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: Omen.config.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).sort.map { |table, kinds| "- `#{table}`: #{named kinds}" }.join "\n"
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 ', '