phronomy 0.22.0 → 0.24.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 (178) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +215 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +147 -0
  15. data/docs/architecture/persistence.md +186 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +89 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +114 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  45. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  46. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  47. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  48. data/docs/decisions/README.md +176 -0
  49. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  50. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  51. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  52. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  53. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  54. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  55. data/docs/features.md +65 -11
  56. data/docs/getting-started.md +77 -45
  57. data/docs/migrations/0.19.md +14 -7
  58. data/docs/migrations/0.22.md +390 -0
  59. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  60. data/docs/persistence-backends.md +129 -40
  61. data/docs/runtime-and-concurrency.md +227 -33
  62. data/examples/README.md +13 -0
  63. data/lib/phronomy/agent/agent_execution.rb +21 -17
  64. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  65. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  66. data/lib/phronomy/agent/agent_root.rb +3 -3
  67. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  68. data/lib/phronomy/agent/async_event_api.rb +162 -72
  69. data/lib/phronomy/agent/base.rb +416 -181
  70. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  71. data/lib/phronomy/agent/context_assembler.rb +437 -178
  72. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  73. data/lib/phronomy/agent/context_plan.rb +18 -13
  74. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  75. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  76. data/lib/phronomy/agent/context_policy.rb +109 -3
  77. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  78. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  79. data/lib/phronomy/agent/exact_execution.rb +153 -0
  80. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  81. data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
  82. data/lib/phronomy/agent/handoff.rb +43 -0
  83. data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
  84. data/lib/phronomy/agent/handoff_context.rb +120 -0
  85. data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
  86. data/lib/phronomy/agent/handoff_policy.rb +143 -0
  87. data/lib/phronomy/agent/handoff_projection.rb +207 -0
  88. data/lib/phronomy/agent/handoff_request.rb +45 -0
  89. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  90. data/lib/phronomy/agent/handoff_state.rb +43 -0
  91. data/lib/phronomy/agent/journal_record.rb +17 -4
  92. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  93. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  94. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  95. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  96. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  97. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +174 -0
  98. data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
  99. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
  100. data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
  101. data/lib/phronomy/agent/recovery_support.rb +504 -0
  102. data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -13
  103. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  104. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  105. data/lib/phronomy/agent/shared_state.rb +38 -1
  106. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  107. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  108. data/lib/phronomy/agent/tool_invocation.rb +339 -103
  109. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  110. data/lib/phronomy/agent.rb +20 -2
  111. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  112. data/lib/phronomy/agent_purged_error.rb +5 -0
  113. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  114. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  115. data/lib/phronomy/engine/event_loop.rb +622 -63
  116. data/lib/phronomy/engine/fsm_session.rb +194 -21
  117. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  118. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  119. data/lib/phronomy/engine/runtime.rb +92 -20
  120. data/lib/phronomy/generator_verifier.rb +12 -14
  121. data/lib/phronomy/invocation_context.rb +9 -29
  122. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  123. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  124. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  125. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  126. data/lib/phronomy/multi_agent/orchestrator.rb +71 -26
  127. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  128. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  129. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  130. data/lib/phronomy/persistence/durable_codec.rb +706 -0
  131. data/lib/phronomy/persistence/durable_record.rb +117 -0
  132. data/lib/phronomy/persistence/in_memory.rb +471 -133
  133. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  134. data/lib/phronomy/persistence/repository_facades.rb +535 -0
  135. data/lib/phronomy/persistence.rb +174 -40
  136. data/lib/phronomy/recovery.rb +186 -0
  137. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  138. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +2 -1
  139. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  140. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  141. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  142. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  143. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  144. data/lib/phronomy/tools/agent.rb +1 -1
  145. data/lib/phronomy/tracing/automatic.rb +176 -0
  146. data/lib/phronomy/tracing/base.rb +11 -2
  147. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  148. data/lib/phronomy/version.rb +1 -1
  149. data/lib/phronomy/workflow.rb +3 -6
  150. data/lib/phronomy/workflow_context.rb +14 -5
  151. data/lib/phronomy/workflow_recovery.rb +123 -0
  152. data/lib/phronomy/workflow_runner.rb +468 -256
  153. data/lib/phronomy.rb +6 -0
  154. data/scripts/api_snapshot.rb +12 -0
  155. data/sig/phronomy/agent.rbs +209 -7
  156. data/sig/phronomy/handoff.rbs +41 -0
  157. data/sig/phronomy/multi_agent.rbs +35 -0
  158. data/sig/phronomy/persistence.rbs +123 -4
  159. data/sig/phronomy/runtime.rbs +1 -4
  160. data/sig/phronomy/workflow.rbs +2 -2
  161. data/sig/phronomy.rbs +10 -0
  162. metadata +84 -18
  163. data/examples/workflows/agent_event_mapping.rb +0 -101
  164. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  165. data/lib/phronomy/agent/activation_registry.rb +0 -28
  166. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  167. data/lib/phronomy/agent/context_candidate.rb +0 -47
  168. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  169. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  170. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  171. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  172. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  173. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  174. data/lib/phronomy/agent/context_request.rb +0 -35
  175. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  176. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  177. data/lib/phronomy/agent/runner.rb +0 -97
  178. data/lib/phronomy/multi_agent/handoff.rb +0 -64
