phronomy 0.26.0 → 0.27.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 (304) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +40 -0
  3. data/README.md +4 -0
  4. data/VERIFY.sh +27 -27
  5. data/benchmark/bench_agent_invoke.rb +26 -22
  6. data/benchmark/bench_context_assembler.rb +4 -5
  7. data/docs/architecture/agent-chat-and-state-ownership.md +147 -0
  8. data/docs/architecture/agent-configuration-and-tool-binding.md +139 -0
  9. data/docs/architecture/agent-context.md +4 -2
  10. data/docs/architecture/agent-transition-ownership.md +89 -0
  11. data/docs/architecture/before-llm-input.md +6 -0
  12. data/docs/architecture/context-management.md +30 -0
  13. data/docs/architecture/context-preparation-steps.md +90 -0
  14. data/docs/architecture/entry-action-and-team-wording.md +72 -0
  15. data/docs/architecture/execution-metadata-and-values.md +80 -0
  16. data/docs/architecture/generator-verifier-ownership.md +111 -0
  17. data/docs/architecture/multi-agent-handoff.md +8 -2
  18. data/docs/architecture/persistence-refactoring-plan.md +50 -0
  19. data/docs/architecture/persistence.md +27 -4
  20. data/docs/architecture/refactoring-closure.md +334 -0
  21. data/docs/architecture/remaining-refactoring-plan.md +374 -0
  22. data/docs/architecture/rubyllm-2-token-ownership.md +82 -0
  23. data/docs/architecture/tool-schema-recording-gap.md +50 -0
  24. data/docs/architecture/tracing.md +1 -1
  25. data/docs/architecture/workflow-terminal-ownership-design.md +104 -0
  26. data/docs/architecture.md +209 -0
  27. data/docs/decisions/014-unified-persistence-durable-state.md +11 -0
  28. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +5 -1
  29. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +13 -3
  30. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +5 -1
  31. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +5 -0
  32. data/docs/decisions/031-durable-multi-agent-coordination.md +5 -0
  33. data/docs/decisions/032-storage-backend-composition.md +80 -0
  34. data/docs/decisions/033-domain-persistence-ownership.md +79 -0
  35. data/docs/decisions/034-handoff-runner-coordination-ownership.md +76 -0
  36. data/docs/decisions/035-tool-executor-capability-ownership.md +68 -0
  37. data/docs/decisions/036-context-contract-ownership.md +86 -0
  38. data/docs/decisions/037-common-definition-ownership.md +62 -0
  39. data/docs/decisions/038-responsibility-based-source-layout.md +131 -0
  40. data/docs/decisions/039-runtime-configuration-lifecycle-ownership.md +74 -0
  41. data/docs/decisions/040-configuration-default-composition.md +94 -0
  42. data/docs/decisions/041-feature-owned-identity-registries.md +100 -0
  43. data/docs/decisions/042-feature-owned-execution-state.md +106 -0
  44. data/docs/decisions/043-storage-execution-constraint-notifications.md +75 -0
  45. data/docs/decisions/044-agent-default-and-one-shot-composition.md +106 -0
  46. data/docs/decisions/045-worker-input-restriction-ownership.md +100 -0
  47. data/docs/decisions/046-agent-responsibility-layout-and-shared-records.md +110 -0
  48. data/docs/decisions/047-recovered-execution-continuation-contract.md +116 -0
  49. data/docs/decisions/048-dispatch-preparation-worker-ownership.md +120 -0
  50. data/docs/decisions/049-initial-preparation-worker-ownership.md +111 -0
  51. data/docs/decisions/050-approval-resume-snapshot-and-commit-ownership.md +98 -0
  52. data/docs/decisions/051-execution-outcome-worker-ownership.md +151 -0
  53. data/docs/decisions/052-tool-invocation-restoration-ownership.md +92 -0
  54. data/docs/decisions/053-shared-state-coordination-ownership.md +71 -0
  55. data/docs/decisions/054-workflow-terminal-save-single-owner.md +76 -0
  56. data/docs/decisions/055-terminal-observer-failure-settlement.md +60 -0
  57. data/docs/decisions/056-workflow-terminal-policy-ownership.md +87 -0
  58. data/docs/decisions/057-storage-transaction-boundaries.md +73 -0
  59. data/docs/decisions/058-neutral-storage-primitives.md +78 -0
  60. data/docs/decisions/README.md +35 -6
  61. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +6 -0
  62. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +6 -0
  63. data/docs/features.md +9 -3
  64. data/docs/getting-started.md +4 -4
  65. data/docs/migrations/0.15.md +5 -0
  66. data/docs/migrations/durable-semantic-coordination-v2.md +6 -0
  67. data/docs/migrations/handoff-runner-multi-agent.md +35 -0
  68. data/docs/migrations/neutral-storage-spi.md +43 -0
  69. data/docs/migrations/parallel-tool-chat-removal.md +49 -0
  70. data/docs/migrations/shared-state-multi-agent.md +44 -0
  71. data/docs/migrations/storage-backend-composition.md +117 -0
  72. data/docs/migrations/storage-transaction-boundaries.md +74 -0
  73. data/docs/persistence-backends.md +162 -204
  74. data/docs/runtime-and-concurrency.md +117 -32
  75. data/lib/phronomy/agent/api/agent.rb +17 -0
  76. data/lib/phronomy/agent/async_event_api.rb +2 -2
  77. data/lib/phronomy/agent/base.rb +51 -241
  78. data/lib/phronomy/{agent.rb → agent/composition/run_once.rb} +4 -13
  79. data/lib/phronomy/agent/context/capability/base.rb +67 -26
  80. data/lib/phronomy/agent/context/capability/tool_executor.rb +62 -0
  81. data/lib/phronomy/agent/{context_assembler.rb → context_assembly/context_assembler.rb} +143 -88
  82. data/lib/phronomy/agent/{context_importer.rb → context_assembly/context_importer.rb} +2 -2
  83. data/lib/phronomy/agent/{ruby_llm_materializer.rb → context_assembly/ruby_llm_materializer.rb} +4 -7
  84. data/lib/phronomy/agent/context_assembly/runtime_chat_builder.rb +36 -0
  85. data/lib/phronomy/agent/context_assembly/saved_context_reader.rb +53 -0
  86. data/lib/phronomy/agent/context_assembly/state_writer.rb +165 -0
  87. data/lib/phronomy/agent/context_assembly/token_budget_resolver.rb +22 -0
  88. data/lib/phronomy/agent/{context_plan.rb → context_contract/context_plan.rb} +1 -1
  89. data/lib/phronomy/agent/{context_policy_input.rb → context_contract/context_policy_input.rb} +6 -6
  90. data/lib/phronomy/agent/{llm_input_build_context.rb → context_contract/llm_input_build_context.rb} +1 -1
  91. data/lib/phronomy/agent/{llm_input_manifest.rb → context_contract/llm_input_manifest.rb} +20 -20
  92. data/lib/phronomy/agent/{llm_input_patch.rb → context_contract/llm_input_patch.rb} +2 -2
  93. data/lib/phronomy/agent/{agent_execution.rb → execution/agent_execution.rb} +8 -4
  94. data/lib/phronomy/agent/{agent_invocation.rb → execution/agent_invocation.rb} +8 -13
  95. data/lib/phronomy/agent/{agent_invocation_session_builder.rb → execution/agent_invocation_session_builder.rb} +19 -95
  96. data/lib/phronomy/agent/execution/approval_resume_commit.rb +108 -0
  97. data/lib/phronomy/agent/execution/dispatch_preparation.rb +305 -0
  98. data/lib/phronomy/agent/{exact_execution.rb → execution/exact_execution.rb} +9 -10
  99. data/lib/phronomy/agent/{execution_cancellation.rb → execution/execution_cancellation.rb} +2 -3
  100. data/lib/phronomy/agent/execution/execution_coordinator.rb +1925 -0
  101. data/lib/phronomy/agent/execution/execution_failure.rb +30 -0
  102. data/lib/phronomy/agent/execution/execution_metadata.rb +53 -0
  103. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +344 -0
  104. data/lib/phronomy/agent/execution/execution_registry.rb +459 -0
  105. data/lib/phronomy/agent/execution/execution_session_runner.rb +118 -0
  106. data/lib/phronomy/agent/execution/initial_preparation.rb +421 -0
  107. data/lib/phronomy/agent/execution/invocation_transitions.rb +86 -0
  108. data/lib/phronomy/agent/{phase_machine_builder.rb → execution/phase_machine_builder.rb} +20 -71
  109. data/lib/phronomy/agent/{provider_call_outcome.rb → execution/provider_call_outcome.rb} +9 -9
  110. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +210 -0
  111. data/lib/phronomy/agent/{handoff_context.rb → handoff/handoff_context.rb} +2 -2
  112. data/lib/phronomy/agent/handoff/handoff_execution_coordinator.rb +15 -0
  113. data/lib/phronomy/agent/handoff/handoff_outcome_committer.rb +131 -0
  114. data/lib/phronomy/agent/{handoff_state.rb → handoff/handoff_state.rb} +1 -1
  115. data/lib/phronomy/agent/{journal_projection.rb → journal/journal_projection.rb} +4 -0
  116. data/lib/phronomy/agent/{journal_record.rb → journal/journal_record.rb} +3 -3
  117. data/lib/phronomy/agent/{llm_call_record.rb → journal/llm_call_record.rb} +2 -2
  118. data/lib/phronomy/agent/{agent_root.rb → lifecycle/agent_root.rb} +6 -2
  119. data/lib/phronomy/agent/lifecycle/default_persistence.rb +29 -0
  120. data/lib/phronomy/{engine/runtime/agent_ownership_registry.rb → agent/lifecycle/ownership_registry.rb} +24 -10
  121. data/lib/phronomy/{agent_already_exists_error.rb → agent/lifecycle_contract/agent_already_exists_error.rb} +2 -0
  122. data/lib/phronomy/{agent_busy_error.rb → agent/lifecycle_contract/agent_busy_error.rb} +2 -0
  123. data/lib/phronomy/{agent_purged_error.rb → agent/lifecycle_contract/agent_purged_error.rb} +2 -0
  124. data/lib/phronomy/agent/lifecycle_contract/handoff_error.rb +7 -0
  125. data/lib/phronomy/{stream_callback_error.rb → agent/lifecycle_contract/stream_callback_error.rb} +2 -0
  126. data/lib/phronomy/agent/persistence/agent_repository.rb +61 -0
  127. data/lib/phronomy/agent/persistence/codec.rb +358 -0
  128. data/lib/phronomy/agent/persistence/execution_repository.rb +108 -0
  129. data/lib/phronomy/agent/persistence/handoff_state_repository.rb +58 -0
  130. data/lib/phronomy/agent/persistence/journal_repository.rb +54 -0
  131. data/lib/phronomy/agent/persistence/queries.rb +61 -0
  132. data/lib/phronomy/agent/persistence/storage_schema.rb +24 -0
  133. data/lib/phronomy/agent/persistence/watermark.rb +27 -0
  134. data/lib/phronomy/agent/recovery/invocation_restorer.rb +132 -0
  135. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/continuation.rb +21 -45
  136. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/installation.rb +32 -37
  137. data/lib/phronomy/agent/{recovery_coordinator → recovery/recovery_coordinator}/resolution.rb +37 -40
  138. data/lib/phronomy/agent/{recovery_coordinator.rb → recovery/recovery_coordinator.rb} +6 -10
  139. data/lib/phronomy/agent/recovery/recovery_support.rb +227 -0
  140. data/lib/phronomy/agent/selection/candidate.rb +1 -1
  141. data/lib/phronomy/agent/{approval_evaluation_request.rb → tool_execution/approval_evaluation_request.rb} +1 -12
  142. data/lib/phronomy/agent/{tool_approval_request.rb → tool_execution/tool_approval_request.rb} +1 -10
  143. data/lib/phronomy/agent/tool_execution/tool_binding.rb +90 -0
  144. data/lib/phronomy/agent/{tool_call_intercepted.rb → tool_execution/tool_call_intercepted.rb} +2 -2
  145. data/lib/phronomy/agent/{tool_definition_set.rb → tool_execution/tool_definition_set.rb} +9 -4
  146. data/lib/phronomy/agent/{tool_invocation.rb → tool_execution/tool_invocation.rb} +69 -34
  147. data/lib/phronomy/common/configuration_error.rb +7 -0
  148. data/lib/phronomy/common/error.rb +5 -0
  149. data/lib/phronomy/{agent → common/values}/immutable.rb +9 -1
  150. data/lib/phronomy/common/values/serializable.rb +32 -0
  151. data/lib/phronomy/{configuration.rb → configuration/configuration.rb} +14 -5
  152. data/lib/phronomy/configuration/global_configuration.rb +26 -0
  153. data/lib/phronomy/content_store/storage_schema.rb +11 -0
  154. data/lib/phronomy/content_store/stored_contents.rb +43 -0
  155. data/lib/phronomy/engine/backpressure_error.rb +7 -0
  156. data/lib/phronomy/{blocking.rb → engine/blocking.rb} +1 -1
  157. data/lib/phronomy/engine/cancellation_error.rb +7 -0
  158. data/lib/phronomy/engine/concurrency/cancellation_token.rb +4 -0
  159. data/lib/phronomy/engine/concurrency/offload_pool.rb +4 -0
  160. data/lib/phronomy/engine/concurrency/operation_binding.rb +1 -1
  161. data/lib/phronomy/engine/concurrency/worker_input_restricted.rb +14 -0
  162. data/lib/phronomy/engine/event_loop.rb +158 -624
  163. data/lib/phronomy/engine/event_loop_reentrancy_error.rb +8 -0
  164. data/lib/phronomy/{execution.rb → engine/execution.rb} +0 -7
  165. data/lib/phronomy/{execution_cancellation_error.rb → engine/execution_cancellation_error.rb} +2 -0
  166. data/lib/phronomy/engine/execution_receiver.rb +65 -0
  167. data/lib/phronomy/{execution_timeout_error.rb → engine/execution_timeout_error.rb} +2 -0
  168. data/lib/phronomy/engine/fsm_protocol.rb +14 -0
  169. data/lib/phronomy/engine/fsm_session.rb +35 -28
  170. data/lib/phronomy/{invalid_async_entry_action_error.rb → engine/invalid_async_entry_action_error.rb} +2 -0
  171. data/lib/phronomy/{invalid_async_transition_action_error.rb → engine/invalid_async_transition_action_error.rb} +2 -0
  172. data/lib/phronomy/{invalid_async_workflow_action_error.rb → engine/invalid_async_workflow_action_error.rb} +2 -0
  173. data/lib/phronomy/engine/pool_shutdown_error.rb +7 -0
  174. data/lib/phronomy/engine/recursion_limit_error.rb +7 -0
  175. data/lib/phronomy/engine/runtime.rb +80 -84
  176. data/lib/phronomy/engine/runtime_shutdown_error.rb +7 -0
  177. data/lib/phronomy/engine/runtime_shutdown_reentrancy_error.rb +7 -0
  178. data/lib/phronomy/engine/scheduler_reentrancy_error.rb +9 -0
  179. data/lib/phronomy/engine/task_result.rb +4 -0
  180. data/lib/phronomy/engine/timeout_error.rb +7 -0
  181. data/lib/phronomy/filter/contract/filter_block_error.rb +14 -0
  182. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +89 -0
  183. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +57 -0
  184. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +112 -0
  185. data/lib/phronomy/generation/generator_verifier.rb +118 -0
  186. data/lib/phronomy/generation/low_confidence_error.rb +14 -0
  187. data/lib/phronomy/llm_context_window/token_budget.rb +6 -7
  188. data/lib/phronomy/llm_contract/authentication_error.rb +7 -0
  189. data/lib/phronomy/{context_budget_exceeded_error.rb → llm_contract/context_budget_exceeded_error.rb} +2 -0
  190. data/lib/phronomy/llm_contract/context_length_error.rb +7 -0
  191. data/lib/phronomy/llm_contract/rate_limit_error.rb +7 -0
  192. data/lib/phronomy/{token_usage.rb → llm_contract/token_usage.rb} +2 -2
  193. data/lib/phronomy/llm_contract/transport_error.rb +7 -0
  194. data/lib/phronomy/multi_agent/admission_registry.rb +22 -2
  195. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +5 -5
  196. data/lib/phronomy/{agent → multi_agent}/handoff_runner.rb +18 -17
  197. data/lib/phronomy/multi_agent/orchestrator.rb +2 -2
  198. data/lib/phronomy/multi_agent/persistence/codec.rb +55 -0
  199. data/lib/phronomy/multi_agent/persistence/queries.rb +30 -0
  200. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +108 -0
  201. data/lib/phronomy/multi_agent/persistence/team_repository.rb +61 -0
  202. data/lib/phronomy/{agent → multi_agent}/shared_state.rb +56 -39
  203. data/lib/phronomy/multi_agent/storage_contract/team_storage_schema.rb +15 -0
  204. data/lib/phronomy/multi_agent/team_coordinator.rb +21 -18
  205. data/lib/phronomy/multi_agent/team_execution.rb +1 -1
  206. data/lib/phronomy/{engine/runtime → multi_agent}/team_ownership_registry.rb +14 -4
  207. data/lib/phronomy/multi_agent/team_root.rb +1 -1
  208. data/lib/phronomy/output_parser/contract/parse_error.rb +7 -0
  209. data/lib/phronomy/persistence/api/persistence.rb +140 -0
  210. data/lib/phronomy/persistence/migration/initial_format_migration.rb +19 -19
  211. data/lib/phronomy/persistence_composition/repositories.rb +77 -0
  212. data/lib/phronomy/persistence_composition/storage_schema.rb +24 -0
  213. data/lib/phronomy/{execution_rehydration_required_error.rb → recovery/execution_rehydration_required_error.rb} +2 -0
  214. data/lib/phronomy/{recovery.rb → recovery/recovery.rb} +1 -1
  215. data/lib/phronomy/runtime_composition/agent_defaults.rb +7 -0
  216. data/lib/phronomy/runtime_composition/configuration_defaults.rb +9 -0
  217. data/lib/phronomy/runtime_composition/global_runtime.rb +19 -0
  218. data/lib/phronomy/storage/backend.rb +101 -0
  219. data/lib/phronomy/storage/backends/in_memory.rb +157 -0
  220. data/lib/phronomy/storage/blob_conflict_error.rb +10 -0
  221. data/lib/phronomy/storage/blobs.rb +31 -0
  222. data/lib/phronomy/storage/condition.rb +25 -0
  223. data/lib/phronomy/storage/condition_failed_error.rb +16 -0
  224. data/lib/phronomy/storage/conflict_error.rb +9 -0
  225. data/lib/phronomy/{persistence → storage}/durable_record.rb +12 -12
  226. data/lib/phronomy/storage/entry.rb +33 -0
  227. data/lib/phronomy/storage/guard_ref.rb +13 -0
  228. data/lib/phronomy/storage/not_found_error.rb +9 -0
  229. data/lib/phronomy/storage/record_codec.rb +177 -0
  230. data/lib/phronomy/storage/records.rb +61 -0
  231. data/lib/phronomy/storage/resource.rb +126 -0
  232. data/lib/phronomy/storage/scope.rb +25 -0
  233. data/lib/phronomy/storage/serialization_error.rb +9 -0
  234. data/lib/phronomy/storage/streams.rb +44 -0
  235. data/lib/phronomy/storage/transaction_error.rb +10 -0
  236. data/lib/phronomy/storage/unique_constraint_error.rb +17 -0
  237. data/lib/phronomy/storage/unsupported_backend_error.rb +9 -0
  238. data/lib/phronomy/storage/validation.rb +53 -0
  239. data/lib/phronomy/storage/view.rb +131 -0
  240. data/lib/phronomy/testing/persistence_contract/a_content_store.rb +1 -1
  241. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +4 -4
  242. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +10 -7
  243. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +2 -2
  244. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +6 -6
  245. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +6 -6
  246. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +8 -8
  247. data/lib/phronomy/testing/persistence_contract/neutral_storage_primitives.rb +263 -0
  248. data/lib/phronomy/testing/persistence_contract/storage_transaction_boundaries.rb +123 -0
  249. data/lib/phronomy/testing/persistence_contract.rb +4 -0
  250. data/lib/phronomy/tool/contract/tool_error.rb +7 -0
  251. data/lib/phronomy/tools/agent.rb +1 -1
  252. data/lib/phronomy/version.rb +1 -1
  253. data/lib/phronomy/{workflow.rb → workflow/execution/workflow.rb} +8 -6
  254. data/lib/phronomy/{workflow_context.rb → workflow/execution/workflow_context.rb} +4 -0
  255. data/lib/phronomy/workflow/execution/workflow_context_ownership_error.rb +7 -0
  256. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +188 -0
  257. data/lib/phronomy/{workflow_runner.rb → workflow/execution/workflow_runner.rb} +122 -71
  258. data/lib/phronomy/workflow/execution/workflow_terminal_policy.rb +40 -0
  259. data/lib/phronomy/workflow/persistence/codec.rb +153 -0
  260. data/lib/phronomy/workflow/persistence/state_repository.rb +57 -0
  261. data/lib/phronomy/workflow/phase_machine_builder.rb +2 -2
  262. data/lib/phronomy/workflow/storage_contract/workflow_storage_schema.rb +9 -0
  263. data/lib/phronomy.rb +52 -94
  264. data/scripts/api_snapshot.rb +1 -1
  265. data/scripts/storage_spi_snapshot.rb +36 -0
  266. data/sig/phronomy/agent.rbs +0 -1
  267. data/sig/phronomy/execution_receiver.rbs +34 -0
  268. data/sig/phronomy/handoff.rbs +4 -2
  269. data/sig/phronomy/multi_agent.rbs +18 -0
  270. data/sig/phronomy/persistence.rbs +7 -91
  271. data/sig/phronomy/storage.rbs +174 -0
  272. data/sig/phronomy/tool.rbs +10 -1
  273. metadata +230 -99
  274. data/lib/phronomy/agent/execution_coordinator.rb +0 -3151
  275. data/lib/phronomy/agent/handoff_execution_coordinator.rb +0 -143
  276. data/lib/phronomy/agent/recovery_support.rb +0 -504
  277. data/lib/phronomy/agent/token_budget_resolver.rb +0 -70
  278. data/lib/phronomy/agent/tool_executor.rb +0 -55
  279. data/lib/phronomy/generator_verifier.rb +0 -369
  280. data/lib/phronomy/invalid_context_budget_configuration_error.rb +0 -8
  281. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +0 -116
  282. data/lib/phronomy/persistence/durable_codec.rb +0 -706
  283. data/lib/phronomy/persistence/in_memory.rb +0 -690
  284. data/lib/phronomy/persistence/repository_facades.rb +0 -535
  285. data/lib/phronomy/persistence.rb +0 -276
  286. data/lib/phronomy/ruby_llm_patches.rb +0 -24
  287. data/lib/phronomy/workflow_recovery.rb +0 -123
  288. /data/lib/phronomy/agent/{context_candidate_resolver.rb → context_assembly/context_candidate_resolver.rb} +0 -0
  289. /data/lib/phronomy/agent/{context_policy_input_builder.rb → context_assembly/context_policy_input_builder.rb} +0 -0
  290. /data/lib/phronomy/agent/{context_plan_validator.rb → context_contract/context_plan_validator.rb} +0 -0
  291. /data/lib/phronomy/agent/{context_policy.rb → context_contract/context_policy.rb} +0 -0
  292. /data/lib/phronomy/agent/{llm_operation_result.rb → execution/llm_operation_result.rb} +0 -0
  293. /data/lib/phronomy/agent/{handoff.rb → handoff/handoff.rb} +0 -0
  294. /data/lib/phronomy/agent/{handoff_capability_factory.rb → handoff/handoff_capability_factory.rb} +0 -0
  295. /data/lib/phronomy/agent/{handoff_policy.rb → handoff/handoff_policy.rb} +0 -0
  296. /data/lib/phronomy/agent/{handoff_projection.rb → handoff/handoff_projection.rb} +0 -0
  297. /data/lib/phronomy/agent/{handoff_request.rb → handoff/handoff_request.rb} +0 -0
  298. /data/lib/phronomy/agent/{tool_invocation_session_builder.rb → tool_execution/tool_invocation_session_builder.rb} +0 -0
  299. /data/lib/phronomy/{canonical_json.rb → common/canonical_json.rb} +0 -0
  300. /data/lib/phronomy/{diagnostics.rb → engine/diagnostics.rb} +0 -0
  301. /data/lib/phronomy/{event.rb → engine/event.rb} +0 -0
  302. /data/lib/phronomy/{invocation_context.rb → engine/invocation_context.rb} +0 -0
  303. /data/lib/phronomy/{metrics.rb → engine/metrics.rb} +0 -0
  304. /data/lib/phronomy/{runnable.rb → engine/runnable.rb} +0 -0
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # Neutral storage SPI. Domain resource declarations are supplied by composition.
6
+ # @api public
7
+ class Backend
8
+ REQUIRED_CAPABILITIES = {spi_version: 2, atomic_resources: true,
9
+ record_cas: true, stream_cas: true, conditional_unique: true,
10
+ guarded_checks: true, nested_savepoints: true}.freeze
11
+ attr_reader :resources, :view
12
+
13
+ # @api public
14
+ def initialize(resources:)
15
+ unless resources.is_a?(Array) && resources.all? { |resource| resource.is_a?(Resource) }
16
+ raise UnsupportedBackendError, "SPI 2 requires Resource declarations"
17
+ end
18
+ @resources = resources.to_h { |resource| [resource.id, resource] }.freeze
19
+ raise UnsupportedBackendError, "duplicate resource identity" unless @resources.length == resources.length
20
+ @resources.each_value do |resource|
21
+ next unless resource.guard
22
+ anchor = @resources[resource.guard.fetch(:resource)]
23
+ raise UnsupportedBackendError, "guard must name a registered records resource" unless anchor && anchor.kind == :records
24
+ end
25
+ @view = View.new(self)
26
+ end
27
+
28
+ # Reject the previous eight-repository SPI before constructing domain wrappers.
29
+ # @api public
30
+ def self.validate_capabilities!(backend)
31
+ capabilities = backend.respond_to?(:capabilities) ? backend.capabilities : {}
32
+ missing = REQUIRED_CAPABILITIES.reject { |key, value| capabilities.is_a?(Hash) && capabilities[key] == value }
33
+ unless missing.empty? && backend.respond_to?(:view) && backend.respond_to?(:transaction)
34
+ raise UnsupportedBackendError, "Persistence requires Storage SPI 2: #{missing.keys.join(", ")}"
35
+ end
36
+ end
37
+
38
+ # @api public
39
+ def capabilities
40
+ REQUIRED_CAPABILITIES.transform_values { |value| (value == true) ? false : value }.freeze
41
+ end
42
+
43
+ # All declared resources participate in one atomic transaction domain.
44
+ # Record and stream revisions provide compare-and-swap conflict detection.
45
+ # This does not
46
+ # mean cross-process Workflow admission or distributed locking.
47
+ # Storage failures whose commit outcome is fundamentally unknown remain
48
+ # database failures; Phronomy does not claim exactly-once semantics.
49
+ # Explicit nesting uses savepoints. Non-local block exits raise and roll back.
50
+ # @api public
51
+ def transaction
52
+ current_view&.validate!
53
+ storage_transaction do |context|
54
+ scope = Scope.new
55
+ bound = View.new(self, scope: scope, context: context)
56
+ views = stack
57
+ views.push(bound)
58
+ completed = false
59
+ begin
60
+ result = yield bound
61
+ scope.check!
62
+ completed = true
63
+ result
64
+ ensure
65
+ scope.close!
66
+ views.pop
67
+ Thread.current.thread_variable_get(:phronomy_storage_scopes)&.delete(self) if views.empty?
68
+ if !completed && $!.nil?
69
+ raise TransactionError, "transaction block must complete normally; non-local exit rolled back"
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ # @api private
76
+ def current_view
77
+ Thread.current.thread_variable_get(:phronomy_storage_scopes)&.dig(self)&.last.tap { |bound| bound&.validate! }
78
+ end
79
+
80
+ # @api private
81
+ def execute(context, operation, resource, **arguments)
82
+ send(operation, context, resource, **arguments)
83
+ end
84
+
85
+ private
86
+
87
+ def stack
88
+ scopes = Thread.current.thread_variable_get(:phronomy_storage_scopes)
89
+ unless scopes
90
+ scopes = {}
91
+ Thread.current.thread_variable_set(:phronomy_storage_scopes, scopes)
92
+ end
93
+ scopes[self] ||= []
94
+ end
95
+
96
+ def storage_transaction
97
+ raise UnsupportedBackendError, "backend must implement an atomic storage transaction"
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Phronomy
6
+ module Storage
7
+ module Backends
8
+ # One Monitor and snapshot cover every declared resource and nested scope.
9
+ # @api public
10
+ class InMemory < Backend
11
+ # @api public
12
+ def initialize(resources: [])
13
+ super
14
+ @monitor = Monitor.new
15
+ @state = resources.to_h { |resource| [resource.id, {}] }
16
+ end
17
+
18
+ # @api public
19
+ def capabilities = REQUIRED_CAPABILITIES
20
+
21
+ private
22
+
23
+ def storage_transaction
24
+ @monitor.synchronize do
25
+ snapshot = Marshal.load(Marshal.dump(@state))
26
+ completed = false
27
+ begin
28
+ result = yield @state
29
+ completed = true
30
+ result
31
+ ensure
32
+ @state.replace(snapshot) unless completed
33
+ end
34
+ end
35
+ end
36
+
37
+ def bucket(state, resource) = state.fetch(resource.id)
38
+
39
+ def read_record(state, resource, key:)
40
+ value = bucket(state, resource)[key]
41
+ value && Entry::Record.new(**value.to_h)
42
+ end
43
+
44
+ def lock_guard(state, resource, key:)
45
+ raise NotFoundError, "guard record not found: #{resource.id}/#{key}" unless bucket(state, resource).key?(key)
46
+ true
47
+ end
48
+
49
+ def guard_record(state, resource, key:, attributes: {}, creating: false)
50
+ guard = resource.guard
51
+ return unless guard
52
+ anchor = resources.fetch(guard.fetch(:resource))
53
+ return if creating && anchor.equal?(resource) && guard[:via] == :key
54
+ guard_key = [:key, :stream].include?(guard[:via]) ? key : attributes.fetch(guard[:via])
55
+ lock_guard(state, anchor, key: guard_key)
56
+ end
57
+
58
+ def check_unique(state, resource, entry)
59
+ resource.unique.each do |constraint|
60
+ next if constraint[:fields].any? { |key| entry.attributes[key].nil? }
61
+ next unless constraint.fetch(:where).all? { |key, value| entry.attributes[key] == value }
62
+ conflict = bucket(state, resource).values.any? do |other|
63
+ other.key != entry.key && constraint[:where].all? { |k, val| other.attributes[k] == val } && constraint[:fields].all? { |k| other.attributes[k] == entry.attributes[k] }
64
+ end
65
+ raise UniqueConstraintError.new(resource: resource, constraint: constraint[:name]) if conflict
66
+ end
67
+ end
68
+
69
+ def insert_record(state, resource, entry:)
70
+ raise ConflictError, "duplicate record identity" if bucket(state, resource).key?(entry.key)
71
+ guard_record(state, resource, key: entry.key, attributes: entry.attributes, creating: true)
72
+ check_unique(state, resource, entry)
73
+ bucket(state, resource)[entry.key] = entry
74
+ Entry::Record.new(**entry.to_h)
75
+ end
76
+
77
+ def replace_record(state, resource, entry:, expected_revision:, expected_attributes:)
78
+ current = bucket(state, resource)[entry.key] || raise(NotFoundError, "record not found: #{entry.key}")
79
+ guard_record(state, resource, key: entry.key, attributes: current.attributes)
80
+ raise ConflictError, "record revision conflict" unless current.revision == expected_revision
81
+ unless expected_attributes.all? { |key, value| current.attributes[key] == value } && resource.immutable_attributes.all? { |key| current.attributes[key] == entry.attributes[key] }
82
+ raise ConflictError, "record attribute precondition failed"
83
+ end
84
+ check_unique(state, resource, entry)
85
+ bucket(state, resource)[entry.key] = entry
86
+ Entry::Record.new(**entry.to_h)
87
+ end
88
+
89
+ def delete_record(state, resource, key:, expected_revision:)
90
+ current = bucket(state, resource)[key]
91
+ if !expected_revision.equal?(Records::UNCHECKED) && (!current || current.revision != expected_revision)
92
+ raise ConflictError, "record deletion revision conflict"
93
+ end
94
+ return nil unless current
95
+ guard_record(state, resource, key: key, attributes: current.attributes)
96
+ bucket(state, resource).delete(key)
97
+ nil
98
+ end
99
+
100
+ def scan_records(state, resource, equals:, after:, limit:)
101
+ entries = bucket(state, resource).values.select { |entry| (!after || entry.key.b > after.b) && equals.all? { |key, value| entry.attributes[key] == value } }.sort_by { |entry| entry.key.b }
102
+ entries = entries.first(limit) if limit
103
+ entries.map { |entry| Entry::Record.new(**entry.to_h) }.freeze
104
+ end
105
+
106
+ def delete_matching_records(state, resource, equals:)
107
+ if resource.guard && ![:key, :stream].include?(resource.guard[:via])
108
+ raise ArgumentError, "deletion must constrain its guard attribute" unless equals.key?(resource.guard[:via])
109
+ guard_record(state, resource, key: "unused", attributes: equals)
110
+ end
111
+ scan_records(state, resource, equals: equals, after: nil, limit: nil).each do |entry|
112
+ delete_record(state, resource, key: entry.key, expected_revision: Records::UNCHECKED)
113
+ end
114
+ nil
115
+ end
116
+
117
+ def append_stream(state, resource, stream:, expected_head:, entries:)
118
+ guard_record(state, resource, key: stream)
119
+ stored = bucket(state, resource).fetch(stream, [])
120
+ raise ConflictError, "stream head conflict" unless stored.length == expected_head
121
+ raise ConflictError, "duplicate stream entry identity" unless (stored.map(&:id) & entries.map(&:id)).empty?
122
+ appended = entries.each_with_index.map { |entry, index| Entry::Stream.new(position: expected_head + index + 1, id: entry.id, record: entry.record) }
123
+ bucket(state, resource)[stream] = stored + appended unless appended.empty?
124
+ appended.map { |entry| Entry::Stream.new(**entry.to_h) }.freeze
125
+ end
126
+
127
+ def read_stream(state, resource, stream:, after:, limit:)
128
+ selected = bucket(state, resource).fetch(stream, []).select { |entry| entry.position > after }
129
+ selected = selected.first(limit) if limit
130
+ selected.map { |entry| Entry::Stream.new(**entry.to_h) }.freeze
131
+ end
132
+
133
+ def stream_head(state, resource, stream:) = bucket(state, resource).fetch(stream, []).length
134
+
135
+ def delete_stream(state, resource, stream:)
136
+ guard_record(state, resource, key: stream)
137
+ bucket(state, resource).delete(stream)
138
+ nil
139
+ end
140
+
141
+ def put_blob(state, resource, blob:)
142
+ stored = bucket(state, resource)[blob.key]
143
+ raise BlobConflictError, "blob bytes are immutable" if stored && stored.bytes != blob.bytes
144
+ stored ||= bucket(state, resource)[blob.key] = blob
145
+ Entry::Blob.new(**stored.to_h)
146
+ end
147
+
148
+ def fetch_blob(state, resource, key:)
149
+ stored = bucket(state, resource)[key] || raise(NotFoundError, "blob not found: #{key}")
150
+ Entry::Blob.new(**stored.to_h)
151
+ end
152
+
153
+ def blob_exists(state, resource, key:) = bucket(state, resource).key?(key)
154
+ end
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # A neutral storage contract failure.
6
+ # @api public
7
+ class BlobConflictError < ConflictError
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # Immutable byte values sharing the same transaction domain as other resources.
6
+ # @api public
7
+ class Blobs
8
+ attr_reader :resource
9
+ def initialize(view, resource) = (@view, @resource = view, resource)
10
+
11
+ # @api public
12
+ def put_if_absent(key:, bytes:, attributes:)
13
+ @view.validate!
14
+ blob = Entry::Blob.new(key: key, bytes: bytes, attributes: resource.validate_attributes(attributes))
15
+ @view.perform(:put_blob, resource, blob: blob)
16
+ end
17
+
18
+ # @api public
19
+ def fetch(key)
20
+ @view.validate!
21
+ @view.perform(:fetch_blob, resource, key: Validation.key(key))
22
+ end
23
+
24
+ # @api public
25
+ def exist?(key)
26
+ @view.validate!
27
+ @view.perform(:blob_exists, resource, key: Validation.key(key))
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # A deliberately closed set of conditions; no payload predicates or callbacks.
6
+ # @api public
7
+ module Condition
8
+ RevisionIs = Data.define(:resource, :key, :expected) do
9
+ def initialize(resource:, key:, expected:)
10
+ super(resource: Resource.normalize_reference(resource), key: Validation.key(key), expected: Validation.revision(expected))
11
+ end
12
+ end
13
+ StreamHeadIs = Data.define(:resource, :stream, :expected) do
14
+ def initialize(resource:, stream:, expected:)
15
+ super(resource: Resource.normalize_reference(resource), stream: Validation.key(stream), expected: Validation.revision(expected))
16
+ end
17
+ end
18
+ NoRows = Data.define(:resource, :index, :equals) do
19
+ def initialize(resource:, index:, equals:)
20
+ super(resource: Resource.normalize_reference(resource), index: index, equals: Validation.immutable(equals))
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # A neutral storage contract failure.
6
+ # @api public
7
+ class ConditionFailedError < ConflictError
8
+ attr_reader :condition
9
+ # @api public
10
+ def initialize(condition)
11
+ @condition = condition
12
+ super("storage condition failed: #{condition.class.name}")
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # A storage identity, revision, or position precondition failed.
6
+ # @api public
7
+ class ConflictError < Phronomy::Error; end
8
+ end
9
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- class Persistence
4
+ module Storage
5
5
  # Immutable logical record exchanged across the Persistence Backend SPI.
