Module: Fosm::Lifecycle

Extended by:
ActiveSupport::Concern
Defined in:
lib/fosm/lifecycle.rb,
lib/fosm/lifecycle/definition.rb,
lib/fosm/lifecycle/role_definition.rb,
lib/fosm/lifecycle/event_definition.rb,
lib/fosm/lifecycle/guard_definition.rb,
lib/fosm/lifecycle/state_definition.rb,
lib/fosm/lifecycle/access_definition.rb,
lib/fosm/lifecycle/side_effect_definition.rb,
lib/fosm/lifecycle/snapshot_configuration.rb

Defined Under Namespace

Classes: AccessDefinition, Definition, EventDefinition, GuardDefinition, RoleDefinition, SideEffectDefinition, SnapshotConfiguration, StateDefinition

Instance Method Summary collapse

Instance Method Details

#available_eventsObject

Returns list of event names that can be fired from the current state



329
330
331
332
333
334
335
336
337
# File 'lib/fosm/lifecycle.rb', line 329

def available_events
  lifecycle = self.class.fosm_lifecycle
  return [] unless lifecycle

  lifecycle.available_events_from(self.state).select { |event_def|
    # ๐Ÿ†• Use evaluate to properly check guards
    event_def.guards.all? { |g| g.evaluate(self).first }
  }.map(&:name)
end

#can_fire?(event_name) ⇒ Boolean

Returns true if the given event can be fired from the current state. Does NOT check RBAC โ€” use fosm_can_fire_with_actor? for that.

Returns:

  • (Boolean)


306
307
308
309
310
311
312
313
314
315
316
317
318
# File 'lib/fosm/lifecycle.rb', line 306

def can_fire?(event_name)
  lifecycle = self.class.fosm_lifecycle
  return false unless lifecycle

  event_def = lifecycle.find_event(event_name)
  return false unless event_def
  # Terminal states block transitions
  return false if lifecycle.find_state(self.state)&.terminal?
  return false unless event_def.valid_from?(self.state)

  # Use evaluate to properly check guards (handles rich return values)
  event_def.guards.all? { |guard_def| guard_def.evaluate(self).first }
end

#can_fire_with_actor?(event_name, actor:) ⇒ Boolean

Returns true if the actor has a role permitting this event AND the transition is valid.

Returns:

  • (Boolean)


321
322
323
324
325
326
# File 'lib/fosm/lifecycle.rb', line 321

def can_fire_with_actor?(event_name, actor:)
  return false unless can_fire?(event_name)
  lifecycle = self.class.fosm_lifecycle
  return true unless lifecycle.access_defined?
  fosm_actor_has_event_permission?(event_name, actor)
end

#current_stateObject

Returns the current state as a symbol



398
399
400
# File 'lib/fosm/lifecycle.rb', line 398

def current_state
  self.state.to_sym
end

#fire!(event_name, actor: nil, metadata: {}, snapshot_data: nil) ⇒ Object

Fire a lifecycle event. This is the ONLY way to change state.

Execution order (all in-memory checks first, then one DB write):

1. Validate event exists
2. Check current state is not terminal
3. Check event is valid from current state
4. Run guards (pure in-memory functions)
5. RBAC check (O(1) cache lookup after first request hit)
6. Acquire row lock (SELECT FOR UPDATE) and re-validate
7. BEGIN TRANSACTION: UPDATE state, run side effects
                      [optionally INSERT log if strategy == :sync]
   COMMIT
8. Emit transition log (:async or :buffered, non-blocking)
9. Enqueue webhook delivery (always async)

RACE CONDITION PROTECTION:

- Uses SELECT FOR UPDATE to lock the row, preventing concurrent transitions
- Re-validates state, guards, and RBAC after acquiring lock
- Guarantees only one transition succeeds when concurrent requests fire
  the same event on the same record