@@ -0,0 +1,706 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Persistence
5
+ # Current-format codec for Phronomy-owned structured Persistence records.
6
+ #
7
+ # Normal Runtime load is current-format-only. Historical conversion belongs
8
+ # to explicit migration code and must never be attempted here.
9
+ #
10
+ # The codec owns durable schema meaning. Backends receive DurableRecord plus
11
+ # explicit index/CAS metadata from RepositoryFacades; they must not inspect
12
+ # payload fields to rediscover Phronomy semantics.
13
+ #
14
+ # @api private
15
+ module DurableCodec
16
+ AGENT_ROOT_RECORD_TYPE = "phronomy.agent_root"
17
+ AGENT_ROOT_FORMAT_VERSION = "0.1"
18
+ AGENT_EXECUTION_RECORD_TYPE = "phronomy.agent_execution"
19
+ AGENT_EXECUTION_FORMAT_VERSION = "0.1"
20
+ JOURNAL_RECORD_TYPE = "phronomy.journal_record"
21
+ JOURNAL_FORMAT_VERSION = "0.1"
22
+ WORKFLOW_STATE_RECORD_TYPE = "phronomy.workflow_state"
23
+ WORKFLOW_STATE_FORMAT_VERSION = "0.1"
24
+
25
+ AGENT_ROOT_KEYS = %w[
26
+ agent_id agent_definition_id agent_definition_version agent_revision
27
+ context_revision journal_position lifecycle_status transcript_generation
28
+ created_at updated_at metadata
29
+ ].freeze
30
+
31
+ AGENT_EXECUTION_KEYS = %w[
32
+ execution_id agent_id execution_revision status phase
33
+ base_agent_revision base_context_revision base_journal_position
34
+ working_records llm_calls approval_request result_ref error_ref
35
+ created_at updated_at terminal_reason metadata
36
+ ].freeze
37
+
38
+ JOURNAL_RECORD_KEYS = %w[
39
+ record_id agent_id sequence execution_id llm_call_id kind channel role
40
+ content_ref parent_id causation_id visibility context_generation
41
+ context_candidate occurred_at metadata
42
+ ].freeze
43
+
44
+ LLM_CALL_RECORD_KEYS = %w[
45
+ llm_call_id execution_id sequence status manifest_ref output_ref
46
+ error_ref usage_ref started_at completed_at metadata
47
+ ].freeze
48
+
49
+ APPROVAL_REQUEST_KEYS = %w[id execution_id items created_at].freeze
50
+ APPROVAL_REQUEST_OPTIONAL_KEYS = %w[approved].freeze
51
+ APPROVAL_ITEM_KEYS = %w[
52
+ tool_invocation_id tool_call_id tool_name arguments facts reason origin metadata
53
+ ].freeze
54
+ WORKFLOW_STATE_KEYS = %w[
55
+ workflow_instance_id workflow_revision snapshot
56
+ ].freeze
57
+ WORKFLOW_SNAPSHOT_KEYS = %w[fields phase].freeze
58
+
59
+ module_function
60
+
61
+ def encode_handoff_state(value)
62
+ payload = value.to_h
63
+ Phronomy::Agent::HandoffState.from_h(payload)
64
+ build_record("phronomy.handoff_state", "0.1", payload)
65
+ rescue Phronomy::Persistence::SerializationError
66
+ raise
67
+ rescue => error
68
+ serialization_error("cannot encode HandoffState", error)
69
+ end
70
+
71
+ def decode_handoff_state(record)
72
+ payload = current_payload!(record, record_type: "phronomy.handoff_state",
73
+ format_version: "0.1", keys: Phronomy::Agent::HandoffState::ATTRIBUTES, label: "HandoffState")
74
+ Phronomy::Agent::HandoffState.from_h(payload)
75
+ rescue Phronomy::Persistence::SerializationError
76
+ raise
77
+ rescue => error
78
+ serialization_error("cannot decode HandoffState", error)
79
+ end
80
+
81
+ def encode_team_root(value)
82
+ payload = value.to_h
83
+ Phronomy::MultiAgent::TeamRoot.from_h(payload)
84
+ build_record("phronomy.team_root", "0.1", payload)
85
+ rescue Phronomy::Persistence::SerializationError
86
+ raise
87
+ rescue => error
88
+ serialization_error("cannot encode TeamRoot", error)
89
+ end
90
+
91
+ def decode_team_root(record)
92
+ payload = current_payload!(record, record_type: "phronomy.team_root",
93
+ format_version: "0.1", keys: Phronomy::MultiAgent::TeamRoot::ATTRIBUTES, label: "TeamRoot")
94
+ Phronomy::MultiAgent::TeamRoot.from_h(payload)
95
+ rescue Phronomy::Persistence::SerializationError
96
+ raise
97
+ rescue => error
98
+ serialization_error("cannot decode TeamRoot", error)
99
+ end
100
+
101
+ def encode_team_execution(value)
102
+ payload = value.to_h
103
+ Phronomy::MultiAgent::TeamExecution.from_h(payload)
104
+ build_record("phronomy.team_execution", "0.1", payload)
105
+ rescue Phronomy::Persistence::SerializationError
106
+ raise
107
+ rescue => error
108
+ serialization_error("cannot encode TeamExecution", error)
109
+ end
110
+
111
+ def decode_team_execution(record)
112
+ payload = current_payload!(record, record_type: "phronomy.team_execution",
113
+ format_version: "0.1", keys: Phronomy::MultiAgent::TeamExecution::ATTRIBUTES, label: "TeamExecution")
114
+ Phronomy::MultiAgent::TeamExecution.from_h(payload)
115
+ rescue Phronomy::Persistence::SerializationError
116
+ raise
117
+ rescue => error
118
+ serialization_error("cannot decode TeamExecution", error)
119
+ end
120
+
121
+ def encode_agent_root(root)
122
+ payload = top_level_string_keys(root.to_h, label: "AgentRoot payload")
123
+ payload["lifecycle_status"] = root.lifecycle_status.to_s
124
+ validate_agent_root_payload!(payload)
125
+ build_record(AGENT_ROOT_RECORD_TYPE, AGENT_ROOT_FORMAT_VERSION, payload)
126
+ rescue Phronomy::Persistence::SerializationError
127
+ raise
128
+ rescue => error
129
+ serialization_error("cannot encode AgentRoot", error)
130
+ end
131
+
132
+ def decode_agent_root(record)
133
+ payload = current_payload!(
134
+ record,
135
+ record_type: AGENT_ROOT_RECORD_TYPE,
136
+ format_version: AGENT_ROOT_FORMAT_VERSION,
137
+ keys: AGENT_ROOT_KEYS,
138
+ label: "AgentRoot payload"
139
+ )
140
+ validate_agent_root_payload!(payload)
141
+ Phronomy::Agent::AgentRoot.from_h(payload)
142
+ rescue Phronomy::Persistence::SerializationError
143
+ raise
144
+ rescue => error
145
+ serialization_error("cannot decode AgentRoot", error)
146
+ end
147
+
148
+ def encode_agent_execution(execution)
149
+ payload = top_level_string_keys(execution.to_h, label: "AgentExecution payload")
150
+ payload["status"] = execution.status.to_s
151
+ payload["phase"] = execution.phase.to_s
152
+ payload["working_records"] = execution.working_records.map do |record|
153
+ journal_payload(record, require_sequence: false)
154
+ end
155
+ payload["llm_calls"] = execution.llm_calls.map do |call|
156
+ llm_call_payload(call)
157
+ end
158
+ validate_agent_execution_payload!(payload)
159
+ build_record(
160
+ AGENT_EXECUTION_RECORD_TYPE,
161
+ AGENT_EXECUTION_FORMAT_VERSION,
162
+ payload
163
+ )
164
+ rescue Phronomy::Persistence::SerializationError
165
+ raise
166
+ rescue => error
167
+ serialization_error("cannot encode AgentExecution", error)
168
+ end
169
+
170
+ def decode_agent_execution(record)
171
+ payload = current_payload!(
172
+ record,
173
+ record_type: AGENT_EXECUTION_RECORD_TYPE,
174
+ format_version: AGENT_EXECUTION_FORMAT_VERSION,
175
+ keys: AGENT_EXECUTION_KEYS,
176
+ label: "AgentExecution payload"
177
+ )
178
+ validate_agent_execution_payload!(payload)
179
+ Phronomy::Agent::AgentExecution.from_h(payload)
180
+ rescue Phronomy::Persistence::SerializationError
181
+ raise
182
+ rescue => error
183
+ serialization_error("cannot decode AgentExecution", error)
184
+ end
185
+
186
+ def encode_journal_record(journal_record)
187
+ payload = journal_payload(journal_record, require_sequence: true)
188
+ build_record(JOURNAL_RECORD_TYPE, JOURNAL_FORMAT_VERSION, payload)
189
+ rescue Phronomy::Persistence::SerializationError
190
+ raise
191
+ rescue => error
192
+ serialization_error("cannot encode JournalRecord", error)
193
+ end
194
+
195
+ def decode_journal_record(record)
196
+ payload = current_payload!(
197
+ record,
198
+ record_type: JOURNAL_RECORD_TYPE,
199
+ format_version: JOURNAL_FORMAT_VERSION,
200
+ keys: JOURNAL_RECORD_KEYS,
201
+ label: "JournalRecord payload"
202
+ )
203
+ validate_journal_payload!(payload, label: "JournalRecord payload", require_sequence: true)
204
+ Phronomy::Agent::JournalRecord.from_h(payload)
205
+ rescue Phronomy::Persistence::SerializationError
206
+ raise
207
+ rescue => error
208
+ serialization_error("cannot decode JournalRecord", error)
209
+ end
210
+
211
+ def encode_workflow_state(workflow_instance_id:, workflow_revision:, snapshot:)
212
+ normalized_snapshot = canonicalize_workflow_snapshot(snapshot)
213
+ validate_workflow_snapshot!(normalized_snapshot)
214
+ revision = Integer(workflow_revision)
215
+ unless revision.positive?
216
+ raise Phronomy::Persistence::SerializationError,
217
+ "Workflow durable revision must be positive"
218
+ end
219
+
220
+ payload = {
221
+ "workflow_instance_id" => String(workflow_instance_id),
222
+ "workflow_revision" => revision,
223
+ "snapshot" => normalized_snapshot
224
+ }
225
+ validate_workflow_state_payload!(payload)
226
+ build_record(WORKFLOW_STATE_RECORD_TYPE, WORKFLOW_STATE_FORMAT_VERSION, payload)
227
+ rescue Phronomy::Persistence::SerializationError
228
+ raise
229
+ rescue => error
230
+ serialization_error("cannot encode Workflow state", error)
231
+ end
232
+
233
+ def decode_workflow_state(record, expected_workflow_instance_id: nil)
234
+ payload = current_payload!(
235
+ record,
236
+ record_type: WORKFLOW_STATE_RECORD_TYPE,
237
+ format_version: WORKFLOW_STATE_FORMAT_VERSION,
238
+ keys: WORKFLOW_STATE_KEYS,
239
+ label: "Workflow state payload"
240
+ )
241
+ validate_workflow_state_payload!(payload)
242
+ workflow_instance_id = payload.fetch("workflow_instance_id")
243
+ if expected_workflow_instance_id &&
244
+ workflow_instance_id != expected_workflow_instance_id.to_s
245
+ raise Phronomy::Persistence::SerializationError,
246
+ "Workflow state identity mismatch: #{workflow_instance_id.inspect} != " \
247
+ "#{expected_workflow_instance_id.to_s.inspect}"
248
+ end
249
+
250
+ {
251
+ snapshot: immutable_copy(payload.fetch("snapshot")),
252
+ revision: payload.fetch("workflow_revision")
253
+ }.freeze
254
+ rescue Phronomy::Persistence::SerializationError
255
+ raise
256
+ rescue => error
257
+ serialization_error("cannot decode Workflow state", error)
258
+ end
259
+
260
+ def validate_agent_root_payload!(payload)
261
+ validate_exact_keys!(payload, AGENT_ROOT_KEYS, label: "AgentRoot payload")
262
+ require_nonempty_string!(payload, "agent_id", label: "AgentRoot payload")
263
+ require_nonempty_string!(payload, "agent_definition_id", label: "AgentRoot payload")
264
+ require_integer!(payload, "agent_definition_version", label: "AgentRoot payload")
265
+ require_nonnegative_integer!(payload, "agent_revision", label: "AgentRoot payload")
266
+ require_nonnegative_integer!(payload, "context_revision", label: "AgentRoot payload")
267
+ require_nonnegative_integer!(payload, "journal_position", label: "AgentRoot payload")
268
+ require_enum_string!(
269
+ payload,
270
+ "lifecycle_status",
271
+ Phronomy::Agent::AgentRoot::LIFECYCLE_STATUSES.map(&:to_s),
272
+ label: "AgentRoot payload"
273
+ )
274
+ require_nonnegative_integer!(payload, "transcript_generation", label: "AgentRoot payload")
275
+ require_nonempty_string!(payload, "created_at", label: "AgentRoot payload")
276
+ require_nonempty_string!(payload, "updated_at", label: "AgentRoot payload")
277
+ require_canonical_hash!(payload, "metadata", label: "AgentRoot payload")
278
+ payload
279
+ end
280
+
281
+ def validate_agent_execution_payload!(payload)
282
+ validate_exact_keys!(payload, AGENT_EXECUTION_KEYS, label: "AgentExecution payload")
283
+ require_nonempty_string!(payload, "execution_id", label: "AgentExecution payload")
284
+ require_nonempty_string!(payload, "agent_id", label: "AgentExecution payload")
285
+ require_nonnegative_integer!(payload, "execution_revision", label: "AgentExecution payload")
286
+ require_enum_string!(
287
+ payload,
288
+ "status",
289
+ Phronomy::Agent::AgentExecution::TRANSITIONS.keys.map(&:to_s),
290
+ label: "AgentExecution payload"
291
+ )
292
+ require_nonempty_string!(payload, "phase", label: "AgentExecution payload")
293
+ require_nonnegative_integer!(payload, "base_agent_revision", label: "AgentExecution payload")
294
+ require_nonnegative_integer!(payload, "base_context_revision", label: "AgentExecution payload")
295
+ require_nonnegative_integer!(payload, "base_journal_position", label: "AgentExecution payload")
296
+ require_optional_string!(payload, "result_ref", label: "AgentExecution payload")
297
+ require_optional_string!(payload, "error_ref", label: "AgentExecution payload")
298
+ require_nonempty_string!(payload, "created_at", label: "AgentExecution payload")
299
+ require_nonempty_string!(payload, "updated_at", label: "AgentExecution payload")
300
+ require_optional_string!(payload, "terminal_reason", label: "AgentExecution payload")
301
+ require_canonical_hash!(payload, "metadata", label: "AgentExecution payload")
302
+
303
+ working_records = payload.fetch("working_records")
304
+ unless working_records.is_a?(Array)
305
+ raise Phronomy::Persistence::SerializationError,
306
+ "AgentExecution payload working_records must be an Array"
307
+ end
308
+ working_records.each_with_index do |record, index|
309
+ validate_journal_payload!(
310
+ record,
311
+ label: "AgentExecution working_records[#{index}]",
312
+ require_sequence: false
313
+ )
314
+ record_agent_id = record.fetch("agent_id")
315
+ unless record_agent_id == payload.fetch("agent_id")
316
+ raise Phronomy::Persistence::SerializationError,
317
+ "AgentExecution working_records[#{index}] agent_id mismatch"
318
+ end
319
+ record_execution_id = record.fetch("execution_id")
320
+ if record_execution_id && record_execution_id != payload.fetch("execution_id")
321
+ raise Phronomy::Persistence::SerializationError,
322
+ "AgentExecution working_records[#{index}] execution_id mismatch"
323
+ end
324
+ end
325
+
326
+ llm_calls = payload.fetch("llm_calls")
327
+ unless llm_calls.is_a?(Array)
328
+ raise Phronomy::Persistence::SerializationError,
329
+ "AgentExecution payload llm_calls must be an Array"
330
+ end
331
+ llm_calls.each_with_index do |call, index|
332
+ validate_llm_call_payload!(call, label: "AgentExecution llm_calls[#{index}]")
333
+ unless call.fetch("execution_id") == payload.fetch("execution_id")
334
+ raise Phronomy::Persistence::SerializationError,
335
+ "AgentExecution llm_calls[#{index}] execution_id mismatch"
336
+ end
337
+ end
338
+
339
+ validate_approval_request!(
340
+ payload.fetch("approval_request"),
341
+ execution_id: payload.fetch("execution_id")
342
+ )
343
+ payload
344
+ end
345
+
346
+ def validate_journal_payload!(payload, label:, require_sequence:)
347
+ validate_exact_keys!(payload, JOURNAL_RECORD_KEYS, label: label)
348
+ require_nonempty_string!(payload, "record_id", label: label)
349
+ require_nonempty_string!(payload, "agent_id", label: label)
350
+ sequence = payload.fetch("sequence")
351
+ if require_sequence
352
+ unless sequence.is_a?(Integer) && sequence.positive?
353
+ raise Phronomy::Persistence::SerializationError,
354
+ "#{label} sequence must be a positive Integer"
355
+ end
356
+ elsif !(sequence.nil? || (sequence.is_a?(Integer) && sequence.positive?))
357
+ raise Phronomy::Persistence::SerializationError,
358
+ "#{label} sequence must be nil or a positive Integer"
359
+ end
360
+ require_optional_string!(payload, "execution_id", label: label)
361
+ require_optional_string!(payload, "llm_call_id", label: label)
362
+ require_nonempty_string!(payload, "kind", label: label)
363
+ require_nonempty_string!(payload, "channel", label: label)
364
+ require_optional_string!(payload, "role", label: label)
365
+ require_optional_string!(payload, "content_ref", label: label)
366
+ require_optional_string!(payload, "parent_id", label: label)
367
+ require_optional_string!(payload, "causation_id", label: label)
368
+ require_nonempty_string!(payload, "visibility", label: label)
369
+ require_nonnegative_integer!(payload, "context_generation", label: label)
370
+ require_boolean!(payload, "context_candidate", label: label)
371
+ require_nonempty_string!(payload, "occurred_at", label: label)
372
+ require_canonical_hash!(payload, "metadata", label: label)
373
+ payload
374
+ end
375
+
376
+ def validate_llm_call_payload!(payload, label:)
377
+ validate_exact_keys!(payload, LLM_CALL_RECORD_KEYS, label: label)
378
+ require_nonempty_string!(payload, "llm_call_id", label: label)
379
+ require_nonempty_string!(payload, "execution_id", label: label)
380
+ require_positive_integer!(payload, "sequence", label: label)
381
+ require_enum_string!(
382
+ payload,
383
+ "status",
384
+ Phronomy::Agent::LLMCallRecord::STATUSES.map(&:to_s),
385
+ label: label
386
+ )
387
+ require_nonempty_string!(payload, "manifest_ref", label: label)
388
+ require_optional_string!(payload, "output_ref", label: label)
389
+ require_optional_string!(payload, "error_ref", label: label)
390
+ require_optional_string!(payload, "usage_ref", label: label)
391
+ require_nonempty_string!(payload, "started_at", label: label)
392
+ require_optional_string!(payload, "completed_at", label: label)
393
+ require_canonical_hash!(payload, "metadata", label: label)
394
+ payload
395
+ end
396
+
397
+ def validate_approval_request!(request, execution_id:)
398
+ return if request.nil?
399
+
400
+ validate_allowed_keys!(
401
+ request,
402
+ required_keys: APPROVAL_REQUEST_KEYS,
403
+ optional_keys: APPROVAL_REQUEST_OPTIONAL_KEYS,
404
+ label: "approval_request"
405
+ )
406
+ require_nonempty_string!(request, "id", label: "approval_request")
407
+ require_nonempty_string!(request, "execution_id", label: "approval_request")
408
+ unless request.fetch("execution_id") == execution_id
409
+ raise Phronomy::Persistence::SerializationError,
410
+ "approval_request execution_id mismatch"
411
+ end
412
+ require_nonempty_string!(request, "created_at", label: "approval_request")
413
+ if request.key?("approved") && !boolean?(request.fetch("approved"))
414
+ raise Phronomy::Persistence::SerializationError,
415
+ "approval_request approved must be true or false"
416
+ end
417
+
418
+ items = request.fetch("items")
419
+ unless items.is_a?(Array) && !items.empty?
420
+ raise Phronomy::Persistence::SerializationError,
421
+ "approval_request items must be a non-empty Array"
422
+ end
423
+ items.each_with_index do |item, index|
424
+ item_label = "approval_request items[#{index}]"
425
+ validate_exact_keys!(item, APPROVAL_ITEM_KEYS, label: item_label)
426
+ require_nonempty_string!(item, "tool_invocation_id", label: item_label)
427
+ require_optional_string!(item, "tool_call_id", label: item_label)
428
+ require_nonempty_string!(item, "tool_name", label: item_label)
429
+ require_canonical_hash!(item, "arguments", label: item_label)
430
+ require_canonical_hash!(item, "facts", label: item_label)
431
+ require_optional_string!(item, "reason", label: item_label)
432
+ require_nonempty_string!(item, "origin", label: item_label)
433
+ require_canonical_hash!(item, "metadata", label: item_label)
434
+ end
435
+ request
436
+ end
437
+
438
+ def validate_workflow_state_payload!(payload)
439
+ validate_exact_keys!(payload, WORKFLOW_STATE_KEYS, label: "Workflow state payload")
440
+ require_nonempty_string!(payload, "workflow_instance_id", label: "Workflow state payload")
441
+ require_positive_integer!(payload, "workflow_revision", label: "Workflow state payload")
442
+ validate_workflow_snapshot!(payload.fetch("snapshot"))
443
+ payload
444
+ end
445
+
446
+ def validate_workflow_snapshot!(snapshot)
447
+ validate_exact_keys!(snapshot, WORKFLOW_SNAPSHOT_KEYS, label: "Workflow snapshot")
448
+ unless snapshot.fetch("fields").is_a?(Hash)
449
+ raise Phronomy::Persistence::SerializationError,
450
+ "Workflow snapshot fields must be a Hash"
451
+ end
452
+ phase = snapshot.fetch("phase")
453
+ unless phase.nil? || phase.is_a?(String)
454
+ raise Phronomy::Persistence::SerializationError,
455
+ "Workflow snapshot phase must be a String or nil"
456
+ end
457
+ Phronomy::CanonicalJSON.dump(snapshot)
458
+ snapshot
459
+ rescue ArgumentError => error
460
+ raise Phronomy::Persistence::SerializationError,
461
+ "Workflow snapshot is not canonical JSON compatible: #{error.message}"
462
+ end
463
+
464
+ def current_payload!(record, record_type:, format_version:, keys:, label:)
465
+ unless record.is_a?(Phronomy::Persistence::DurableRecord)
466
+ raise Phronomy::Persistence::SerializationError,
467
+ "backend returned #{record.class}; expected Persistence::DurableRecord"
468
+ end
469
+ unless record.record_type == record_type
470
+ raise Phronomy::Persistence::SerializationError,
471
+ "durable record type mismatch: expected #{record_type.inspect}, " \
472
+ "got #{record.record_type.inspect}"
473
+ end
474
+ unless record.format_version == format_version
475
+ raise Phronomy::Persistence::SerializationError,
476
+ "unsupported #{record_type} format version: #{record.format_version.inspect}; " \
477
+ "current version is #{format_version.inspect}"
478
+ end
479
+ validate_exact_keys!(record.payload, keys, label: label)
480
+ record.payload
481
+ end
482
+
483
+ def validate_allowed_keys!(hash, required_keys:, optional_keys:, label:)
484
+ unless hash.is_a?(Hash)
485
+ raise Phronomy::Persistence::SerializationError, "#{label} must be a Hash"
486
+ end
487
+ unless hash.keys.all? { |key| key.is_a?(String) }
488
+ raise Phronomy::Persistence::SerializationError,
489
+ "#{label} keys must all be String"
490
+ end
491
+
492
+ actual = hash.keys.sort
493
+ missing = required_keys.sort - actual
494
+ unknown = actual - (required_keys + optional_keys).sort
495
+ return hash if missing.empty? && unknown.empty?
496
+
497
+ details = []
498
+ details << "missing=#{missing.inspect}" unless missing.empty?
499
+ details << "unknown=#{unknown.inspect}" unless unknown.empty?
500
+ raise Phronomy::Persistence::SerializationError,
501
+ "#{label} schema mismatch (#{details.join(", ")})"
502
+ end
503
+
504
+ def validate_exact_keys!(hash, expected_keys, label:)
505
+ unless hash.is_a?(Hash)
506
+ raise Phronomy::Persistence::SerializationError, "#{label} must be a Hash"
507
+ end
508
+ unless hash.keys.all? { |key| key.is_a?(String) }
509
+ raise Phronomy::Persistence::SerializationError,
510
+ "#{label} keys must all be String"
511
+ end
512
+
513
+ actual = hash.keys.sort
514
+ expected = expected_keys.sort
515
+ return hash if actual == expected
516
+
517
+ missing = expected - actual
518
+ unknown = actual - expected
519
+ details = []
520
+ details << "missing=#{missing.inspect}" unless missing.empty?
521
+ details << "unknown=#{unknown.inspect}" unless unknown.empty?
522
+ raise Phronomy::Persistence::SerializationError,
523
+ "#{label} schema mismatch (#{details.join(", ")})"
524
+ end
525
+
526
+ def top_level_string_keys(value, label:)
527
+ unless value.is_a?(Hash)
528
+ raise Phronomy::Persistence::SerializationError, "#{label} must be a Hash"
529
+ end
530
+ value.each_with_object({}) do |(key, child), result|
531
+ unless key.is_a?(String) || key.is_a?(Symbol)
532
+ raise Phronomy::Persistence::SerializationError,
533
+ "#{label} key must be String or Symbol, got #{key.class}"
534
+ end
535
+ string_key = key.to_s
536
+ if result.key?(string_key)
537
+ raise Phronomy::Persistence::SerializationError,
538
+ "#{label} contains duplicate key after normalization: #{string_key.inspect}"
539
+ end
540
+ result[string_key] = child
541
+ end
542
+ end
543
+
544
+ # Workflow fields historically normalize Ruby structural keys and Symbol
545
+ # values to strings before durable comparison. Keep that rule explicit and
546
+ # isolated here instead of applying Symbol#to_s generically to every codec.
547
+ def canonicalize_workflow_snapshot(snapshot)
548
+ source = top_level_string_keys(snapshot, label: "Workflow snapshot")
549
+ fields = source.fetch("fields")
550
+ unless fields.is_a?(Hash)
551
+ raise Phronomy::Persistence::SerializationError,
552
+ "Workflow snapshot fields must be a Hash"
553
+ end
554
+ {
555
+ "fields" => canonicalize_workflow_value(fields),
556
+ "phase" => source["phase"]&.to_s
557
+ }
558
+ end
559
+
560
+ def canonicalize_workflow_value(value)
561
+ case value
562
+ when Hash
563
+ value.each_with_object({}) do |(key, child), result|
564
+ unless key.is_a?(String) || key.is_a?(Symbol)
565
+ raise Phronomy::Persistence::SerializationError,
566
+ "Workflow field key must be String or Symbol, got #{key.class}"
567
+ end
568
+ string_key = key.to_s
569
+ if result.key?(string_key)
570
+ raise Phronomy::Persistence::SerializationError,
571
+ "duplicate Workflow field key after normalization: #{string_key.inspect}"
572
+ end
573
+ result[string_key] = canonicalize_workflow_value(child)
574
+ end
575
+ when Array
576
+ value.map { |child| canonicalize_workflow_value(child) }
577
+ when Symbol
578
+ value.to_s
579
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
580
+ value
581
+ else
582
+ raise Phronomy::Persistence::SerializationError,
583
+ "unsupported Workflow durable value: #{value.class}"
584
+ end
585
+ end
586
+
587
+ def journal_payload(record, require_sequence:)
588
+ payload = top_level_string_keys(record.to_h, label: "JournalRecord payload")
589
+ %w[kind channel role visibility].each do |key|
590
+ value = payload[key]
591
+ payload[key] = value.to_s if value
592
+ end
593
+ validate_journal_payload!(payload, label: "JournalRecord payload", require_sequence: require_sequence)
594
+ payload
595
+ end
596
+
597
+ def llm_call_payload(call)
598
+ payload = top_level_string_keys(call.to_h, label: "LLMCallRecord payload")
599
+ payload["status"] = call.status.to_s
600
+ validate_llm_call_payload!(payload, label: "LLMCallRecord payload")
601
+ payload
602
+ end
603
+
604
+ def require_nonempty_string!(hash, key, label:)
605
+ value = hash.fetch(key)
606
+ return value if value.is_a?(String) && !value.empty?
607
+
608
+ raise Phronomy::Persistence::SerializationError,
609
+ "#{label} #{key} must be a non-empty String"
610
+ end
611
+
612
+ def require_optional_string!(hash, key, label:)
613
+ value = hash.fetch(key)
614
+ return value if value.nil? || value.is_a?(String)
615
+
616
+ raise Phronomy::Persistence::SerializationError,
617
+ "#{label} #{key} must be a String or nil"
618
+ end
619
+
620
+ def require_integer!(hash, key, label:)
621
+ value = hash.fetch(key)
622
+ return value if value.is_a?(Integer)
623
+
624
+ raise Phronomy::Persistence::SerializationError,
625
+ "#{label} #{key} must be an Integer"
626
+ end
627
+
628
+ def require_positive_integer!(hash, key, label:)
629
+ value = hash.fetch(key)
630
+ return value if value.is_a?(Integer) && value.positive?
631
+
632
+ raise Phronomy::Persistence::SerializationError,
633
+ "#{label} #{key} must be a positive Integer"
634
+ end
635
+
636
+ def require_nonnegative_integer!(hash, key, label:)
637
+ value = hash.fetch(key)
638
+ return value if value.is_a?(Integer) && value >= 0
639
+
640
+ raise Phronomy::Persistence::SerializationError,
641
+ "#{label} #{key} must be a non-negative Integer"
642
+ end
643
+
644
+ def require_boolean!(hash, key, label:)
645
+ value = hash.fetch(key)
646
+ return value if boolean?(value)
647
+
648
+ raise Phronomy::Persistence::SerializationError,
649
+ "#{label} #{key} must be true or false"
650
+ end
651
+
652
+ def require_enum_string!(hash, key, allowed, label:)
653
+ value = hash.fetch(key)
654
+ return value if value.is_a?(String) && allowed.include?(value)
655
+
656
+ raise Phronomy::Persistence::SerializationError,
657
+ "#{label} #{key} must be one of #{allowed.inspect}"
658
+ end
659
+
660
+ def require_canonical_hash!(hash, key, label:)
661
+ value = hash.fetch(key)
662
+ unless value.is_a?(Hash)
663
+ raise Phronomy::Persistence::SerializationError,
664
+ "#{label} #{key} must be a Hash"
665
+ end
666
+ Phronomy::CanonicalJSON.dump(value)
667
+ value
668
+ rescue ArgumentError => error
669
+ raise Phronomy::Persistence::SerializationError,
670
+ "#{label} #{key} is not canonical JSON compatible: #{error.message}"
671
+ end
672
+
673
+ def boolean?(value)
674
+ value.equal?(true) || value.equal?(false)
675
+ end
676
+
677
+ def build_record(record_type, format_version, payload)
678
+ Phronomy::Persistence::DurableRecord.new(
679
+ record_type: record_type,
680
+ format_version: format_version,
681
+ payload: payload
682
+ )
683
+ end
684
+
685
+ def immutable_copy(value)
686
+ case value
687
+ when Hash
688
+ value.each_with_object({}) do |(key, child), result|
689
+ result[key.dup.freeze] = immutable_copy(child)
690
+ end.freeze
691
+ when Array
692
+ value.map { |child| immutable_copy(child) }.freeze
693
+ when String
694
+ value.dup.freeze
695
+ else
696
+ value
697
+ end
698
+ end
699
+
700
+ def serialization_error(prefix, error)
701
+ raise Phronomy::Persistence::SerializationError,
702
+ "#{prefix}: #{error.class}: #{error.message}"
703
+ end
704
+ end
705
+ end
706
+ end