rails_tool_views 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+ class CreateRailsToolViews < ActiveRecord::Migration[7.2]
3
+ # Identifier columns are bounded to 150 bytes in RailsToolViews::Data, and the
4
+ # value is stored in a byte-limited column. This keeps every index key within
5
+ # utf8mb4's 3072-byte limit on MySQL/InnoDB: the widest index is the five
6
+ # presentation columns below (5 * 150 * 4 = 3000 bytes).
7
+ def change
8
+ create_table :rails_tool_views_surfaces, id: :string do |t|
9
+ t.string :actor_ref, null: false, limit: 150
10
+ t.string :tenant_ref, null: false, limit: 150
11
+ t.string :context_ref, limit: 150
12
+ t.string :component_key, null: false
13
+ t.string :component_digest, null: false
14
+ t.integer :contract_version, default: 1, null: false
15
+ t.json :props, null: false
16
+ t.json :state, null: false
17
+ t.integer :revision, default: 1, null: false
18
+ t.integer :lock_version, default: 0, null: false
19
+ t.timestamps
20
+ end
21
+ add_index :rails_tool_views_surfaces, [:tenant_ref, :actor_ref]
22
+ create_table :rails_tool_views_presentations do |t|
23
+ t.references :surface, type: :string, foreign_key: { to_table: :rails_tool_views_surfaces }, null: false
24
+ t.string :actor_ref, null: false, limit: 150
25
+ t.string :tenant_ref, null: false, limit: 150
26
+ t.string :adapter, null: false, limit: 150
27
+ t.string :call_id, null: false, limit: 150
28
+ t.string :context_ref, null: false, limit: 150
29
+ t.string :request_digest, null: false
30
+ t.json :result, null: false
31
+ t.timestamps
32
+ end
33
+ add_index :rails_tool_views_presentations, [:tenant_ref, :actor_ref, :adapter, :call_id, :context_ref], unique: true, name: "index_rails_tool_views_call_scope"
34
+ create_table :rails_tool_views_action_executions do |t|
35
+ t.references :surface, type: :string, foreign_key: { to_table: :rails_tool_views_surfaces }, null: false
36
+ t.string :actor_ref, null: false, limit: 150
37
+ t.string :action_key, null: false
38
+ t.string :idempotency_key, null: false, limit: 150
39
+ t.string :request_digest, null: false
40
+ t.integer :base_revision, null: false
41
+ t.json :result, null: false
42
+ t.timestamps
43
+ end
44
+ add_index :rails_tool_views_action_executions, [:surface_id, :idempotency_key], unique: true, name: "index_rails_tool_views_action_replay"
45
+ create_table :rails_tool_views_agent_interactions do |t|
46
+ t.references :surface, type: :string, foreign_key: { to_table: :rails_tool_views_surfaces }, null: false
47
+ t.references :action_execution, foreign_key: { to_table: :rails_tool_views_action_executions }, null: false, index: { unique: true, name: "index_rails_tool_views_interaction_execution" }
48
+ t.json :payload, null: false
49
+ t.string :status, null: false, default: "pending"
50
+ t.string :error_code
51
+ t.datetime :claimed_at
52
+ t.integer :attempts, null: false, default: 0
53
+ t.timestamps
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+ RailsToolViews.configure do |config|
3
+ # Fail closed until host authentication resolves stable actor AND tenant refs.
4
+ # config.scope_resolver = ->(controller) {
5
+ # RailsToolViews::Scope.new(actor: controller.current_user.id.to_s,
6
+ # tenant: controller.current_account.id.to_s)
7
+ # }
8
+ # Mounted controllers inherit this host controller's filters and helpers.
9
+ # config.parent_controller = "::ApplicationController"
10
+ end
11
+ # Register trusted components and explicit policies in Rails.application.config.to_prepare.
12
+ # Read the gem README for a complete ChoiceCard definition and optional adapter.
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+ # Only this explicitly required file loads RubyLLM.
3
+ require "rails_tool_views"
4
+ require "ruby_llm"
5
+
6
+ module RailsToolViews
7
+ module Adapters
8
+ module RubyLLM
9
+ # Optional RubyLLM tool exposing a scoped, approved component catalog.
10
+ class PresentUI < ::RubyLLM::Tool
11
+ description "Present one trusted interactive Rails component to the user."
12
+ # Returns the stable provider-facing tool name.
13
+ def self.tool_name
14
+ "present_ui"
15
+ end
16
+ attr_reader :references
17
+ # Captures an authorized catalog and server-owned correlation context.
18
+ def initialize(keys:, scope:, context:, adapter_key: "ruby_llm")
19
+ @scope = scope
20
+ @context = Data.identifier(context, code: "INVALID_CONTEXT")
21
+ @adapter_key = Data.identifier(adapter_key, code: "INVALID_CORRELATION")
22
+ @components = RailsToolViews.registry.catalog(keys: keys, scope: scope)
23
+ raise Error.new("EMPTY_CATALOG") if @components.empty?
24
+ @references = []
25
+ end
26
+ # Returns the bounded schema presented to the provider.
27
+ def parameters_schema
28
+ { "type" => "object", "additionalProperties" => false, "required" => %w[component props],
29
+ "properties" => { "component" => { "type" => "string", "enum" => @components.map(&:key) },
30
+ "props" => { "anyOf" => @components.map { |component| component.schema.json }.uniq } } }
31
+ end
32
+ # Presents a component using a real RubyLLM ToolCall; returns an acknowledgment or rejection.
33
+ def execute(component:, props:, tool_call: nil)
34
+ definition = @components.find { |item| item.key == component.to_s }
35
+ raise Error.new("NOT_ALLOWED", status: 403) unless definition
36
+ raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) unless RailsToolViews.registry.fetch(definition.key).digest == definition.digest
37
+ # A server-owned ToolCall is required for provider replay safety.
38
+ raise Error.new("MISSING_TOOL_CALL") unless tool_call.is_a?(::RubyLLM::ToolCall)
39
+ raise Error.new("INVALID_TOOL_CALL") unless tool_call.name == name
40
+ surface = RailsToolViews.present(definition.key, props: props, scope: @scope, context: @context,
41
+ correlation: { adapter: @adapter_key, call_id: tool_call.id, context: @context })
42
+ reference = { surface_id: surface.id, context_ref: @context }
43
+ @references << reference.freeze unless @references.include?(reference)
44
+ RailsToolViews.notify("adapter.tool_called", surface_id: surface.id, revision: surface.revision)
45
+ # The persisted presentation receipt preserves the original tool result.
46
+ # Match the receipt byte for byte; a non-binary collation must not return
47
+ # another call's acknowledgment.
48
+ presentation = RailsToolViews::Presentation.find_by(surface_id: surface.id, adapter: @adapter_key, call_id: tool_call.id, context_ref: @context)
49
+ unless presentation && presentation.adapter == @adapter_key && presentation.call_id == tool_call.id && presentation.context_ref == @context
50
+ raise Error.new("INVALID_CORRELATION")
51
+ end
52
+ presentation.result.symbolize_keys
53
+ rescue Error => error
54
+ error.to_h
55
+ end
56
+ end
57
+
58
+ # Host owns durable chat lookup and delivery deduplication. This adapter
59
+ # only starts a supported user turn; RubyLLM prevents pending-tool mixing.
60
+ class Continuation
61
+ # Accepts authorized chat resolution and durable host delivery deduplication.
62
+ def initialize(chat_resolver:, delivery:)
63
+ @chat_resolver, @delivery = chat_resolver, delivery
64
+ end
65
+ # Starts a user turn within the host delivery deduplication block.
66
+ def call(payload, delivery_key:)
67
+ @delivery.call(delivery_key) do
68
+ chat = @chat_resolver.call(payload.fetch("context_ref"))
69
+ chat.ask(JSON.generate(payload))
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,288 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require "digest"
4
+ require "securerandom"
5
+ require "json_schemer"
6
+ require_relative "version"
7
+
8
+ # Registers trusted Rails views and orchestrates persistent, authorized interactions.
9
+ module RailsToolViews
10
+ # A recoverable rejection with a stable code and HTTP status.
11
+ class Error < StandardError
12
+ attr_reader :code, :status
13
+ MESSAGES = {
14
+ "NOT_FOUND" => "This view is unavailable.", "NOT_ALLOWED" => "You do not have permission to use this view.",
15
+ "CONFLICT" => "This view changed. Refresh to continue.", "INVALID_ARGUMENTS" => "Check the submitted values and try again.",
16
+ "VALIDATION_FAILED" => "Check the highlighted fields and try again.",
17
+ "INCOMPATIBLE_COMPONENT" => "This view's contract changed. Ask the application owner to migrate or recreate it.",
18
+ "RENDER_FAILED" => "This view cannot be rendered. Your saved state is preserved.",
19
+ "AGENT_UNAVAILABLE" => "Your changes were saved, but the AI follow-up could not complete.",
20
+ "IDEMPOTENCY_CONFLICT" => "This request identifier was already used for a different request."
21
+ }.freeze
22
+ # Hosts own the wording of their codes: pass message:, or translate
23
+ # rails_tool_views.errors.<downcased code>. Built-in codes translate the same way.
24
+ def self.message_for(code)
25
+ fallback = MESSAGES.fetch(code) { "The request was rejected (#{code})." }
26
+ defined?(::I18n) ? ::I18n.t(code.downcase, scope: %i[rails_tool_views errors], default: fallback) : fallback
27
+ end
28
+ # Creates a rejection; message overrides the default or translated wording.
29
+ def initialize(code, status: 400, message: nil)
30
+ @code, @status = code, status
31
+ super(message || self.class.message_for(code))
32
+ end
33
+ # Returns a bounded rejection payload for the caller.
34
+ def to_h
35
+ { status: "rejected", code: code, message: message }
36
+ end
37
+ end
38
+
39
+ module Data # :nodoc:
40
+ module_function
41
+ def normalize(value, depth = 0)
42
+ raise Error.new("PAYLOAD_TOO_DEEP") if depth > RailsToolViews.configuration.max_depth
43
+ case value
44
+ when Hash
45
+ result = {}
46
+ value.each do |key, item|
47
+ raise Error.new("INVALID_JSON") unless key.is_a?(String) || key.is_a?(Symbol)
48
+ raise Error.new("INVALID_JSON") if result.key?(key.to_s)
49
+ result[key.to_s] = normalize(item, depth + 1)
50
+ end
51
+ result
52
+ when Array then value.map { |item| normalize(item, depth + 1) }
53
+ when String then value.dup
54
+ when Integer, TrueClass, FalseClass, NilClass then value
55
+ when Float
56
+ raise Error.new("INVALID_JSON") unless value.finite?
57
+ value
58
+ else raise Error.new("INVALID_JSON")
59
+ end
60
+ end
61
+ def bounded(value)
62
+ normalized = normalize(value)
63
+ raise Error.new("PAYLOAD_TOO_LARGE", status: 413) if JSON.generate(normalized).bytesize > RailsToolViews.configuration.max_payload_bytes
64
+ normalized
65
+ rescue JSON::GeneratorError, EncodingError
66
+ raise Error.new("INVALID_JSON")
67
+ end
68
+ def freeze_deep(value)
69
+ case value
70
+ when Hash then value.each { |key, item| key.freeze; freeze_deep(item) }
71
+ when Array then value.each { |item| freeze_deep(item) }
72
+ end
73
+ value.freeze
74
+ end
75
+ def canonical(value)
76
+ case value
77
+ when Hash then value.keys.sort.to_h { |key| [key, canonical(value[key])] }
78
+ when Array then value.map { |item| canonical(item) }
79
+ else value
80
+ end
81
+ end
82
+ def digest(value)
83
+ "sha256:#{Digest::SHA256.hexdigest(JSON.generate(canonical(bounded(value))))}"
84
+ end
85
+ # Actor, tenant, context, adapter, call and replay references are persisted
86
+ # in string columns that carry multi-column indexes. Cap them in bytes so a
87
+ # value can never overflow a byte-limited column and every unique key fits
88
+ # utf8mb4's 3072-byte index limit on MySQL/InnoDB. A byte cap also bounds the
89
+ # character count, which is what a varchar length actually limits.
90
+ IDENTIFIER_BYTES = 150
91
+ def reference(value, code: "INVALID_SCOPE", limit: 255)
92
+ raise Error.new(code) unless value.is_a?(String) && !value.strip.empty? && value.bytesize <= limit
93
+ value.dup.freeze
94
+ end
95
+ # A bounded reference for a stored, indexed identifier.
96
+ def identifier(value, code: "INVALID_SCOPE")
97
+ reference(value, code: code, limit: IDENTIFIER_BYTES)
98
+ end
99
+ end
100
+
101
+ # Host-owned validation feedback. Only return approved, schema-validated form
102
+ # values; rejected requests do not change Surface or domain persistence.
103
+ # A rejected form with field messages and explicitly safe retained values.
104
+ class ValidationError < Error
105
+ attr_reader :fields, :values
106
+ # Creates field feedback with only the values the host approves for redisplay.
107
+ def initialize(fields:, values: {}, message: nil)
108
+ @fields, @values = Data.bounded(fields), Data.bounded(values)
109
+ unless @fields.is_a?(Hash) && @values.is_a?(Hash) && @fields.all? { |key, text| key.match?(/\A[a-z][a-z0-9_]*\z/) && text.is_a?(String) && text.length <= 500 }
110
+ raise Error.new("INVALID_VALIDATION_FEEDBACK")
111
+ end
112
+ Data.freeze_deep(@fields)
113
+ Data.freeze_deep(@values)
114
+ super("VALIDATION_FAILED", status: 422, message: message)
115
+ end
116
+ # Returns a bounded rejection payload for the caller.
117
+ def to_h
118
+ super.merge(fields: fields, values: values)
119
+ end
120
+ end
121
+
122
+ # Validates a bounded, closed JSON contract.
123
+ class Schema
124
+ KEYWORDS = %w[type properties required additionalProperties items enum const minimum maximum minLength maxLength pattern minItems maxItems uniqueItems description title anyOf oneOf allOf].freeze
125
+ TYPES = %w[object array string number integer boolean null].freeze
126
+ COMBINATORS = %w[anyOf oneOf allOf].freeze
127
+ attr_reader :json
128
+ # Builds a closed schema; rejects unsupported or unbounded nested contracts.
129
+ def initialize(schema)
130
+ @json = Data.bounded(schema)
131
+ inspect_schema!(@json)
132
+ raise Error.new("INVALID_SCHEMA") unless @json["type"] == "object" && @json["additionalProperties"] == false
133
+ @validator = JSONSchemer.schema(@json, ref_resolver: ->(_uri) { raise Error.new("INVALID_SCHEMA") })
134
+ raise Error.new("INVALID_SCHEMA") unless @validator.valid_schema?
135
+ Data.freeze_deep(@json)
136
+ rescue JSONSchemer::UnknownRef, ArgumentError
137
+ raise Error.new("INVALID_SCHEMA")
138
+ end
139
+ # Returns normalized validated data, or raises Error with the supplied code.
140
+ def validate!(value, code: "INVALID_PROPS")
141
+ data = Data.bounded(value)
142
+ raise Error.new(code) unless @validator.valid?(data)
143
+ data
144
+ end
145
+ private
146
+ # Every subschema is walked, and every value position must say what it
147
+ # accepts: an untyped or empty schema admits any JSON, which would reopen a
148
+ # closed object from the inside. `bounded` is true only for a combinator
149
+ # branch whose enclosing schema already constrains the same value.
150
+ def inspect_schema!(schema, bounded: false) # :nodoc:
151
+ raise Error.new("INVALID_SCHEMA") unless schema.is_a?(Hash) && (schema.keys - KEYWORDS).empty?
152
+ types = Array(schema["type"])
153
+ combinators = COMBINATORS.select { |key| schema.key?(key) }
154
+ constrained = types.any? || schema.key?("enum") || schema.key?("const")
155
+ valid = (types - TYPES).empty? && (constrained || bounded || combinators.any?) &&
156
+ (!schema.key?("enum") || schema["enum"].is_a?(Array)) &&
157
+ (!schema.key?("additionalProperties") || schema["additionalProperties"] == false) &&
158
+ schema.fetch("properties", {}).is_a?(Hash) && schema.fetch("items", {}).is_a?(Hash) &&
159
+ (!types.include?("object") || schema["additionalProperties"] == false) &&
160
+ (!types.include?("array") || schema.key?("items")) &&
161
+ combinators.all? { |key| schema[key].is_a?(Array) && schema[key].any? }
162
+ raise Error.new("INVALID_SCHEMA") unless valid
163
+ schema.fetch("properties", {}).each_value { |child| inspect_schema!(child) }
164
+ inspect_schema!(schema["items"]) if schema.key?("items")
165
+ combinators.each { |key| schema[key].each { |child| inspect_schema!(child, bounded: bounded || constrained) } }
166
+ end
167
+ end
168
+
169
+ # Immutable actor and tenant references resolved by the host.
170
+ class Scope
171
+ attr_reader :actor, :tenant
172
+ # Creates frozen, nonempty string references for an authenticated identity.
173
+ def initialize(actor:, tenant:)
174
+ @actor, @tenant = Data.identifier(actor), Data.identifier(tenant)
175
+ freeze
176
+ end
177
+ end
178
+
179
+ # Policy and handler context populated by the runtime.
180
+ Access = Struct.new(:actor, :tenant, :operation, :props, :surface, :action, :arguments, :context_ref, keyword_init: true)
181
+ # Handler result containing the next UI state and an optional agent flag.
182
+ Transition = Struct.new(:state, :agent, keyword_init: true)
183
+ Action = Struct.new(:key, :schema, :policy, :handler, keyword_init: true)
184
+
185
+ # An immutable renderer, schema, version, and authorization contract.
186
+ class Component
187
+ # A ViewComponent opts into these by naming them as initializer keywords.
188
+ CONTEXT = %i[surface state error].freeze
189
+ attr_reader :key, :description, :schema, :partial, :component, :policy, :actions, :expose_to, :digest, :version, :context
190
+ # Builds an immutable component with exactly one trusted renderer.
191
+ def initialize(key, description:, schema:, partial: nil, component: nil, policy: nil, actions: {}, expose_to: [], version: "1")
192
+ @key = key.to_s.freeze
193
+ raise Error.new("INVALID_COMPONENT") unless @key.match?(/\A[a-z][a-z0-9_]{0,63}\z/)
194
+ @description = Data.reference(description, code: "INVALID_COMPONENT")
195
+ @version = Data.reference(version.to_s, code: "INVALID_COMPONENT")
196
+ @schema = Schema.new(schema)
197
+ if (!!partial == !!component) || (partial && (!partial.is_a?(String) || !partial.match?(/\A[a-zA-Z0-9_\/]+\z/))) || (component && (!component.is_a?(Class) || component.name.nil?))
198
+ raise Error.new("INVALID_RENDERER")
199
+ end
200
+ @partial, @component, @policy = partial&.dup&.freeze, component, policy
201
+ keywords = component ? component.instance_method(:initialize).parameters.filter_map { |kind, name| name if %i[key keyreq].include?(kind) } : []
202
+ @context = (CONTEXT & keywords).freeze
203
+ raise Error.new("INVALID_COMPONENT") if (@context.map(&:to_s) & @schema.json.fetch("properties", {}).keys).any?
204
+ @expose_to = expose_to.map(&:to_sym).freeze
205
+ @actions = actions.to_h do |name, definition|
206
+ name = name.to_s.freeze
207
+ raise Error.new("INVALID_ACTION") unless name.match?(/\A[a-z][a-z0-9_]{0,63}\z/) && (definition.keys - %i[schema policy handler]).empty? && definition[:handler].respond_to?(:call)
208
+ [name, Action.new(key: name, schema: Schema.new(definition.fetch(:schema)), policy: definition[:policy], handler: definition[:handler]).freeze]
209
+ end.freeze
210
+ @digest = Data.digest(key: @key, version: @version, schema: @schema.json, renderer: @partial || @component.name,
211
+ actions: @actions.transform_values { |action| action.schema.json }, contract_version: 1).freeze
212
+ freeze
213
+ end
214
+ def authorize!(access) # :nodoc:
215
+ raise Error.new("NOT_ALLOWED", status: 403) unless policy&.call(access) == true
216
+ end
217
+ end
218
+
219
+ # Stores explicit component definitions and filters discoverable catalogs.
220
+ class Registry
221
+ # Creates an empty, process-local registry.
222
+ def initialize
223
+ @components = {}
224
+ @mutex = Mutex.new
225
+ end
226
+ # Registers a component; duplicate keys raise DUPLICATE_COMPONENT.
227
+ def register(key, **options)
228
+ component = Component.new(key, **options)
229
+ @mutex.synchronize do
230
+ raise Error.new("DUPLICATE_COMPONENT") if @components.key?(component.key)
231
+ @components[component.key] = component
232
+ end
233
+ component
234
+ end
235
+ # Returns the named component, or raises UNKNOWN_COMPONENT.
236
+ def fetch(key)
237
+ @components.fetch(key.to_s) { raise Error.new("UNKNOWN_COMPONENT", status: 404) }
238
+ end
239
+ # Returns explicitly exposed components allowed by discovery policy for scope.
240
+ def catalog(keys:, scope:, exposure: :agent)
241
+ raise Error.new("INVALID_SCOPE", status: 403) unless scope.is_a?(Scope)
242
+ raise Error.new("CATALOG_TOO_LARGE") unless keys.is_a?(Array) && keys.length.between?(1, RailsToolViews.configuration.max_catalog_components) && keys.map(&:to_s).uniq.length == keys.length
243
+ keys.map { |key| fetch(key) }.select do |component|
244
+ component.expose_to.include?(exposure) && component.policy&.call(Access.new(actor: scope.actor, tenant: scope.tenant, operation: :discover, props: {}, context_ref: nil)) == true
245
+ end.freeze
246
+ end
247
+ end
248
+
249
+ # Process-local host hooks, request limits, and delivery settings.
250
+ class Configuration
251
+ attr_accessor :scope_resolver, :agent_adapter, :max_payload_bytes, :max_depth, :max_catalog_components, :max_actions_per_minute, :auto_deliver,
252
+ :parent_controller, :delivery_lease
253
+ # Initializes conservative request limits with no host identity or agent adapter.
254
+ def initialize
255
+ @max_payload_bytes, @max_depth, @max_catalog_components, @max_actions_per_minute = 32_768, 16, 20, 60
256
+ @auto_deliver = true
257
+ # Name the host controller that carries authentication; seconds before an unfinished delivery may be reclaimed.
258
+ @parent_controller, @delivery_lease = "::ApplicationController", 300
259
+ end
260
+ end
261
+
262
+ class << self
263
+ # Returns the process-local component registry.
264
+ def registry
265
+ @registry ||= Registry.new
266
+ end
267
+ # Returns the process-local configuration.
268
+ def configuration
269
+ @configuration ||= Configuration.new
270
+ end
271
+ # Yields configuration to the host initializer.
272
+ def configure
273
+ yield configuration
274
+ end
275
+ # Registers a component; duplicate keys raise DUPLICATE_COMPONENT.
276
+ def register(key, **options)
277
+ registry.register(key, **options)
278
+ end
279
+ # Clears definitions while retaining configuration; use in Rails reload hooks.
280
+ def reset_registry!
281
+ @registry = Registry.new
282
+ end
283
+ # Clears definitions and configuration; intended for isolated tests.
284
+ def reset!
285
+ @registry, @configuration = Registry.new, Configuration.new
286
+ end
287
+ end
288
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ class Engine < ::Rails::Engine
4
+ isolate_namespace RailsToolViews
5
+ # Log redaction lives on this engine's own controller and models, not in the
6
+ # host-wide filter list, where names like `state` would redact host data too.
7
+ initializer "rails_tool_views.helpers" do
8
+ ActiveSupport.on_load(:action_controller_base) { helper RailsToolViews::SurfacesHelper }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+ module RailsToolViews
3
+ class << self
4
+ # Validates and authorizes props, then returns a persisted Surface. Correlation replays the original presentation.
5
+ def present(key, props:, scope:, context: nil, correlation: nil)
6
+ check_scope!(scope)
7
+ component = registry.fetch(key)
8
+ props = component.schema.validate!(props)
9
+ context = Data.identifier(context, code: "INVALID_CONTEXT") if context
10
+ component.authorize!(access(scope, :present, props: props, context_ref: context))
11
+ correlation = normalize_correlation(correlation) if correlation
12
+ request_digest = Data.digest(component: component.key, digest: component.digest, props: props, context: context)
13
+ lookup = correlation&.merge(actor_ref: scope.actor, tenant_ref: scope.tenant)
14
+ if lookup && (prior = correlated_presentation(lookup))
15
+ return correlated_surface(prior, request_digest, scope)
16
+ end
17
+ surface = nil
18
+ Surface.transaction(requires_new: true) do
19
+ surface = Surface.create!(actor_ref: scope.actor, tenant_ref: scope.tenant, context_ref: context,
20
+ component_key: component.key, component_digest: component.digest, props: props, state: {})
21
+ if lookup
22
+ Presentation.create!(**lookup, surface: surface, request_digest: request_digest,
23
+ result: { status: "presented", surface_id: surface.id, revision: surface.revision })
24
+ end
25
+ end
26
+ notify("presentation.accepted", surface_id: surface.id, component: component.key, revision: surface.revision)
27
+ surface
28
+ rescue ActiveRecord::RecordNotUnique
29
+ prior = lookup && correlated_presentation(lookup)
30
+ raise unless prior
31
+ correlated_surface(prior, request_digest, scope)
32
+ rescue Error => error
33
+ notify("presentation.rejected", code: error.code)
34
+ raise
35
+ end
36
+
37
+ # Returns a compatible Surface after checking scope and current read policy.
38
+ def restore(id, scope:)
39
+ check_scope!(scope)
40
+ surface = Surface.find_by(id: id, actor_ref: scope.actor, tenant_ref: scope.tenant)
41
+ # A database collation that is not binary (for example a case-insensitive
42
+ # MySQL or PostgreSQL collation) can match a stored actor, tenant or id
43
+ # that merely compares equal to the requested one. Only a byte-exact
44
+ # match may stand in for this scope's Surface, or a lookup could disclose
45
+ # another actor's or tenant's record.
46
+ raise Error.new("NOT_FOUND", status: 404) unless surface && exact_match?(surface, id: id.to_s, actor_ref: scope.actor, tenant_ref: scope.tenant)
47
+ component = compatible_component!(surface)
48
+ component.authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
49
+ surface
50
+ end
51
+
52
+ # Commits an authorized action and returns its receipt. Matching retries replay without executing the handler.
53
+ def dispatch(id, scope:, action:, arguments:, base_revision:, idempotency_key:, source_component: nil, target: nil)
54
+ surface = restore(id, scope: scope)
55
+ raise Error.new("INVALID_SOURCE") if source_component && source_component.to_s != surface.component_key
56
+ raise Error.new("INVALID_TARGET") if target && target != surface.dom_id
57
+ component = compatible_component!(surface)
58
+ definition = component.actions.fetch(action.to_s) { raise Error.new("UNKNOWN_ACTION") }
59
+ arguments = definition.schema.validate!(arguments, code: "INVALID_ARGUMENTS")
60
+ raise Error.new("INVALID_REVISION") unless base_revision.is_a?(Integer) && base_revision.positive?
61
+ idempotency_key = Data.identifier(idempotency_key, code: "INVALID_IDEMPOTENCY_KEY")
62
+ request_digest = Data.digest(action: definition.key, arguments: arguments, base_revision: base_revision)
63
+ result = nil
64
+ surface.with_lock do
65
+ compatible_component!(surface).authorize!(access(scope, :read, surface: surface, props: surface.props, context_ref: surface.context_ref))
66
+ request = access(scope, :action, surface: surface, props: surface.props, action: definition.key,
67
+ arguments: arguments, context_ref: surface.context_ref)
68
+ raise Error.new("NOT_ALLOWED", status: 403) unless definition.policy&.call(request) == true
69
+ prior = ActionExecution.find_by(surface_id: surface.id, idempotency_key: idempotency_key)
70
+ if prior
71
+ # A non-binary collation could match a different key; only replay the
72
+ # exact key, and keep the digest check as the second gate.
73
+ raise Error.new("IDEMPOTENCY_CONFLICT", status: 409) unless prior.idempotency_key == idempotency_key && prior.request_digest == request_digest
74
+ result = prior.result.symbolize_keys
75
+ next
76
+ end
77
+ raise Error.new("CONFLICT", status: 409) unless surface.revision == base_revision
78
+ if surface.action_executions.where("created_at >= ?", 1.minute.ago).count >= configuration.max_actions_per_minute
79
+ raise Error.new("RATE_LIMITED", status: 429)
80
+ end
81
+ transition = definition.handler.call(request)
82
+ raise Error.new("INVALID_TRANSITION", status: 422) unless transition.is_a?(Transition) && transition.state.is_a?(Hash)
83
+ state = Data.bounded(transition.state)
84
+ surface.update!(state: state, revision: base_revision + 1)
85
+ result = { status: "completed", surface_id: surface.id, revision: surface.revision }
86
+ execution = ActionExecution.create!(surface: surface, actor_ref: scope.actor, action_key: definition.key,
87
+ base_revision: base_revision, idempotency_key: idempotency_key, request_digest: request_digest, result: result)
88
+ if transition.agent
89
+ interaction = AgentInteraction.create!(surface: surface, action_execution: execution,
90
+ payload: { type: "agent_interaction", surface_id: surface.id, action: definition.key, values: arguments,
91
+ context_ref: surface.context_ref, revision: surface.revision }, status: configuration.agent_adapter ? "pending" : "skipped")
92
+ result[:interaction_id] = interaction.id
93
+ execution.update!(result: result)
94
+ end
95
+ end
96
+ notify("action.executed", surface_id: surface.id, revision: result[:revision], action: definition.key)
97
+ result
98
+ rescue ActiveRecord::StaleObjectError, ActiveRecord::TransactionRollbackError, ActiveRecord::LockWaitTimeout
99
+ # The database rolled the whole action back (deadlock, serialization failure,
100
+ # lock timeout); retrying with the same key is safe.
101
+ raise Error.new("CONFLICT", status: 409)
102
+ rescue ActiveRecord::StatementInvalid => error
103
+ raise Error.new("CONFLICT", status: 409) if error.cause.class.name.include?("Busy") || error.cause.class.name.include?("Locked")
104
+ raise
105
+ rescue Error => error
106
+ notify(error.code == "CONFLICT" ? "action.conflict" : "action.rejected", code: error.code)
107
+ raise
108
+ end
109
+
110
+ # The adapter must deduplicate by delivery_key. A crash after remote acceptance
111
+ # can leave a lease in delivering state; retry is explicit and keeps the key.
112
+ # Delivers a saved interaction after authorization; retry_failed explicitly retries failed delivery.
113
+ def deliver(id, scope:, adapter: configuration.agent_adapter, retry_failed: false)
114
+ interaction = AgentInteraction.find_by(id: id)
115
+ raise Error.new("NOT_FOUND", status: 404) unless interaction
116
+ surface = restore(interaction.surface_id, scope: scope)
117
+ definition = compatible_component!(surface).actions.fetch(interaction.payload.fetch("action")) { raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) }
118
+ arguments = definition.schema.validate!(interaction.payload.fetch("values"), code: "INVALID_ARGUMENTS")
119
+ request = access(scope, :action, surface: surface, props: surface.props, action: definition.key, arguments: arguments, context_ref: surface.context_ref)
120
+ raise Error.new("NOT_ALLOWED", status: 403) unless definition.policy&.call(request) == true
121
+ claimed = false
122
+ interaction.with_lock do
123
+ if %w[completed skipped].include?(interaction.status)
124
+ next
125
+ end
126
+ if interaction.status == "delivering" && interaction.claimed_at && interaction.claimed_at > Time.current - configuration.delivery_lease
127
+ next
128
+ end
129
+ next if interaction.status == "failed" && !retry_failed
130
+ if adapter.nil?
131
+ interaction.update!(status: "skipped")
132
+ next
133
+ end
134
+ interaction.update!(status: "delivering", claimed_at: Time.current, attempts: interaction.attempts + 1, error_code: nil)
135
+ claimed = true
136
+ end
137
+ return interaction.reload unless claimed
138
+ begin
139
+ adapter.call(Data.freeze_deep(Data.normalize(interaction.payload)), delivery_key: interaction.delivery_key)
140
+ interaction.update!(status: "completed", claimed_at: nil)
141
+ notify("agent_returned", surface_id: interaction.surface_id, interaction_id: interaction.id)
142
+ rescue StandardError => error
143
+ interaction.update!(status: "failed", error_code: "AGENT_UNAVAILABLE", claimed_at: nil)
144
+ report(error, "agent_failed", surface_id: interaction.surface_id, interaction_id: interaction.id)
145
+ end
146
+ interaction.reload
147
+ end
148
+
149
+ # Renders the trusted component after a fresh restore and authorization check.
150
+ def render(surface, view_context:, scope:, error: nil)
151
+ # Never trust an already-loaded object as an authorization decision.
152
+ surface = restore(surface.id, scope: scope)
153
+ component = compatible_component!(surface)
154
+ view_context.render(partial: "rails_tool_views/surfaces/surface", formats: [:html], locals: { surface: surface, definition: component, error: error })
155
+ rescue Error => failure
156
+ view_context.render(partial: "rails_tool_views/surfaces/error", formats: [:html], locals: { error: failure })
157
+ end
158
+
159
+ def compatible_component!(surface) # :nodoc:
160
+ component = registry.fetch(surface.component_key)
161
+ raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) unless surface.contract_version == 1 && component.digest == surface.component_digest
162
+ component.schema.validate!(surface.props)
163
+ component
164
+ rescue Error => error
165
+ raise Error.new("INCOMPATIBLE_COMPONENT", status: 422) if %w[UNKNOWN_COMPONENT INVALID_PROPS].include?(error.code)
166
+ raise
167
+ end
168
+
169
+ # The mounted controller needs CSRF protection, helpers and HTML rendering.
170
+ def parent_controller # :nodoc:
171
+ parent = configuration.parent_controller.to_s.constantize
172
+ return parent if parent <= ::ActionController::Base
173
+ raise ArgumentError, "RailsToolViews parent_controller must inherit from ActionController::Base; #{parent} does not"
174
+ end
175
+
176
+ def access(scope, operation, **attributes) # :nodoc:
177
+ Access.new(actor: scope.actor, tenant: scope.tenant, operation: operation, **attributes).freeze
178
+ end
179
+
180
+ def notify(event, **payload) # :nodoc:
181
+ ActiveSupport::Notifications.instrument("#{event}.rails_tool_views", payload)
182
+ end
183
+
184
+ # Host renderer or adapter code failed behind a rescue. Surface it the way
185
+ # Rails surfaces handled errors; the event itself stays metadata-only.
186
+ def report(error, event, **payload) # :nodoc:
187
+ notify(event, **payload, error_class: error.class.name)
188
+ Rails.error.report(error, handled: true, severity: :error, context: payload, source: "rails_tool_views")
189
+ Rails.logger&.error("RailsToolViews #{event} handled #{error.class} (#{error.message}) - #{error.backtrace&.first}")
190
+ end
191
+
192
+ private
193
+ def check_scope!(scope)
194
+ raise Error.new("INVALID_SCOPE", status: 403) unless scope.is_a?(Scope)
195
+ end
196
+ # True only when every attribute equals the requested value byte for byte,
197
+ # regardless of the database collation used to find the row.
198
+ def exact_match?(record, **expected)
199
+ expected.all? { |attribute, value| record.public_send(attribute) == value }
200
+ end
201
+ # Returns the durable presentation for this exact correlation, or nil. A row
202
+ # that a non-binary collation matched but that is not byte-identical is a
203
+ # conflict, never a replay of another correlation.
204
+ def correlated_presentation(lookup)
205
+ prior = Presentation.find_by(lookup)
206
+ return nil unless prior
207
+ return prior if exact_match?(prior, **lookup)
208
+ raise Error.new("IDEMPOTENCY_CONFLICT", status: 409)
209
+ end
210
+ def normalize_correlation(value)
211
+ raise Error.new("INVALID_CORRELATION") unless value.is_a?(Hash) && (value.keys - %i[adapter call_id context]).empty? && value.keys.sort == %i[adapter call_id context].sort
212
+ { adapter: Data.identifier(value[:adapter], code: "INVALID_CORRELATION"),
213
+ call_id: Data.identifier(value[:call_id], code: "INVALID_CORRELATION"),
214
+ context_ref: Data.identifier(value[:context], code: "INVALID_CORRELATION") }
215
+ end
216
+ def correlated_surface(prior, digest, scope)
217
+ raise Error.new("IDEMPOTENCY_CONFLICT", status: 409) unless prior.request_digest == digest
218
+ restore(prior.surface_id, scope: scope)
219
+ end
220
+ end
221
+ end