rails_ai_admin 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.
Files changed (193) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +458 -0
  3. data/LICENSE +21 -0
  4. data/README.md +292 -0
  5. data/SECURITY.md +14 -0
  6. data/THIRD_PARTY_NOTICES.md +19 -0
  7. data/app/assets/builds/rails_ai_admin.LICENSE.txt +55 -0
  8. data/app/assets/builds/rails_ai_admin.css +345 -0
  9. data/app/assets/builds/rails_ai_admin.js +33 -0
  10. data/app/controllers/rails_ai_admin/application_controller.rb +255 -0
  11. data/app/controllers/rails_ai_admin/assistant_controller.rb +164 -0
  12. data/app/controllers/rails_ai_admin/audit_controller.rb +16 -0
  13. data/app/controllers/rails_ai_admin/collection_jobs_controller.rb +33 -0
  14. data/app/controllers/rails_ai_admin/collection_operations_controller.rb +79 -0
  15. data/app/controllers/rails_ai_admin/custom_controller.rb +26 -0
  16. data/app/controllers/rails_ai_admin/dashboard_controller.rb +17 -0
  17. data/app/controllers/rails_ai_admin/exports_controller.rb +31 -0
  18. data/app/controllers/rails_ai_admin/files_controller.rb +37 -0
  19. data/app/controllers/rails_ai_admin/imports_controller.rb +41 -0
  20. data/app/controllers/rails_ai_admin/mcp_controller.rb +81 -0
  21. data/app/controllers/rails_ai_admin/mcp_tokens_controller.rb +45 -0
  22. data/app/controllers/rails_ai_admin/operations_controller.rb +105 -0
  23. data/app/controllers/rails_ai_admin/references_controller.rb +16 -0
  24. data/app/controllers/rails_ai_admin/resources_controller.rb +290 -0
  25. data/app/controllers/rails_ai_admin/search_controller.rb +39 -0
  26. data/app/controllers/rails_ai_admin/selections_controller.rb +95 -0
  27. data/app/controllers/rails_ai_admin/surfaces_controller.rb +122 -0
  28. data/app/helpers/rails_ai_admin/dashboard_helper.rb +28 -0
  29. data/app/helpers/rails_ai_admin/fields_helper.rb +242 -0
  30. data/app/helpers/rails_ai_admin/filters_helper.rb +40 -0
  31. data/app/helpers/rails_ai_admin/history_helper.rb +36 -0
  32. data/app/helpers/rails_ai_admin/navigation_helper.rb +54 -0
  33. data/app/helpers/rails_ai_admin/tool_views_helper.rb +48 -0
  34. data/app/jobs/rails_ai_admin/assistant_turn_job.rb +15 -0
  35. data/app/jobs/rails_ai_admin/collections/execution_job.rb +12 -0
  36. data/app/jobs/rails_ai_admin/outbox/delivery_job.rb +13 -0
  37. data/app/models/rails_ai_admin/application_record.rb +8 -0
  38. data/app/models/rails_ai_admin/audit_event.rb +12 -0
  39. data/app/models/rails_ai_admin/batch_item.rb +8 -0
  40. data/app/models/rails_ai_admin/operation_run.rb +20 -0
  41. data/app/models/rails_ai_admin/selection.rb +9 -0
  42. data/app/models/rails_ai_admin/selection_item.rb +9 -0
  43. data/app/models/rails_ai_admin/work_item.rb +18 -0
  44. data/app/views/layouts/rails_ai_admin/application.html.erb +49 -0
  45. data/app/views/layouts/rails_ai_admin/frame.html.erb +5 -0
  46. data/app/views/layouts/rails_ai_admin/status.html.erb +13 -0
  47. data/app/views/rails_ai_admin/assistant/_ask.html.erb +16 -0
  48. data/app/views/rails_ai_admin/assistant/_transcript.html.erb +45 -0
  49. data/app/views/rails_ai_admin/assistant/frame.html.erb +1 -0
  50. data/app/views/rails_ai_admin/assistant/index.html.erb +14 -0
  51. data/app/views/rails_ai_admin/assistant/show.html.erb +44 -0
  52. data/app/views/rails_ai_admin/audit/index.html.erb +40 -0
  53. data/app/views/rails_ai_admin/collection_jobs/index.html.erb +5 -0
  54. data/app/views/rails_ai_admin/collection_jobs/show.html.erb +24 -0
  55. data/app/views/rails_ai_admin/collection_operations/confirmation.html.erb +25 -0
  56. data/app/views/rails_ai_admin/collection_operations/failure.html.erb +10 -0
  57. data/app/views/rails_ai_admin/collection_operations/new.html.erb +11 -0
  58. data/app/views/rails_ai_admin/dashboard/index.html.erb +33 -0
  59. data/app/views/rails_ai_admin/fields/_index.html.erb +1 -0
  60. data/app/views/rails_ai_admin/fields/_input.html.erb +21 -0
  61. data/app/views/rails_ai_admin/fields/_reference.html.erb +1 -0
  62. data/app/views/rails_ai_admin/fields/_reference_picker.html.erb +14 -0
  63. data/app/views/rails_ai_admin/fields/_row.html.erb +27 -0
  64. data/app/views/rails_ai_admin/fields/_show.html.erb +1 -0
  65. data/app/views/rails_ai_admin/imports/new.html.erb +34 -0
  66. data/app/views/rails_ai_admin/mcp_tokens/index.html.erb +33 -0
  67. data/app/views/rails_ai_admin/operations/confirmation.html.erb +32 -0
  68. data/app/views/rails_ai_admin/operations/failure.html.erb +5 -0
  69. data/app/views/rails_ai_admin/operations/form.html.erb +13 -0
  70. data/app/views/rails_ai_admin/resources/_associations.html.erb +12 -0
  71. data/app/views/rails_ai_admin/resources/_errors.html.erb +14 -0
  72. data/app/views/rails_ai_admin/resources/_recovery.html.erb +5 -0
  73. data/app/views/rails_ai_admin/resources/confirm_destroy.html.erb +11 -0
  74. data/app/views/rails_ai_admin/resources/edit.html.erb +1 -0
  75. data/app/views/rails_ai_admin/resources/form.html.erb +16 -0
  76. data/app/views/rails_ai_admin/resources/history.html.erb +27 -0
  77. data/app/views/rails_ai_admin/resources/index.html.erb +88 -0
  78. data/app/views/rails_ai_admin/resources/new.html.erb +1 -0
  79. data/app/views/rails_ai_admin/resources/show.html.erb +12 -0
  80. data/app/views/rails_ai_admin/search/index.html.erb +20 -0
  81. data/app/views/rails_ai_admin/selections/confirm_destroy.html.erb +13 -0
  82. data/app/views/rails_ai_admin/selections/destroyed.html.erb +15 -0
  83. data/app/views/rails_ai_admin/selections/show.html.erb +19 -0
  84. data/app/views/rails_ai_admin/shared/_input_fields.html.erb +23 -0
  85. data/app/views/rails_ai_admin/shared/_navigation.html.erb +8 -0
  86. data/app/views/rails_ai_admin/shared/_session.html.erb +5 -0
  87. data/app/views/rails_ai_admin/statuses/show.html.erb +4 -0
  88. data/app/views/rails_ai_admin/surfaces/_operation_review.html.erb +43 -0
  89. data/app/views/rails_ai_admin/surfaces/show.html.erb +4 -0
  90. data/config/locales/en.yml +549 -0
  91. data/config/routes.rb +64 -0
  92. data/db/assistant_migrate/20261103000001_create_rails_ai_admin_assistant.rb +138 -0
  93. data/db/assistant_migrate/20261112000001_upgrade_rails_ai_admin_assistant_to_ruby_llm_2_2.rb +102 -0
  94. data/db/assistant_migrate/20261114000001_add_streaming_and_access_tokens_to_rails_ai_admin_assistant.rb +29 -0
  95. data/db/migrate/20261029000001_create_rails_ai_admin_execution_tables.rb +35 -0
  96. data/db/migrate/20261029000002_add_callback_warning_to_rails_ai_admin_runs.rb +7 -0
  97. data/db/migrate/20261030000003_create_rails_ai_admin_work_items.rb +26 -0
  98. data/db/migrate/20261030000004_create_rails_ai_admin_selections.rb +28 -0
  99. data/db/migrate/20261030000005_create_rails_ai_admin_collection_runs.rb +41 -0
  100. data/db/migrate/20261113000001_add_record_index_to_rails_ai_admin_audit_events.rb +8 -0
  101. data/lib/generators/rails_ai_admin/assistant/assistant_generator.rb +25 -0
  102. data/lib/generators/rails_ai_admin/assistant/templates/admin_assistant.rb +18 -0
  103. data/lib/generators/rails_ai_admin/engine_migrations.rb +42 -0
  104. data/lib/generators/rails_ai_admin/install/engine_mounts.rb +19 -0
  105. data/lib/generators/rails_ai_admin/install/install_generator.rb +53 -0
  106. data/lib/generators/rails_ai_admin/install/templates/access_test.rb +17 -0
  107. data/lib/generators/rails_ai_admin/install/templates/admin_context.rb +25 -0
  108. data/lib/generators/rails_ai_admin/install/templates/admin_policy_adapter.rb +28 -0
  109. data/lib/generators/rails_ai_admin/install/templates/example_admin.rb +10 -0
  110. data/lib/generators/rails_ai_admin/install/templates/initializer.rb +7 -0
  111. data/lib/generators/rails_ai_admin/override/override_generator.rb +46 -0
  112. data/lib/generators/rails_ai_admin/resource/resource_generator.rb +88 -0
  113. data/lib/rails_ai_admin/assistant/continuation.rb +33 -0
  114. data/lib/rails_ai_admin/assistant/coordinator.rb +306 -0
  115. data/lib/rails_ai_admin/assistant/records.rb +71 -0
  116. data/lib/rails_ai_admin/assistant/runtime.rb +49 -0
  117. data/lib/rails_ai_admin/assistant/tools.rb +477 -0
  118. data/lib/rails_ai_admin/assistant/transcript.rb +140 -0
  119. data/lib/rails_ai_admin/assistant.rb +50 -0
  120. data/lib/rails_ai_admin/authorization/access.rb +296 -0
  121. data/lib/rails_ai_admin/authorization/adapter.rb +92 -0
  122. data/lib/rails_ai_admin/authorization/pundit_adapter.rb +195 -0
  123. data/lib/rails_ai_admin/collections/claim.rb +91 -0
  124. data/lib/rails_ai_admin/collections/contract.rb +113 -0
  125. data/lib/rails_ai_admin/collections/dispatcher.rb +126 -0
  126. data/lib/rails_ai_admin/collections/queries.rb +79 -0
  127. data/lib/rails_ai_admin/collections/result.rb +15 -0
  128. data/lib/rails_ai_admin/collections/runner.rb +208 -0
  129. data/lib/rails_ai_admin/configuration.rb +178 -0
  130. data/lib/rails_ai_admin/context.rb +29 -0
  131. data/lib/rails_ai_admin/doctor.rb +301 -0
  132. data/lib/rails_ai_admin/engine.rb +24 -0
  133. data/lib/rails_ai_admin/error.rb +16 -0
  134. data/lib/rails_ai_admin/field.rb +625 -0
  135. data/lib/rails_ai_admin/fields/association.rb +188 -0
  136. data/lib/rails_ai_admin/fields/computation.rb +43 -0
  137. data/lib/rails_ai_admin/fields/dependency_graph.rb +45 -0
  138. data/lib/rails_ai_admin/fields/file_value.rb +23 -0
  139. data/lib/rails_ai_admin/fields/hook_signatures.rb +18 -0
  140. data/lib/rails_ai_admin/fields/projection.rb +78 -0
  141. data/lib/rails_ai_admin/fields/registry.rb +48 -0
  142. data/lib/rails_ai_admin/fields/scalar_schema.rb +49 -0
  143. data/lib/rails_ai_admin/fields/type.rb +50 -0
  144. data/lib/rails_ai_admin/forms/plan.rb +90 -0
  145. data/lib/rails_ai_admin/input_schema.rb +76 -0
  146. data/lib/rails_ai_admin/integrations/propose_operation.rb +150 -0
  147. data/lib/rails_ai_admin/integrations/tool_views.rb +367 -0
  148. data/lib/rails_ai_admin/mcp.rb +130 -0
  149. data/lib/rails_ai_admin/navigation/list_state.rb +194 -0
  150. data/lib/rails_ai_admin/navigation/menu.rb +31 -0
  151. data/lib/rails_ai_admin/navigation/settings.rb +28 -0
  152. data/lib/rails_ai_admin/operation.rb +120 -0
  153. data/lib/rails_ai_admin/operations/default_handlers.rb +17 -0
  154. data/lib/rails_ai_admin/operations/execution_context.rb +38 -0
  155. data/lib/rails_ai_admin/operations/form_intent.rb +69 -0
  156. data/lib/rails_ai_admin/operations/registry.rb +80 -0
  157. data/lib/rails_ai_admin/operations/result.rb +21 -0
  158. data/lib/rails_ai_admin/operations/runner.rb +633 -0
  159. data/lib/rails_ai_admin/operations/runner_fingerprint.rb +12 -0
  160. data/lib/rails_ai_admin/outbox/delivery_context.rb +51 -0
  161. data/lib/rails_ai_admin/outbox/dispatcher.rb +189 -0
  162. data/lib/rails_ai_admin/outbox/registry.rb +36 -0
  163. data/lib/rails_ai_admin/outbox/request_digest.rb +42 -0
  164. data/lib/rails_ai_admin/outbox/writer.rb +43 -0
  165. data/lib/rails_ai_admin/presentation/association_panel.rb +90 -0
  166. data/lib/rails_ai_admin/presentation/audit_log.rb +63 -0
  167. data/lib/rails_ai_admin/presentation/csv_export.rb +85 -0
  168. data/lib/rails_ai_admin/presentation/csv_import.rb +142 -0
  169. data/lib/rails_ai_admin/presentation/dashboard.rb +122 -0
  170. data/lib/rails_ai_admin/presentation/history.rb +46 -0
  171. data/lib/rails_ai_admin/presentation/index_settings.rb +77 -0
  172. data/lib/rails_ai_admin/presentation/outcomes.rb +44 -0
  173. data/lib/rails_ai_admin/presentation/status.rb +40 -0
  174. data/lib/rails_ai_admin/prune.rb +61 -0
  175. data/lib/rails_ai_admin/queries/list.rb +162 -0
  176. data/lib/rails_ai_admin/queries/named_scope.rb +65 -0
  177. data/lib/rails_ai_admin/record_version.rb +40 -0
  178. data/lib/rails_ai_admin/registry.rb +45 -0
  179. data/lib/rails_ai_admin/request_log_filter.rb +24 -0
  180. data/lib/rails_ai_admin/resource.rb +249 -0
  181. data/lib/rails_ai_admin/resource_identity.rb +15 -0
  182. data/lib/rails_ai_admin/schema_suggestions.rb +105 -0
  183. data/lib/rails_ai_admin/selections/builder.rb +162 -0
  184. data/lib/rails_ai_admin/templates/catalog.rb +84 -0
  185. data/lib/rails_ai_admin/templates/inspector.rb +94 -0
  186. data/lib/rails_ai_admin/templates/metadata.rb +48 -0
  187. data/lib/rails_ai_admin/templates/safe_paths.rb +30 -0
  188. data/lib/rails_ai_admin/test_kit.rb +103 -0
  189. data/lib/rails_ai_admin/version.rb +5 -0
  190. data/lib/rails_ai_admin/work.rb +48 -0
  191. data/lib/rails_ai_admin.rb +112 -0
  192. data/lib/tasks/rails_ai_admin.rake +58 -0
  193. metadata +328 -0
