Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

- Publish RBS contracts for effect callback envelopes and Ruby error summaries.
Check the runtime constructors and packaged consumer signatures strictly,
preserving ordinary hashes, keyword callbacks, serialization, and retries.

## 0.14.6 - 2026-09-12

- Preserve committed turns when an Active Record after-commit callback raises.
Expand Down
2 changes: 1 addition & 1 deletion Rakefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ task :rbs do
FileUtils.rm_rf(File.expand_path("sig/generated", __dir__))
sh "bundle exec rbs-inline --base lib --base app --output sig/generated lib app"
FileUtils.rm_f(File.expand_path("sig/generated/lib/generators/solid_objects/templates/solid_objects.rbs", __dir__))
sh "bundle exec rbs -I sig/generated -I sig/support validate"
sh "bundle exec rbs -I sig/generated -I sig/support -I sig/public validate"
end

desc "Run Standard Ruby"
Expand Down
11 changes: 11 additions & 0 deletions Steepfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,20 @@
target :lib do
signature "sig/generated"
signature "sig/support"
signature "sig/public"
check "lib"

configure_code_diagnostics(Diagnostic::Ruby.lenient)

ignore "lib/solid_objects/engine.rb"
ignore "lib/solid_objects/effect_payload.rb"
end

target :effect_payloads do
signature "sig/generated"
signature "sig/support"
signature "sig/public"
check "lib/solid_objects/effect_payload.rb"

configure_code_diagnostics(Diagnostic::Ruby.strict)
end
44 changes: 44 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,50 @@ and `result:`. A failure callback receives `effect_id:`, `arguments:`, and
`error:`, so an actor can correlate concurrent effects without storing a
separate callback ledger.

### Typing your on_failure handler

