Agent Skills: State Records (37signals)

>-

UncategorizedID: thibautbaissac/rails_ai_agents/state-records

Install this agent skill to your local

pnpm dlx add-skill https://github.com/ThibautBaissac/rails_ai_agents/tree/HEAD/.claude_37signals/skills/state-records

Skill Files

Browse the full folder contents for state-records.

Download Skill

Loading file tree…

.claude_37signals/skills/state-records/SKILL.md

Skill Metadata

Name
state-records
Description
>-

State Records (37signals)

State as records, not booleans. Instead of closed: boolean, create a Closure record.

Project knowledge

Tech Stack: Rails 8.2 (edge), UUIDs everywhere, ActiveRecord associations Pattern: One state model per boolean you'd normally add Naming: Noun forms (Closure, Publication, Goldness, NotNow, Archival)

Commands:

bin/rails generate model Closure card:references:uuid user:references:uuid account:references:uuid
bin/rails db:migrate
bin/rails console                              # Test: Card.open.count
bin/rails test test/models/

Why state records over booleans

Boolean columns give you:

  • Current state (open/closed)

State records give you:

  • Current state (closure.present?)
  • When it changed (closure.created_at)
  • Who changed it (closure.user)
  • Why it changed (closure.reason)
  • Change history (via events)

The pattern

Boolean approach (avoid for business state):

# BAD
class Card < ApplicationRecord
  def close
    update!(closed: true, closed_at: Time.current)
  end

  scope :open, -> { where(closed: false) }
end

State record approach:

# GOOD
class Closure < ApplicationRecord
  # touch: true ensures the parent's updated_at changes when state changes,
  # which drives cache invalidation (Russian doll caching, ETags, etc.)
  belongs_to :card, touch: true
  belongs_to :user, optional: true
  belongs_to :account, default: -> { card.account }

  validates :card, uniqueness: true
end

class Card < ApplicationRecord
  has_one :closure, dependent: :destroy

  def close(user: Current.user)
    create_closure!(user: user)
  end

  def reopen
    closure&.destroy!
  end

  def closed?
    closure.present?
  end

  scope :open, -> { where.missing(:closure) }
  scope :closed, -> { joins(:closure) }
end

State record model template

Every state record model follows this structure:

class Closure < ApplicationRecord
  belongs_to :account, default: -> { card.account }
  belongs_to :card, touch: true
  belongs_to :user, optional: true

  validates :card, uniqueness: true

  after_create_commit :notify_watchers
  after_destroy_commit :notify_watchers

  private

  def notify_watchers
    card.notify_watchers_later
  end
end

State concern template

Every state concern follows this structure:

module Card::Closeable
  extend ActiveSupport::Concern

  included do
    has_one :closure, dependent: :destroy

    scope :open, -> { where.missing(:closure) }
    scope :closed, -> { joins(:closure) }
  end

  def close(user: Current.user)
    create_closure!(user: user)
    track_event "card_closed", user: user
  end

  def reopen
    closure&.destroy!
    track_event "card_reopened"
  end

  def closed?
    closure.present?
  end

  def open?
    !closed?
  end

  def closed_at
    closure&.created_at
  end

  def closed_by
    closure&.user
  end
end

State record with metadata

When state needs additional data (secure tokens, descriptions):

class Board::Publication < ApplicationRecord
  belongs_to :account, default: -> { board.account }
  belongs_to :board, touch: true

  has_secure_token :key

  validates :board, uniqueness: true

  def public_url
    Rails.application.routes.url_helpers.public_board_url(key)
  end
end

module Board::Publishable
  extend ActiveSupport::Concern

  included do
    has_one :publication, dependent: :destroy

    scope :published, -> { joins(:publication) }
    scope :private, -> { where.missing(:publication) }
  end

  def publish(description: nil)
    create_publication!(description: description)
    track_event "board_published"
  end

  def unpublish
    publication&.destroy!
    track_event "board_unpublished"
  end

  def published?
    publication.present?
  end

  def public_url
    publication&.public_url
  end
end

Query patterns with state records

# Finding by state: positive uses joins, negative uses where.missing
Card.open                    # where.missing(:closure)
Card.closed                  # joins(:closure)
Board.published              # joins(:publication)
Card.golden                  # joins(:goldness)

# Complex combinations
scope :actionable, -> {
  where.missing(:closure).where.missing(:not_now).where.missing(:archival)
}

# Sorting by state
scope :with_golden_first, -> {
  left_outer_joins(:goldness)
    .select("cards.*", "card_goldnesses.created_at as golden_at")
    .order(Arel.sql("golden_at IS NULL, golden_at DESC"))
}

# Filtering by actor
scope :closed_by, ->(user) { joins(:closure).where(closures: { user: user }) }

Controller patterns

State changes map to singular resources with create/destroy:

# config/routes.rb
resources :cards do
  resource :closure, only: [:create, :destroy], module: :cards
  resource :goldness, only: [:create, :destroy], module: :cards
  resource :not_now, only: [:create, :destroy], module: :cards
end

# app/controllers/cards/closures_controller.rb
class Cards::ClosuresController < ApplicationController
  include CardScoped

  def create
    @card.close(user: Current.user)
    render_card_replacement
  end

  def destroy
    @card.reopen
    render_card_replacement
  end
end

View patterns

<%# Toggle button %>
<%= button_to card_goldness_path(card),
    method: card.golden? ? :delete : :post,
    data: { turbo_frame: dom_id(card) } do %>
  <%= card.golden? ? "Ungild" : "Gild" %>
<% end %>

<%# State badge %>
<% if card.closed? %>
  <span class="badge badge--closed">
    Closed <%= time_ago_in_words(card.closed_at) %> ago
    <% if card.closed_by %>by <%= card.closed_by.name %><% end %>
  </span>
<% end %>

When to use state records vs booleans

Use state records when:

  • You need to know when state changed
  • You need to know who changed it
  • You might store metadata (reason, notes)
  • State changes are important business events
  • You need queries like "recently closed" or "closed by X"

Use booleans when:

  • State is purely technical (cached, processed)
  • Timestamp/actor don't matter
  • Performance is critical (millions of rows, frequent updates)
  • State changes are not business events

Quick reference:

  • State records: closed, published, archived, suspended, verified, pinned, golden, postponed
  • Booleans: admin, cached, processed, visible

See references/state-record-examples.md for complete examples of each state type.

Migration from boolean to state record

  1. Create state record model + migration
  2. Backfill existing data
  3. Update model code to use concern
  4. Remove boolean column (after verification)

Boundaries

  • Always: Create state record for business-meaningful states, track who and when, use where.missing for negative scopes, add unique index on parent_id, touch parent record, write tests for state transitions
  • Ask first: Before using boolean columns for business state, before adding complex metadata (might need separate model)
  • Never: Use booleans for important business state, skip who/when tracking, create multiple state records per parent (use has_one with unique index), skip event tracking for state changes
State Records (37signals) Skill | Agent Skills