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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f96e34cbde627e0781a12488ba4308d704c6896a71fc44b031b7d412512c22eb
4
- data.tar.gz: 408ad7c0b4a13f8ef3aef1b1c271e81ac116791ff627ffa2d056f5ecb6c45e34
3
+ metadata.gz: 38d16f1a8a34b75c8ec0c68256c468a1964433fd7700c3d70cac77a442d112a5
4
+ data.tar.gz: de2689fb094379753948c4e7d8a98446b92e7870ea52ec26448f4e9dae19cc04
5
5
  SHA512:
6
- metadata.gz: fd53d5b7acb9fea6560bf0edbd5e1fb8f26200d99edaa52dfd9a81c9945dbd154a69514a7ba0028a025f36284bbd04be01e7aa57df61b7c01fa68ceb974638fd
7
- data.tar.gz: a5b4226127e861f2600ebf62eb1d0c10ae2921886caf8e27b8041b411ac1b904c5e3406a0265f11e1f4c54a80ae9fa33994f396ec9adbc0df74c57b2304946df
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 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 major:
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.2'
21
+ gem 'omen', '~> 0.5.0'
22
22
  ```
23
23
 
24
- Omen follows [Semantic Versioning](https://semver.org), so `~> major.minor` means `bundle update`
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 # adds three migrations + an initializer you can delete
74
- bin/rails db:migrate # creates the tables `omen_readings`, `omen_questions`, `omen_answers`
75
- 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
76
31
  ```
77
32
 
78
- `db:omen:grant` is worth running from the tasks that build a database, so a fresh one is never
79
- missing the role. In `lib/tasks` of the host:
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
- ### Requirements
36
+ ## What your app has to have first
93
37
 
94
- - **A read-only connection role.** `connects_to database: { writing: :primary, reading: :reader }`
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
- ### 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.
107
48
 
108
- Every setting has a default, so the initializer is optional. `rails generate omen:install`
109
- writes it with each line commented out, as the list of what there is to say.
49
+ ## What you get
110
50
 
111
- | Setting | Default |
112
- |---|---|
113
- | `narrow_role` | `'omen_inquirer'` |
114
- | `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.
115
56
 
116
- ## What a host builds on top
57
+ ## Two kinds of reading
117
58
 
118
- `Omen::Reading` has a `type` column nowhere, so a subclass is a transparent second name for the
119
- same rows: `Inquiry.all` carries no type condition, and `to_partial_path` becomes
120
- `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.
121
63
 
122
- ```ruby
123
- class Inquiry < Omen::Reading
124
- belongs_to :agent
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
- `Omen::Reading.create! question: 'Where are the homes we serve?'` is the whole of asking; a
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
- Two things a subclass cannot reach, because the gem's own class is what a job loads:
133
- `broadcasts_refreshes`, and anything else that has to be declared on `Omen::Reading` itself. One
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
- ActiveSupport.on_load(:omen_reading) { broadcasts_refreshes }
138
- ```
74
+ class Consult < Omen::Reading
75
+ belongs_to :provider
139
76
 
140
- ## 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/
141
83
 
142
- The narrow role is granted `SELECT` on every table and then refused Omen's own three, which can
143
- only happen once those tables exist. On a database that forbids `CREATE ROLE` — a managed one
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
- A managed database also refuses `ALTER ROLE ... NOSUPERUSER`, since only a superuser may say it.
151
- Omen skips that statement and carries on rather than stopping, then reads the role back and says
152
- so if it holds `SUPERUSER`, `BYPASSRLS` or `REPLICATION` -- which a role it created never does.
153
-
154
- 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.
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
- 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.
@@ -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 `%{eastern}()` is the one way to read one: it hands the same
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', %{eastern}(created_at))`. Never
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}. Resolve every relative date yourself; the query has no idea what "last
41
- month" means, and it must never ask the database what time it is -- `now()` and
42
- `current_timestamp` are the clock of the machine, not the date above.
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
- `%{miles}(lat1, lng1, lat2, lng2)`, so a radius reads
46
- `WHERE %{miles}(l.lat, l.lng, u.lat, u.lng) <= 2`. Never write the trigonometry yourself: a
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. These are colleagues reading quickly, not a report.
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