Omen

You ask Claude a complex question about data stored by your Rails app. Claude answers with the SQL. Rails runs it.

Omen is an engine that you too can use. You just need a Rails app running on PostgreSQL. Omen provides the models and the logic to talk to Claude; to parse what it says; to run read-only statements.

Your data never travels. Claude is shown the schema and writes one SELECT; Rails runs it and draws the answer (including encrypted attributes) for whoever asked. Nothing that statement returned is ever sent back.

How to install

gem install omen

Or, in a Gemfile, pinned to the current minor while this is still below 1.0:

gem 'omen', '~> 0.4.0'

Omen follows Semantic Versioning from 1.0 onwards. Until then a release may break whatever it likes, so the pin stops short of the next minor: ~> 0.4.0 takes every fix in 0.4 and nothing beyond it.

Requirements

PostgreSQL only

This is not a gap waiting to be filled. Two of the guarantees Omen makes are Postgres features with no equivalent elsewhere: it identifies an encrypted column by the table OID and column number Postgres reports for each result column, which is what stops an alias or an expression from laundering one; and it narrows privileges for the statement it runs with `SET LOCAL ROLE`, which reverts when the transaction ends. MySQL has `SET ROLE` but nothing transaction-scoped, so a raised exception would leave a pooled connection holding the role. Omen raises at boot on any other adapter rather than running with a weaker promise.

db/schema.rb in Rails

Only the :ruby schema is supported. The schema is what Claude is shown, so an app on `db/structure.sql` cannot use Omen. Checked at boot and raised on, because copying a `structure.sql` to that path fails silently and with teeth: the strip regexes read the Ruby DSL, so they match nothing, Omen's own tables stay in the prompt, and Claude is shown the log of every question ever asked.

Three database functions, all prefixed

A timestamp, a day and a distance go through a function, never an expression. `db:omen:grant` creates all three, each named `omen_` so that none of them can take a name an app wanted for itself -- `today` especially. `omen_time_zone()` hands a stored timestamp back in the zone the company works in, so every date means the same whole days. `omen_today()` answers what day it is there, and the prompt builds every relative window on it, so a statement that is kept and run again answers "last month" for the month it is run in rather than the month it was written in. `omen_miles_between(lat1, lng1, lat2, lng2)` answers a great-circle distance in miles. The prompt names each and forbids writing any of them by hand: a conversion assembled per query drifts, and a great-circle expression runs to a dozen nested calls that a reply balances by hand and gets wrong. The first and last are `IMMUTABLE`; `omen_today()` is `STABLE`, because it reads the clock and an immutable function of the clock may be folded to a constant -- which is exactly the sliding this one exists to keep. All are executable by anyone, so none needs a grant. An app in another zone renames the first two; an app whose tables carry no coordinates never calls the last. None can be a migration: Rails' `:ruby` schema format dumps no functions, so `db:schema:load` would drop one a migration had made.

Configuration

Installing by adding to your Gemfile and running three commands in your terminal:

bin/rails g omen:install # adds three migrations + an initializer you can delete
bin/rails db:migrate     # creates the tables `omen_readings`, `omen_questions`, `omen_answers`
bin/rails db:omen:grant  # set the read-only role statements run as

db:omen:grant is worth running from the tasks that build a database, so a fresh one is never missing the role. In lib/tasks of the host:

granted = Rake::Task['db:omen:grant']

%w[ db:create db:prepare db:reset db:test:prepare ].each do |name|
  Rake::Task[name].enhance do
    granted.reenable
    granted.invoke
  end
end

Requirements

  • A read-only connection role. connects_to database: { writing: :primary, reading: :reader } on the record class, with the reading entry logging in as a Postgres role granted SELECT and nothing else. Omen raises rather than falling back to a role that could write, which is the point. Creating that role is the app's own business — Omen has no name for it, and discovers it when granting.
  • Active Record Encryption keys. Without them an encrypted column reads back as the placeholder rather than as the value, quietly.
  • An ApplicationJob. A reading is answered outside the request, and the job descends from the app's own base class.
  • A db/schema.rb. It is the prompt, so a reading cannot happen before the first db:migrate has dumped one.

The options

Every setting has a default, so the initializer is optional. rails generate omen:install writes it with each line commented out, as the list of what there is to say.

Setting Default
narrow_role 'omen_inquirer'
notes none, so the prompt says nothing about this app beyond its schema

What a host builds on top

Omen::Reading has a type column nowhere, so a subclass is a transparent second name for the same rows: Inquiry.all carries no type condition, and to_partial_path becomes inquiries/inquiry.

class Inquiry < Omen::Reading
  belongs_to :agent
end

Omen::Reading.create! question: 'Where are the homes we serve?' is the whole of asking; a follow-up is reading.ask '...'. Each question is answered in a job, and the answer carries the statement Claude wrote, the rows it found, and which header of theirs held an encrypted column.

Two things a subclass cannot reach, because the gem's own class is what a job loads: broadcasts_refreshes, and anything else that has to be declared on Omen::Reading itself. One line in the host does it:

ActiveSupport.on_load(:omen_reading) { broadcasts_refreshes }

After the first deploy

The narrow role is granted SELECT on every table and then refused Omen's own three, which can only happen once those tables exist. On a database that forbids CREATE ROLE — a managed one usually does — the role is made by hand and the revocation with it:

REVOKE SELECT ON omen_readings, omen_questions, omen_answers FROM omen_inquirer;

A managed database also refuses ALTER ROLE ... NOSUPERUSER, since only a superuser may say it. Omen skips that statement and carries on rather than stopping, then reads the role back and says so if it holds SUPERUSER, BYPASSRLS or REPLICATION -- which a role it created never does.

A missed table there means Claude is shown the log of every question ever asked.

License

MIT, see LICENSE.txt.