@@ -0,0 +1,633 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module RailsAiAdmin
6
+ module Operations
7
+ # Executes registered database operations through one atomic boundary.
8
+ class Runner
9
+ ORIGINS = %w[ui surface assistant].freeze
10
+ Proposal = Data.define(:intent, :values, :changes)
11
+ Preview = Data.define(:target_id, :expected_version, :values, :changes)
12
+
13
+ def prepare(...)
14
+ result = preflight(...)
15
+ raise AccessDenied, result.code if result.is_a?(Result)
16
+ result.intent
17
+ end
18
+
19
+ # intent_id lets a replay-safe adapter (an assistant tool call) derive one
20
+ # stable intent per call; it must be a UUID and creates no authority.
21
+ def preflight(context:, resource:, operation:, target_id: nil, expected_version: nil, input: nil, origin: :ui, intent_id: nil)
22
+ unless intent_id.nil? || intent_id.is_a?(String) && intent_id.match?(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
23
+ raise ConfigurationError, "intent_id must be a lowercase UUID"
24
+ end
25
+ definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
26
+ if definition.custom? && definition.scope == :collection
27
+ raise ConfigurationError, "Collection intents require fixed-manifest preflight and admission"
28
+ end
29
+ result = preview(context: context, resource: resource, operation: operation, target_id: target_id,
30
+ expected_version: expected_version, input: input, origin: origin)
31
+ return result if result.is_a?(Result)
32
+ intent = OperationRun.create!(id: intent_id || SecureRandom.uuid, actor_ref: context.actor_ref,
33
+ tenant_ref: context.tenant_ref, security_version: context.security_version,
34
+ resource_key: resource.key.to_s, operation: operation.to_s,
35
+ operation_version: operation_version(resource, operation), target_id: result.target_id,
36
+ expected_version: result.expected_version, input_digest: input && InputSchema.digest(result.values),
37
+ origin: origin.to_s, expires_at: Time.current + RailsAiAdmin.configuration.intent_ttl)
38
+ Proposal.new(intent, result.values, result.changes)
39
+ end
40
+
41
+ FormSession = Data.define(:intent, :record)
42
+ FORM_RECOVERY_WINDOW = 1.hour
43
+ # id is the future OperationRun id; ref is what the form submits.
44
+ FormReference = Data.define(:id, :ref)
45
+
46
+ # Native form pages run the same checks as prepare but write nothing.
47
+ # The signed reference becomes an intent on first submit (ADR 0025).
48
+ def issue_form_intent(context:, resource:, operation:, target_id: nil, expected_version: nil)
49
+ definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
50
+ raise ConfigurationError, "Form intents cover built-in create, update and destroy" if definition.custom?
51
+ result = preview(context: context, resource: resource, operation: operation, target_id: target_id,
52
+ expected_version: expected_version, origin: :ui)
53
+ raise AccessDenied, result.code if result.is_a?(Result)
54
+ now = Time.current
55
+ id = SecureRandom.uuid
56
+ ref = FormIntent.generate({ id: id, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
57
+ security_version: context.security_version, resource_key: resource.key.to_s, operation: operation.to_s,
58
+ operation_version: operation_version(resource, operation), target_id: result.target_id,
59
+ expected_version: result.expected_version, created_at: now.utc.iso8601(6),
60
+ expires_at: (now + RailsAiAdmin.configuration.intent_ttl).utc.iso8601(6) }, recovery_window: FORM_RECOVERY_WINDOW)
61
+ FormReference.new(id, ref)
62
+ end
63
+
64
+ # Turns a submitted form reference into its OperationRun id, creating the
65
+ # proposed row from the signed payload the first time. A UUID passes
66
+ # through unchanged; anything unverifiable, or another owner's reference,
67
+ # returns nil. The row commits on its own so a rejected submit keeps the
68
+ # same intent for the next attempt, exactly as a GET-created one did.
69
+ def materialize_form_intent(context:, reference:)
70
+ return reference if FormIntent.uuid?(reference)
71
+ payload = FormIntent.verify(reference)
72
+ return unless payload && context.is_a?(Context) && payload["actor_ref"] == context.actor_ref && payload["tenant_ref"] == context.tenant_ref
73
+ unless OperationRun.exists?(id: payload["id"])
74
+ OperationRun.transaction(requires_new: true) { OperationRun.create!(FormIntent.run_attributes(payload)) }
75
+ end
76
+ payload["id"]
77
+ rescue ActiveRecord::RecordNotUnique
78
+ payload["id"]
79
+ end
80
+
81
+ # Reopens an unconsumed native form after fresh authentication. No input,
82
+ # business write, approval or replacement intent is admitted here.
83
+ def resume_form(context:, resource:, intent_ref:)
84
+ raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
85
+ unless FormIntent.uuid?(intent_ref)
86
+ # An unsubmitted form has no row yet; only its own recoverable kind
87
+ # is materialized, then the same checks as a stored intent apply.
88
+ payload = FormIntent.verify(intent_ref)
89
+ raise AccessDenied unless payload && payload["resource_key"] == resource.key.to_s && %w[create update].include?(payload["operation"])
90
+ intent_ref = materialize_form_intent(context: context, reference: intent_ref) || raise(AccessDenied)
91
+ end
92
+ raise AccessDenied unless intent_ref.is_a?(String) && intent_ref.match?(/\A[0-9a-f-]{36}\z/)
93
+ OperationRun.transaction(requires_new: true) do
94
+ run = OperationRun.lock.find_by(id: intent_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
95
+ unless run && run.resource_key == resource.key.to_s && %w[create update].include?(run.operation) &&
96
+ run.origin == "ui" && run.status == "proposed" && run.parent_run_id.nil? && run.selection_id.nil? &&
97
+ run.input_digest.nil? && run.confirmed_at.nil? && run.approved_by_ref.nil? && run.approved_digest.nil?
98
+ raise AccessDenied
99
+ end
100
+ raise AccessDenied, "APPROVAL_EXPIRED" unless run.created_at && run.created_at > Time.current - FORM_RECOVERY_WINDOW
101
+ definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
102
+ raise AccessDenied unless !definition.custom? && run.operation_version == operation_version(resource, run.operation)
103
+ ensure_connection!(resource, definition)
104
+ access = Authorization::Access.new(context, resource)
105
+ record = load_record(access, run, definition)
106
+ access.authorize!(run.operation, record)
107
+ if run.operation == "update" && run.expected_version != RecordVersion.of(record)
108
+ raise AccessDenied, "CONFLICT"
109
+ end
110
+ raise AccessDenied unless run.operation != "create" || run.target_id.nil? && run.expected_version.nil?
111
+ definition.fields(access, record)
112
+ run.update!(security_version: context.security_version,
113
+ expires_at: [ Time.current + RailsAiAdmin.configuration.intent_ttl, run.created_at + FORM_RECOVERY_WINDOW ].min)
114
+ FormSession.new(run, record)
115
+ end
116
+ end
117
+
118
+ # Computes a safe typed preview without creating an intent or saving domain
119
+ # data. Collection admission and native member preparation share this path.
120
+ def preview(context:, resource:, operation:, target_id: nil, expected_version: nil, input: nil, origin: :ui)
121
+ operation = operation.to_s
122
+ check_origin!(origin)
123
+ definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
124
+ access = Authorization::Access.new(context, resource)
125
+ ensure_connection!(resource, definition)
126
+ read_proposal = lambda do
127
+ record = operation == "create" ? access.relation.new : access.find!(target_id, action: operation)
128
+ access.authorize!(operation, record)
129
+ version = operation == "create" ? nil : parse_version(expected_version)
130
+ raise AccessDenied, "CONFLICT" if version && version != RecordVersion.of(record)
131
+ raise ConfigurationError, "Create must not have a target" if operation == "create" && target_id
132
+ raise ConfigurationError, "Custom operations require input-bound preflight" if definition.custom? && input.nil?
133
+ values = input.nil? ? nil : coerce!(access, record, definition, input)
134
+ changes = definition.custom? ? preview_changes(access, record, definition, values) : {}
135
+ [ record, version, values, changes ]
136
+ end
137
+ record, version, values, changes = definition.custom? ? ActiveRecord::Base.while_preventing_writes(&read_proposal) : read_proposal.call
138
+ Preview.new(operation == "create" ? nil : record.id.to_s, version, values || {}, changes)
139
+ rescue Rejection => error
140
+ error.result
141
+ rescue ExecutionContext::DeadlineExceeded
142
+ Result.new(code: "TIMEOUT")
143
+ rescue StandardError => error
144
+ raise if !definition&.custom? || error.is_a?(ConfigurationError) || error.is_a?(AccessDenied)
145
+ Result.new(code: "OPERATION_FAILED")
146
+ end
147
+
148
+ # Reauthorizes and consumes a human event atomically with the domain write.
149
+ # human_confirmed comes only from a trusted HTTP/Surface adapter, never from
150
+ # an assistant tool parameter. Origin is audit metadata, not authority.
151
+ def execute(context:, intent_ref:, input:, human_confirmed: false, origin: :ui, binding: nil)
152
+ execute_intent(context: context, intent_ref: intent_ref, input: input, human_confirmed: human_confirmed, origin: origin, binding: binding)
153
+ end
154
+
155
+ # Internal worker entry: its authority is the admitted parent and lease,
156
+ # never another synthetic human confirmation. No HTTP adapter calls it.
157
+ def execute_admitted(claim:)
158
+ raise ConfigurationError, "Expected a collection lease claim" unless claim.is_a?(Collections::Claim)
159
+ Collections::Contract.ensure_outside_transaction!
160
+ execute_intent(context: claim.context, intent_ref: claim.operation_run_id,
161
+ input: claim.payload, origin: claim.origin, admission: claim)
162
+ rescue AccessDenied => error
163
+ Result.new(code: error.code)
164
+ end
165
+
166
+ def execute_intent(context:, intent_ref:, input:, human_confirmed: false, origin: :ui, binding: nil, admission: nil)
167
+ run = access = definition = nil
168
+ transaction_body_finished = false
169
+ raise AccessDenied, "UNAUTHENTICATED" unless context.is_a?(Context)
170
+ reject!("NOT_FOUND") unless intent_ref.is_a?(String) && intent_ref.match?(/\A[0-9a-f-]{36}\z/)
171
+ result = OperationRun.transaction(requires_new: true) do
172
+ admission&.lock!(context)
173
+ run = OperationRun.lock.find_by(id: intent_ref, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref)
174
+ reject!("NOT_FOUND") unless run
175
+ reject!("NOT_FOUND") if run.parent_run_id && !admission
176
+ check_origin!(origin)
177
+ reject!("APPROVAL_INVALIDATED") unless run.security_version == context.security_version
178
+ reject!("APPROVAL_EXPIRED") unless run.expires_at && run.expires_at > Time.current
179
+ check_binding!(run, binding) if binding
180
+ resource = RailsAiAdmin.registry.fetch(run.resource_key)
181
+ definition = RailsAiAdmin.operation_registry.fetch(resource, run.operation)
182
+ ensure_connection!(resource, definition)
183
+ reject!("NOT_FOUND") if definition.custom? && definition.scope == :collection && !admission
184
+ admission&.validate_child!(run, resource, definition)
185
+ reject!("APPROVAL_INVALIDATED") unless run.operation_version == operation_version(resource, run.operation)
186
+ access = Authorization::Access.new(context, resource)
187
+ record = load_record(access, run, definition)
188
+ if definition.custom? && run.status == "succeeded"
189
+ access.authorize_receipt!(run.operation, record)
190
+ else
191
+ access.authorize!(run.operation, record)
192
+ end
193
+ values = coerce!(access, record, definition, input, replay: run.status == "succeeded")
194
+ if run.operation == "create"
195
+ record.assign_attributes(column_attributes(resource, values))
196
+ access.authorize!(:create, record)
197
+ writable = access.fields(:write, record, operation: :create).map { |field| field.name.to_s }
198
+ reject!("NOT_FOUND") unless (values.keys - writable).empty?
199
+ end
200
+ digest = InputSchema.digest(values)
201
+ if run.input_digest && run.input_digest != digest
202
+ reject!(run.status == "succeeded" ? "IDEMPOTENCY_CONFLICT" : "APPROVAL_INVALIDATED")
203
+ end
204
+ if run.status == "succeeded"
205
+ if run.operation == "create"
206
+ record = access.relation.find_by(resource.model_class.primary_key => run.record_id)
207
+ reject!("NOT_FOUND") unless record
208
+ end
209
+ next projection(access, run, record, replayed: true)
210
+ end
211
+ reject!("CONFLICT") unless run.status == "proposed"
212
+ reject!("APPROVAL_REQUIRED") unless admission || human_confirmed == true
213
+ if run.operation != "create" && RecordVersion.of(record) != run.expected_version
214
+ reject!("CONFLICT")
215
+ end
216
+ # A write claim, not a process mutex, serializes SQLite writers too.
217
+ run.update!(status: "running", input_digest: digest)
218
+ if definition.custom?
219
+ before = record.attributes_before_type_cast.deep_dup
220
+ context_for_handler = ExecutionContext.new(context, resource, record, run: run, duration: definition.handler_class.max_duration)
221
+ outcome = call_handler(definition, context_for_handler, record, values)
222
+ unless outcome.is_a?(Operation::Outcome)
223
+ raise ConfigurationError, "Operation must return success, invalid or conflict"
224
+ end
225
+ raise ConfigurationError, "Unknown operation outcome" unless %w[OK VALIDATION_FAILED CONFLICT].include?(outcome.code)
226
+ if outcome.code != "OK"
227
+ names = outcome.fields & definition.fields(access, record).map { |field| field.name.to_s }
228
+ reject!(outcome.code, errors: names.any? ? names.to_h { |name| [ name, [ Field.error_message(:invalid, "is invalid") ] ] } : { "base" => [ Field.error_message(:operation_rejected, "operation was rejected") ] })
229
+ end
230
+ unless record.persisted? && !record.destroyed? && !record.has_changes_to_save? && outcome.record_id == run.target_id
231
+ reject!("VALIDATION_FAILED", errors: { "base" => [ "operation did not persist its target" ] })
232
+ end
233
+ record.reload
234
+ if before != record.attributes_before_type_cast && RecordVersion.of(record) == run.expected_version
235
+ raise ConfigurationError, "Custom writes must advance the target lock_version or updated_at"
236
+ end
237
+ changed_fields = resource.fields.select { |field| before[field.column] != record.attributes_before_type_cast[field.column] }.map { |field| field.name.to_s }
238
+ WorkItem.where(operation_run_id: run.id).update_all(expected_version: RecordVersion.of(record))
239
+ else
240
+ lists = resource.fields.select { |field| field.scalar_type == :references && values.key?(field.name.to_s) }
241
+ before_lists = lists.to_h { |field| [ field.name.to_s, stored_references(record, field) ] }
242
+ record.assign_attributes(column_attributes(resource, unchanged_precision(resource, record, values))) if run.operation == "update"
243
+ names = field_names_by_column(resource)
244
+ changed_fields = record.changes.keys.map { |column| names.fetch(column, column) } & values.keys
245
+ # A password writer changes a digest column the resource never names.
246
+ changed_fields |= resource.fields.select { |field| field.scalar_type == :password && !values[field.name.to_s].nil? }.map { |field| field.name.to_s }
247
+ # Action Text keeps the content in its own record.
248
+ changed_fields |= resource.fields.select { |field| field.scalar_type == :rich_text && values.key?(field.name.to_s) && record.public_send(field.column)&.changed? }.map { |field| field.name.to_s }
249
+ # Active Storage keeps attachment changes outside the attributes.
250
+ if record.respond_to?(:attachment_changes)
251
+ changed_fields |= resource.fields.select { |field| field.scalar_type == :file && values.key?(field.name.to_s) && record.attachment_changes.key?(field.column) }.map { |field| field.name.to_s }
252
+ end
253
+ changed_files = apply_files(access, record, values)
254
+ changed_fields |= changed_files
255
+ # Join rows change no owner column, so a changed list touches the
256
+ # record: its version advances and a concurrent form conflicts.
257
+ changed_lists = lists.map { |field| field.name.to_s }.reject { |name| before_lists.fetch(name) == values.fetch(name) }
258
+ changed_fields |= changed_lists
259
+ persisted = definition.handler_class.new.perform(nil, record, values)
260
+ reject!("VALIDATION_FAILED", errors: { "base" => [ "could not be saved" ] }) unless persisted
261
+ record.touch if run.operation == "update" && (changed_lists.any? || changed_files.any?)
262
+ end
263
+ unless run.operation == "destroy" || access.relation.where(resource.model_class.primary_key => record.id).exists?
264
+ reject!("NOT_FOUND")
265
+ end
266
+ audit = AuditEvent.create!(actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
267
+ resource_key: run.resource_key, operation: run.operation, status: "succeeded", code: "OK",
268
+ record_id: record.id.to_s, correlation_ref: run.id, origin: origin.to_s,
269
+ changed_fields: changed_fields, created_at: Time.current)
270
+ attributes = { status: "succeeded", record_id: record.id.to_s, audit_event_id: audit.id, completed_at: Time.current }
271
+ attributes.merge!(approved_by_ref: context.actor_ref, approved_digest: digest, confirmed_at: Time.current) unless admission
272
+ run.update!(attributes)
273
+ receipt = projection(access, run, record)
274
+ admission&.complete!(run)
275
+ transaction_body_finished = true
276
+ receipt
277
+ end
278
+ result || committed_callback_result(context, run, transaction_body_finished) || reject!("VALIDATION_FAILED", errors: { "base" => [ "transaction was rolled back" ] })
279
+ rescue StandardError => error
280
+ committed = committed_callback_result(context, run, transaction_body_finished)
281
+ return committed if committed
282
+ case error
283
+ when Rejection
284
+ audit_failure(context, run, error.result.code, origin) unless admission
285
+ error.result
286
+ when AccessDenied
287
+ audit_failure(context, run, error.code, origin) unless admission
288
+ Result.new(code: error.code)
289
+ when ActiveRecord::RecordInvalid
290
+ allowed = if definition&.custom?
291
+ access&.operation_fields(run.operation, error.record)&.map { |field| field.name.to_s } || []
292
+ else
293
+ access&.fields(:write, error.record, operation: run.operation)&.map { |field| field.name.to_s } || []
294
+ end
295
+ names = definition&.custom? || !access ? {} : field_names_by_column(access.resource)
296
+ errors = validation_errors(error.record.errors, names, allowed)
297
+ audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
298
+ Result.new(code: "VALIDATION_FAILED", errors: errors.presence || { "base" => [ Field.error_message(:invalid, "is invalid") ] })
299
+ when ActiveRecord::RecordNotDestroyed
300
+ if run&.operation == "destroy" && !definition&.custom?
301
+ # dependent: :restrict_with_error (or a before_destroy abort) says
302
+ # why; it is a lasting state, not a stale form (RAA-012).
303
+ audit_failure(context, run, "RESTRICTED", origin) unless admission
304
+ Result.new(code: "RESTRICTED", errors: { "base" => restriction_messages(error.record) })
305
+ else
306
+ audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
307
+ Result.new(code: "VALIDATION_FAILED", errors: { "base" => [ "operation was not saved" ] })
308
+ end
309
+ when ActiveRecord::RecordNotSaved
310
+ audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
311
+ Result.new(code: "VALIDATION_FAILED", errors: { "base" => [ "operation was not saved" ] })
312
+ when ActiveRecord::InvalidForeignKey
313
+ # On delete, a foreign key means other rows still reference the
314
+ # record; elsewhere a referenced target vanished after admission.
315
+ if run&.operation == "destroy" && !definition&.custom?
316
+ audit_failure(context, run, "RESTRICTED", origin) unless admission
317
+ Result.new(code: "RESTRICTED", errors: { "base" => [ RESTRICTED_MESSAGE ] })
318
+ else
319
+ Result.new(code: "CONFLICT")
320
+ end
321
+ when ActiveRecord::RangeError
322
+ audit_failure(context, run, "VALIDATION_FAILED", origin) unless admission
323
+ Result.new(code: "VALIDATION_FAILED", errors: { "base" => [ "value is out of range" ] })
324
+ when ExecutionContext::DeadlineExceeded
325
+ audit_failure(context, run, "TIMEOUT", origin) unless admission
326
+ Result.new(code: "TIMEOUT")
327
+ when ActiveRecord::StaleObjectError, ActiveRecord::RecordNotUnique, ActiveRecord::Deadlocked, ActiveRecord::LockWaitTimeout,
328
+ # A references target deleted after its check fails the id writer.
329
+ ActiveRecord::RecordNotFound
330
+ Result.new(code: "CONFLICT")
331
+ when ActiveRecord::StatementInvalid
332
+ if defined?(SQLite3::BusyException) && error.cause.is_a?(SQLite3::BusyException)
333
+ Result.new(code: "CONFLICT")
334
+ elsif definition&.custom?
335
+ audit_failure(context, run, "OPERATION_FAILED", origin) unless admission
336
+ Result.new(code: "OPERATION_FAILED")
337
+ else
338
+ raise
339
+ end
340
+ else
341
+ if definition&.custom? && !error.is_a?(ConfigurationError)
342
+ audit_failure(context, run, "OPERATION_FAILED", origin) unless admission
343
+ Result.new(code: "OPERATION_FAILED")
344
+ else
345
+ raise
346
+ end
347
+ end
348
+ end
349
+
350
+ private :execute_intent
351
+
352
+ private
353
+
354
+ class Rejection < StandardError # :nodoc:
355
+ attr_reader :result
356
+ def initialize(result)
357
+ @result = result
358
+ super(result.code)
359
+ end
360
+ end
361
+
362
+ # The host model's own messages for fields this actor may write, and its
363
+ # base messages (RAA-011). config.validation_messages = :generic keeps
364
+ # the earlier "is invalid" for every field instead.
365
+ def validation_errors(model_errors, names, allowed)
366
+ generic = RailsAiAdmin.configuration.validation_messages == :generic
367
+ errors = model_errors.attribute_names.each_with_object({}) do |attribute, result|
368
+ next if attribute == :base
369
+ name = names.fetch(attribute.to_s, attribute.to_s)
370
+ next unless allowed.include?(name)
371
+ result[name] = generic ? [ Field.error_message(:invalid, "is invalid") ] : bounded_messages(model_errors.messages_for(attribute))
372
+ end
373
+ base = generic ? [] : bounded_messages(model_errors.messages_for(:base), fallback: false)
374
+ errors["base"] = base if base.any?
375
+ errors
376
+ end
377
+
378
+ def bounded_messages(messages, fallback: true)
379
+ kept = Array(messages).select { |message| message.is_a?(String) && message.valid_encoding? }
380
+ .map { |message| message.gsub(/[[:cntrl:]]/, " ").squish.truncate(200) }.reject(&:empty?).uniq.first(3)
381
+ kept.empty? && fallback ? [ Field.error_message(:invalid, "is invalid") ] : kept
382
+ end
383
+
384
+ RESTRICTED_MESSAGE = "This record is still referenced by other records and cannot be deleted."
385
+
386
+ # Host base messages from restrict_with_error are model-authored text
387
+ # about the record itself; keep at most three, bounded and plain.
388
+ def restriction_messages(record)
389
+ messages = Array(record&.errors&.[](:base)).select { |message| message.is_a?(String) && message.valid_encoding? }
390
+ .map { |message| message.gsub(/[[:cntrl:]]/, " ").squish.truncate(200) }.reject(&:empty?).first(3)
391
+ messages.presence || [ RESTRICTED_MESSAGE ]
392
+ end
393
+
394
+ def reject!(code, errors: {})
395
+ raise Rejection, Result.new(code: code, errors: errors)
396
+ end
397
+
398
+ def check_origin!(origin)
399
+ raise ConfigurationError, "Unsupported origin" unless ORIGINS.include?(origin.to_s)
400
+ end
401
+
402
+ def check_binding!(run, binding)
403
+ expected = { resource_key: run.resource_key, operation: run.operation, target_id: run.target_id }
404
+ reject!("NOT_FOUND") unless binding.transform_values { |value| value&.to_s } == expected
405
+ end
406
+
407
+ def parse_version(value)
408
+ raise AccessDenied, "CONFLICT" unless value.is_a?(Integer) || value.to_s.match?(/\A\d+\z/)
409
+ value.is_a?(String) ? Integer(value, 10) : value
410
+ end
411
+
412
+ def ensure_connection!(resource, definition)
413
+ model = resource.model_class
414
+ unless model.connection_pool.equal?(OperationRun.connection_pool) && model.connection_pool.equal?(AuditEvent.connection_pool) && model.connection_pool.equal?(WorkItem.connection_pool) && model.connection_pool.equal?(BatchItem.connection_pool)
415
+ raise ConfigurationError, "Domain, intent and audit must use the same connection pool"
416
+ end
417
+ unless RecordVersion.supported?(model)
418
+ raise ConfigurationError, "Writes require lock_version or an updated_at timestamp, and a single primary key"
419
+ end
420
+ version_column = RecordVersion.column(model)
421
+ if resource.fields.any? { |field| field.column == version_column && (field.writable?(:create) || field.writable?(:update)) }
422
+ raise ConfigurationError, "#{version_column} carries the record version and cannot be a writable field"
423
+ end
424
+ unless %w[SQLite PostgreSQL].include?(model.connection.adapter_name)
425
+ raise ConfigurationError, "Only SQLite and PostgreSQL are supported for writes"
426
+ end
427
+ if definition.custom?
428
+ definition.handler_class.atomic_models.each do |name|
429
+ related = name.constantize
430
+ unless related.is_a?(Class) && related < ActiveRecord::Base && related.connection_pool.equal?(model.connection_pool)
431
+ raise ConfigurationError, "Every declared atomic model must use the operation connection pool"
432
+ end
433
+ end
434
+ end
435
+ end
436
+
437
+ def operation_version(resource, operation)
438
+ definition = RailsAiAdmin.operation_registry.fetch(resource, operation)
439
+ RunnerFingerprint.call(resource, definition)
440
+ end
441
+
442
+ def load_record(access, run, definition)
443
+ if run.status == "succeeded" && run.operation == "destroy"
444
+ # Deleted records have no current projection. Require a fresh
445
+ # collection-level destroy decision before returning even a receipt.
446
+ nil
447
+ elsif run.operation == "create"
448
+ access.relation.new
449
+ elsif definition.custom? && run.status == "succeeded"
450
+ access.relation.lock.find_by(access.resource.model_class.primary_key => run.record_id).tap { |record| reject!("NOT_FOUND") unless record }
451
+ else
452
+ access.find!(run.target_id, action: run.operation, lock: true)
453
+ end
454
+ end
455
+
456
+ def coerce!(access, record, definition, input, replay: false)
457
+ fields = replay && definition.custom? ? definition.handler_class.input : definition.fields(access, record)
458
+ result = InputSchema.new(fields, partial: definition.key == "update" && !definition.custom?).coerce(input, time_zone: access.context.time_zone)
459
+ reject!(replay ? "IDEMPOTENCY_CONFLICT" : "VALIDATION_FAILED", errors: replay ? {} : result.errors) unless result.valid?
460
+ check_references!(access, record, fields, result.values) unless replay
461
+ retain_hidden_references(access, record, fields, result.values)
462
+ end
463
+
464
+ def stored_references(record, field)
465
+ return [] unless record&.persisted?
466
+ record.public_send(field.column).map(&:to_s).uniq.sort
467
+ end
468
+
469
+ # A list keeps the stored targets this actor cannot see: a person can
470
+ # neither remove nor learn about records hidden by the target's policy.
471
+ def retain_hidden_references(access, record, fields, values)
472
+ lists = fields.select { |field| field.scalar_type == :references && values.key?(field.name.to_s) }
473
+ return values if lists.empty?
474
+ resolver = Fields::Association.new(access.context, access.resource)
475
+ merged = values.dup
476
+ lists.each do |field|
477
+ stored = stored_references(record, field)
478
+ if stored.size > Field::MAX_REFERENCES
479
+ reject!("VALIDATION_FAILED", errors: { field.name.to_s => [ Field.error_message(:too_many, "can name at most %{count} records", count: Field::MAX_REFERENCES) ] })
480
+ end
481
+ resolver.prime(field, stored)
482
+ hidden = stored.reject { |id| resolver.admissible?(field, id) }
483
+ merged[field.name.to_s] = (values.fetch(field.name.to_s) | hidden).sort.freeze
484
+ end
485
+ merged.freeze
486
+ end
487
+
488
+ # A changed reference must name a target this actor can currently see in
489
+ # the target resource. Keeping the stored key is not a new attachment, so
490
+ # an edit never forces clearing a reference the actor cannot see.
491
+ def check_references!(access, record, fields, values)
492
+ references = fields.select { |field| Field::REFERENCE_TYPES.include?(field.scalar_type) && !values[field.name.to_s].nil? }
493
+ return if references.empty?
494
+ resolver = Fields::Association.new(access.context, access.resource)
495
+ errors = references.each_with_object({}) do |field, result|
496
+ value = values.fetch(field.name.to_s)
497
+ if field.scalar_type == :references
498
+ # Only newly added targets need to be visible now.
499
+ added = value - stored_references(record, field)
500
+ resolver.prime(field, added)
501
+ result[field.name.to_s] = [ Field.error_message(:unavailable_choice, Fields::Association::UNAVAILABLE) ] unless added.all? { |id| resolver.admissible?(field, id) }
502
+ next
503
+ end
504
+ stored = record&.persisted? && record.has_attribute?(field.column) ? record.read_attribute(field.column) : nil
505
+ next if !stored.nil? && stored.to_s == value
506
+ result[field.name.to_s] = [ Field.error_message(:unavailable_choice, Fields::Association::UNAVAILABLE) ] unless resolver.admissible?(field, value)
507
+ end
508
+ reject!("VALIDATION_FAILED", errors: errors) if errors.any?
509
+ end
510
+
511
+ # A form shows a timestamp to the second. Submitting it back unchanged
512
+ # must not drop the stored fraction (RAA-002/RAA-015), so a whole-second
513
+ # value equal to the stored one truncated keeps the stored one.
514
+ def unchanged_precision(resource, record, values)
515
+ fields = resource.fields.index_by { |field| field.name.to_s }
516
+ values.to_h do |name, value|
517
+ field = fields[name]
518
+ stored = field && field.scalar_type == :datetime && record.has_attribute?(field.column) ? record.read_attribute(field.column) : nil
519
+ kept = stored.respond_to?(:usec) && value.respond_to?(:usec) && value.usec.zero? && stored.usec.nonzero? && stored.change(usec: 0) == value
520
+ [ name, kept ? stored : value ]
521
+ end
522
+ end
523
+
524
+ # files changes are applied by apply_files, never assigned as a list.
525
+ def column_attributes(resource, values)
526
+ columns = resource.fields.to_h { |field| [ field.name.to_s, field.column ] }
527
+ lists = resource.fields.select { |field| field.scalar_type == :files }.map { |field| field.name.to_s }
528
+ values.except(*lists).transform_keys { |name| columns.fetch(name, name) }
529
+ end
530
+
531
+ # Removes the named attachments of this record and attaches the new
532
+ # blobs. Returns the names of files fields that changed.
533
+ def apply_files(access, record, values)
534
+ access.resource.fields.select { |field| field.scalar_type == :files && values.key?(field.name.to_s) }.filter_map do |field|
535
+ change = values.fetch(field.name.to_s) || { "add" => [], "remove" => [] }
536
+ attached = record.public_send(field.column)
537
+ stored = record.persisted? ? access.stored_attachments(record, field) : nil
538
+ removed = stored ? stored.where(id: change["remove"]).to_a : []
539
+ unless removed.size == change["remove"].size
540
+ reject!("VALIDATION_FAILED", errors: { field.name.to_s => [ Field.error_message(:unavailable_choice, Fields::Association::UNAVAILABLE) ] })
541
+ end
542
+ total = (stored ? stored.count : 0) - removed.size + change["add"].size
543
+ if total > field.options[:max_files]
544
+ reject!("VALIDATION_FAILED", errors: { field.name.to_s => [ Field.error_message(:too_many_files, "can hold at most %{count} files", count: field.options[:max_files]) ] })
545
+ end
546
+ # The attachment rows go with this transaction; their blobs are
547
+ # purged only after it commits, so a rollback loses no file.
548
+ blobs = removed.map(&:blob)
549
+ removed.each(&:delete)
550
+ ActiveRecord.after_all_transactions_commit { blobs.each { |blob| blob.purge_later unless blob.attachments.exists? } } if blobs.any?
551
+ attached.attach(*change["add"]) if change["add"].any?
552
+ field.name.to_s if removed.any? || change["add"].any?
553
+ end
554
+ end
555
+
556
+ def field_names_by_column(resource) = resource.fields.to_h { |field| [ field.column, field.name.to_s ] }
557
+
558
+ def preview_changes(access, record, definition, values)
559
+ handler_context = ExecutionContext.new(access.context, access.resource, record, duration: definition.handler_class.max_duration)
560
+ changes = ActiveRecord::Base.while_preventing_writes { definition.handler_class.new.preflight(handler_context, record, values) }
561
+ handler_context.check_deadline!
562
+ if changes.is_a?(Operation::Outcome) && %w[VALIDATION_FAILED CONFLICT].include?(changes.code)
563
+ names = changes.fields & definition.fields(access, record).map { |field| field.name.to_s }
564
+ reject!(changes.code, errors: names.any? ? names.to_h { |name| [ name, [ Field.error_message(:invalid, "is invalid") ] ] } : { "base" => [ Field.error_message(:operation_rejected, "operation was rejected") ] })
565
+ end
566
+ raise ConfigurationError, "Preflight must return a field-value hash without mutating its target" unless changes.is_a?(Hash) && !record.has_changes_to_save?
567
+ readable = access.fields(:read, record, view: :show)
568
+ original = begin
569
+ access.project(record, view: :show)
570
+ rescue AccessDenied
571
+ {}
572
+ end
573
+ readable.filter_map do |field|
574
+ name = field.name.to_s
575
+ next if field.virtual?
576
+ next unless original.key?(name) && (changes.key?(name) || changes.key?(field.name))
577
+ value = changes.key?(name) ? changes[name] : changes[field.name]
578
+ value = field.coerce_output(value, time_zone: access.context.time_zone)
579
+ [ name, { "before" => original[name], "after" => value } ]
580
+ end.to_h
581
+ rescue ArgumentError, ActiveRecord::ReadOnlyError
582
+ raise ConfigurationError, "Preflight violated its pure typed preview contract", cause: nil
583
+ end
584
+
585
+ def call_handler(definition, context, record, values)
586
+ nested_savepoint = false
587
+ connection = OperationRun.connection
588
+ listener = ->(_event, _start, _finish, _id, payload) { nested_savepoint = true if payload[:connection].equal?(connection) && payload[:sql].match?(/\A(?:SAVEPOINT|RELEASE SAVEPOINT|ROLLBACK TO SAVEPOINT)\b/i) }
589
+ context.check_deadline!
590
+ outcome = ActiveSupport::Notifications.subscribed(listener, "sql.active_record") { definition.handler_class.new.perform(context, record, values) }
591
+ raise ConfigurationError, "Custom handlers must not open requires_new transactions" if nested_savepoint
592
+ context.check_deadline!
593
+ outcome
594
+ end
595
+
596
+ def projection(access, run, record, replayed: false)
597
+ values = if run.operation == "destroy"
598
+ {}
599
+ else
600
+ begin
601
+ access.project(record)
602
+ rescue AccessDenied
603
+ {}
604
+ end
605
+ end
606
+ Result.new(code: "OK", record_id: run.record_id, audit_ref: run.audit_event_id, values: values, replayed: replayed,
607
+ warning: run.warning_code, work_refs: WorkItem.where(operation_run_id: run.id).pluck(:id))
608
+ end
609
+
610
+ def committed_callback_result(context, run, body_finished)
611
+ return unless body_finished && run && !OperationRun.connection.transaction_open?
612
+ committed = OperationRun.uncached do
613
+ OperationRun.find_by(id: run.id, actor_ref: context.actor_ref, tenant_ref: context.tenant_ref, status: "succeeded")
614
+ end
615
+ return unless committed
616
+ committed.update!(warning_code: "POST_COMMIT_CALLBACK_FAILED")
617
+ # after_commit exceptions cannot turn an already committed business write
618
+ # into a failure. Keep its receipt, without running callbacks or exposing
619
+ # record content again. External-effect reconciliation belongs to host.
620
+ Result.new(code: "OK", record_id: committed.record_id, audit_ref: committed.audit_event_id,
621
+ warning: "POST_COMMIT_CALLBACK_FAILED", work_refs: WorkItem.where(operation_run_id: committed.id).pluck(:id))
622
+ end
623
+
624
+ def audit_failure(context, run, code, origin)
625
+ return unless run && run.actor_ref == context.actor_ref && run.tenant_ref == context.tenant_ref
626
+ return if run.parent_run_id || run.selection_id
627
+ AuditEvent.create!(actor_ref: context.actor_ref, tenant_ref: context.tenant_ref,
628
+ resource_key: run.resource_key, operation: run.operation, status: "rejected", code: code,
629
+ record_id: run.target_id, correlation_ref: run.id, origin: origin.to_s, changed_fields: [], created_at: Time.current)
630
+ end
631
+ end
632
+ end
633
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsAiAdmin
4
+ module Operations
5
+ module RunnerFingerprint
6
+ def self.call(resource, definition)
7
+ "operation:1:#{InputSchema.digest(identity: ResourceIdentity.call(resource), policy: resource.policy,
8
+ fields: resource.fields.map(&:contract), operation: definition.key, definition: definition.signature)}"
9
+ end
10
+ end
11
+ end
12
+ end