The gem ships `SolidObjects::effect_error`,
`SolidObjects::effect_failure_payload[Arguments]`, and
`SolidObjects::effect_success_payload[Arguments, Result]` as public RBS aliases.
They describe the existing string-keyed hashes; they are not Ruby wrapper classes.
See [signature loading](development.md#public-effect-payload-signatures) for Steep setup.

For an actor with `generation` and `status` attributes, declare the callback keywords
in the application's RBS:

```rbs
class ChatRun < SolidObjects::Actor
type run_arguments = { "generation" => Integer }
def fail_turn: (effect_id: String, arguments: run_arguments, error: SolidObjects::effect_error) -> void
end
```

```ruby
def fail_turn(effect_id:, arguments:, error:)
return unless arguments["generation"] == generation

self.status = "failed"
end
```

Record access with `arguments["generation"]` retains its declared `Integer` type.
The callback receives top-level keywords, while nested arguments and error keys
remain strings. The failure envelope requires `"effect_id"`, `"arguments"`, and
`"error"`; the success envelope replaces `"error"` with `"result"`. Empty original
arguments remain `{}`, and a success result may be `nil` or any supported JSON value.
Ruby errors contain `"class"` (`String?`, including anonymous exception classes),
`"message"` (`String`, limited to 8,192 bytes), and `"backtrace"` (`Array[String]`,
limited to 50 entries and possibly empty).

Applications supply the generic argument/result types to describe their serialized
JSON values. These aliases do not infer or validate independently registered effect
handlers. Their type parameters are deliberately unconstrained: Ruby serialization
accepts and normalizes values such as symbols, and RBS cannot express that conversion
as a generic bound. The constructors and consumer fixtures are checked with strict
Steep diagnostics. JavaScript exposes equivalent contracts with its existing
camelCase ID and `{ name, message }` error shape in
[solid-objects-js#47](https://github.com/cardmagic/solid-objects-js/issues/47).

A commit action is registered the same way and runs inside the short fenced
transaction:

Expand Down
20 changes: 20 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,26 @@ bundle exec rake rbs

This follows the inline convention used by `cardmagic/classifier`.

### Public effect payload signatures

The packaged `sig/public` directory owns the reusable effect payload aliases and
survives `rake rbs` regeneration. A host application's Steep target can load the
installed gem's complete signature tree alongside its own signatures:

```ruby
target :app do
library "solid_objects"
signature "sig"
check "app/actors"
configure_code_diagnostics(Diagnostic::Ruby.strict)
end
```

This requires no internal runtime imports. See the
[typed callback example](architecture.md#typing-your-on_failure-handler).
The gem's strict payload target checks the actual constructors; its packaged
consumer test also verifies that missing keys and incorrect field types fail.

## Formatting and security

```bash
Expand Down
4 changes: 4 additions & 0 deletions docs/reminders.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ raises, and nothing is logged except a `solid_objects.reminder.replaced` event.

## An alarm per item, with `key:`

For watchdogs paired with an effect's give-up callback, see
[typing your `on_failure` handler](architecture.md#typing-your-on_failure-handler)
to retain the original argument types without repeating the error hash contract.

Pass `key:` when an actor is waiting on several things at once. The key is your
own identifier for the item, and it names that item's alarm, so each item gets
one:
Expand Down
2 changes: 2 additions & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
dead letters, and tail retry
- Transactional effects with success/failure actor messages carrying the
originally staged arguments for callback correlation
- Public RBS effect success/failure envelopes and error records, checked against
the runtime constructors and a packaged consumer with strict Steep diagnostics
- Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
portable status rows with polling indexes and database check constraints on
status, which works on all three adapters; a future version may add narrow
Expand Down
1 change: 1 addition & 0 deletions lib/solid_objects.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
require "solid_objects/wake_up_adapters"
require "solid_objects/polling_backoff"
require "solid_objects/effect_registry"
require "solid_objects/effect_payload"
require "solid_objects/commit_action_registry"
require "solid_objects/lease"
require "solid_objects/lease_renewer"
Expand Down
26 changes: 11 additions & 15 deletions lib/solid_objects/effect_executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -184,11 +184,11 @@ def complete(effect, result)
effect: locked_effect,
operation: locked_effect.success_operation,
outcome: "success",
arguments: {
"effect_id" => locked_effect.effect_id,
"arguments" => locked_effect.arguments,
"result" => serialized_result
}
arguments: EffectPayload.success(
effect_id: locked_effect.effect_id,
arguments: locked_effect.arguments,
result: serialized_result
)
)
locked_effect.update!(
status: "completed",
Expand Down Expand Up @@ -216,21 +216,17 @@ def fail_effect(effect, error)
locked_effect = Effect.lock.find(effect.id)
verify_claim!(locked_effect)
dead = locked_effect.attempt_count >= locked_effect.max_attempts
error_details = {
"class" => error.class.name,
"message" => error.message.to_s.byteslice(0, 8_192),
"backtrace" => Array(error.backtrace).first(50)
}
error_details = EffectPayload.error(error)
if dead
result_message = enqueue_result_message(
effect: locked_effect,
operation: locked_effect.failure_operation,
outcome: "failure",
arguments: {
"effect_id" => locked_effect.effect_id,
"arguments" => locked_effect.arguments,
"error" => error_details
}
arguments: EffectPayload.failure(
effect_id: locked_effect.effect_id,
arguments: locked_effect.arguments,
error: error_details
)
)
end
locked_effect.update!(
Expand Down
27 changes: 27 additions & 0 deletions lib/solid_objects/effect_payload.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# rbs_inline: enabled

module SolidObjects
module EffectPayload
class << self
# @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
def success(effect_id:, arguments:, result:)
{ "effect_id" => effect_id, "arguments" => arguments, "result" => result }
end

# @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]
def failure(effect_id:, arguments:, error:)
{ "effect_id" => effect_id, "arguments" => arguments, "error" => error }
end

# @rbs (Exception) -> effect_error
def error(exception)
message = exception.message.to_s.byteslice(0, 8_192) # : String
{
"class" => exception.class.name,
"message" => message,
"backtrace" => Array(exception.backtrace).first(50)
}
end
end
end
end
14 changes: 14 additions & 0 deletions sig/generated/lib/solid_objects/effect_payload.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Generated from lib/solid_objects/effect_payload.rb with RBS::Inline

module SolidObjects
module EffectPayload
# @rbs [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]
def self.success: [Arguments, Result] (effect_id: String, arguments: Arguments, result: Result) -> effect_success_payload[Arguments, Result]

# @rbs [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]
def self.failure: [Arguments] (effect_id: String, arguments: Arguments, error: effect_error) -> effect_failure_payload[Arguments]

# @rbs (Exception) -> effect_error
def self.error: (Exception) -> effect_error
end
end
19 changes: 19 additions & 0 deletions sig/public/effect_payload.rbs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
module SolidObjects
type effect_error = {
"class" => String?,
"message" => String,
"backtrace" => Array[String]
}

type effect_failure_payload[Arguments] = {
"effect_id" => String,
"arguments" => Arguments,
"error" => effect_error
}

type effect_success_payload[Arguments, Result] = {
"effect_id" => String,
"arguments" => Arguments,
"result" => Result
}
end
108 changes: 108 additions & 0 deletions test/integration/effect_payload_types_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
# rbs_inline: enabled

require "test_helper"
require "fileutils"
require "open3"
require "tmpdir"
require "rubygems/package"
require "rubygems/installer"

class EffectPayloadTypesTest < ActiveSupport::TestCase
test "packaged payload contracts check consumers and the actual constructors" do
Dir.mktmpdir("solid-objects-effect-types") do |directory|
package = build_package(directory)
project = File.join(directory, "consumer")
Gem::Package.new(package).extract_files(project)
FileUtils.cp(root_path("test/types/effect_payloads.rbs"), File.join(project, "sig/consumer.rbs"))
consumer_path = File.join(project, "consumer.rb")
consumer = File.read(root_path("test/types/effect_payloads.rb"))
File.write(consumer_path, consumer)
File.write(File.join(project, "Steepfile"), <<~RUBY)
target :consumer do
signature "sig"
check "consumer.rb"
check "lib/solid_objects/effect_payload.rb"
configure_code_diagnostics(Diagnostic::Ruby.strict)
end
RUBY

output, status = typecheck(project)
assert status.success?, output

configuration_path = File.join(project, "Steepfile")
configuration = File.read(configuration_path)
File.write(configuration_path, configuration.sub('signature "sig"', "library \"solid_objects\"\n signature \"sig/consumer.rbs\""))
output, status = typecheck_installed(project, package)
assert status.success?, output
File.write(configuration_path, configuration)

[
[ '"effect_id" => "effect-1"', '"effect_identifier" => "effect-1"' ],
[ '"message" => "failed"', '"message" => 42' ],
[ 'arguments["generation"]', 'arguments["generation"].to_s' ]
].each do |original, invalid|
File.write(consumer_path, consumer.sub(original, invalid))
output, status = typecheck(project)
refute status.success?, "invalid consumer escaped the compiler: #{invalid}"
assert_includes output, "Ruby::MethodBodyTypeMismatch"
end
File.write(consumer_path, consumer)

constructor_path = File.join(project, "lib/solid_objects/effect_payload.rb")
constructors = File.read(constructor_path)
[
[ '"result" => result', '"outcome" => result' ],
[ '"error" => error', '"failure" => error' ],
[ '"backtrace" => Array(exception.backtrace).first(50)', '"backtrace" => [42]' ]
].each do |original, invalid|
File.write(constructor_path, constructors.sub(original, invalid))
output, status = typecheck(project)
refute status.success?, "invalid constructor escaped the compiler: #{invalid}"
assert_includes output, "Ruby::MethodBodyTypeMismatch"
end
end
end

private

def build_package(directory)
artifact = File.join(directory, "solid_objects.gem")
specification = Gem::Specification.load(root_path("solid_objects.gemspec"))
Gem::DefaultUserInteraction.use_ui(Gem::SilentUI.new) do
Gem::Package.build(specification, false, false, artifact)
end
artifact
end

def typecheck(project)
output, error_output, status = Open3.capture3(
Gem.ruby, Gem.bin_path("steep", "steep"), "check", "--no-daemon", "-j", "1",
chdir: project
)
[ output + error_output, status ]
end

def typecheck_installed(project, package)
gem_directory = File.join(project, "gems")
specification = Gem::Installer.at(package, install_dir: gem_directory, ignore_dependencies: true).install
script = <<~RUBY
gem "solid_objects", #{"= #{SolidObjects::VERSION}".inspect}
resolved = Gem.loaded_specs.fetch("solid_objects").full_gem_path
abort "loaded signatures outside the built gem: \#{resolved}" unless resolved == #{specification.full_gem_path.inspect}
load ARGV.shift
RUBY
output, error_output, status = Open3.capture3(
{
"RUBYOPT" => nil, "BUNDLE_GEMFILE" => nil,
"GEM_HOME" => gem_directory, "GEM_PATH" => ([ gem_directory ] + Gem.path).join(File::PATH_SEPARATOR)
},
Gem.ruby, "-e", script, Gem.bin_path("steep", "steep"), "check", "--no-daemon", "-j", "1",
chdir: project
)
[ output + error_output, status ]
end

def root_path(path)
File.expand_path("../../#{path}", __dir__)
end
end
Loading
Loading