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,249 @@
1
+ # ADR-025: Process-Local Agent Ownership and Runtime Admission
2
+
3
+ **Status**: Accepted
4
+ **Date**: 2026-08-24
5
+ **Partially supersedes**: [ADR-014](014-unified-persistence-durable-state.md) for same-process Agent live-instance ownership and top-level execution admission
6
+ **Complements**: [ADR-018](018-durability-guarantees-and-failure-model.md), [ADR-022](022-agent-execution-parent-identity-and-runtime-routing-boundary.md), [ADR-023](023-fsm-session-incarnation-identity-and-routing.md), [ADR-024](024-event-loop-single-writer-agent-runtime.md)
7
+
8
+ ---
9
+
10
+ ## Context
11
+
12
+ Phronomy's durable Agent identity is `agent_id`. The architecture baseline treats
13
+ that value as the identity of one logical Agent, not as a database lookup key
14
+ that may be reused by several independent mutable Ruby objects.
15
+
16
+ Before this decision, `Agent::Base.load(agent_id, persistence:)` could hydrate a
17
+ new mutable Agent every time it was called. The same process could therefore
18
+ hold two independent live objects that both represented the same logical Agent.
19
+ Separately, top-level `invoke` requests reached EventLoop, but the first concrete
20
+ same-Agent exclusion was still `Persistence#executions.create_active` inside the
21
+ initial Offload/Persistence operation.
22
+
23
+ That placement confused three different authorities:
24
+
25
+ ```text
26
+ Runtime
27
+ process-local live Agent ownership
28
+
29
+ EventLoop
30
+ process-local top-level execution admission and live execution progression
31
+
32
+ Persistence
33
+ last confirmed durable representation, atomic durable transition,
34
+ optimistic conflict detection and recovery source
35
+ ```
36
+
37
+ ADR-024 made EventLoop the single writer of Phronomy-managed live Agent
38
+ execution state. ACS-12 adds the process-local logical-Agent ownership and
39
+ admission layer on top of that foundation without turning Persistence into a
40
+ live ownership service.
41
+
42
+ ## Decision
43
+
44
+ ### One `agent_id` has one mutable live Agent owner per Runtime
45
+
46
+ A Runtime owns a purpose-specific Agent ownership registry keyed only by
47
+ `agent_id`:
48
+
49
+ ```text
50
+ agent_id
51
+ -> one mutable live Agent instance
52
+ ```
53
+
54
+ The registry is an ownership authority, not a cache. Immutable snapshots,
55
+ durable records, read-only projections and handles may still have multiple
56
+ representations of the same Agent identity.
57
+
58
+ Agent ownership is reserved before durable create/load and before the mutable
59
+ Agent is published. Concurrent materialization of the same identity therefore
60
+ cannot create two independent live objects.
61
+
62
+ The registry is separate from EventLoop's `execution_id -> AgentExecutionState`
63
+ directory. Agent lifetime and Execution lifetime are different semantic
64
+ lifetimes and must not be represented by one registry.
65
+
66
+ ### Public Agent construction/resolution semantics
67
+
68
+ The public operations have distinct meanings:
69
+
70
+ ```text
71
+ new / create
72
+ create a new logical Agent
73
+ existing live or durable identity -> AgentAlreadyExistsError
74
+
75
+ load(agent_id, persistence:)
76
+ resolve an existing logical Agent
77
+ live owner -> return the exact same Ruby object without Persistence reload
78
+ durable-only -> hydrate once and publish as the live owner
79
+ missing durable Agent -> Persistence::NotFoundError
80
+
81
+ get(agent_id)
82
+ process-local live-owner lookup only
83
+ live -> exact same Ruby object
84
+ not live -> nil
85
+ never loads Persistence
86
+ ```
87
+
88
+ A live identity resolved through an incompatible Agent class/definition is an
89
+ explicit configuration error; it is not treated as a cache miss. A `load` call
90
+ that supplies a different Persistence instance from the already-live Agent is
91
+ also rejected rather than silently ignoring the caller's backend argument.
92
+
93
+ Ruby `.new` remains supported because it is an established application-facing
94
+ construction path. Its meaning is creation, not lookup: `.new(agent_id: "A")`
95
+ does not return an already-existing A.
96
+
97
+ ### Ownership normally lasts for the Runtime lifetime
98
+
99
+ Once a mutable Agent is live, the Runtime keeps a strong ownership reference.
100
+ Execution completion, idleness and Ruby GC do not release that identity.
101
+
102
+ A clean Runtime shutdown detaches all live Agent objects from that Runtime. An
103
+ old Ruby reference is no longer a usable mutable Agent after shutdown; attempts
104
+ to operate on it fail with `RuntimeShutdownError`. This prevents a stale object
105
+ from remaining mutable while a new Runtime hydrates the same `agent_id`.
106
+
107
+ Live Agent entries do not themselves keep Runtime shutdown from completing.
108
+ Only an in-progress ownership transition such as construction or purge must
109
+ settle before clean ownership detachment.
110
+
111
+ ### `purge!` is explicit logical-Agent destruction
112
+
113
+ `purge!` is the explicit exception to Runtime-lifetime ownership. It first moves
114
+ the exact current live owner into a process-local purging state, preventing new
115
+ materialization/admission, then deletes the durable Agent state.
116
+
117
+ On known successful purge:
118
+
119
+ ```text
120
+ old Agent object -> permanently purged / unusable
121
+ Runtime registry -> identity released
122
+ Persistence -> Agent/Journal/Execution records deleted
123
+ ```
124
+
125
+ The same textual `agent_id` may then be used to create a new logical Agent. A
126
+ stale Ruby reference to the old purged object remains invalid and cannot purge
127
+ or mutate the replacement. Repeating `purge!` on that already-purged stale
128
+ object is an idempotent no-op.
129
+
130
+ If the purge is known not to have committed, the process-local purging state is
131
+ rolled back to live. If durable outcome is uncertain, the transition becomes a
132
+ stable `RECOVERY_REQUIRED` ownership state. The identity stays fail-closed, but
133
+ load/shutdown waiters are not left blocked on a transition that can no longer
134
+ settle by itself. ACS-15 recovery/reconciliation work is responsible for
135
+ resolving that uncertainty.
136
+
137
+ ### EventLoop owns same-process top-level execution admission
138
+
139
+ A live Agent may be idle while still owned. Top-level Execution admission is a
140
+ separate EventLoop-owned map keyed by `agent_id`.
141
+
142
+ For one logical Agent, at most one nonterminal top-level Execution is admitted:
143
+
144
+ ```text
145
+ IDLE + invoke(E1)
146
+ -> ADMITTING(E1)
147
+
148
+ ADMITTING / EXECUTING / SUSPENDED / RECOVERY_REQUIRED + invoke(E2)
149
+ -> AgentBusyError
150
+ ```
151
+
152
+ The admission is acquired on EventLoop before the initial Offload/Persistence
153
+ operation. `Persistence#executions.create_active` remains in that operation as a
154
+ durable second line of defense.
155
+
156
+ A short-lived opaque owner token protects the pre-durable `ADMITTING` entry so
157
+ only the request that acquired it may release/bind it. The token is a
158
+ process-local coordination capability only. It is not a domain identity,
159
+ semantic operation ID, generic generation counter or asynchronous result
160
+ authority. After durable establishment, the admission is bound to the canonical
161
+ `execution_id`.
162
+
163
+ ### Admission follows the logical Execution lifetime
164
+
165
+ `preparing`, `active` and `suspended` are all nonterminal. Suspension keeps the
166
+ same admission; approval resume continues the same `execution_id` and does not
167
+ create a new top-level Execution.
168
+
169
+ The slot is released only after a known-successful durable terminal transition.
170
+ Caller-facing Task settlement and terminal callbacks are notification boundaries
171
+ and do not extend the logical Execution lifetime.
172
+
173
+ A known pre-durable failure releases the process-local admission. An uncertain
174
+ durable establishment/terminal outcome does not. It is marked
175
+ `RECOVERY_REQUIRED`/fail-closed so another top-level Execution cannot be admitted
176
+ from an unproven state lineage.
177
+
178
+ ### Persistence admission remains a durable integrity capability
179
+
180
+ `atomic_admission` and `executions.create_active` remain required Persistence
181
+ capabilities. They continue to guarantee durable execution-ID uniqueness and
182
+ that durable nonterminal executions for one `agent_id` do not overlap.
183
+
184
+ They are no longer described as the primary same-process live/execution
185
+ ownership mechanism. Their role is durable integrity and defensive conflict
186
+ detection, including protection against stale code paths and unsupported
187
+ multi-process races.
188
+
189
+ Optimistic revision, Journal position and watermark guards remain unchanged.
190
+ Phronomy does not reload/merge durable state after a conflict to continue the
191
+ same logical execution.
192
+
193
+ ## Required invariants
194
+
195
+ 1. One Runtime never publishes two independent mutable Agent objects for the same `agent_id`.
196
+ 2. Repeated `load` of a live Agent returns the same Ruby object and does not reload durable state.
197
+ 3. `get` is process-local and never performs Persistence I/O.
198
+ 4. Agent ownership and EventLoop execution-state directories remain separate responsibilities.
199
+ 5. EventLoop admission occurs before initial Persistence execution establishment.
200
+ 6. Competing same-Agent top-level requests are rejected with `AgentBusyError`; core does not promise automatic queueing.
201
+ 7. Suspension retains the same logical Execution admission.
202
+ 8. Known durable terminal success releases admission before caller notification is required to settle.
203
+ 9. Unknown durable outcome never causes a blind admission release.
204
+ 10. Persistence `create_active`/CAS/revision/watermark protection remains enabled as durable defense.
205
+ 11. Runtime shutdown makes old live Agent objects unusable before a later Runtime can authoritatively hydrate the same identity.
206
+ 12. Successful `purge!` invalidates the old object before the identity may represent a replacement live Agent.
207
+
208
+ ## Explicitly deferred work
209
+
210
+ This decision does **not** implement:
211
+
212
+ - cross-process Agent ownership, lease/fencing or stable routing (ACS-14);
213
+ - durable outcome reconciliation/Agent execution rehydration (ACS-15);
214
+ - cancellation-wide physical-work quiescence supervision (ACS-16);
215
+ - semantic external-operation retry/causal barriers (ACS-17);
216
+ - a general Agent duplication contract.
217
+
218
+ The future Agent duplication operation is named **`copy`**, not `fork`. It must
219
+ create a new `agent_id`. Which Context, Knowledge, Journal history, metadata or
220
+ provenance is copied, and how nonterminal Execution state is handled, remain a
221
+ separate API/semantic decision. ACS-12 does not add `copy` to the runtime API.
222
+
223
+ ## Rejected alternatives
224
+
225
+ ### Use Persistence `create_active` as the only same-process exclusion
226
+
227
+ Rejected. It preserves durable exclusion behavior but leaves Runtime authority
228
+ ambiguous and allows competing requests to reach durable I/O before the
229
+ process-local owner has decided which continuation is authoritative.
230
+
231
+ ### Put Agent live ownership into EventLoop's execution directory
232
+
233
+ Rejected. A live Agent exists while idle and across many sequential Executions.
234
+ `agent_id` ownership and `execution_id` Runtime state have different lifetimes.
235
+
236
+ ### Let repeated `load` create a new object and rely on CAS later
237
+
238
+ Rejected. CAS detects stale durable writes after two mutable owners already
239
+ exist; it does not satisfy the logical Agent identity invariant.
240
+
241
+ ### Make `.new` return an existing instance
242
+
243
+ Rejected. Ruby `.new` is creation semantics. Returning an existing object would
244
+ make identity lookup implicit and surprising; `load`/`get` provide resolution.
245
+
246
+ ### Release ownership when an Execution completes or when GC collects the Agent
247
+
248
+ Rejected. Execution lifetime is shorter than logical Agent live lifetime, and GC
249
+ timing is not an architecture ownership protocol.
@@ -0,0 +1,257 @@
1
+ # ADR-026: Workflow Runtime Admission and Durable Terminal Barrier
2
+
3
+ **Status**: Accepted
4
+ **Date**: 2026-08-24
5
+ **Partially supersedes**: [ADR-014](014-unified-persistence-durable-state.md) for same-process Workflow admission ownership and terminal durable-barrier ordering
6
+ **Refines**: [ADR-023](023-fsm-session-incarnation-identity-and-routing.md) by removing the transitional Workflow FSMSession-identity reservation bridge
7
+ **Complements**: [ADR-018](018-durability-guarantees-and-failure-model.md), [ADR-020](020-canonical-workflow-instance-identity.md), [ADR-024](024-event-loop-single-writer-agent-runtime.md), [ADR-025](025-process-local-agent-ownership-and-runtime-admission.md)
8
+
9
+ ---
10
+
11
+ ## Context
12
+
13
+ `workflow_instance_id` is the canonical logical/durable Workflow identity.
14
+ `fsm_session_id` identifies one concrete Runtime FSMSession incarnation. Those
15
+ identities have different responsibilities and lifetimes.
16
+
17
+ Before this decision, WorkflowRunner reserved the future FSMSession identity
18
+ before durable hydration and reused that value as the process-local Workflow
19
+ admission owner. This preserved admission-before-load ordering, but it conflated
20
+ Runtime coordination ownership with concrete event-routing identity.
21
+
22
+ Durable Workflow terminalization also had the wrong lifecycle order. A
23
+ FSMSession first became `halted`/`finished`, EventLoop removed the session and
24
+ settled its source completion, and only then WorkflowRunner saved the final
25
+ Workflow snapshot. A save failure could therefore occur after the runtime
26
+ lifecycle had already declared the execution segment terminal.
27
+
28
+ The Architecture Baseline requires both inconsistencies to be removed:
29
+
30
+ ```text
31
+ acquire Workflow admission
32
+
33
+ load / hydrate durable Workflow state
34
+
35
+ create and run one concrete FSMSession
36
+
37
+ logical halt / completion reached
38
+
39
+ persist terminal snapshot
40
+
41
+ known-successful persistence result returns to that FSMSession
42
+
43
+ HALTED / COMPLETED
44
+
45
+ release Workflow admission
46
+
47
+ settle caller-facing Task
48
+ ```
49
+
50
+ ## Decision
51
+
52
+ ### Workflow admission uses an opaque Runtime owner token
53
+
54
+ EventLoop owns a process-local Workflow admission entry keyed by
55
+ `workflow_instance_id`. Admission ownership uses a fresh opaque owner token that
56
+ is independent from the later concrete `fsm_session_id`:
57
+
58
+ ```text
59
+ workflow_instance_id = W
60
+
61
+ admission owner token = T1
62
+
63
+ concrete FSMSession id = S1
64
+ ```
65
+
66
+ The token is a Runtime coordination capability only. It is not a domain
67
+ identity, durable identifier, application correlation value, generic generation
68
+ counter, or asynchronous semantic-result authority.
69
+
70
+ Only the exact owner token that acquired the admission may bind or release it.
71
+ A competing/stale token cannot release the current Workflow execution segment.
72
+
73
+ ### Admission precedes mutable durable hydration
74
+
75
+ Start/resume requests are first posted to EventLoop. EventLoop acquires the
76
+ `workflow_instance_id` admission before any `workflow_states.load` is submitted
77
+ to OffloadPool.
78
+
79
+ A durable load returns only operation-specific value/snapshot data. Hydration,
80
+ WorkflowContext construction, concrete FSMSession construction, admission-to-
81
+ FSMSession binding, and registration are applied on EventLoop.
82
+
83
+ This preserves the ordering:
84
+
85
+ ```text
86
+ admit W with T1
87
+
88
+ load W
89
+
90
+ hydrate live Workflow state
91
+
92
+ create S1
93
+
94
+ bind T1 -> S1 for routing
95
+ ```
96
+
97
+ `fsm_session_id` remains the direct EventLoop routing identity. Application
98
+ `Workflow#signal(workflow_instance_id: ...)` resolves the current admission to
99
+ its bound `fsm_session_id`; the owner token is never used as an event target.
100
+
101
+ ### Durable Workflow terminalization is part of the FSMSession lifecycle
102
+
103
+ A durable Workflow that reaches logical halt/completion does not immediately
104
+ become runtime-terminal. The logical Workflow phase and the terminal-persistence
105
+ lifecycle are separate.
106
+
107
+ Conceptually:
108
+
109
+ ```text
110
+ RUNNING
111
+ ↓ logical halt/completion result
112
+ PERSISTING_TERMINAL
113
+ ↓ terminal persistence result
114
+ ├─ known success -> HALTED / COMPLETED
115
+ ├─ known failure -> ERROR
116
+ └─ outcome unknown -> RECOVERY_REQUIRED
117
+ ```
118
+
119
+ `PERSISTING_TERMINAL` is not injected into the application-defined Workflow
120
+ state graph. It is private FSMSession/runtime lifecycle state.
121
+
122
+ When a durable terminal boundary is reached, FSMSession keeps its concrete
123
+ session alive and asks WorkflowRunner to persist the terminal snapshot.
124
+ WorkflowRunner submits a Workflow-specific immutable/value persistence command
125
+ to OffloadPool. The persistence result returns through the same FSMSession's
126
+ session-local event sink. Only that FSMSession may accept the result and advance
127
+ its terminal lifecycle.
128
+
129
+ Ephemeral Workflow executions that do not require durable terminal persistence
130
+ retain their direct terminal behavior.
131
+
132
+ ### The FSM consumes semantic persistence outcomes, not backend details
133
+
134
+ FSMSession does not know whether the backend is local, remote, SQL, HTTP-based,
135
+ or otherwise networked. It does not classify database-driver or transport
136
+ exceptions.
137
+
138
+ The Workflow persistence operation normalizes the save result into three
139
+ semantic outcomes:
140
+
141
+ ```text
142
+ success
143
+ Phronomy has a known-successful durable result.
144
+
145
+ known_failure
146
+ the Phronomy Persistence contract establishes that the intended terminal
147
+ save did not become the successful durable transition.
148
+
149
+ outcome_unknown
150
+ Phronomy cannot establish whether the durable transition committed.
151
+ ```
152
+
153
+ For the durable-barrier question, only `success` is permission to proceed.
154
+ Both other outcomes keep the success barrier closed.
155
+
156
+ Portable Persistence semantic errors whose contract establishes ordinary
157
+ failure, such as optimistic conflict or serialization rejection, may be treated
158
+ as `known_failure`. An arbitrary backend/storage/transport error is not assumed
159
+ to mean "not committed"; when non-commit is not established by contract, the
160
+ result is conservatively `outcome_unknown`.
161
+
162
+ This classification depends on semantic certainty, not on whether the physical
163
+ backend is on the same machine or reached over a network.
164
+
165
+ ### Known failure follows the Workflow error path
166
+
167
+ A `known_failure` terminal save result is delivered back to the owning
168
+ FSMSession. The FSMSession does not enter `HALTED`/`COMPLETED`; it terminalizes
169
+ through its error path instead. EventLoop then releases the admission using the
170
+ opaque owner token and the caller-facing Task settles as failed.
171
+
172
+ Phronomy does not automatically retry the entire Workflow segment merely because
173
+ the terminal durable save failed. External-effect retry/duplicate semantics are
174
+ separate architecture concerns.
175
+
176
+ ### Outcome uncertainty fails closed
177
+
178
+ An `outcome_unknown` result is not converted to ordinary failure and is not
179
+ assumed to be success. The known-success durable barrier remains closed.
180
+
181
+ The concrete FSMSession loses normal continuation authority and is retired. The
182
+ process-local Workflow admission becomes `recovery_required` and remains owned,
183
+ so the Runtime cannot admit a fresh top-level segment from an unproven lineage.
184
+ The caller-facing Workflow Task is not falsely settled as success or failure.
185
+
186
+ Actual persistence-outcome reconciliation and restart-safe continuation are
187
+ ACS-15 responsibilities. ACS-13 establishes the fail-closed boundary but does
188
+ not claim that recovery is already implemented.
189
+
190
+ ### Successful terminalization releases admission after FSM acceptance
191
+
192
+ On a known-successful terminal save, the result first returns to the same
193
+ FSMSession. The FSMSession accepts it and only then emits its normal
194
+ `halted`/`finished` terminal event.
195
+
196
+ EventLoop removes the concrete session, and WorkflowRunner releases the
197
+ `workflow_instance_id` admission using its opaque owner token before settling
198
+ the caller-facing Task.
199
+
200
+ The save completing on a worker thread is therefore not itself Workflow
201
+ completion. Authoritative logical completion occurs only after the EventLoop-
202
+ owned lifecycle accepts that save result.
203
+
204
+ ## Required invariants
205
+
206
+ 1. One Runtime admits at most one live Workflow execution segment for a `workflow_instance_id`.
207
+ 2. Workflow admission is acquired before mutable durable Workflow load/hydration.
208
+ 3. Workflow admission owner token and `fsm_session_id` are different Runtime concepts.
209
+ 4. Only the exact admission owner token may bind/release its Workflow admission.
210
+ 5. `fsm_session_id` remains the concrete EventLoop event-routing identity.
211
+ 6. Durable halt/completion does not become runtime-terminal before a known-successful terminal snapshot save is accepted by the owning FSMSession.
212
+ 7. Terminal Persistence I/O never blocks EventLoop and never mutates the live FSMSession from a worker.
213
+ 8. FSMSession receives backend-independent semantic save outcomes, not backend/transport classifications.
214
+ 9. Known terminal save failure does not become successful `HALTED`/`COMPLETED`.
215
+ 10. Unknown terminal save outcome does not release admission or settle the caller Workflow Task as a terminal success/failure.
216
+ 11. Ephemeral Workflows do not acquire a fake durable barrier merely to match the durable path.
217
+ 12. Persistence optimistic revision remains a durable integrity/conflict defense and is not the same-process ownership mechanism.
218
+
219
+ ## Explicitly deferred work
220
+
221
+ This decision does **not** implement:
222
+
223
+ - cross-process Workflow ownership, routing, lease/fencing (ACS-14);
224
+ - Persistence F1 outcome reconciliation or restart-safe Workflow rehydration (ACS-15);
225
+ - cancellation/semantic-deadline quiescence and terminalization integration (ACS-16);
226
+ - general external semantic-operation retry/idempotency protocol (ACS-17);
227
+ - a new public Workflow lifecycle-state API;
228
+ - Persistence-backend-specific network/driver exception taxonomies in FSM code.
229
+
230
+ ## Rejected alternatives
231
+
232
+ ### Continue using the future `fsm_session_id` as admission owner
233
+
234
+ Rejected. It preserves pre-load exclusion but conflates coordination ownership
235
+ with concrete routing identity and makes the admission authority depend on a
236
+ session that does not yet exist.
237
+
238
+ ### Save only after FSMSession has emitted `halted`/`finished`
239
+
240
+ Rejected. Runtime terminalization would precede the durable outcome that is
241
+ required to justify it, violating the Workflow durable barrier.
242
+
243
+ ### Block EventLoop until Persistence returns
244
+
245
+ Rejected. A durable barrier is logical execution ordering, not EventLoop-wide
246
+ blocking. Persistence remains OffloadPool work.
247
+
248
+ ### Treat every save exception as known failure
249
+
250
+ Rejected. A backend may have committed even though its success response was not
251
+ observed. Blind release/retry could branch Workflow lineage or duplicate later
252
+ semantic work.
253
+
254
+ ### Teach FSMSession about SQL/network/backend exception classes
255
+
256
+ Rejected. Physical backend topology and driver error taxonomies belong below the
257
+ Workflow FSM boundary. FSMSession consumes semantic persistence outcomes only.
@@ -0,0 +1,93 @@
1
+ # ADR-027: Phronomy-Owned LLM Adapter and RubyLLM Default Provider Boundary
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ ADR-001 selected RubyLLM as Phronomy's LLM provider layer. Its original
10
+ Decision text also encoded an implementation boundary in which Agent code and
11
+ the legacy Chain API called `RubyLLM.chat` directly.
12
+
13
+ The current architecture has evolved:
14
+
15
+ ```text
16
+ Phronomy Agent / Context / Manifest
17
+ |
18
+ v
19
+ RubyLLMMaterializer
20
+ |
21
+ v
22
+ Phronomy::LLMAdapter::Base
23
+ |
24
+ v
25
+ configured adapter
26
+ (default: Phronomy::LLMAdapter::RubyLLM)
27
+ ```
28
+
29
+ Phronomy now owns Agent lifecycle, Runtime coordination, canonical Journal /
30
+ Context Policy / Manifest semantics, durable execution state, and the
31
+ framework-owned asynchronous/offload bridge around Provider calls.
32
+
33
+ At the same time, the current materialization path is still RubyLLM-specific:
34
+ the LLMAdapter SPI receives the configured/materialized chat runtime object.
35
+ Making the Provider-call boundary replaceable does not by itself make the
36
+ complete LLM-input materialization pipeline provider-neutral.
37
+
38
+ The public LLMAdapter SPI is currently classified Beta. Architecture
39
+ reconciliation must not silently promote its API stability.
40
+
41
+ ## Decision
42
+
43
+ 1. Phronomy owns Agent lifecycle, Context/Manifest authority, Runtime
44
+ coordination, and durable execution semantics. These responsibilities are
45
+ not delegated to `RubyLLM::Agent`.
46
+ 2. `Phronomy::LLMAdapter::Base#complete` and `#stream` are the public
47
+ Provider-call extension boundary. Phronomy owns the framework-side
48
+ asynchronous/offload wrappers around that synchronous adapter contract.
49
+ 3. `Phronomy::LLMAdapter::RubyLLM` remains the default configured LLM adapter
50
+ and RubyLLM remains Phronomy's default Provider integration.
51
+ 4. A custom LLMAdapter may replace Provider-call behavior, but the LLMAdapter
52
+ SPI does not imply that the complete input-materialization pipeline is
53
+ Provider-neutral. The current Agent pipeline still materializes canonical
54
+ LLM input through RubyLLM-specific runtime objects.
55
+ 5. Provider transport timeout, retry, backoff, jitter, and rate-limit handling
56
+ remain adapter/provider-client responsibilities, consistent with
57
+ [`011-delegate-transport-policy-to-adapters`](011-delegate-transport-policy-to-adapters.md).
58
+ 6. This decision does not expand Phronomy scope merely because RubyLLM or
59
+ another Provider exposes additional features.
60
+ 7. This decision does not change the existing API stability classification of
61
+ the LLMAdapter SPI.
62
+
63
+ ## Consequences
64
+
65
+ ### Positive
66
+
67
+ - The normative architecture matches the current Phronomy-owned Agent /
68
+ Context / Manifest pipeline.
69
+ - RubyLLM remains the default integration without making direct RubyLLM calls
70
+ the Phronomy extension contract.
71
+ - Applications can supply a custom call adapter through one explicit boundary.
72
+ - Runtime/offload semantics stay Phronomy-owned while transport policy stays
73
+ adapter-owned.
74
+ - Provider-call replaceability is not confused with full materialization
75
+ neutrality.
76
+
77
+ ### Tradeoffs
78
+
79
+ - Custom adapters currently receive Phronomy's materialized chat runtime object
80
+ and therefore may still depend on the RubyLLM-shaped materialization boundary.
81
+ - Replacing the current RubyLLM-specific materializer would require a separate
82
+ architecture/API decision if Phronomy later wants end-to-end Provider-neutral
83
+ materialization.
84
+ - The Beta LLMAdapter SPI may still evolve according to the repository's
85
+ compatibility policy.
86
+
87
+ ## Supersession
88
+
89
+ This decision supersedes
90
+ [`001-rubyllm-as-provider-layer`](001-rubyllm-as-provider-layer.md).
91
+
92
+ ADR-001 remains preserved as historical rationale for adopting RubyLLM, but its
93
+ direct-call boundary is no longer normative.