From 54fb394a56d3ad54f0f0b93eb54a7a33f8e1a78d Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 14 Sep 2026 11:05:03 -0700 Subject: [PATCH 1/2] feat: generate actor-specific RBS dispatch types Combine registered message names with application-declared signatures. Keep strict checks opt-in and preserve dynamic Ruby dispatch and global registry names. Verify actual consumer source and packaged tooling. Closes #63 --- CHANGELOG.md | 4 + docs/architecture.md | 4 + docs/development.md | 90 +++++++++++ docs/operations.md | 6 + docs/reminders.md | 4 + docs/roadmap.md | 4 + lib/solid_objects/actor_signatures.rb | 80 ++++++++++ .../lib/solid_objects/actor_signatures.rbs | 24 +++ test/integration/actor_signatures_test.rb | 141 ++++++++++++++++++ test/integration/load_contract_test.rb | 1 + test/types/actor_operations.rb | 68 +++++++++ test/types/actor_operations.rbs | 18 +++ 12 files changed, 444 insertions(+) create mode 100644 lib/solid_objects/actor_signatures.rb create mode 100644 sig/generated/lib/solid_objects/actor_signatures.rbs create mode 100644 test/integration/actor_signatures_test.rb create mode 100644 test/types/actor_operations.rb create mode 100644 test/types/actor_operations.rbs diff --git a/CHANGELOG.md b/CHANGELOG.md index b62651f..9465608 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ Check the runtime constructors and packaged consumer signatures strictly, preserving ordinary hashes, keyword callbacks, serialization, and retries. +- Add optional actor-specific RBS generation for staged schedule/transmit calls + and effect callback names. Reuse application-declared operation argument types + without changing Ruby dispatch, runtime validation, or global effect registries. + ## 0.14.6 - 2026-09-12 - Preserve committed turns when an Active Record after-commit callback raises. diff --git a/docs/architecture.md b/docs/architecture.md index 0856790..2936787 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -443,6 +443,10 @@ a dead letter. `emit` creates a staged effect: +Typed applications can generate [actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures) +to check callback names and staged `schedule`/`transmit` calls without changing +their Ruby syntax. Effect and commit-action registry names remain independent. + ```ruby emit( :charge_payment, diff --git a/docs/development.md b/docs/development.md index 0c91376..045d39f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -123,6 +123,96 @@ This requires no internal runtime imports. See the The gem's strict payload target checks the actual constructors; its packaged consumer test also verifies that missing keys and incorrect field types fail. +### Actor-specific dispatch signatures + +Typed applications can opt into `SolidObjects::ActorSignatures` to check ordinary +`schedule`, `transmit`, and effect callback names inside actor methods. Add `rbs` +and `steep` to the application's development dependencies. This tool is loaded +explicitly and is not required by workers or ordinary Ruby applications. + +Declare application operation types first, including inherited operations and +block-defined `message` operations. Inline RBS can generate this input, or keep +handwritten declarations in a separate directory such as `sig/actors`: + +```rbs +class ChatRun < SolidObjects::Actor + def recover_if_stuck: (generation: Integer) -> nil + def fail_turn: (effect_id: String, arguments: Hash[String, untyped], error: Hash[String, untyped]) -> nil + def start: () -> nil +end +``` + +After loading the application's actor classes, generate a separate output file: + +```ruby +require "solid_objects/actor_signatures" + +Rails.application.reloader.wrap do + signatures = SolidObjects::ActorSignatures.generate( + actors: [ChatRun], + signatures: [Rails.root.join("sig/actors").to_s] + ) + FileUtils.mkdir_p(Rails.root.join("sig/generated")) + File.write(Rails.root.join("sig/generated/solid_objects.rbs"), signatures) +end +``` + +Run that script with `bin/rails runner` during development or CI. Outside Rails, +require the actor definitions and call `generate` directly. The generator returns +a string and does not write files, execute actor operations, start workers, or run +migrations. Rails boot follows the application's normal loading configuration; +the explicit actor list resolves its autoloaded classes within the reloader boundary. +Keep generated output out of the input signature paths, and regenerate after a +message is renamed or removed. Output is deterministic; handwritten signatures +remain separate. + +Load the gem and both application signature directories in `Steepfile`: + +```ruby +target :actors do + library "solid_objects" + signature "sig/actors" + signature "sig/generated" + check "app/actors" + configure_code_diagnostics(Diagnostic::Ruby.strict) +end +``` + +The usual Ruby code now passes Steep without a dispatcher cast: + +```ruby +schedule(at: Time.now, key: "watchdog").recover_if_stuck(generation: 1) +transmit.recover_if_stuck(generation: 1) +emit :run_model, generation: 1, on_failure: :fail_turn +``` + +Misspelled operations/callbacks, queries, attributes, private methods, infrastructure +methods, and incorrect keyword arguments fail the strict check. Both string and +symbol callback literals work. Staging returns `nil` even when an operation's own +return type differs. Effect and commit-action names remain global registry names; +registry contract inference is separate work. + +Reflection supplies message names only. Values come from declared RBS signatures; +missing signatures and positional/block arguments are rejected. Block-defined +messages require explicit method declarations in RBS; annotate their block-local +values separately when checking the block body. Generic actor classes currently +require application-owned dispatcher signatures. The generator preserves method +overloads and method type parameters. + +Deliberately dynamic names can use Ruby's explicit dynamic dispatch: + +```ruby +schedule(at: Time.now).public_send(operation_name, generation: generation) +public_send(:emit, :run_model, on_failure: callback_name, generation: generation) +``` + +Those calls opt out of name/argument checking and retain the existing runtime +validation. Ordinary calls on the generated dispatcher have no string-name fallback. +`send_to`, `Reference#async`, direct calls, and queries retain their existing +signatures and runtime behavior; this generator does not provide complete reference +typing or Sorbet/Tapioca actor-specific RBI generation. The corresponding TypeScript +work is tracked in [solid-objects-js#46](https://github.com/cardmagic/solid-objects-js/issues/46). + ## Formatting and security ```bash diff --git a/docs/operations.md b/docs/operations.md index 6249bdd..50f6d30 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -69,6 +69,12 @@ Rails schema migrations and actor state migrations are separate concerns. ### Host application tooling +RBS/Steep applications can generate +[actor-specific dispatch signatures](development.md#actor-specific-dispatch-signatures) +for reminders, transmit calls, and effect callback names. This is optional development +tooling; generated signatures do not change runtime dispatch. The generator does +not supply equivalent Sorbet/Tapioca actor-specific types. + Installed engine migrations are copied as `db/migrate/*_create_solid_objects_tables.solid_objects.rb`. If the host enables `Rails/CreateTableWithTimestamps`, exclude engine-owned migrations rather than diff --git a/docs/reminders.md b/docs/reminders.md index b794b83..2f51d1a 100644 --- a/docs/reminders.md +++ b/docs/reminders.md @@ -43,6 +43,10 @@ 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. +For static checking of watchdog operation names and keyword arguments, opt into +[actor-specific RBS signatures](development.md#actor-specific-dispatch-signatures). +The Ruby `schedule(...).recover_if_stuck(generation: ...)` syntax stays the same. + 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: diff --git a/docs/roadmap.md b/docs/roadmap.md index b590dcb..839d9ce 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -67,6 +67,10 @@ gem's dependencies - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact RuboCop policy, and a warning-free Brakeman scan +- Opt-in actor-specific RBS generation for schedule/transmit keyword arguments + and effect callback names, using application declarations and checked consumer + fixtures. Dynamic names remain an explicit escape hatch; complete reference + typing and actor-specific RBI generation are separate work - Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against Rails 7.1, 7.2, 8.0, and 8.1, pinned through `RAILS_VERSION` so the advertised range is verified rather than assumed. The compatibility job runs SQLite only; diff --git a/lib/solid_objects/actor_signatures.rb b/lib/solid_objects/actor_signatures.rb new file mode 100644 index 0000000..dde82ae --- /dev/null +++ b/lib/solid_objects/actor_signatures.rb @@ -0,0 +1,80 @@ +# rbs_inline: enabled + +require "solid_objects" +require "rbs" +require "pathname" + +module SolidObjects + class ActorSignatures + # @rbs (actors: Array[Class], signatures: Array[String]) -> String + def self.generate(actors:, signatures:) + new(signatures:).generate(actors:) + end + + # @rbs @builder: untyped + + # @rbs (signatures: Array[String]) -> void + def initialize(signatures:) + loader = RBS::EnvironmentLoader.new + loader.add(path: Pathname.new(File.expand_path("../../sig", __dir__))) + signatures.sort.each { |path| loader.add(path: Pathname.new(path)) } + environment = RBS::Environment.from_loader(loader).resolve_type_names + @builder = RBS::DefinitionBuilder.new(env: environment) + end + + # @rbs (actors: Array[Class]) -> String + def generate(actors:) + actors.uniq.sort_by { |actor| actor.name.to_s }.map { |actor| actor_signature(actor) }.join("\n") + end + + private + + # @rbs (untyped) -> String + def actor_signature(actor) + unless actor < Actor && actor.name + raise ArgumentError, "actor signatures require named SolidObjects::Actor subclasses" + end + + name = RBS::TypeName.parse("::#{actor.name}") + definition = @builder.build_instance(name) + if definition.type_params.any? + raise ArgumentError, "generic actor classes require application-owned dispatcher signatures" + end + messages = actor.definition.messages.keys.sort + methods = messages.map do |operation| + method = definition.methods[operation] + unless method && method.accessibility == :public + raise ArgumentError, "declare a public RBS signature for #{actor.name}##{operation}" + end + types = method.method_types.map { |type| staged_type(type, actor.name, operation).to_s } + " def #{operation}: #{types.join("\n | ")}" + end + callback_names = messages.flat_map { |operation| [ operation.inspect, operation.to_s.inspect ] } + callbacks = (callback_names + [ "nil" ]).join(" | ") + <<~RBS + class #{name} + interface _SolidObjectsOperations + #{methods.join("\n")} + def public_send: (Symbol | String, **untyped) -> nil + end + + def schedule: (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> #{name}::_SolidObjectsOperations + def transmit: () -> #{name}::_SolidObjectsOperations + def emit: (Symbol | String, ?on_success: (#{callbacks}), ?on_failure: (#{callbacks}), **untyped) -> nil + end + RBS + end + + # @rbs (untyped, String, Symbol) -> untyped + def staged_type(method_type, actor_name, operation) + function = method_type.type + if !function.is_a?(RBS::Types::Function) || method_type.block || + function.required_positionals.any? || function.optional_positionals.any? || + function.rest_positionals || function.trailing_positionals.any? + raise ArgumentError, "#{actor_name}##{operation} must declare keyword-only arguments without a block" + end + + method_type.update(type: function.update(return_type: RBS::Types::Bases::Nil.new(location: nil))) + end + end +end diff --git a/sig/generated/lib/solid_objects/actor_signatures.rbs b/sig/generated/lib/solid_objects/actor_signatures.rbs new file mode 100644 index 0000000..b7d5852 --- /dev/null +++ b/sig/generated/lib/solid_objects/actor_signatures.rbs @@ -0,0 +1,24 @@ +# Generated from lib/solid_objects/actor_signatures.rb with RBS::Inline + +module SolidObjects + class ActorSignatures + # @rbs (actors: Array[Class], signatures: Array[String]) -> String + def self.generate: (actors: Array[Class], signatures: Array[String]) -> String + + @builder: untyped + + # @rbs (signatures: Array[String]) -> void + def initialize: (signatures: Array[String]) -> void + + # @rbs (actors: Array[Class]) -> String + def generate: (actors: Array[Class]) -> String + + private + + # @rbs (untyped) -> String + def actor_signature: (untyped) -> String + + # @rbs (untyped, String, Symbol) -> untyped + def staged_type: (untyped, String, Symbol) -> untyped + end +end diff --git a/test/integration/actor_signatures_test.rb b/test/integration/actor_signatures_test.rb new file mode 100644 index 0000000..1841ef9 --- /dev/null +++ b/test/integration/actor_signatures_test.rb @@ -0,0 +1,141 @@ +# rbs_inline: enabled + +require "test_helper" +require "tmpdir" +require "fileutils" +require "open3" +require "rubygems/package" +require_relative "../types/actor_operations" + +class ActorSignaturesTest < ActiveSupport::TestCase + test "generates checked actor-specific signatures without changing dispatch" do + require "solid_objects/actor_signatures" + signatures = SolidObjects::ActorSignatures.generate( + actors: [ SignatureChat ], signatures: [ fixture_path("actor_operations.rbs") ] + ) + assert_equal signatures, SolidObjects::ActorSignatures.generate( + actors: [ SignatureChat ], signatures: [ fixture_path("actor_operations.rbs") ] + ) + assert_includes signatures, "recover_if_stuck" + refute_includes signatures, "def helper:" + refute_includes signatures, "def status:" + + Dir.mktmpdir("solid-objects-actor-signatures") do |directory| + assert_packaged_generator(directory, signatures) + FileUtils.mkdir_p(File.join(directory, "sig")) + FileUtils.cp(fixture_path("actor_operations.rbs"), File.join(directory, "sig/actors.rbs")) + File.write(File.join(directory, "sig/generated.rbs"), signatures) + consumer_path = File.join(directory, "consumer.rb") + consumer = File.read(fixture_path("actor_operations.rb")) + File.write(consumer_path, consumer) + File.write(File.join(directory, "Steepfile"), <<~RUBY) + target :consumer do + library "solid_objects" + signature "sig" + check "consumer.rb" + configure_code_diagnostics(Diagnostic::Ruby.strict) + end + RUBY + output, status = typecheck(directory) + assert status.success?, output + + generated_path = File.join(directory, "sig/generated.rbs") + File.write(generated_path, "") + output, status = typecheck(directory) + refute status.success?, output + assert_includes output, "Ruby::NoMethod" + assert_includes output, "recover_if_stuck" + File.write(generated_path, signatures) + + invalid_calls = <<~RUBY + schedule(at: Time.now).recover_if_stcuk(generation: 1) + schedule(at: Time.now).recover_if_stuck + schedule(at: Time.now).recover_if_stuck(generation: "wrong") + schedule(at: Time.now).recover_if_stuck(generation: 1, extra: true) + transmit.recover_if_stcuk(generation: 1) + transmit.recover_if_stuck(generation: "wrong") + schedule(at: Time.now).helper + schedule(at: Time.now).status + schedule(at: Time.now).generation + emit :run_model, on_failure: :fail_trun + emit :run_model, on_success: "finsih" + emit :run_model, on_failure: :helper + emit :run_model, on_success: :status + emit :run_model, on_failure: :schedule + RUBY + File.write(consumer_path, consumer.sub(" commit_action :global_action, generation: 1", invalid_calls)) + output, status = typecheck(directory) + refute status.success?, output + invalid_calls.lines.each do |line| + assert_includes output, line.strip + end + end + end + + test "requires explicit compatible application signatures" do + require "solid_objects/actor_signatures" + original = File.read(fixture_path("actor_operations.rbs")) + [ + original.sub(" def recover_if_stuck: (generation: Integer) -> Integer\n", ""), + original.sub("(generation: Integer) -> Integer", "(Integer) -> Integer"), + original.sub("(generation: Integer) -> Integer", "(generation: Integer) { () -> void } -> Integer"), + original.sub("class SignatureChat <", "class SignatureChat[Value] <") + ].each do |invalid| + Dir.mktmpdir("solid-objects-invalid-signatures") do |directory| + path = File.join(directory, "actors.rbs") + File.write(path, invalid) + assert_raises(ArgumentError) do + SolidObjects::ActorSignatures.generate(actors: [ SignatureChat ], signatures: [ path ]) + end + end + end + end + + test "refreshes removed operations and orders actors deterministically" do + require "solid_objects/actor_signatures" + arguments = { signatures: [ fixture_path("actor_operations.rbs") ] } + assert_equal SolidObjects::ActorSignatures.generate(actors: [ SignatureParent, SignatureChat ], **arguments), + SolidObjects::ActorSignatures.generate(actors: [ SignatureChat, SignatureParent ], **arguments) + + original_method = SignatureChat.instance_method(:optional) + SignatureChat.remove_method(:optional) + regenerated = SolidObjects::ActorSignatures.generate(actors: [ SignatureChat ], **arguments) + refute_includes regenerated, "def optional:" + refute_includes regenerated, ":optional" + ensure + SignatureChat.define_method(:optional, original_method) if original_method + end + + private + + def assert_packaged_generator(directory, expected) + artifact = File.join(directory, "solid_objects.gem") + specification = Gem::Specification.load(File.expand_path("../../solid_objects.gemspec", __dir__)) + Gem::DefaultUserInteraction.use_ui(Gem::SilentUI.new) do + Gem::Package.build(specification, false, false, artifact) + end + package = File.join(directory, "package") + Gem::Package.new(artifact).extract_files(package) + script = <<~RUBY + require #{File.join(package, "lib/solid_objects/actor_signatures.rb").inspect} + require #{fixture_path("actor_operations.rb").inspect} + print SolidObjects::ActorSignatures.generate( + actors: [SignatureChat], signatures: [#{fixture_path("actor_operations.rbs").inspect}] + ) + RUBY + output, errors, status = Open3.capture3(Gem.ruby, "-e", script) + assert status.success?, errors + assert_equal expected, output + end + + def fixture_path(name) + File.expand_path("../types/#{name}", __dir__) + end + + def typecheck(directory) + output, errors, status = Open3.capture3( + Gem.ruby, Gem.bin_path("steep", "steep"), "check", "--no-daemon", "-j", "1", chdir: directory + ) + [ output + errors, status ] + end +end diff --git a/test/integration/load_contract_test.rb b/test/integration/load_contract_test.rb index 6e77d44..680455c 100644 --- a/test/integration/load_contract_test.rb +++ b/test/integration/load_contract_test.rb @@ -13,6 +13,7 @@ class LoadContractTest < ActiveSupport::TestCase # else that stops being loaded is a role waiting to fail in production, so # this list is the place to argue that a role never reaches it. DEFERRED = { + "actor_signatures" => "opt-in development tooling that requires RBS", "caller_process" => "the caller path, required by SolidObjects.caller_process", "cli" => "loaded by exe/solid_objects, and pulls in thor", "client" => "the caller path, required by SolidObjects.client", diff --git a/test/types/actor_operations.rb b/test/types/actor_operations.rb new file mode 100644 index 0000000..337dec7 --- /dev/null +++ b/test/types/actor_operations.rb @@ -0,0 +1,68 @@ +# rbs_inline: enabled + +class SignatureParent < SolidObjects::Actor + def recover_if_stuck(generation:) + generation + end +end + +class SignatureChat < SignatureParent + actor_type "signature-chat" + attribute :generation, default: 1 + query(:status) { "running" } + message :block_callback do |result:| + # @type var result: String + result + end + + def start + schedule(at: Time.now, key: "watchdog").recover_if_stuck(generation: 1) + transmit.recover_if_stuck(generation: 1) + schedule(at: Time.now).finish + schedule(at: Time.now).optional + schedule(at: Time.now).optional(generation: 1) + schedule(at: Time.now).overloaded(value: "next") + schedule(at: Time.now).overloaded(value: 1) + transmit.echo(value: { "generation" => 1 }) + emit :run_model, on_success: "block_callback", on_failure: :fail_turn, generation: 1 + emit "another_effect", on_success: :finish, on_failure: "fail_turn" + commit_action :global_action, generation: 1 + nil + end + + def dynamic(operation:, callback:) + schedule(at: Time.now).public_send(operation, generation: 1) + public_send(:emit, :run_model, on_failure: callback, generation: 1) + nil + end + + def finish + nil + end + + def staged_result + schedule(at: Time.now).echo(value: 1) + end + + def echo(value:) + value + end + + def overloaded(value:) + value + end + + def optional(generation: 1) + generation + end + + def fail_turn(effect_id:, arguments:, error:) + error["message"] + end + + private + + def helper + nil + end +end diff --git a/test/types/actor_operations.rbs b/test/types/actor_operations.rbs new file mode 100644 index 0000000..c15610f --- /dev/null +++ b/test/types/actor_operations.rbs @@ -0,0 +1,18 @@ +class SignatureParent < SolidObjects::Actor + def recover_if_stuck: (generation: Integer) -> Integer +end + +class SignatureChat < SignatureParent + def start: () -> nil + def dynamic: (operation: String, callback: Symbol) -> nil + def finish: () -> nil + def staged_result: () -> nil + def echo: [Value] (value: Value) -> Value + def optional: (?generation: Integer) -> Integer + def overloaded: (value: Integer) -> Integer + | (value: String) -> String + def fail_turn: (effect_id: String, arguments: { "generation" => Integer }, error: { "message" => String }) -> String + def block_callback: (result: String) -> String + private + def helper: () -> nil +end From d29fccfdb7f3d5fdfb71f18a32cb14186bffe744 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 14 Sep 2026 19:47:19 -0700 Subject: [PATCH 2/2] chore: prepare version 0.14.7 Combine operation and payload typing release notes after rebasing onto main. Regenerate the lockfile from the gem version. --- CHANGELOG.md | 3 +-- Gemfile.lock | 4 ++-- lib/solid_objects/version.rb | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9465608..a7d6ae0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,10 @@ # Changelog -## Unreleased +## 0.14.7 - 2026-09-14 - 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. - - Add optional actor-specific RBS generation for staged schedule/transmit calls and effect callback names. Reuse application-declared operation argument types without changing Ruby dispatch, runtime validation, or global effect registries. diff --git a/Gemfile.lock b/Gemfile.lock index 8d500f3..d99d59a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - solid_objects (0.14.6) + solid_objects (0.14.7) actioncable (>= 7.1) actionpack (>= 7.1) actionview (>= 7.1) @@ -384,7 +384,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 - solid_objects (0.14.6) + solid_objects (0.14.7) sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b diff --git a/lib/solid_objects/version.rb b/lib/solid_objects/version.rb index 6796d1a..ddd4d5a 100644 --- a/lib/solid_objects/version.rb +++ b/lib/solid_objects/version.rb @@ -1,5 +1,5 @@ # rbs_inline: enabled module SolidObjects - VERSION = "0.14.6" + VERSION = "0.14.7" end