Skip to content

Interaction Gem Evolution — Audit + PRD

Product: Objectuve (codename: Enkidu) — and the upstream joshlock3/interaction gem Feature: Interaction gem API evolution, usage audit, proposed upgrades Status: Shipped — April 2026 Date: April 2026 Type: Library / DX / Infrastructure PRD Gem version audited: 3.1.0Enkidu interactions audited: 43 total across 8 domain folders; 12 read in full


✓ SHIPPED — Delivered April 2026. All acceptance criteria met and live in production.

TL;DR

The interaction gem is the backbone of Enkidu's business logic layer. All 43 interactions in rails_api/app/interactions/ inherit from Interaction::Base, and every GraphQL mutation delegates to one. The gem is small (241 lines across 8 source files), well-scoped, and has shipped reliably for multiple Enkidu releases — but after a year of production use, a concrete usage audit reveals several features that missed the mark, several that are missing entirely, and a handful of latent bugs.

This document has two purposes:

  • Part A: Audit. What's in the gem today, how Enkidu actually uses it (with quantitative adoption stats from the real codebase), and where the seams are showing.
  • Part B: PRD. A list of proposed gem-level changes grouped by pain area (ergonomics, typed errors, composition, testing & documentation), each with a code sketch and backwards-compatibility note. Designed to be submitted upstream to the gem repo as a roadmap.

The most concrete findings — each backed by grep over the Enkidu codebase:

FindingCountSignal
delegate_input adoption43 / 43 (100%)This is the gem's dominant feature. Anything new should compose with it.
require_input adoption6 / 43 (14%)The gem's only validation feature is nearly dead on the vine. 86% of interactions skip it.
action_guards generator convention0 / 43 (0%)The gem ships a Rails generator that scaffolds an action_guards pattern. Nobody uses it. The convention is a ghost.
Interaction-to-interaction composition0 / 43 (0%)No interaction calls another via ::SomeOther.call(...). Either the domain doesn't need composition, or the gem makes it too awkward.
result.fail(error: ...) vs result.fail(errors: ...)113 vs 3No convention enforcement; developers drift between singular and plural shapes.
Distinct error strings returned from interactions>40 variantsNo typed/coded error system. Every interaction invents its own messages, making programmatic differentiation impossible.

The three largest opportunities for the gem:

  1. Kill the dead require_input feature and replace it with something people actually use — a richer input DSL that supports presence, type coercion, defaults, and optional inputs. ActiveInteraction-style but smaller. 14% adoption today is a clear feature-miss signal.
  2. Add typed errors / coded failures. result.fail should accept a code: alongside error:. The caller (GraphQL resolver, controller, another interaction) can then differentiate :unauthorized, :forbidden, :validation, :not_found, :server_error programmatically. This directly removes an entire class of bug flagged by Enkidu's recent auth audit.
  3. Add first-class composition primitives. chain, compose, or an inline DSL for calling one interaction from another, automatically propagating failure. 0% composition in a 43-interaction codebase is the loudest signal in this document — interactions aren't being composed because the gem makes it painful.

Secondary opportunities: proper documentation (the README still says "TODO: Delete this and describe your gem"), RSpec matchers, before/after hooks, fixing the InputError constant collision, and making Result#fail non-destructive.

This PRD does not advocate replacing the gem with ActiveInteraction, introducing a state machine, or pivoting to a different pattern. The gem's core shape is right. The proposed changes are additive and backwards-compatible.


Definitions used in this document

  • Interaction. A Ruby class inheriting from Interaction::Base that encapsulates one unit of business logic. Called via SomeClass.call(args). Returns a Result object.
  • Input. The argument hash passed to .call(...). Wrapped in an Interaction::Input object that exposes keys as methods via method_missing.
  • Result. The object returned from .call(...). Has success?, failure?, details (hash), and error (shortcut for details[:error]).
  • Gem (lowercase). The interaction library itself.
  • Caller. Anything that invokes an interaction — typically a GraphQL mutation resolver in Enkidu, but can also be a controller, background job, or another interaction.

Part A: Audit

A.1 What the gem actually contains

The gem is 241 lines across 8 source files. Full inventory (from rails_api/vendor/bundle/ruby/4.0.0/bundler/gems/interaction-3fa820813be6/):

