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