6
6
  #
7
7
  # Phronomy owns +record_type+, +format_version+, and the semantic meaning of
@@ -28,20 +28,20 @@ module Phronomy
28
28
  # @api public
29
29
  def initialize(record_type: MISSING, format_version: MISSING, payload: MISSING)
30
30
  if record_type.equal?(MISSING)
31
- raise Phronomy::Persistence::SerializationError, "durable record_type is missing"
31
+ raise Phronomy::Storage::SerializationError, "durable record_type is missing"
32
32
  end
33
33
  if format_version.equal?(MISSING)
34
- raise Phronomy::Persistence::SerializationError, "durable format_version is missing"
34
+ raise Phronomy::Storage::SerializationError, "durable format_version is missing"
35
35
  end
36
36
  if payload.equal?(MISSING)
37
- raise Phronomy::Persistence::SerializationError, "durable payload is missing"
37
+ raise Phronomy::Storage::SerializationError, "durable payload is missing"
38
38
  end
39
39
  unless record_type.is_a?(String)
40
- raise Phronomy::Persistence::SerializationError,
40
+ raise Phronomy::Storage::SerializationError,
41
41
  "durable record_type must be a String"
42
42
  end
43
43
  unless format_version.is_a?(String)
44
- raise Phronomy::Persistence::SerializationError,
44
+ raise Phronomy::Storage::SerializationError,
45
45
  "durable format_version must be a String"