FileLinesResponsibility
lib/interaction/base.rb55Interaction::Base class. .call class method, #call instance method, handle_exception, fail_from_exception.
lib/interaction/result.rb53Interaction::Result object. success?, failure?, details, fail, fail_from_exception, error.
lib/interaction/input_validator.rb38require_input :foo, :bar — raises InputError if inputs are missing or blank.
lib/interaction/configuration.rb35`Interaction.configure {
lib/interaction/input.rb29Interaction::Input — wraps hash, exposes keys via method_missing.
lib/interaction/input_delegator.rb21delegate_input :foo, :bar — defines instance methods that delegate to input.foo / input.bar.
lib/interaction/exception.rb7One-line delegation to configuration.on_error.
lib/interaction/version.rb3VERSION = "3.1.0".

Plus a Rails generator:

  • lib/generators/interaction_generator.rbrails g interaction SomeThing --inputs=a,b,c
  • lib/generators/templates/interaction.template — the scaffold template
  • lib/generators/templates/interaction_spec.template — the spec scaffold

A.2 What the generator template advertises (that the gem doesn't enforce)

The generator template ships a specific pattern:

ruby
# lib/generators/templates/interaction.template
class <%= name.classify %> < Interaction::Base
  delegate_input <%- options.inputs ... %>

  def call
    return result.fail(error: action_guards) if action_guards.present? # Fail if guard message

    # Do some thing

    result.details = {} # Output details
  end

  private

  def action_guards
    # Add guard clauses in order of importance
    # return 'Some error prevented this interaction from running' if true != false
  end
end

The template implies an intended usage pattern: define guards in one method, let call short-circuit if guards return a message. This is a reasonable pattern.

Adoption in Enkidu: 0 of 43 interactions (0%). Nobody uses action_guards. Instead, every interaction invents its own guard style: inline return statements, private _response methods, or a dedicated validate_inputs method. The convention is a ghost.

Implication: the gem has a pedagogical feature (the generator template) that teaches a pattern it doesn't support. Either formalize the pattern into the gem's API (a guard DSL) or remove it from the template.

A.3 What Enkidu's 43 interactions actually look like

A.3.1 Sample methodology

I read 12 of 43 interactions in full, chosen to cover diverse patterns: simple CRUD, admin-gated, toggle/state-change, external service calls, feed-building, content moderation, infrastructure utility, job-enqueueing. For the other 31 I ran targeted grep queries across all 43 files to produce the usage counts in this audit.

The 12 I read in full:

InteractionLinesPattern
GoalTracking::AddGoal163Large CRUD with auth, 15 inputs, 3 Sidekiq enqueues, conditional gamification
UserIdentity::UpdateUser85CRUD with external HTTP call (Clerk API), auth check
Social::ToggleGoalEventEncouragement56Toggle/state-change with soft-fail on missing auth (auth audit finding)
Social::BuildUnifiedFeed167Read-only query builder with 4 sub-methods, each with defensive rescue
ContentModeration::ReviewContentFlag27Admin-only, case-based action dispatch
Feedback::CreateFeedbackPost48Auth-gated CRUD with gamification badge triggers
UserIdentity::ClerkUserSync42Multi-branch create-or-update logic
Infrastructure::GeneratePublicId34Utility interaction (collision-free token generation)
Social::CreateCommunity32CRUD with file attachment and implicit member creation
Gamification::AcknowledgeNotification26Simple mark-as-read with ownership check
GoalTracking::CalculateGoalProgress126Analytics/aggregation with grouping and pacing logic
Social::SendPushNotification79External service fan-out (APNs + FCM) — the only file that uses require_input

A.3.2 Quantitative findings (exact grep counts)

$ grep -l "delegate_input" app/interactions/ -r --include="*.rb" | wc -l
43

$ grep -l "require_input" app/interactions/ -r --include="*.rb" | wc -l
6

$ grep -rn "action_guards" app/interactions/ --include="*.rb" | wc -l
0

$ grep -rn "result.fail(error" app/interactions/ --include="*.rb" | wc -l
113

$ grep -rn "result.fail(errors" app/interactions/ --include="*.rb" | wc -l
3

$ grep -rn "::[A-Z][a-zA-Z]*::[A-Z][a-zA-Z]*\.call(" app/interactions/ --include="*.rb" | wc -l
0

Interpretation:

  • delegate_input is the gem's killer feature. 100% adoption.
  • require_input is dead on the vine. 14% adoption. When something this central is this unused, the feature is wrong-shaped.
  • action_guards — the convention the generator teaches — is used nowhere. The gem is teaching a pattern the gem itself doesn't help you write.
  • The error: key dominates at 113 uses vs errors: at 3. No enforcement of convention.
  • Zero interaction-to-interaction composition. Not a single file calls another interaction. This is either a reflection of the domain (all logic fits in one interaction) or of the gem (composition is too awkward to do). Given that interactions like GoalTracking::AddGoal enqueue three different jobs, have 15 inputs, and span 163 lines, the more likely explanation is the gem.

A.3.3 Recurring workaround patterns (the symptoms of missing features)

Every sampled interaction exhibited at least one of these workarounds. They appear because the gem lacks the corresponding feature.

Workaround 1: Manual default values via input.to_h[:key] || default

The gem has no optional_input :foo, default: 20 affordance, so interactions write:

ruby
# Social::BuildUnifiedFeed
feed_limit = (input.to_h[:limit] || 20).to_i
feed_offset = (input.to_h[:offset] || 0).to_i

# GoalTracking::CalculateGoalProgress
selected_period = input.to_h[:period] || 'week'
selected_lookback = (input.to_h[:lookback_days] || 90).to_i

This appears 30+ times across the 12 sampled files.

Workaround 2: Manual type coercion with .to_i, .to_date, to_s.truncate

The gem has no input type declarations, so interactions coerce manually at every use site:

ruby
# GoalTracking::AddGoal
return invalid_goal_type_response if goal_type.blank? && input.to_h.key?(:goal_type_id) && goal_type_id.to_i.positive?

Workaround 3: Manual input presence checks via input.to_h.key?(:foo)

Because delegate_input returns nil via method_missing for missing keys (rather than raising), interactions re-check presence:

ruby
# GoalTracking::AddGoal (pattern repeated 7 times in this file alone)
return invalid_parent_goal_response if parent_goal.blank? && input.to_h.key?(:parent_goal_id) && parent_goal_id.present?

Workaround 4: Private _response methods returning result.fail

Instead of the action_guards pattern the generator advertises, interactions use a manual convention:

ruby
# Infrastructure::GeneratePublicId
def call
  return model_not_found_response if model.blank?
  # ...
end

private

def model_not_found_response
  result.fail(error: 'Model name not found')
end

This adds ceremony (two lines per guard) that a gem-level guard DSL could eliminate.

Workaround 5: Inline auth checks with bespoke error strings

ruby
# ContentModeration::ReviewContentFlag
return result.fail(error: 'Unauthorized') unless current_user&.admin

# Feedback::CreateFeedbackPost
return result.fail(error: 'Authentication required') if current_user.blank?

# UserIdentity::UpdateUser
return invalid_user_response unless valid_user?

# Gamification::AcknowledgeNotification (!)
return not_found_response if current_user.blank?

Each interaction invents its own string and its own failure-path pattern. No code differentiation possible for callers. (This is the auth audit finding from the companion docs/product/auth-audit-and-prd.md — same pattern, reached via different path.)

Workaround 6: Defensive per-method rescue StandardError

ruby
# Social::BuildUnifiedFeed (each of 4 sub-methods)
def ally_activity_items
  GoalEvent...
rescue StandardError => e
  Rails.logger.error("BuildUnifiedFeed ally_activity_items error: #{e.message}")
  []
end

Because Interaction::Base#call rescues all StandardError at the top level (converting everything to a generic result.fail), interactions that want partial resilience (return empty array, keep going) have to wrap each sub-method. The gem's all-or-nothing exception handling isn't granular enough.

Workaround 7: Per-interaction job-timing shimming

ruby
# GoalTracking::AddGoal (pattern appears in 4 interactions)
def create_first_goal_action
  perform_timing = Rails.env.development? || Rails.env.test? ? :perform_now : :perform_later
  Gamification::CreateFirstGoalActionJob.send(perform_timing, user.id)
end

Not strictly a gem concern, but shows that interactions routinely mix Sidekiq enqueueing into their call method. A gem-level enqueue DSL that respects test environments would remove this duplication.

A.3.4 A representative example: GoalTracking::AddGoal annotated

This is the largest interaction in the codebase (163 lines) and illustrates almost every workaround pattern in one file. Inline annotations showing what a richer gem API would remove:

ruby
class GoalTracking::AddGoal < Interaction::Base
  delegate_input :user_id, :name, :content, :goal_type_id,
                 :goal_category_id, :target_date, :private, :current_user,
                 :parent_goal_id, :days_to_update, :image_url, :file,
                 :recurrence_type, :recurrence_days, :recurrence_interval
  # ^ 15 inputs. No types, no defaults, no optional marker, no validation.

  def call
    return if validate_inputs   # ← workaround 4: private _response pattern
    # ...
  end

  private

  def validate_inputs
    return no_user_response if user.blank?
    return invalid_user_response unless input.to_h.key?(:current_user) && current_user.present? && valid_user?
    # ^ workaround 3: manual input.to_h.key? presence check
    return invalid_goal_type_response if goal_type.blank? && input.to_h.key?(:goal_type_id) && goal_type_id.to_i.positive?
    return invalid_goal_category_response if goal_category.blank? && input.to_h.key?(:goal_category_id) && goal_category_id.to_i.positive?
    return invalid_parent_goal_response if parent_goal.blank? && input.to_h.key?(:parent_goal_id) && parent_goal_id.present?
    nil
  end

  def create_goal
    Goal.create!(
      user: user,
      name: name,
      content: input.to_h[:content],          # ← workaround 3
      target_date: input.to_h[:target_date],  # ← workaround 3
      private: input.to_h.fetch(:private, true),  # ← workaround 1: manual default
      parent_goal_id: input.to_h[:parent_goal_id],
      days_to_update: input.to_h[:days_to_update],
      # ...
    )
  end

  def valid_user?
    current_user.id == user.id || current_user.admin  # ← workaround 5: inline auth
  end

  def invalid_user_response
    result.fail(error: 'You can not add a goal for this user')  # ← workaround 5: bespoke error string
  end
  # ... 4 more private _response methods ...

  def create_first_goal_action
    perform_timing = Rails.env.development? || Rails.env.test? ? :perform_now : :perform_later
    # ^ workaround 7: per-interaction job timing
    Gamification::CreateFirstGoalActionJob.send(perform_timing, user.id)
  end
  # ... 2 more identical job-enqueue methods ...
end

If every one of these workarounds had a gem-level affordance, AddGoal would drop from 163 lines to roughly 60-80 lines of actual business logic without losing any behavior.

A.4 Latent bugs in the gem

Found during the source read, independent of usage patterns.

A.4.1 InputError is defined twice with different identities

ruby
# lib/interaction/input_delegator.rb
module InputDelegator
  # ...
  InputError = Class.new(StandardError)    # ← constant #1
  # ...
end

# lib/interaction/input_validator.rb
module Interaction
  InputError = Class.new(StandardError)    # ← constant #2: different object!
  module InputValidator
    # ...
    module Validator
      def call
        # ...
        raise InputError, "..."    # ← raises constant #2 (top-level)
        # ...
      end
    end
  end
end
  • Interaction::InputDelegator::InputError is defined but unused
  • Interaction::InputError is defined at the top of input_validator.rb and is what require_input raises

Two constants with the same name, different fully-qualified paths, same purpose. Any caller that does rescue Interaction::InputDelegator::InputError will miss failures; any caller that does rescue Interaction::InputError catches them correctly. Latent compatibility bug. Fix: delete the unused one, or consolidate into a single Interaction::Errors::Input namespace.

A.4.2 Result#fail destructively overwrites details

ruby
# lib/interaction/result.rb
def fail(details = {})
  @failure = true
  @details = details    # ← replaces, doesn't merge
end

This means the following idiomatic-looking code silently drops work:

ruby
def call
  result.details = { draft_goal: draft }   # prepare some detail
  return result.fail(error: 'Something went wrong') if problem
  # ^ draft_goal is now gone. details = { error: '...' }
end

At minimum this needs documentation; ideally fail should merge-not-replace, or the API should distinguish fail_with(error: ...) (merges) from fail_replacing(...) (replaces).

A.4.3 Base.call rescues all StandardError indiscriminately

ruby
# lib/interaction/base.rb
def self.call(args = {})
  instance = new(args)
  begin
    instance.call
  rescue => error
    instance.handle_exception(error)   # ← converts ANY StandardError to a failure
  end
  instance.result
end

This is both a feature and a bug:

  • Feature: callers never have to wrap SomeInteraction.call(...) in a rescue. Programmer errors get Sentry-captured automatically.
  • Bug: genuinely-broken programmer errors (NoMethodError, ArgumentError) silently become result.fail(error: "undefined method 'foo'..."). The caller sees a business-logic failure, not a bug. Callers can't opt into exception propagation.

Fix: allow per-class opt-out. Either rescue_from StandardError => :handle as an explicit DSL, or a class-level propagate_exceptions SomeError.

A.4.4 require_input blank-detection is inconsistent for booleans

ruby
# lib/interaction/input_validator.rb
inputs_with_nil_values = required_inputs.select do |i|
  input.to_h[i].nil? || (input.to_h[i].respond_to?(:empty?) && input.to_h[i].empty?)
end
  • nil → fails validation ✓
  • "" → fails validation (responds to empty?) ✓
  • [] → fails validation (responds to empty?) ✓
  • false → passes validation (doesn't respond to empty?, not nil) ✓
  • 0 → passes validation ✓

This is probably correct, but the logic is subtle and undocumented. A test for boolean/integer edge cases is missing. Plus the word "blank" doesn't really describe what this checks — it's really "nil or empty collection."

A.4.5 require_input only stores the last call

ruby
# lib/interaction/input_validator.rb
def require_input(*expected_inputs)
  @expected_inputs = expected_inputs   # ← overwrites, doesn't append
  prepend Validator
end
ruby
class SomeInteraction < Interaction::Base
  require_input :a, :b
  require_input :c         # ← :a and :b are now forgotten
end

Subtle footgun. The natural Ruby idiom is to accumulate. Fix: @expected_inputs ||= []; @expected_inputs.concat(expected_inputs).

A.4.6 Generator uses .template extension, not the Rails-standard .tt

ruby
# lib/generators/interaction_generator.rb
def copy_initializer
  template "interaction.template", "app/interactions/#{name}.rb"
end

Rails's Rails::Generators::NamedBase#template method defaults to .tt extension for templates and this .template naming is non-idiomatic. The generator still works because template accepts any filename, but it means the gem's scaffolds don't look like other Rails generators.

A.4.7 README is literally a stub

markdown
# Interaction

Welcome to your new gem! In this directory, you'll find the files you need
to be able to package up your Ruby library into a gem. Put your Ruby code
in the file `lib/interaction`. To experiment with that code, run `bin/console`
for an interactive prompt.

TODO: Delete this and the text above, and describe your gem

Plus:

  • ### Exceptions → "coming soon"
  • ### Input → validation section says "coming soon"
  • ## Contributing → still says https://github.com/[USERNAME]/interaction

The gem is used in production at Enkidu but has a README that still contains Bundler's generated scaffolding text.

A.5 What the gem gets right

This audit is critical, but it's worth naming the things the gem gets right because they should be preserved:

  1. Interaction::Base.call pattern is clean. One class method, returns a single Result object.
  2. delegate_input is universally adopted because it's a small, targeted improvement over input.foo that saves repetition.
  3. Interaction::Configuration#on_error is a nicely extensible seam. Enkidu already uses the default Sentry integration without custom code.
  4. Small surface area. 241 lines of gem code means minimal cognitive load. Any proposed addition should respect this.
  5. No Rails hard dependency for the core. (The generator requires Rails, but Base, Result, Input don't.)
  6. Single-file interactions feel right. Enkidu has no "god-interaction" anti-patterns; every file is focused on one operation.

A.6 Audit summary

CategoryFinding count
Latent bugs in gem source4 (InputError dup, fail overwrites, rescue-all, require_input overwrite)
Workaround patterns in caller code7 (manual defaults, manual coercion, manual presence, _response methods, inline auth, defensive rescue, job timing)
Dead/unused features2 (require_input at 14% adoption, action_guards at 0%)
Missing features (heavy friction)5 (composition, typed errors, hooks, input types/defaults, test helpers)
Documentation gaps4 (README stub, no exception docs, no validation docs, no composition docs)

Part B: PRD — Proposed Gem Evolution

Each proposal below covers:

  • Why (which finding from Part A motivates it)
  • Proposed API (Ruby sketch)
  • Backwards compatibility
  • Enkidu impact (what this removes from the caller side)

Proposals are grouped by pain area as requested. Every proposal is designed to be additive and non-breaking.

B.1 Pain area: API ergonomics & DX

B.1.1 Proposal: Replace require_input with a richer input DSL

Why: require_input has 14% adoption (6/43). The feature is too limited (presence-only, no types, no defaults, no optional markers) and sits alongside delegate_input making two separate declarations feel redundant. Enkidu interactions instead write manual defaults, manual presence checks, and manual type coercion inline.

Proposed API:

ruby
class UpdateUser < Interaction::Base
  input :user_id,    String,  required: true
  input :username,   String,  required: false
  input :current_user, User,  required: true
  input :delete_user, :boolean, default: false
  input :target_date, :date,   required: false, coerce: true
end

One input declaration per field. The DSL:

  • Declares the name. Replaces delegate_input. The field is automatically accessible as username (instance method) instead of input.username.
  • Declares the type. Symbols (:string, :integer, :date, :boolean, :hash, :array) or classes (User, Goal).
  • Declares required/optional. Default is required: true (strict by default is the right default — opt into permissiveness).
  • Declares defaults. default: 20 or default: -> { 20.minutes.from_now }.
  • Optional coercion. coerce: true converts inputs to the declared type when possible ("42"42).

Under the hood, input replaces both delegate_input and require_input with one call. The old methods remain as thin shims for backwards compatibility.

Validation fires at the start of call (same as today), so invalid inputs produce result.fail(code: :invalid_input, error: "user_id is required") before business logic runs.

Backwards compatibility: delegate_input and require_input continue to work unchanged. New code uses input. Old code migrates at its own pace. Internal implementation: both old methods delegate to input under the hood, which centralizes the validation logic.

Enkidu impact: removes the 30+ input.to_h[:key] || default sites, the 15+ manual .to_i coercions, and the 20+ input.to_h.key?(:foo) presence checks. The AddGoal interaction alone would shrink by roughly 20 lines.

B.1.2 Proposal: First-class guard DSL

Why: The action_guards pattern from the generator template has 0% adoption. Developers either inline their guards or invent their own _response private methods. A gem-level guard DSL formalizes the pattern the generator already teaches.

Proposed API:

ruby
class AddGoal < Interaction::Base
  input :user_id, String, required: true
  input :current_user, User, required: true

  guard :user_must_exist
  guard :current_user_must_match_or_be_admin, code: :forbidden

  def call
    # guards already ran. call body focuses on happy path.
    goal = Goal.create!(user: user, name: name, ...)
    result.details = { goal: goal }
  end

  private

  def user_must_exist
    fail_with(error: 'User not found', code: :not_found) if user.blank?
  end

  def current_user_must_match_or_be_admin
    fail_with(error: 'You cannot act for this user', code: :forbidden) unless valid_user?
  end
end

guard :method_name registers a private method that runs before call. If any guard calls fail_with (or sets the result to failure), execution stops and call is never invoked. Guards run in declaration order; first failure wins.

Backwards compatibility: fully additive. Classes that don't declare guards behave exactly as today.

Enkidu impact: removes the validate_inputs and _response private-method patterns. Replaces ~40 inline auth checks with declarative guards.

B.1.3 Proposal: Non-destructive fail_with

Why: Result#fail destructively overwrites details (latent bug A.4.2).

Proposed API: add a new method, keep the old one:

ruby
# lib/interaction/result.rb
def fail_with(**attrs)
  @failure = true
  @details = @details.merge(attrs)   # ← merges, doesn't replace
end

# Old destructive version kept as a shim
def fail(details = {})
  @failure = true
  @details = details
end

Interactions that want the existing destructive behavior keep using result.fail(...). New code uses result.fail_with(...). Deprecate fail in a future version (3.2? 4.0?).

Backwards compatibility: additive.

Enkidu impact: no immediate change; opt-in for new interactions. Unlocks patterns like:

ruby
def call
  draft = build_draft
  result.details = { draft: draft }   # set preliminary details
  return result.fail_with(error: 'Save failed', code: :validation) unless draft.valid?
  # draft is preserved in details even on failure
end

B.1.4 Proposal: before_call / after_call / around_call hooks

Why: No hooks today. Patterns like logging, metrics, audit trails, or universal auth checks require every interaction to reinvent them inline. Enkidu's auth audit PRD proposed a gem-level Admin::Logged concern that would benefit from hooks.

Proposed API:

ruby
class Interaction::Base
  class << self
    def before_call(*methods, &block)
      # ...
    end

    def after_call(*methods, &block)
      # ...
    end

    def around_call(*methods, &block)
      # ...
    end
  end
end

# Usage
class AddGoal < Interaction::Base
  before_call :log_start
  after_call  :enqueue_gamification

  def call
    # ...
  end

  private

  def log_start
    Rails.logger.tagged(self.class.name) { Rails.logger.info("starting for user=#{user_id}") }
  end

  def enqueue_gamification
    return if result.failure?
    Gamification::CreateGoalActionJob.perform_later(user_id)
  end
end

Backwards compatibility: additive.

Enkidu impact: enables shared audit logging, enables removing the Rails.env job-timing shim from every interaction (move to an after_call :enqueue_timing_safe), enables the auth-audit PRD's Admin::Logged concern at the gem level.

B.1.5 Proposal: enqueue helper that respects test environment

Why: workaround 7. Four interactions repeat the perform_now vs perform_later conditional.

Proposed API:

ruby
class Interaction::Base
  # Enqueue a job. In test/development (when configured), runs synchronously.
  def enqueue(job_class, *args)
    if Interaction.configuration.enqueue_synchronously?
      job_class.perform_now(*args)
    else
      job_class.perform_later(*args)
    end
  end
end

# Configuration default: true in Rails.env.test? and Rails.env.development?
Interaction.configure do |c|
  c.enqueue_synchronously = ->(rails_env) { rails_env&.test? || rails_env&.development? }
end

Backwards compatibility: additive.

Enkidu impact: removes the perform_timing = Rails.env.development? || Rails.env.test? ? :perform_now : :perform_later pattern from 4 interactions.

B.2 Pain area: Error contracts & typed failures

This is the area with the largest impact on Enkidu, because the companion auth audit (docs/product/auth-audit-and-prd.md) documented that interactions return ~40 distinct error strings like 'Unauthorized', 'You can not update this user', 'Authentication required' — all representing different failure types that callers cannot differentiate programmatically.

B.2.1 Proposal: Add code: to Result#fail

Why: Interactions need a way to signal why something failed in a form callers can pattern-match on. String messages are not a machine-readable contract.

Proposed API:

ruby
# lib/interaction/result.rb
def fail(details = {})
  @failure = true
  @failure_code = details[:code]
  @details = details
end

def code
  @failure_code
end

def failed_with?(symbol)
  failure? && code == symbol
end

And a curated list of conventional codes (these are recommendations, not enforced):

ruby
module Interaction::Codes
  INVALID_INPUT = :invalid_input
  UNAUTHORIZED  = :unauthorized
  FORBIDDEN     = :forbidden
  NOT_FOUND     = :not_found
  CONFLICT      = :conflict
  SERVER_ERROR  = :server_error
end

Usage:

ruby
# Interaction
result.fail(error: 'You cannot update this user', code: :forbidden)

# Caller
if result.failed_with?(:forbidden)
  raise GraphQL::ExecutionError.new(result.error, extensions: { code: 'FORBIDDEN' })
elsif result.failed_with?(:unauthorized)
  raise GraphQL::ExecutionError.new(result.error, extensions: { code: 'UNAUTHORIZED' })
else
  raise GraphQL::ExecutionError.new(result.error)
end

Backwards compatibility: code: is an optional key in the details hash. Existing calls like result.fail(error: 'Something') continue to work — they just return nil from result.code. Adopters can migrate incrementally.

Enkidu impact: directly unblocks the auth audit PRD's Part 2 recommendation about GraphQL extension codes. Every result.fail(error: ...) site in Enkidu gains a programmatic code without changing the error string.

B.2.2 Proposal: fail_with as the ergonomic entry point

Combine B.1.3 with B.2.1:

ruby
result.fail_with(error: 'Not found', code: :not_found)
result.fail_with(error: 'Access denied', code: :forbidden, resource: 'goal')

Same as today, plus code: support, plus merge-not-replace semantics. Interactions get structured failure with zero extra ceremony.

B.2.3 Proposal: Guard against silent exception rescue

Why: Base.call's rescue => swallows everything (A.4.3).

Proposed API:

ruby
class Interaction::Base
  class << self
    # Opt OUT of the default rescue-all behavior.
    def propagate_exceptions(*exception_classes)
      @propagate_exceptions = exception_classes
    end

    def propagated_exceptions
      @propagate_exceptions || []
    end
  end
end

# Usage
class DangerousOperation < Interaction::Base
  propagate_exceptions ActiveRecord::RecordInvalid, ActiveRecord::RecordNotFound

  def call
    # RecordInvalid will now bubble up instead of becoming a generic failure.
  end
end

Or, more radically, change the default so that NoMethodError, ArgumentError, NameError are always propagated (programmer errors should never look like business failures), and only domain errors (ActiveRecord::*, network errors) become failures. But that's a 4.0 change.

Backwards compatibility: opt-in for now. Breaking change for 4.0.

Enkidu impact: removes the need for rescue StandardError wrappers in BuildUnifiedFeed and similar files. Programmer errors surface properly in development and tests.

B.3 Pain area: Composition & chaining

This is the largest gap in the gem. 0 of 43 interactions in Enkidu call another interaction. Composition is happening elsewhere (in GraphQL resolvers, background jobs, rake tasks) but never inside interactions themselves. The gem's current shape makes composition look like this:

ruby
# Manual composition as it would be written today
def call
  sub_result = SomeOther::Interaction.call(user: current_user, foo: foo)
  return result.fail(error: sub_result.error) if sub_result.failure?
  thing = sub_result.details[:thing]
  # ... use thing ...
end

That's 4 lines of ceremony per sub-interaction. Repeat for each composition → people don't do it.

B.3.1 Proposal: run / pipeline helpers

Proposed API:

ruby
class Interaction::Base
  # Run another interaction. Automatically propagate failure into
  # this interaction's result. Returns the sub-result's details hash.
  def run(interaction_class, **args)
    sub_result = interaction_class.call(**args)
    if sub_result.failure?
      result.fail_with(**sub_result.details)
      throw :halt_interaction
    end
    sub_result.details
  end
end

Usage:

ruby
class CreateGoalAndFollow < Interaction::Base
  input :user, User, required: true
  input :name, String, required: true
  input :target_date, :date, required: true

  def call
    goal_details = run GoalTracking::AddGoal, user: user, name: name, target_date: target_date
    # if AddGoal failed, we never got here. this interaction already failed with the same details.

    run Social::FollowGoal, user: user, goal: goal_details[:goal]

    result.details = { goal: goal_details[:goal] }
  end
end

run handles the boilerplate. If any sub-interaction fails, the parent fails with the same details, and the block below run never executes. Uses a throw/catch internally so there's no exception overhead.

Alternative form — pipeline DSL:

ruby
class CreateGoalWithCommunityShare < Interaction::Base
  input :user, User, required: true
  input :name, String, required: true
  input :community, Community, required: true

  pipeline do
    step GoalTracking::AddGoal, as: :goal
    step Social::AddGoalToCommunity, using: ->(context) { { goal: context[:goal][:goal], community: context[:community] } }
  end
end

The pipeline form is more declarative but introduces new syntax. I'd ship run first and evaluate whether pipeline pulls its weight.

Backwards compatibility: additive.

Enkidu impact: unlocks pattern separation. AddGoal could become small because gamification and notification concerns move to child interactions called via run. The 163-line file could become 60-80 lines plus 3 smaller child interactions, each individually testable.

B.3.2 Proposal: Shared context across composed interactions

Why: When InteractionA runs InteractionB, certain inputs are nearly always shared (current_user, request-scoped tracing IDs, feature flags). Passing them through every run call is ceremony.

Proposed API:

ruby
class Interaction::Base
  class << self
    # Declare inputs that should be auto-propagated to composed interactions.
    def propagate :current_user, :request_id
  end
end

# Usage
class CreateGoalAndFollow < Interaction::Base
  input :current_user, User, required: true
  input :goal_name, String, required: true
  propagate :current_user

  def call
    # current_user is automatically passed to AddGoal and FollowGoal
    run GoalTracking::AddGoal, name: goal_name, user_id: current_user.public_id
    run Social::FollowGoal, goal_id: result.details[:goal_id]
  end
end

Backwards compatibility: additive. Low priority compared to B.3.1.

B.4 Pain area: Testing & documentation

B.4.1 Proposal: Ship RSpec matchers

Why: Every interaction spec in Enkidu repeats:

ruby
expect(result).to be_success
expect(result).to be_failure
expect(result.details[:error]).to match(/user/)
expect(result.details[:goal]).to be_a(Goal)

The gem could ship matchers that make the tests cleaner.

Proposed API:

ruby
# spec/support/interaction_matchers.rb (auto-loaded via gem)
require 'rspec/expectations'

RSpec::Matchers.define :be_a_successful_interaction do
  match { |result| result.success? }
  failure_message { |result| "expected success, got failure: #{result.error}" }
end

RSpec::Matchers.define :have_failed_with do |code_or_message|
  match do |result|
    return false unless result.failure?
    if code_or_message.is_a?(Symbol)
      result.code == code_or_message
    else
      result.error.to_s.include?(code_or_message.to_s)
    end
  end
end

RSpec::Matchers.define :have_interaction_details do |**expected|
  match { |result| result.success? && expected.all? { |k, v| result.details[k] == v } }
end

Usage:

ruby
RSpec.describe GoalTracking::AddGoal do
  it 'creates a goal' do
    result = described_class.call(user_id: user.public_id, name: 'test', current_user: user)
    expect(result).to be_a_successful_interaction
    expect(result).to have_interaction_details(goal: an_instance_of(Goal))
  end

  it 'rejects unauthenticated requests' do
    result = described_class.call(name: 'test')
    expect(result).to have_failed_with(:unauthorized)
  end
end

Backwards compatibility: additive. Auto-load only if RSpec is already loaded. Gate behind require 'interaction/rspec' or equivalent.

Enkidu impact: every spec/interactions/ file gets cleaner. Encourages testing failure codes explicitly (supports B.2).

B.4.2 Proposal: Shared examples for common patterns

ruby
# gem-provided shared examples
RSpec.shared_examples 'a guarded interaction' do |guard_method:, invalid_args:, expected_code:|
  it "fails with #{expected_code} when #{guard_method} fails" do
    result = described_class.call(**invalid_args)
    expect(result).to have_failed_with(expected_code)
  end
end

# Usage in Enkidu
RSpec.describe ContentModeration::ReviewContentFlag do
  it_behaves_like 'a guarded interaction',
    guard_method: :must_be_admin,
    invalid_args: { flag_id: 'xyz', action: 'approve', current_user: non_admin_user },
    expected_code: :forbidden
end

Backwards compatibility: additive.

B.4.3 Proposal: Finish the README

The README needs:

  1. Delete the "TODO: Delete this and the text above" scaffold text.
  2. Replace the placeholder contributor URL ([USERNAME]) with the real repo.
  3. Document every feature that currently says "coming soon":
    • Exception handling (handle_exception, custom_exception_detail, Configuration#on_error)
    • Input validation (require_input today; input DSL after B.1.1)
  4. Add a "Usage patterns" section covering:
    • Guards (B.1.2)
    • Composition (B.3.1)
    • Testing with RSpec matchers (B.4.1)
  5. Add a "Philosophy" section explaining what the gem is vs. ActiveInteraction (why someone would choose this).
  6. Add a "Migration from 3.x to 4.x" section if any of these proposals ship as a breaking change.

Suggested tone for the upstream PR: "This README has been on the same scaffold for multiple releases. Here's a rewrite using the real usage patterns from a production app that has 43 interactions inheriting from it."

B.4.4 Proposal: Ship a documentation site (or at least a YARD setup)

Small gems like this benefit from a single docs/ directory with:

  • usage.md (expanded from README)
  • patterns.md (guards, composition, propagation)
  • migration-3.x-to-4.x.md (if applicable)
  • Auto-generated YARD docs for the public API

Not strictly necessary but a nice-to-have for community adoption.

B.5 Prioritization and sequencing

Not everything in §B has the same ROI. Suggested sequencing for the gem:

Gem version 3.2 (minor, fully backwards-compatible):

  • B.1.3 fail_with non-destructive merge
  • B.2.1 code: support on Result#fail
  • B.4.1 RSpec matchers
  • B.4.3 README rewrite
  • Fix latent bugs A.4.1 (dup InputError), A.4.5 (require_input overwrite)

Gem version 3.3 (minor, backwards-compatible additions):

  • B.1.1 input DSL (keeps delegate_input + require_input as shims)
  • B.1.2 guard DSL
  • B.1.4 before_call / after_call / around_call hooks
  • B.1.5 enqueue helper
  • B.3.1 run helper for composition

Gem version 4.0 (major, has breaking changes):

  • B.2.3 Change default exception handling to propagate programmer errors
  • Remove deprecated delegate_input / require_input / fail (destructive)
  • Rename detailsdata or similar if desired
  • B.3.2 propagate context (if B.3.1 landed successfully)

Each version is independently shippable and value-positive.

B.6 Non-goals

Things this PRD deliberately does NOT propose, even though someone might ask:

  1. Replacing the gem with ActiveInteraction. ActiveInteraction has a richer input system but is 3-4x larger, has stronger Rails coupling, and forces a different result shape. The interaction gem's small footprint is a feature; we'd lose that.
  2. Replacing the result object with a Ruby 3 Data class. Data.define(:success, :details) would be cleaner but breaks backwards compatibility. Defer.
  3. Introducing a state machine or transitions library. Interactions are single-call; there are no multi-step flows that need a state machine.
  4. Pulling auth logic into the gem itself. The auth audit PRD proposes authn/authz helpers at the GraphQL layer. The gem should stay agnostic about authentication — it just needs to support code: :unauthorized / code: :forbidden in the result.
  5. Input coercion via Dry::Types or similar. Would bloat the gem. Keep coercion to a small set of built-in types (string, integer, date, boolean, hash, array).
  6. A CLI / inspection tool. Out of scope.

Part C: Enkidu Migration Plan

Even if every gem proposal lands upstream, Enkidu has to migrate to the new APIs. This section is specifically for the Enkidu team — when submitting to the upstream gem repo, cut this section.

C.1 Phase 1: Land gem 3.2 (additive)

  1. Bump gem 'interaction', github: 'joshlock3/interaction' to the 3.2 tag once it exists.
  2. No Enkidu code changes required — 3.2 is fully backwards-compatible.
  3. Start using result.fail(error: ..., code: ...) in NEW interactions only. Don't batch-migrate.
  4. Start using RSpec matchers in new specs.

Acceptance: all 43 existing interactions still pass specs with no modification.

C.2 Phase 2: Land gem 3.3 (additive DSL)

  1. Bump to 3.3.
  2. Pick one interaction to serve as the pattern reference. Recommend GoalTracking::AddGoal because it exercises every workaround simultaneously. Migrate it to the new DSL:
    • Replace delegate_input with input declarations
    • Extract private _response methods into guard declarations
    • Move conditional job enqueueing into after_call hooks
    • Use run to factor out gamification side effects into a child interaction
  3. Document the before/after as an internal reference.
  4. Migrate the other 42 interactions over several sprints, in order of file size (largest first — biggest wins).

Acceptance: at the end of phase 2, every interaction uses the new input DSL and at least one guard declaration where applicable.

C.3 Phase 3: Land gem 4.0 (breaking changes)

  1. Bump to 4.0.
  2. Fix any interactions that relied on the old destructive fail semantics (audit this during phase 2 migration).
  3. Remove any rescue StandardError workarounds that now work via the new default exception propagation rules.
  4. Remove delegate_input and require_input call sites (deprecated).

Acceptance: all 43 interactions compile against 4.0 without deprecation warnings.

C.4 Enkidu tie-ins with other PRDs

  • Auth audit (docs/product/auth-audit-and-prd.md): B.2.1 (code: support) is the gem-side counterpart of the auth audit's extension-code proposal. The auth PRD's require_auth! helper at the GraphQL layer translates code: :unauthorized from interaction results into UNAUTHORIZED extension codes. These two PRDs compose.
  • Standalone Admin App (docs/product/standalone-admin-app-prd.md): B.1.4 (hooks) unlocks the admin PRD's Admin::Logged concern as a before/after hook at the gem layer, so every admin mutation automatically audit-logs without per-interaction boilerplate.
  • Dedicated AI Service (docs/product/dedicated-ai-service-prd.md): B.3.1 (composition) matters here. AI-touching interactions today duplicate the LLM client wiring. With run, an Ai::CallCoachModel interaction could be called from any caller and automatically propagate failures.

Part D: Open Questions (for the gem maintainer)

When submitting upstream, I'd like the maintainer's input on:

  1. Scope of gem version 3.2. Is the maintainer comfortable shipping B.1.3, B.2.1, B.4.1, B.4.3, and the latent bug fixes as a minor version? Or would they prefer to batch more of the additive changes into a larger 3.3 release?

  2. delegate_input deprecation timeline. If the new input DSL ships in 3.3, should delegate_input be deprecated (with warning) in 3.3 and removed in 4.0, or kept as a permanent alias? I'd vote deprecation+removal, but the maintainer may prefer stability.

  3. Exception propagation default. B.2.3's proposed default change (propagate NoMethodError, ArgumentError, NameError; catch only ActiveRecord::* and similar) is the most opinionated change in this PRD. Is that aligned with the maintainer's philosophy, or is "catch everything" intentional?

  4. Is code: the right key? Alternatives: failure_code:, reason:, type:, error_type:. I prefer code: for brevity but it's a bikeshed worth raising.

  5. Should run return the sub-result's details, or the sub-result itself? Trade-off: returning details is more convenient (goal = run(AddGoal, ...)[:goal]), but returning the sub-result lets you inspect code, check success?, etc. ActiveInteraction's compose returns the outcome, for comparison.

  6. What's the maintainer's release cadence? If releases are quarterly, Enkidu can plan its migration around that. If releases are ad-hoc, Enkidu will have to pin to commits during migration.

  7. Documentation site? Does the maintainer want help standing up a docs site (B.4.4), or is the README enough?

  8. Who owns the migration examples? If we land these changes, the README will reference "usage patterns from Enkidu" for realism. Can Enkidu contribute those examples directly, or does the maintainer prefer synthetic examples?


Part E: Upstream-ready summary (submit this as an issue/PR)

This section is deliberately concise and ignores the Enkidu-specific sections above. It's designed to be lifted verbatim into a GitHub issue on the joshlock3/interaction repo.


Issue: Proposed evolution of the interaction gem (v3.2, v3.3, v4.0)

Hi — we've been using interaction in production at Objectuve (gamified goal-tracking app) for over a year. The gem currently powers 43 interactions across 8 domain folders. This issue summarizes what we've learned from the usage audit and proposes a set of changes for future releases. Happy to submit PRs for any of these; wanted to align on direction first.

Usage stats from a 43-interaction production codebase

FeatureAdoption
delegate_input43/43 (100%) — universal
require_input6/43 (14%) — nearly unused
action_guards generator convention0/43 (0%) — never adopted
Interaction-to-interaction composition0/43 (0%) — too awkward
result.fail(error: ...)113 uses
result.fail(errors: ...)3 uses (inconsistent convention)

Latent bugs found

  1. Interaction::InputError defined twice — once in InputDelegator (unused) and once in InputValidator (the one actually raised). Rescue-blocks targeting the wrong constant will silently miss failures.
  2. Result#fail destructively overwrites details — any result.details = {...} set before fail(...) is lost.
  3. Base.call rescues all StandardErrorNoMethodError and other programmer errors silently become generic failures.
  4. require_input overwrites instead of appending — only the last require_input call in a class is honored.

Proposed changes

v3.2 (backwards-compatible):

  • Add code: to Result#fail for typed failures (symbols: :invalid_input, :unauthorized, :forbidden, :not_found, :conflict, :server_error)
  • Add Result#fail_with that merges instead of replacing
  • Add RSpec matchers (be_a_successful_interaction, have_failed_with(:symbol), have_interaction_details(**kwargs))
  • Rewrite the README (currently still has "TODO: Delete this and the text above")
  • Fix the InputError duplication and require_input overwrite bugs

v3.3 (backwards-compatible additions):

  • New input :name, Type, required:, default:, coerce: DSL. Keep delegate_input + require_input as shims.
  • guard :method_name DSL formalizing the action_guards pattern the generator template teaches but nobody uses
  • before_call / after_call / around_call hooks
  • run SomeInteraction, **args helper for composition with automatic failure propagation
  • enqueue helper for Sidekiq jobs that runs synchronously in test env

v4.0 (breaking):

  • Exception-handling default changes: programmer errors (NoMethodError, ArgumentError, NameError) propagate instead of becoming failures
  • Remove deprecated delegate_input, require_input, destructive fail

Happy to submit PRs

I can put up PRs for any of these, starting with the v3.2 scope. Would love your input on:

  1. Is this direction aligned with your vision for the gem?
  2. Is code: the right key name?
  3. Should exception-handling change be in v4.0 or is it too opinionated?
  4. Is there anything in here you'd reject or want to scope differently?

Context: we're Objectuve Softworks (PBC in Chicago building a gamified goal-tracking app). Happy to share our full audit report if helpful.


  • docs/product/auth-audit-and-prd.md — auth audit that surfaced the typed-error need
  • docs/product/standalone-admin-app-prd.md — admin app that would benefit from the proposed hooks
  • docs/product/dedicated-ai-service-prd.md — AI service that would benefit from composition
  • joshlock3/interaction on GitHub — the gem repo to submit Part E to

Last updated: 2026-05-22

Loading…