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:
| Finding | Count | Signal |
|---|---|---|
delegate_input adoption | 43 / 43 (100%) | This is the gem's dominant feature. Anything new should compose with it. |
require_input adoption | 6 / 43 (14%) | The gem's only validation feature is nearly dead on the vine. 86% of interactions skip it. |
action_guards generator convention | 0 / 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 composition | 0 / 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 3 | No convention enforcement; developers drift between singular and plural shapes. |
| Distinct error strings returned from interactions | >40 variants | No typed/coded error system. Every interaction invents its own messages, making programmatic differentiation impossible. |
The three largest opportunities for the gem:
- Kill the dead
require_inputfeature and replace it with something people actually use — a richerinputDSL that supports presence, type coercion, defaults, and optional inputs. ActiveInteraction-style but smaller. 14% adoption today is a clear feature-miss signal. - Add typed errors / coded failures.
result.failshould accept acode:alongsideerror:. The caller (GraphQL resolver, controller, another interaction) can then differentiate:unauthorized,:forbidden,:validation,:not_found,:server_errorprogrammatically. This directly removes an entire class of bug flagged by Enkidu's recent auth audit. - 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::Basethat encapsulates one unit of business logic. Called viaSomeClass.call(args). Returns aResultobject. - Input. The argument hash passed to
.call(...). Wrapped in anInteraction::Inputobject that exposes keys as methods viamethod_missing. - Result. The object returned from
.call(...). Hassuccess?,failure?,details(hash), anderror(shortcut fordetails[:error]). - Gem (lowercase). The
interactionlibrary 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/):
| File | Lines | Responsibility |
|---|---|---|
lib/interaction/base.rb | 55 | Interaction::Base class. .call class method, #call instance method, handle_exception, fail_from_exception. |
lib/interaction/result.rb | 53 | Interaction::Result object. success?, failure?, details, fail, fail_from_exception, error. |
lib/interaction/input_validator.rb | 38 | require_input :foo, :bar — raises InputError if inputs are missing or blank. |
lib/interaction/configuration.rb | 35 | `Interaction.configure { |
lib/interaction/input.rb | 29 | Interaction::Input — wraps hash, exposes keys via method_missing. |
lib/interaction/input_delegator.rb | 21 | delegate_input :foo, :bar — defines instance methods that delegate to input.foo / input.bar. |
lib/interaction/exception.rb | 7 | One-line delegation to configuration.on_error. |
lib/interaction/version.rb | 3 | VERSION = "3.1.0". |
Plus a Rails generator:
lib/generators/interaction_generator.rb—rails g interaction SomeThing --inputs=a,b,clib/generators/templates/interaction.template— the scaffold templatelib/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:
# 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
endThe 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:
| Interaction | Lines | Pattern |
|---|---|---|
GoalTracking::AddGoal | 163 | Large CRUD with auth, 15 inputs, 3 Sidekiq enqueues, conditional gamification |
UserIdentity::UpdateUser | 85 | CRUD with external HTTP call (Clerk API), auth check |
Social::ToggleGoalEventEncouragement | 56 | Toggle/state-change with soft-fail on missing auth (auth audit finding) |
Social::BuildUnifiedFeed | 167 | Read-only query builder with 4 sub-methods, each with defensive rescue |
ContentModeration::ReviewContentFlag | 27 | Admin-only, case-based action dispatch |
Feedback::CreateFeedbackPost | 48 | Auth-gated CRUD with gamification badge triggers |
UserIdentity::ClerkUserSync | 42 | Multi-branch create-or-update logic |
Infrastructure::GeneratePublicId | 34 | Utility interaction (collision-free token generation) |
Social::CreateCommunity | 32 | CRUD with file attachment and implicit member creation |
Gamification::AcknowledgeNotification | 26 | Simple mark-as-read with ownership check |
GoalTracking::CalculateGoalProgress | 126 | Analytics/aggregation with grouping and pacing logic |
Social::SendPushNotification | 79 | External 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
0Interpretation:
delegate_inputis the gem's killer feature. 100% adoption.require_inputis 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 vserrors: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::AddGoalenqueue 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:
# 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_iThis 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:
# 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:
# 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:
# 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')
endThis adds ceremony (two lines per guard) that a gem-level guard DSL could eliminate.
Workaround 5: Inline auth checks with bespoke error strings
# 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
# 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}")
[]
endBecause 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
# 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)
endNot 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:
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 ...
endIf 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
# 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
endInteraction::InputDelegator::InputErroris defined but unusedInteraction::InputErroris defined at the top ofinput_validator.rband is whatrequire_inputraises
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
# lib/interaction/result.rb
def fail(details = {})
@failure = true
@details = details # ← replaces, doesn't merge
endThis means the following idiomatic-looking code silently drops work:
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: '...' }
endAt 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
# 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
endThis 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 becomeresult.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
# 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?)
endnil→ 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
# lib/interaction/input_validator.rb
def require_input(*expected_inputs)
@expected_inputs = expected_inputs # ← overwrites, doesn't append
prepend Validator
endclass SomeInteraction < Interaction::Base
require_input :a, :b
require_input :c # ← :a and :b are now forgotten
endSubtle 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
# lib/generators/interaction_generator.rb
def copy_initializer
template "interaction.template", "app/interactions/#{name}.rb"
endRails'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
# 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 gemPlus:
### Exceptions→ "coming soon"### Input→ validation section says "coming soon"## Contributing→ still sayshttps://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:
Interaction::Base.callpattern is clean. One class method, returns a single Result object.delegate_inputis universally adopted because it's a small, targeted improvement overinput.foothat saves repetition.Interaction::Configuration#on_erroris a nicely extensible seam. Enkidu already uses the default Sentry integration without custom code.- Small surface area. 241 lines of gem code means minimal cognitive load. Any proposed addition should respect this.
- No Rails hard dependency for the core. (The generator requires Rails, but
Base,Result,Inputdon't.) - Single-file interactions feel right. Enkidu has no "god-interaction" anti-patterns; every file is focused on one operation.
A.6 Audit summary
| Category | Finding count |
|---|---|
| Latent bugs in gem source | 4 (InputError dup, fail overwrites, rescue-all, require_input overwrite) |
| Workaround patterns in caller code | 7 (manual defaults, manual coercion, manual presence, _response methods, inline auth, defensive rescue, job timing) |
| Dead/unused features | 2 (require_input at 14% adoption, action_guards at 0%) |
| Missing features (heavy friction) | 5 (composition, typed errors, hooks, input types/defaults, test helpers) |
| Documentation gaps | 4 (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:
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
endOne input declaration per field. The DSL:
- Declares the name. Replaces
delegate_input. The field is automatically accessible asusername(instance method) instead ofinput.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: 20ordefault: -> { 20.minutes.from_now }. - Optional coercion.
coerce: trueconverts 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:
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
endguard :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:
# 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
endInteractions 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:
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
endB.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:
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
endBackwards 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:
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? }
endBackwards 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:
# 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
endAnd a curated list of conventional codes (these are recommendations, not enforced):
module Interaction::Codes
INVALID_INPUT = :invalid_input
UNAUTHORIZED = :unauthorized
FORBIDDEN = :forbidden
NOT_FOUND = :not_found
CONFLICT = :conflict
SERVER_ERROR = :server_error
endUsage:
# 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)
endBackwards 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:
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:
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
endOr, 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:
# 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 ...
endThat'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:
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
endUsage:
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
endrun 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:
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
endThe 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:
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
endBackwards 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:
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:
# 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 } }
endUsage:
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
endBackwards 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
# 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
endBackwards compatibility: additive.
B.4.3 Proposal: Finish the README
The README needs:
- Delete the "TODO: Delete this and the text above" scaffold text.
- Replace the placeholder contributor URL (
[USERNAME]) with the real repo. - Document every feature that currently says "coming soon":
- Exception handling (
handle_exception,custom_exception_detail,Configuration#on_error) - Input validation (
require_inputtoday;inputDSL after B.1.1)
- Exception handling (
- Add a "Usage patterns" section covering:
- Guards (B.1.2)
- Composition (B.3.1)
- Testing with RSpec matchers (B.4.1)
- Add a "Philosophy" section explaining what the gem is vs. ActiveInteraction (why someone would choose this).
- 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_withnon-destructive merge - B.2.1
code:support onResult#fail - B.4.1 RSpec matchers
- B.4.3 README rewrite
- Fix latent bugs A.4.1 (dup
InputError), A.4.5 (require_inputoverwrite)
Gem version 3.3 (minor, backwards-compatible additions):
- B.1.1
inputDSL (keepsdelegate_input+require_inputas shims) - B.1.2
guardDSL - B.1.4
before_call/after_call/around_callhooks - B.1.5
enqueuehelper - B.3.1
runhelper 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
details→dataor similar if desired - B.3.2
propagatecontext (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:
- 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. Theinteractiongem's small footprint is a feature; we'd lose that. - Replacing the result object with a Ruby 3
Dataclass.Data.define(:success, :details)would be cleaner but breaks backwards compatibility. Defer. - Introducing a state machine or transitions library. Interactions are single-call; there are no multi-step flows that need a state machine.
- 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: :forbiddenin the result. - 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).
- 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)
- Bump
gem 'interaction', github: 'joshlock3/interaction'to the 3.2 tag once it exists. - No Enkidu code changes required — 3.2 is fully backwards-compatible.
- Start using
result.fail(error: ..., code: ...)in NEW interactions only. Don't batch-migrate. - 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)
- Bump to 3.3.
- Pick one interaction to serve as the pattern reference. Recommend
GoalTracking::AddGoalbecause it exercises every workaround simultaneously. Migrate it to the new DSL:- Replace
delegate_inputwithinputdeclarations - Extract private
_responsemethods intoguarddeclarations - Move conditional job enqueueing into
after_callhooks - Use
runto factor out gamification side effects into a child interaction
- Replace
- Document the before/after as an internal reference.
- 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)
- Bump to 4.0.
- Fix any interactions that relied on the old destructive
failsemantics (audit this during phase 2 migration). - Remove any
rescue StandardErrorworkarounds that now work via the new default exception propagation rules. - Remove
delegate_inputandrequire_inputcall 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'srequire_auth!helper at the GraphQL layer translatescode: :unauthorizedfrom interaction results intoUNAUTHORIZEDextension codes. These two PRDs compose. - Standalone Admin App (
docs/product/standalone-admin-app-prd.md): B.1.4 (hooks) unlocks the admin PRD'sAdmin::Loggedconcern 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. Withrun, anAi::CallCoachModelinteraction 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:
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?
delegate_inputdeprecation timeline. If the newinputDSL ships in 3.3, shoulddelegate_inputbe 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.Exception propagation default. B.2.3's proposed default change (propagate
NoMethodError,ArgumentError,NameError; catch onlyActiveRecord::*and similar) is the most opinionated change in this PRD. Is that aligned with the maintainer's philosophy, or is "catch everything" intentional?Is
code:the right key? Alternatives:failure_code:,reason:,type:,error_type:. I prefercode:for brevity but it's a bikeshed worth raising.Should
runreturn 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, checksuccess?, etc. ActiveInteraction'scomposereturns theoutcome, for comparison.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.
Documentation site? Does the maintainer want help standing up a docs site (B.4.4), or is the README enough?
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
| Feature | Adoption |
|---|---|
delegate_input | 43/43 (100%) — universal |
require_input | 6/43 (14%) — nearly unused |
action_guards generator convention | 0/43 (0%) — never adopted |
| Interaction-to-interaction composition | 0/43 (0%) — too awkward |
result.fail(error: ...) | 113 uses |
result.fail(errors: ...) | 3 uses (inconsistent convention) |
Latent bugs found
Interaction::InputErrordefined twice — once inInputDelegator(unused) and once inInputValidator(the one actually raised). Rescue-blocks targeting the wrong constant will silently miss failures.Result#faildestructively overwritesdetails— anyresult.details = {...}set beforefail(...)is lost.Base.callrescues allStandardError—NoMethodErrorand other programmer errors silently become generic failures.require_inputoverwrites instead of appending — only the lastrequire_inputcall in a class is honored.
Proposed changes
v3.2 (backwards-compatible):
- Add
code:toResult#failfor typed failures (symbols::invalid_input,:unauthorized,:forbidden,:not_found,:conflict,:server_error) - Add
Result#fail_withthat 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
InputErrorduplication andrequire_inputoverwrite bugs
v3.3 (backwards-compatible additions):
- New
input :name, Type, required:, default:, coerce:DSL. Keepdelegate_input+require_inputas shims. guard :method_nameDSL formalizing theaction_guardspattern the generator template teaches but nobody usesbefore_call/after_call/around_callhooksrun SomeInteraction, **argshelper for composition with automatic failure propagationenqueuehelper 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, destructivefail
Happy to submit PRs
I can put up PRs for any of these, starting with the v3.2 scope. Would love your input on:
- Is this direction aligned with your vision for the gem?
- Is
code:the right key name? - Should exception-handling change be in v4.0 or is it too opinionated?
- 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.
Related documents
docs/product/auth-audit-and-prd.md— auth audit that surfaced the typed-error needdocs/product/standalone-admin-app-prd.md— admin app that would benefit from the proposed hooksdocs/product/dedicated-ai-service-prd.md— AI service that would benefit from compositionjoshlock3/interactionon GitHub — the gem repo to submit Part E to
Last updated: 2026-05-22