46
46
  end
47
47
 
@@ -51,10 +51,10 @@ module Phronomy
51
51
  validate_payload!(payload)
52
52
  @payload = immutable_copy(payload)
53
53
  freeze
54
- rescue Phronomy::Persistence::SerializationError
54
+ rescue Phronomy::Storage::SerializationError
55
55
  raise
56
56
  rescue => error
57
- raise Phronomy::Persistence::SerializationError, error.message
57
+ raise Phronomy::Storage::SerializationError, error.message
58
58
  end
59
59
 
60
60
  # Returns an independent immutable record with the same logical value.
@@ -75,26 +75,26 @@ module Phronomy
75
75
 
76
76
  def validate_header!
77
77
  if record_type.empty?
78
- raise Phronomy::Persistence::SerializationError,
78
+ raise Phronomy::Storage::SerializationError,
79
79
  "durable record_type must not be empty"
80
80
  end
81
81
 
82
82
  return if FORMAT_VERSION_PATTERN.match?(format_version)
83
83
 
84
- raise Phronomy::Persistence::SerializationError,
84
+ raise Phronomy::Storage::SerializationError,
85
85
  "invalid durable format_version: #{format_version.inspect}"
86
86
  end
87
87
 
88
88
  def validate_payload!(payload)
89
89
  unless payload.is_a?(Hash)
