Skip to content

Latest commit

 

History

History
328 lines (246 loc) · 8.74 KB

File metadata and controls

328 lines (246 loc) · 8.74 KB

Operational — AI Agent Reference

This document is for AI coding agents. It describes the exact API of the operational gem so you can generate correct code without guessing.

Architecture

Operational has four components:

  1. Operation — orchestrates a business process as a railway of steps
  2. Form — validates and transforms user input, decoupled from models (built on ActiveModel)
  3. Contract — step helpers that wire forms into operations (Build → Validate → Sync)
  4. Controller — Rails mixin that runs operations from controller actions

Operations

Subclass Operational::Operation. Define steps with step, pass, or fail at the class level. Call with .call(state_hash). Returns an Operational::Result.

class CreateArticleOperation < Operational::Operation
  step :init
  step Contract::Build(contract: ArticleForm)
  step Contract::Validate()
  step Contract::Sync()
  step :save
  pass :notify    # return value ignored, never derails
  fail :handle    # only runs on failure track

  def init(state)
    state[:model] = Article.new
    # must return truthy to continue, falsy switches to failure track
  end

  def save(state)
    state[:model].save  # returns true/false naturally
  end

  def notify(state)
    # side effect, return value doesn't matter
  end

  def handle(state)
    # runs on failure track
    # return truthy to recover back to success track
    # return falsy to stay on failure track
    false
  end
end

Step types

Type Runs when Truthy return Falsy return
step On success track Continue success Switch to failure track
fail On failure track Recover to success Continue failure
pass On success track Continue success Continue success

Step actions

A step action can be:

  • Symbol — calls instance method with (state) argument
  • Lambda/Proc — called with (state) argument
  • Any object responding to .call — called with (state) argument

State

State is a plain Ruby hash passed to .call. It is mutable — steps read from and write to it. The result's state is a frozen duplicate.

result = MyOperation.call(user: user, params: params_hash)

Result

result.succeeded?  # => true/false
result.failed?     # => true/false
result.state       # => frozen hash
result[:key]       # => shorthand for result.state[:key]
result.operation   # => the operation instance

Nested operations

Use Nested::Operation to call one operation from within another. State is merged back. The nested result's succeeded? determines if the parent continues on success or failure track.

class CreateArticleOperation < Operational::Operation
  class Present < Operational::Operation
    step :init
    step Contract::Build(contract: ArticleForm)

    def init(state)
      state[:model] = Article.new
    end
  end

  step Nested::Operation(operation: Present)
  step Contract::Validate()
  step Contract::Sync()
  pass :persist

  def persist(state)
    state[:model].save!
  end
end

Forms

Subclass Operational::Form. Uses ActiveModel::Model, ActiveModel::Attributes, ActiveModel::Dirty.

class ArticleForm < Operational::Form
  attribute :title, :string
  attribute :body, :string

  validates :title, presence: true
  validates :body, presence: true
end

Form.build

Form.build(
  model: nil,              # ActiveModel instance — copies matching attributes to form
  model_persisted: nil,    # override persisted? detection (true/false/nil)
  state: {},               # context hash, available as @state in the form
  build_method: :on_build  # method to call during build
)
  • Only attributes defined on the form are copied from the model (nil values are skipped)
  • State is frozen and stored as @state
  • If the form defines on_build(state), it is called during build after attribute assignment
  • changes_applied is called after build so dirty tracking starts clean

Form.validate

form.validate(params_hash)  # => true/false
  • Converts ActionController::Parameters automatically via to_unsafe_h
  • Only assigns params matching defined attributes (ignores unknown keys)
  • Calls valid? and returns the result

Form.sync

form.sync(
  model: nil,          # ActiveModel instance — copies matching attributes back
  state: {},           # passed to on_sync
  sync_method: :on_sync  # custom hook method name
)
  • Copies form attributes to model where attribute names match
  • Calls on_sync(state) if defined on the form
  • Always returns true

Important: do NOT define #sync on a form subclass

Defining #sync raises MethodCollision. Use #on_sync instead — it is called automatically during sync.

Helper methods

  • persisted? — returns whether the model was persisted at build time
  • other_validators_have_passed? — returns errors.blank?, useful for conditional validators
  • @state — access the frozen state hash passed at build time

Contract step helpers

These are used inside operations as step actions. They return lambdas.

Contract::Build

step Contract::Build(
  contract: MyForm,          # required — the form class
  name: :contract,           # state key to store the form instance
  model_key: :model,         # state key containing the model to build from (used only if present in state)
  model_persisted: nil,      # override persisted? detection
  build_method: :on_build
)

Always returns true.

Contract::Validate

step Contract::Validate(
  name: :contract,      # state key where the form is stored
  params_path: nil       # nil → state[:params]
                         # :symbol → state[:params][:symbol]
                         # [:a, :b] → state.dig(:a, :b)
)

Returns the result of form.validate(params)true/false.

Contract::Sync

step Contract::Sync(
  name: :contract,       # state key where the form is stored
  model_key: :model,     # state key containing the model to sync to
  sync_method: :on_sync  # custom sync hook method name
)

Returns true (from form.sync).

Controller mixin

class MyController < ApplicationController
  include Operational::Controller

  def create
    if run CreateArticleOperation
      redirect_to @state[:model]
    else
      render :new, status: :unprocessable_entity
    end
  end
end

run(operation, **extras)

  • Merges extras with default state (params and current_user if available)
  • Calls operation.call(state)
  • Sets @state to the frozen result state
  • Returns result.succeeded?

Overridable methods

  • _operational_default_state — override to inject custom default state
  • _operational_state_variable — override to change the instance variable name (default: @state)

Errors

Error class Raised when
Operational::InvalidContractModel Model doesn't respond to attributes
Operational::UnknownStepType Step action is not a Symbol or callable
Operational::MethodCollision Form subclass defines #sync instead of #on_sync

File structure convention

app/concepts/<domain>/
  <name>_form.rb
  <name>_operation.rb

Example: app/concepts/article/article_form.rb, app/concepts/article/create_article_operation.rb

Common patterns

New/Create with nested Present

class CreateThingOperation < Operational::Operation
  class Present < Operational::Operation
    step :init
    step Contract::Build(contract: ThingForm)

    def init(state)
      state[:model] = Thing.new
    end
  end

  step Nested::Operation(operation: Present)
  step Contract::Validate()
  step Contract::Sync()
  pass :persist

  def persist(state)
    state[:model].save!
  end
end

Controller uses CreateThingOperation::Present for new and CreateThingOperation for create.

To use a descriptive state key instead of :model, pass model_key: explicitly:

step Contract::Build(contract: ThingForm, model_key: :thing)
step Contract::Sync(model_key: :thing)
# state[:thing] instead of state[:model]

Multi-model form

class OrderForm < Operational::Form
  attribute :item_name, :string
  attribute :shipping_address, :string

  def on_build(state)
    self.shipping_address = state[:user]&.default_address
  end

  def on_sync(state)
    state[:shipping].update!(address: shipping_address)
  end
end

State-dependent validation

class ArticleForm < Operational::Form
  attribute :published, :boolean
  validate :admin_only_publish

  def admin_only_publish
    if published && !@state[:current_user]&.admin?
      errors.add(:published, "requires admin privileges")
    end
  end
end