Here is a question worth asking about any application you have shipped: what, precisely, stops a row with an empty name from reaching your database?

I could not answer it for my own until I measured, and the answer was thinner than I expected.

The code I trusted

Validation in Hanami lives on the action, in a block that is both the whitelist and the contract:

params do
  required(:challenge).hash do
    required(:name).filled(:string)
    required(:category).filled(:string)
    optional(:difficulty).filled(:integer)
    optional(:points).filled(:integer)
    optional(:event_id).maybe(:integer)
  end
end

The column, meanwhile, was declared in the migration the obvious way:

column :name, String, null: false

Two guards, one at the edge and one at the bottom. A challenge without a name had nowhere to get through.

Each guard, measured

So I asked, in a console, rather than reasoning about it. Creating a challenge straight through the repo with a nil name raised ROM::SQL::NotNullConstraintError, which is what I expected. Creating one with an empty string was accepted, and the row landed with name set to "".

null: false refuses NULL. That is all it has ever promised, and it kept its promise exactly. An empty string is a perfectly good string as far as SQLite is concerned, and it went straight in.

Which leaves filled(:string) as the only thing standing between my table and a nameless row. It works, and I have a passing test that proves it works. But it lives on one action, and it only runs when a request goes through that action.

I checked the layer in between too, because by then I had stopped trusting my assumptions. The operation that creates a challenge takes the empty name without complaint. Of course it does. Nobody told it not to.

So the guard is one layer thick, at the HTTP edge. A seed file, a background job, a console session, a second action written next month by somebody in a hurry: none of them pass through that contract, and none of them will be stopped.

The rule that had no home

The thinness became a bug the same afternoon, in a way I did not plan.

Challenges can be attached to an event, so the form has a select. I posted one with an event_id that no longer existed. Not an attack, just a stale form.

ROM::SQL::ForeignKeyConstraintError: SQLite3::ConstraintException: FOREIGN KEY constraint failed

A 500. A crash page, for a situation the application could have described in a sentence.

Look at where that fell. filled(:integer) checked the shape of the parameter and was satisfied: 999999 is an integer. The foreign key checked existence and was not satisfied, but a database constraint answers with an exception, not with a message a person can read. The question “does this event still exist?” belongs to neither. It is not a fact about the shape of an HTTP parameter, and it is not a fact about a column. It is a rule about what it means to create a challenge, and there was nowhere in my code that such a rule lived.

Giving it somewhere to live

Hanami’s answer is an operation, and once the rule has an address the code stops being clever:

def call(attributes)
  step ensure_event_exists(attributes[:event_id])

  challenge_repo.create(attributes)
end

private

def ensure_event_exists(event_id)
  return Success(nil) if event_id.nil?

  event_repo.find(event_id) ? Success(event_id) : Failure(:unknown_event)
end

The action stops hoping and starts mapping outcomes:

case create.call(request.params[:challenge])
in Success(_challenge)
  response.redirect_to "/challenges"
in Failure(:unknown_event)
  reject(response, {challenge: {event_id: ["does not exist"]}})
end

Same request as before, driven against the running app: 422 now, with “does not exist” rendered in the form where the user can see it. The nominal paths still redirect, and a blank name is still refused at the edge.

The same hole, in Rails

None of this is really about Hanami.

In Rails I would have written validates :name, presence: true on the model and stopped thinking about it, genuinely believing the question was settled. It would not have been: update_column, insert_all and any console session go straight around it. The hole I found here exists in most Rails applications I have worked on, including ones I wrote, and I never went looking for it because the model made me feel like somebody had.

That is the difference, and it does not flatter Hanami much. The Rails model never closed that gap, it just stopped me asking about it. Take the model away and the question comes back, at the least convenient moment, which in my case was a 500 in a form I had built an hour earlier.

The question I ask now

I ask where a rule lives, and I ask it as a separate question from where the rule is written down.

A shape belongs in the contract. A column’s floor belongs in the migration. Anything that depends on the state of the world, like whether the thing you are pointing at is still there, belongs in something that can look. Those are three different places, and the moment I stopped assuming they overlapped, I found a crash I had shipped and a column I had never really protected.

The state described here is tagged 04-rules-need-a-home.

Comments, questions, or just a reaction?

Send an email to ~bounga/bounga.org-discuss@lists.sr.ht. It is public and archived, so other readers can follow along and answer too.