90
- raise Phronomy::Persistence::SerializationError,
90
+ raise Phronomy::Storage::SerializationError,
91
91
  "durable payload must be a Hash"
92
92
  end
93
93
 
94
94
  Phronomy::CanonicalJSON.dump(payload)
95
95
  true
96
96
  rescue ArgumentError => error
97
- raise Phronomy::Persistence::SerializationError,
97
+ raise Phronomy::Storage::SerializationError,
98
98
  "durable payload is not canonical JSON compatible: #{error.message}"
99
99
  end
100
100
 
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # Values crossing the driver boundary never expose mutable storage state.
6
+ # @api public
7
+ module Entry
8
+ Record = Data.define(:key, :revision, :attributes, :record) do
9
+ def initialize(key:, revision:, attributes:, record:)
10
+ super(key: Validation.key(key), revision: Validation.revision(revision),
11
+ attributes: Validation.attributes(attributes), record: Validation.record(record))
12
+ end
13
+ end
14
+ Append = Data.define(:id, :record) do
15
+ def initialize(id:, record:)
16
+ super(id: Validation.key(id), record: Validation.record(record))
17
+ end
18
+ end
19
+ Stream = Data.define(:position, :id, :record) do
20
+ def initialize(position:, id:, record:)
21
+ super(position: Validation.revision(position), id: Validation.key(id), record: Validation.record(record))
22
+ end
23
+ end
24
+ Blob = Data.define(:key, :bytes, :attributes) do
25
+ def initialize(key:, bytes:, attributes:)
26
+ raise ArgumentError, "blob bytes must be a String" unless bytes.is_a?(String)
27
+ super(key: Validation.key(key), bytes: bytes.dup.force_encoding(Encoding::BINARY).freeze,
28
+ attributes: Validation.attributes(attributes))
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # A stable existing record used as a transaction lock anchor.
6
+ # @api public
7
+ GuardRef = Data.define(:resource, :key) do
8
+ def initialize(resource:, key:)
9
+ super(resource: Resource.normalize_reference(resource), key: Validation.key(key))
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # A requested stored record does not exist.
6
+ # @api public
7
+ class NotFoundError < Phronomy::Error; end
8
+ end
9
+ end
@@ -0,0 +1,177 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Storage
5
+ # Shared record-envelope and scalar validation; domain schemas live with their domains.
6
+ # @api private
7
+ module RecordCodec
8
+ private
9
+
10
+ def current_payload!(record, record_type:, format_version:, keys:, label:)
11
+ unless record.is_a?(Phronomy::Storage::DurableRecord)
12
+ raise Phronomy::Storage::SerializationError,
13
+ "backend returned #{record.class}; expected Storage::DurableRecord"
14
+ end
15
+ unless record.record_type == record_type
16
+ raise Phronomy::Storage::SerializationError,
17
+ "durable record type mismatch: expected #{record_type.inspect}, " \
18
+ "got #{record.record_type.inspect}"
19
+ end
20
+ unless record.format_version == format_version
21
+ raise Phronomy::Storage::SerializationError,
22
+ "unsupported #{record_type} format version: #{record.format_version.inspect}; " \
23
+ "current version is #{format_version.inspect}"
24
+ end
25
+ validate_exact_keys!(record.payload, keys, label: label)
26
+ record.payload
27
+ end
28
+
29
+ def validate_allowed_keys!(hash, required_keys:, optional_keys:, label:)
30
+ unless hash.is_a?(Hash)
31
+ raise Phronomy::Storage::SerializationError, "#{label} must be a Hash"
32
+ end
33
+ unless hash.keys.all? { |key| key.is_a?(String) }
34
+ raise Phronomy::Storage::SerializationError,
35
+ "#{label} keys must all be String"
36
+ end
37
+
38
+ actual = hash.keys.sort
39
+ missing = required_keys.sort - actual
40
+ unknown = actual - (required_keys + optional_keys).sort
41
+ return hash if missing.empty? && unknown.empty?
42
+
43
+ details = []
44
+ details << "missing=#{missing.inspect}" unless missing.empty?
45
+ details << "unknown=#{unknown.inspect}" unless unknown.empty?
46
+ raise Phronomy::Storage::SerializationError,
47
+ "#{label} schema mismatch (#{details.join(", ")})"
48
+ end
49
+
50
+ def validate_exact_keys!(hash, expected_keys, label:)
51
+ unless hash.is_a?(Hash)
52
+ raise Phronomy::Storage::SerializationError, "#{label} must be a Hash"
53
+ end
54
+ unless hash.keys.all? { |key| key.is_a?(String) }
55
+ raise Phronomy::Storage::SerializationError,
56
+ "#{label} keys must all be String"
57
+ end
58
+
59
+ actual = hash.keys.sort
60
+ expected = expected_keys.sort
61
+ return hash if actual == expected
62
+
63
+ missing = expected - actual
64
+ unknown = actual - expected
65
+ details = []
66
+ details << "missing=#{missing.inspect}" unless missing.empty?
67
+ details << "unknown=#{unknown.inspect}" unless unknown.empty?
68
+ raise Phronomy::Storage::SerializationError,
69
+ "#{label} schema mismatch (#{details.join(", ")})"
70
+ end
71
+
72
+ def top_level_string_keys(value, label:)
73
+ unless value.is_a?(Hash)
74
+ raise Phronomy::Storage::SerializationError, "#{label} must be a Hash"
75
+ end
76
+ value.each_with_object({}) do |(key, child), result|
77
+ unless key.is_a?(String) || key.is_a?(Symbol)
78
+ raise Phronomy::Storage::SerializationError,
79
+ "#{label} key must be String or Symbol, got #{key.class}"
80
+ end
81
+ string_key = key.to_s
82
+ if result.key?(string_key)
83
+ raise Phronomy::Storage::SerializationError,
84
+ "#{label} contains duplicate key after normalization: #{string_key.inspect}"
85
+ end
86
+ result[string_key] = child
87
+ end
88
+ end
89
+
90
+ def require_nonempty_string!(hash, key, label:)
91
+ value = hash.fetch(key)
92
+ return value if value.is_a?(String) && !value.empty?
93
+
94
+ raise Phronomy::Storage::SerializationError,
95
+ "#{label} #{key} must be a non-empty String"
96
+ end
97
+
98
+ def require_optional_string!(hash, key, label:)
99
+ value = hash.fetch(key)
100
+ return value if value.nil? || value.is_a?(String)
101
+
102
+ raise Phronomy::Storage::SerializationError,
103
+ "#{label} #{key} must be a String or nil"
104
+ end
105
+
106
+ def require_integer!(hash, key, label:)
107
+ value = hash.fetch(key)
108
+ return value if value.is_a?(Integer)
109
+
110
+ raise Phronomy::Storage::SerializationError,
111
+ "#{label} #{key} must be an Integer"
112
+ end
113
+
114
+ def require_positive_integer!(hash, key, label:)
115
+ value = hash.fetch(key)
116
+ return value if value.is_a?(Integer) && value.positive?
117
+
118
+ raise Phronomy::Storage::SerializationError,
119
+ "#{label} #{key} must be a positive Integer"
120
+ end
121
+
122
+ def require_nonnegative_integer!(hash, key, label:)
123
+ value = hash.fetch(key)
124
+ return value if value.is_a?(Integer) && value >= 0
125
+
126
+ raise Phronomy::Storage::SerializationError,
127
+ "#{label} #{key} must be a non-negative Integer"
128
+ end
129
+
130
+ def require_boolean!(hash, key, label:)
131
+ value = hash.fetch(key)
132
+ return value if boolean?(value)
133
+
134
+ raise Phronomy::Storage::SerializationError,
135
+ "#{label} #{key} must be true or false"
136
+ end
137
+
138
+ def require_enum_string!(hash, key, allowed, label:)
139
+ value = hash.fetch(key)
140
+ return value if value.is_a?(String) && allowed.include?(value)
141
+
142
+ raise Phronomy::Storage::SerializationError,
143
+ "#{label} #{key} must be one of #{allowed.inspect}"
144
+ end
145
+
146
+ def require_canonical_hash!(hash, key, label:)
147
+ value = hash.fetch(key)
148
+ unless value.is_a?(Hash)
149
+ raise Phronomy::Storage::SerializationError,
150
+ "#{label} #{key} must be a Hash"
151
+ end
152
+ Phronomy::CanonicalJSON.dump(value)
153
+ value
154
+ rescue ArgumentError => error
155
+ raise Phronomy::Storage::SerializationError,
156
+ "#{label} #{key} is not canonical JSON compatible: #{error.message}"
157
+ end
158
+
159
+ def boolean?(value)
160
+ value.equal?(true) || value.equal?(false)
161
+ end
162
+
163
+ def build_record(record_type, format_version, payload)
164
+ Phronomy::Storage::DurableRecord.new(
165
+ record_type: record_type,
166
+ format_version: format_version,
167
+ payload: payload
168
+ )
169
+ end
170
+
171
+ def serialization_error(prefix, error)
172
+ raise Phronomy::Storage::SerializationError,
173
+ "#{prefix}: #{error.class}: #{error.message}"
174
+ end
175
+ end
176
+ end
177
+ end