Parameters:

  • event_name (Symbol, String)

    the event to fire

  • actor (Object) (defaults to: nil)

    who/what is firing the event (User, or :system/:agent symbol)

  • metadata (Hash) (defaults to: {})

    optional metadata stored in the transition log

  • snapshot_data (Hash) (defaults to: nil)

    arbitrary observations/data to include in state snapshot This allows capturing adhoc observations alongside schema attributes. Merged with schema data under the โ€˜_observations` key in the snapshot.

Raises:



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
# File 'lib/fosm/lifecycle.rb', line 80

def fire!(event_name, actor: nil, metadata: {}, snapshot_data: nil)
  lifecycle = self.class.fosm_lifecycle
  raise Fosm::Error, "No lifecycle defined on #{self.class.name}" unless lifecycle

  event_def = lifecycle.find_event(event_name)
  raise Fosm::UnknownEvent.new(event_name, self.class) unless event_def

  current           = self.state.to_s
  current_state_def = lifecycle.find_state(current)

  # Block terminal states from further transitions
  if current_state_def&.terminal?
    raise Fosm::TerminalState.new(current, self.class)
  end

  # Check the transition is valid from current state
  unless event_def.valid_from?(current)
    raise Fosm::InvalidTransition.new(event_name, current, self.class)
  end

  # Run guards (pure functions โ€” no side effects, evaluated before any writes)
  # ๐Ÿ†• Use evaluate for rich error messages
  event_def.guards.each do |guard_def|
    allowed, reason = guard_def.evaluate(self)
    unless allowed
      raise Fosm::GuardFailed.new(guard_def.name, event_name, reason)
    end
  end

  # RBAC check โ€” fail fast before touching the DB
  if lifecycle.access_defined?
    fosm_enforce_event_access!(event_name, actor)
  end

  from_state      = current
  to_state        = event_def.to_state.to_s
  transition_data = { from: from_state, to: to_state, event: event_name.to_s, actor: actor }

  # Auto-capture triggered_by when called from within a side effect
  triggered_by = Thread.current[:fosm_trigger_context]

  log_data = {
    "record_type" => self.class.name,
    "record_id"   => self.id.to_s,
    "event_name"  => event_name.to_s,
    "from_state"  => from_state,
    "to_state"    => to_state,
    "actor_type"  => actor_type_for(actor),
    "actor_id"    => actor_id_for(actor),
    "actor_label" => actor_label_for(actor),
    "metadata"    => .merge(
      triggered_by ? { triggered_by: triggered_by } : {}
    ).compact
  }

  # ==========================================================================
  # SNAPSHOT CONFIGURATION
  # ==========================================================================
  # If snapshots are configured, determine if we should capture one for this
  # transition based on the strategy (every, count, time, terminal, manual).
  # ==========================================================================
  snapshot_config = lifecycle.snapshot_configuration
  if snapshot_config && [:snapshot] != false  # allow manual opt-out
    # Calculate metrics for snapshot decision
    last_snapshot = Fosm::TransitionLog
      .where(record_type: self.class.name, record_id: self.id.to_s)
      .where.not(state_snapshot: nil)
      .order(created_at: :desc)
      .first

    transitions_since = last_snapshot ?
      Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s)
        .where("created_at > ?", last_snapshot.created_at).count :
      Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s).count

    seconds_since = last_snapshot ?
      (Time.current - last_snapshot.created_at).to_f :
      Float::INFINITY

    to_state_def = lifecycle.find_state(to_state)

    # Check if we should snapshot (respecting manual: false unless forced)
    force_snapshot = [:snapshot] == true
    should_snapshot = snapshot_config.should_snapshot?(
      transition_count: transitions_since,
      seconds_since_last: seconds_since,
      to_state: to_state,
      to_state_terminal: to_state_def&.terminal? || false,
      force: force_snapshot
    )

    if should_snapshot
      # Build snapshot: schema attributes + arbitrary observations
      schema_snapshot = snapshot_config.build_snapshot(self)

      # Merge arbitrary observations if provided (stored under _observations key)
      if snapshot_data.present?
        schema_snapshot["_observations"] = snapshot_data.as_json
      end

      log_data["state_snapshot"] = schema_snapshot
      log_data["snapshot_reason"] = determine_snapshot_reason(
        snapshot_config.strategy, force_snapshot, to_state_def
      )
    end
  end

  # ==========================================================================
  # RACE CONDITION FIX: SELECT FOR UPDATE
  # ==========================================================================
  # Acquire a row-level lock before proceeding. This prevents concurrent
  # transactions from reading stale state and attempting concurrent transitions.
  #
  # Behavior:
  # - PostgreSQL/MySQL: SELECT ... FOR UPDATE blocks until lock acquired
  # - SQLite: No-op (database-level locking makes it already serializable)
  #
  # We re-read state after locking to ensure we have the latest value.
  # If another transaction committed while we waited for the lock, we get
  # the fresh state and must re-validate our checks.
  # ==========================================================================

  # Acquire lock - this blocks if another transaction holds the lock
  locked_record = self.class.lock.find(self.id)

  # Re-validate with locked state - the world may have changed while waiting
  locked_current = locked_record.state.to_s
  locked_current_state_def = lifecycle.find_state(locked_current)

  # If state changed while waiting for lock, transition may no longer be valid
  if locked_current_state_def&.terminal?
    raise Fosm::TerminalState.new(locked_current, self.class)
  end

  unless event_def.valid_from?(locked_current)
    raise Fosm::InvalidTransition.new(event_name, locked_current, self.class)
  end

  # Re-check guards with locked record (guards may depend on fresh state)
  event_def.guards.each do |guard_def|
    allowed, reason = guard_def.evaluate(locked_record)
    unless allowed
      raise Fosm::GuardFailed.new(guard_def.name, event_name, reason)
    end
  end

  # Re-check RBAC with locked record (for consistency, though RBAC uses cache)
  if lifecycle.access_defined?
    unless fosm_rbac_bypass?(actor)
      unless fosm_actor_has_event_permission_for_record?(event_name, actor, locked_record)
        raise Fosm::AccessDenied.new(event_name, actor)
      end
    end
  end

  # Update from_state to reflect the locked state's current value
  from_state = locked_current
  log_data["from_state"] = from_state

  ActiveRecord::Base.transaction do
    # Use the locked record for the update to ensure we hold the lock
    locked_record.update!(state: to_state)

    # Sync our instance state with what was written
    self.state = to_state

    # :sync strategy โ€” INSERT inside transaction for strict consistency
    if Fosm.config.transition_log_strategy == :sync
      Fosm::TransitionLog.create!(log_data)
    end

    # Run immediate side effects inside transaction so they roll back on error
    # Set context for auto-capturing triggered_by in nested transitions
    begin
      Thread.current[:fosm_trigger_context] = {
        record_type: self.class.name,
        record_id: self.id.to_s,
        event_name: event_name.to_s
      }
      # Call side effects on self (not locked_record) so instance state is preserved
      event_def.side_effects.reject(&:deferred?).each do |side_effect_def|
        side_effect_def.call(self, transition_data)
      end
    ensure
      Thread.current[:fosm_trigger_context] = nil
    end

    # ๐Ÿ†• Queue deferred side effects to run after commit
    deferred_effects = event_def.side_effects.select(&:deferred?)
    if deferred_effects.any?
      # Set instance variables on locked_record (the instance that gets saved)
      # so after_commit callback can access them
      locked_record.instance_variable_set(:@_fosm_deferred_side_effects, deferred_effects)
      locked_record.instance_variable_set(:@_fosm_transition_data, transition_data)
      # Use after_commit to run after transaction completes
      locked_record.class.after_commit :_fosm_run_deferred_side_effects, on: :update
    end
  end

  # :async strategy โ€” enqueue job after transaction commits (non-blocking)
  if Fosm.config.transition_log_strategy == :async
    Fosm::TransitionLogJob.perform_later(log_data)
  end

  # :buffered strategy โ€” push to in-memory buffer (bulk INSERT every ~1s)
  if Fosm.config.transition_log_strategy == :buffered
    Fosm::TransitionBuffer.push(log_data)
  end

  # Deliver webhooks asynchronously (outside transaction, skipped when disabled)
  if Fosm.config.webhooks_enabled
    Fosm::WebhookDeliveryJob.perform_later(
      record_type: self.class.name,
      record_id:   self.id.to_s,
      event_name:  event_name.to_s,
      from_state:  from_state,
      to_state:    to_state,
      metadata:    
    )
  end

  true
end

#last_snapshotFosm::TransitionLog?

Returns the most recent snapshot for this record, or nil if none exists.

Returns:



408
409
410
411
412
413
414
# File 'lib/fosm/lifecycle.rb', line 408

def last_snapshot
  Fosm::TransitionLog
    .where(record_type: self.class.name, record_id: self.id.to_s)
    .where.not(state_snapshot: nil)
    .order(created_at: :desc)
    .first
end

#last_snapshot_dataHash?

Returns the snapshot data from the most recent snapshot, or nil.

Returns:

  • (Hash, nil)

    the snapshot data



418
419
420
# File 'lib/fosm/lifecycle.rb', line 418

def last_snapshot_data
  last_snapshot&.state_snapshot
end

#replay_from(from_snapshot) {|transition_log| ... } ⇒ Array<Fosm::TransitionLog>

Replays events from a specific snapshot forward to the current state. Yields each transition to a block for custom processing.

Parameters:

Yields:

  • (transition_log)

    optional block to process each transition

Returns:



453
454
455
456
457
458
459
460
461
462
463
464
465
466
# File 'lib/fosm/lifecycle.rb', line 453

def replay_from(from_snapshot)
  snapshot_id = from_snapshot.is_a?(Fosm::TransitionLog) ? from_snapshot.id : from_snapshot

  transitions = Fosm::TransitionLog
    .where(record_type: self.class.name, record_id: self.id.to_s)
    .where("id > ?", snapshot_id)
    .order(:created_at)

  if block_given?
    transitions.each { |log| yield log }
  end

  transitions.to_a
end

#snapshotsActiveRecord::Relation

Returns all snapshots for this record in chronological order. Useful for audit trails and debugging.

Returns:

  • (ActiveRecord::Relation)

    snapshot transition logs



471
472
473
474
475
476
# File 'lib/fosm/lifecycle.rb', line 471

def snapshots
  Fosm::TransitionLog
    .where(record_type: self.class.name, record_id: self.id.to_s)
    .where.not(state_snapshot: nil)
    .order(:created_at)
end

#state_at_transition(transition_log_id) ⇒ Hash

Returns the state of the record at a specific transition log ID. This is a โ€œtime-travelโ€ query that reconstructs state from snapshot + replay.

Parameters:

  • transition_log_id (Integer)

    the ID of the transition log entry

Returns:

  • (Hash)

    the reconstructed state at that point in time



427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
# File 'lib/fosm/lifecycle.rb', line 427

def state_at_transition(transition_log_id)
  log = Fosm::TransitionLog.find_by(id: transition_log_id)
  return nil unless log
  return nil unless log.record_type == self.class.name && log.record_id == self.id.to_s

  # If this log entry has a snapshot, use it directly
  return log.state_snapshot if log.state_snapshot.present?

  # Otherwise, find the most recent snapshot before this log entry
  prior_snapshot = Fosm::TransitionLog
    .where(record_type: self.class.name, record_id: self.id.to_s)
    .where.not(state_snapshot: nil)
    .where("created_at <= ?", log.created_at)
    .order(created_at: :desc)
    .first

  # Return the prior snapshot data, or nil if no snapshot exists
  prior_snapshot&.state_snapshot
end

#transitions_since_snapshotInteger

Returns the number of transitions since the last snapshot. Useful for monitoring snapshot coverage.

Returns:

  • (Integer)

    transitions since last snapshot



481
482
483
484
485
486
487
488
489
# File 'lib/fosm/lifecycle.rb', line 481

def transitions_since_snapshot
  last_snap = last_snapshot
  return Fosm::TransitionLog.where(record_type: self.class.name, record_id: self.id.to_s).count unless last_snap

  Fosm::TransitionLog
    .where(record_type: self.class.name, record_id: self.id.to_s)
    .where("created_at > ?", last_snap.created_at)
    .count
end

#why_cannot_fire?(event_name) ⇒ Boolean

๐Ÿ†• Detailed introspection: why can this event (not) be fired? Returns a hash with diagnostic information for debugging and UI messages.

Returns:

  • (Boolean)


341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# File 'lib/fosm/lifecycle.rb', line 341

def why_cannot_fire?(event_name)
  lifecycle = self.class.fosm_lifecycle
  return { can_fire: false, reason: "No lifecycle defined" } unless lifecycle

  event_def = lifecycle.find_event(event_name)
  return { can_fire: false, reason: "Unknown event '#{event_name}'" } unless event_def

  current = self.state.to_s
  current_state_def = lifecycle.find_state(current)
  result = {
    can_fire: true,
    event: event_name.to_s,
    current_state: current
  }

  # Check terminal state
  if current_state_def&.terminal?
    result[:can_fire] = false
    result[:reason] = "State '#{current}' is terminal and cannot transition further"
    result[:is_terminal] = true
    return result
  end

  # Check valid from state
  unless event_def.valid_from?(current)
    result[:can_fire] = false
    result[:reason] = "Cannot fire '#{event_name}' from '#{current}' (valid from: #{event_def.from_states.join(', ')})"
    result[:valid_from_states] = event_def.from_states
    return result
  end

  # Evaluate guards
  failed_guards = []
  passed_guards = []

  event_def.guards.each do |guard_def|
    allowed, reason = guard_def.evaluate(self)
    if allowed
      passed_guards << guard_def.name
    else
      failed_guards << { name: guard_def.name, reason: reason }
    end
  end

  if failed_guards.any?
    result[:can_fire] = false
    result[:failed_guards] = failed_guards
    result[:passed_guards] = passed_guards
    first_failure = failed_guards.first
    result[:reason] = "Guard '#{first_failure[:name]}' failed"
    result[:reason] += ": #{first_failure[:reason]}" if first_failure[:reason]
  end

  result
end