activegraph 1.6.0__tar.gz → 1.7.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. {activegraph-1.6.0/activegraph.egg-info → activegraph-1.7.0}/PKG-INFO +1 -1
  2. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/__init__.py +1 -1
  3. activegraph-1.7.0/activegraph/sandbox/__init__.py +440 -0
  4. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/sandbox/_child.py +24 -5
  5. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/retention.py +55 -3
  6. {activegraph-1.6.0 → activegraph-1.7.0/activegraph.egg-info}/PKG-INFO +1 -1
  7. {activegraph-1.6.0 → activegraph-1.7.0}/pyproject.toml +1 -1
  8. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_compaction.py +35 -0
  9. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_sandbox_trial.py +233 -0
  10. activegraph-1.6.0/activegraph/sandbox/__init__.py +0 -257
  11. {activegraph-1.6.0 → activegraph-1.7.0}/LICENSE +0 -0
  12. {activegraph-1.6.0 → activegraph-1.7.0}/NOTICE +0 -0
  13. {activegraph-1.6.0 → activegraph-1.7.0}/README.md +0 -0
  14. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/__main__.py +0 -0
  15. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/_signature.py +0 -0
  16. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/behaviors/__init__.py +0 -0
  17. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/behaviors/base.py +0 -0
  18. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/behaviors/decorators.py +0 -0
  19. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/__init__.py +0 -0
  20. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/main.py +0 -0
  21. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/quickstart.py +0 -0
  22. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/cli/renderers.py +0 -0
  23. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/__init__.py +0 -0
  24. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/clock.py +0 -0
  25. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/event.py +0 -0
  26. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/graph.py +0 -0
  27. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/graph_store.py +0 -0
  28. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/ids.py +0 -0
  29. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/patch.py +0 -0
  30. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/core/view.py +0 -0
  31. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/errors.py +0 -0
  32. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/frame.py +0 -0
  33. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/__init__.py +0 -0
  34. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/anthropic.py +0 -0
  35. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/cache.py +0 -0
  36. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/embedding.py +0 -0
  37. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/errors.py +0 -0
  38. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/native.py +0 -0
  39. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/openai.py +0 -0
  40. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/parsing.py +0 -0
  41. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/prompt.py +0 -0
  42. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/provider.py +0 -0
  43. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/recorded.py +0 -0
  44. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/types.py +0 -0
  45. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/llm/wire.py +0 -0
  46. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/__init__.py +0 -0
  47. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/logging.py +0 -0
  48. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/metrics.py +0 -0
  49. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/migration.py +0 -0
  50. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/otel.py +0 -0
  51. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/prometheus.py +0 -0
  52. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/observability/status.py +0 -0
  53. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/__init__.py +0 -0
  54. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/__init__.py +0 -0
  55. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/behaviors.py +0 -0
  56. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
  57. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/fixtures/companies.py +0 -0
  58. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/object_types.py +0 -0
  59. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
  60. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
  61. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
  62. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
  63. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/settings.py +0 -0
  64. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/diligence/tools.py +0 -0
  65. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/loader.py +0 -0
  66. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/manifest.py +0 -0
  67. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/packs/scaffold.py +0 -0
  68. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/policy.py +0 -0
  69. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/__init__.py +0 -0
  70. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/_live.py +0 -0
  71. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/behavior_graph.py +0 -0
  72. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/budget.py +0 -0
  73. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/config_errors.py +0 -0
  74. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/diff.py +0 -0
  75. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/errors.py +0 -0
  76. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/exec_errors.py +0 -0
  77. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/patterns.py +0 -0
  78. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/promote.py +0 -0
  79. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/queue.py +0 -0
  80. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/registration_errors.py +0 -0
  81. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/registry.py +0 -0
  82. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/runtime.py +0 -0
  83. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/scheduler.py +0 -0
  84. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/runtime/view_builder.py +0 -0
  85. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/__init__.py +0 -0
  86. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/base.py +0 -0
  87. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/conformance.py +0 -0
  88. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/errors.py +0 -0
  89. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/falkordb.py +0 -0
  90. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/graph_conformance.py +0 -0
  91. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/memory.py +0 -0
  92. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/postgres.py +0 -0
  93. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/serde.py +0 -0
  94. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/sqlite.py +0 -0
  95. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/store/url.py +0 -0
  96. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/__init__.py +0 -0
  97. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/base.py +0 -0
  98. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/cache.py +0 -0
  99. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/context.py +0 -0
  100. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/decorators.py +0 -0
  101. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/errors.py +0 -0
  102. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/graph_query.py +0 -0
  103. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/recorded.py +0 -0
  104. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/tools/web_fetch.py +0 -0
  105. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/trace/__init__.py +0 -0
  106. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/trace/causal.py +0 -0
  107. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph/trace/printer.py +0 -0
  108. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/SOURCES.txt +0 -0
  109. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/dependency_links.txt +0 -0
  110. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/entry_points.txt +0 -0
  111. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/requires.txt +0 -0
  112. {activegraph-1.6.0 → activegraph-1.7.0}/activegraph.egg-info/top_level.txt +0 -0
  113. {activegraph-1.6.0 → activegraph-1.7.0}/setup.cfg +0 -0
  114. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_activate_after.py +0 -0
  115. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_causal_cross_tool.py +0 -0
  116. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_cli.py +0 -0
  117. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_cli_docs_flags.py +0 -0
  118. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_clock.py +0 -0
  119. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_diff.py +0 -0
  120. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_diligence_pack.py +0 -0
  121. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_diligence_with_tools.py +0 -0
  122. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_disable_pack.py +0 -0
  123. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_doc_links.py +0 -0
  124. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_doc_python_snippets.py +0 -0
  125. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_doc_site_reachable.py +0 -0
  126. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_embedding_provider.py +0 -0
  127. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_errors_format.py +0 -0
  128. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_event.py +0 -0
  129. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_falkordb_store.py +0 -0
  130. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_fork.py +0 -0
  131. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_graph.py +0 -0
  132. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_graph_store.py +0 -0
  133. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_ids.py +0 -0
  134. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_license.py +0 -0
  135. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_anthropic.py +0 -0
  136. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_behavior.py +0 -0
  137. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_budget.py +0 -0
  138. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_causal.py +0 -0
  139. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_claim_extraction.py +0 -0
  140. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_default_model.py +0 -0
  141. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_determinism.py +0 -0
  142. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_failure.py +0 -0
  143. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_native_structured_output.py +0 -0
  144. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_openai.py +0 -0
  145. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_prompt.py +0 -0
  146. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_provider_fixtures.py +0 -0
  147. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_replay.py +0 -0
  148. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_tool_loop.py +0 -0
  149. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_trace.py +0 -0
  150. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_trace_snapshot.py +0 -0
  151. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_types.py +0 -0
  152. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llm_wire.py +0 -0
  153. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_llms_txt.py +0 -0
  154. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_manifest_warning_tier.py +0 -0
  155. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_migration.py +0 -0
  156. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_observability_logging.py +0 -0
  157. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_observability_metrics.py +0 -0
  158. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_operate_example.py +0 -0
  159. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pack_manifest.py +0 -0
  160. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pack_scaffold.py +0 -0
  161. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_packs.py +0 -0
  162. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_patch.py +0 -0
  163. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pattern_matcher.py +0 -0
  164. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pattern_parser.py +0 -0
  165. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_pattern_subscriptions.py +0 -0
  166. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_persistence.py +0 -0
  167. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_postgres_store.py +0 -0
  168. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_promote.py +0 -0
  169. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_quickstart.py +0 -0
  170. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_quickstart_snapshot.py +0 -0
  171. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_reason_codes_docs.py +0 -0
  172. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_registration_validation.py +0 -0
  173. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_replay.py +0 -0
  174. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_replay_trace_snapshot.py +0 -0
  175. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_requeue_unfired.py +0 -0
  176. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_resume_example.py +0 -0
  177. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_runtime.py +0 -0
  178. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_runtime_status.py +0 -0
  179. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_serde.py +0 -0
  180. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_store_conformance.py +0 -0
  181. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_store_url.py +0 -0
  182. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tool_replay.py +0 -0
  183. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tool_trace_snapshot.py +0 -0
  184. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tools.py +0 -0
  185. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_trace.py +0 -0
  186. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_trace_accessors.py +0 -0
  187. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_tutorial_snippets.py +0 -0
  188. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_v1_0_1_patches.py +0 -0
  189. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_v1_0_3_behavior_failed_ux.py +0 -0
  190. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_v1_0_3_tool_multiturn.py +0 -0
  191. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_version_sync.py +0 -0
  192. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_view.py +0 -0
  193. {activegraph-1.6.0 → activegraph-1.7.0}/tests/test_wheel_completeness.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activegraph
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: An event-sourced reactive graph runtime for long-running, auditable, agentic systems.
5
5
  Author: Active Graph contributors
6
6
  License-Expression: Apache-2.0
@@ -242,4 +242,4 @@ __all__ = [
242
242
  "tool",
243
243
  ]
244
244
 
245
- __version__ = "1.6.0"
245
+ __version__ = "1.7.0"
@@ -0,0 +1,440 @@
1
+ """Subprocess fork-trial isolation. CONTRACT v1.5 #1.
2
+
3
+ Implements ``trial-isolation-design.md``: candidate pack code trials
4
+ run in a **fresh interpreter**, against a fork of the parent run,
5
+ materialized from artifacts pinned by the bundle hash — so the parent
6
+ process is out of the blast radius of the T1 accident class (runaway
7
+ memory/CPU, corrupted in-process state) and the bytes trialed are the
8
+ bytes the proposal recorded.
9
+
10
+ The division of authority is the design's:
11
+
12
+ * **The parent forks.** The child receives only the fork's run id —
13
+ it never gets fork authority, and it appends only to that run.
14
+ * **The child is a fresh interpreter** (``sys.executable -m
15
+ activegraph.sandbox._child``), not ``os.fork()`` — no inherited
16
+ Python state, no shared clients. Environment is allow-list only.
17
+ * **Three independent nets**: ``resource.setrlimit`` (address
18
+ space + CPU, POSIX; degrade to the other two nets on Windows),
19
+ parent-side wall-clock kill, and the runtime's own event/LLM
20
+ budgets inside the child.
21
+ * **The store is the record.** The child's stdout tail is a signal;
22
+ the parent re-reads the fork run from the store and the store's
23
+ numbers win any disagreement.
24
+
25
+ TWO CHANNELS, KEPT SEPARATE (v1.7, the Replit soak fix):
26
+
27
+ * **The environment allow-list stays CLOSED.** Only ``PATH``,
28
+ ``HOME``, ``LANG`` plus an explicit ``env_passthrough`` cross the
29
+ boundary. No ambient parent env — secrets, API keys, platform
30
+ config like ``REPLIT_*`` — leaks into a child running hostile
31
+ candidate code. This is a security control and does not widen.
32
+ * **Code location is an EXPLICIT channel, not an ambient one.** A
33
+ child must be able to import ``activegraph`` (and its deps) on any
34
+ box where the *parent* could — but on some platforms (Replit,
35
+ Nix) the package is discoverable only via a platform env var the
36
+ allow-list correctly strips, so a naive child dies at import
37
+ before running. The parent therefore RESOLVES the ``sys.path``
38
+ entries that make its own code importable and passes them to the
39
+ child as a computed ``PYTHONPATH`` (see :func:`_child_env`).
40
+ ``PYTHONPATH`` is thus derived from the parent's resolved
41
+ ``sys.path``, never forwarded from ambient env — code locations
42
+ are not secrets, and making the child's discovery of its own code
43
+ an explicit input keeps the allow-list closed while fixing the
44
+ restricted-env break. Call :func:`preflight` once at boot to
45
+ verify a child can start under this env and fail loud if not.
46
+
47
+ HONEST LIMITS (the design's §5, restated where users will read it):
48
+ this is crash/state isolation, **not a security sandbox**. A fresh
49
+ interpreter with rlimits does not stop a malicious candidate from
50
+ opening sockets or reading the filesystem — syscall and network
51
+ confinement are host territory (containers, seccomp), and this module
52
+ does not imitate them. The child can also, in principle, open the
53
+ shared SQLite file directly and touch other runs; per-run
54
+ authorization inside one SQLite file is not something SQLite gives us
55
+ honestly, so that caveat is stated rather than solved. The evolution
56
+ pack's static gates remain the pre-execution filter.
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import json
62
+ import os
63
+ import subprocess
64
+ import sys
65
+ from dataclasses import dataclass, field
66
+ from pathlib import Path
67
+ from typing import Optional
68
+
69
+
70
+ #: The closed outcome set. ``crashed`` is the implementation's one
71
+ #: addition over the design draft: a child that dies without a
72
+ #: parseable tail (segfault, os._exit, kill) is reported as what it
73
+ #: is, never guessed into a neater bucket.
74
+ TRIAL_OUTCOMES = (
75
+ "completed",
76
+ "scenario_failed",
77
+ "limits_exceeded",
78
+ "materialization_failed",
79
+ "crashed",
80
+ )
81
+
82
+ # Child exit codes, mirrored in _child.py. Anything else → crashed.
83
+ _EXIT_TO_OUTCOME = {
84
+ 0: "completed",
85
+ 30: "scenario_failed",
86
+ 40: "limits_exceeded",
87
+ 50: "materialization_failed",
88
+ }
89
+
90
+
91
+ @dataclass(frozen=True)
92
+ class PackSource:
93
+ """Where the child materializes the candidate pack from.
94
+
95
+ ``expected_bundle_hash`` is the external pin (the §4 walk WITH
96
+ ``manifest.toml``, per the v1.4 bundle-hash amendment) — the child
97
+ verifies it via ``activegraph.packs.manifest.verify_bundle_hash``
98
+ before importing anything, so the bytes trialed are the bytes the
99
+ proposal recorded. ``manifest_required=True`` additionally runs
100
+ ``load_manifest`` + ``verify_surface`` against the live ``Pack``,
101
+ making the trial child the first consumer of the full manifest
102
+ chain end-to-end.
103
+ """
104
+
105
+ root_dir: str
106
+ expected_bundle_hash: str = ""
107
+ manifest_required: bool = True
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class TrialLimits:
112
+ """The trial's resource nets. Zero/None disables a given net.
113
+
114
+ ``max_llm_calls=0`` (the default) means key-freedom is
115
+ STRUCTURAL: the child configures no LLM provider at all, so a
116
+ candidate with LLM behaviors fails loud at registration
117
+ (``MissingProviderError``) rather than reaching a network. A
118
+ positive cap is accepted and recorded for a future
119
+ provider-wiring seam; the v1 child never configures a live
120
+ provider either way. ``env_passthrough`` is the ONLY ambient parent
121
+ environment forwarded beyond the closed allow-list
122
+ (``PATH``/``HOME``/``LANG``) — the helper never forwards the
123
+ environment wholesale, so parent API keys don't leak into
124
+ candidate code by default. The child's ``PYTHONPATH`` is NOT an
125
+ ambient forward: it is computed from the parent's resolved
126
+ ``sys.path`` so the child can import its own code (see the module
127
+ docstring's "two channels").
128
+ """
129
+
130
+ wall_clock_seconds: float = 120.0
131
+ max_rss_bytes: Optional[int] = None
132
+ max_events: Optional[int] = 2_000
133
+ max_llm_calls: Optional[int] = 0
134
+ env_passthrough: tuple[str, ...] = ()
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class TrialReport:
139
+ """What a trial produced. Store-derived numbers, child-signaled shape.
140
+
141
+ ``events_appended`` and ``behavior_failures`` are re-read from the
142
+ fork's run in the store by the parent AFTER the child exits — the
143
+ stdout tail is a signal only, and any disagreement resolves in
144
+ favor of the store. Richer evidence (tracebacks, diffs) is read
145
+ from the fork's log directly: ``Runtime.load(store, run_id=
146
+ report.fork_run_id)`` then ``trace.failures()`` / ``diff()``.
147
+ """
148
+
149
+ outcome: str
150
+ fork_run_id: str
151
+ events_appended: int
152
+ behavior_failures: int
153
+ detail: str
154
+ exit_code: Optional[int]
155
+
156
+
157
+ class SandboxStartupError(RuntimeError):
158
+ """A trial child could not START under the sandbox env.
159
+
160
+ Raised by :func:`preflight` when the child fails before it can run
161
+ any trial — the common cause is the sandbox env being unable to
162
+ import ``activegraph`` on this box (the exact restricted-env break
163
+ the explicit package-path channel exists to prevent). The message
164
+ carries the child's stderr tail so the cause is never opaque.
165
+ """
166
+
167
+
168
+ def _child_code_paths() -> list[str]:
169
+ """The ``sys.path`` entries a child needs to import the parent's
170
+ code, resolved in the parent (v1.7 explicit package-path channel).
171
+
172
+ ``activegraph``'s own root comes first, resolved from
173
+ ``activegraph.__file__`` — this is what an editable install or a
174
+ platform that surfaces packages via a stripped env var (Replit's
175
+ ``REPLIT_PYTHONPATH``) would otherwise hide from the child. The
176
+ parent's real ``sys.path`` directories follow so third-party deps
177
+ resolve too. Non-directory entries (namespace-finder hooks, the
178
+ empty CWD entry, zip imports) are dropped: they either don't help
179
+ a fresh child or would import from its CWD, which we do not want.
180
+ """
181
+ import activegraph
182
+
183
+ ordered: list[str] = []
184
+ ag_root = str(Path(activegraph.__file__).resolve().parent.parent)
185
+ ordered.append(ag_root)
186
+ for entry in sys.path:
187
+ if entry and os.path.isdir(entry) and entry not in ordered:
188
+ ordered.append(entry)
189
+ return ordered
190
+
191
+
192
+ def _child_env(limits: TrialLimits) -> dict[str, str]:
193
+ """Build the child's environment: closed allow-list + the computed
194
+ ``PYTHONPATH`` code channel. See the module docstring's "two
195
+ channels".
196
+
197
+ The allow-list is ``PATH``/``HOME``/``LANG`` plus the caller's
198
+ explicit ``env_passthrough`` — no other ambient env crosses. Then
199
+ ``PYTHONPATH`` is set from :func:`_child_code_paths` (the parent's
200
+ resolved code locations), prepended before any ``PYTHONPATH`` the
201
+ caller opted to pass through, so the child imports the same code
202
+ the parent can regardless of platform package discovery.
203
+ """
204
+ env = {
205
+ key: os.environ[key]
206
+ for key in ("PATH", "HOME", "LANG")
207
+ if key in os.environ
208
+ }
209
+ for key in limits.env_passthrough:
210
+ if key in os.environ:
211
+ env[key] = os.environ[key]
212
+ channel = os.pathsep.join(_child_code_paths())
213
+ passed_through = env.get("PYTHONPATH", "")
214
+ env["PYTHONPATH"] = (
215
+ channel + os.pathsep + passed_through if passed_through else channel
216
+ )
217
+ return env
218
+
219
+
220
+ def _stderr_tail(stderr: str, *, max_lines: int = 20, max_chars: int = 1500) -> str:
221
+ """The last few lines of a child's stderr, capped — the actual
222
+ cause of a pre-``_report`` crash the DEVNULL default used to lose.
223
+ """
224
+ text = (stderr or "").strip()
225
+ if not text:
226
+ return ""
227
+ lines = text.splitlines()[-max_lines:]
228
+ tail = "\n".join(lines)
229
+ return tail[-max_chars:]
230
+
231
+
232
+ def _parse_report_tail(stdout: str) -> dict[str, object]:
233
+ """The child's last valid JSON report line, or ``{}``."""
234
+ for line in reversed((stdout or "").strip().splitlines()):
235
+ try:
236
+ parsed = json.loads(line)
237
+ except json.JSONDecodeError:
238
+ continue
239
+ if isinstance(parsed, dict) and (
240
+ "outcome" in parsed or "preflight" in parsed
241
+ ):
242
+ return parsed
243
+ return {}
244
+
245
+
246
+ def _run_child(
247
+ job: dict[str, object],
248
+ *,
249
+ env: dict[str, str],
250
+ wall_clock: float,
251
+ python_flags: tuple[str, ...] = (),
252
+ ) -> tuple[Optional[int], str, str, bool]:
253
+ """Spawn the trial child, feed it ``job`` on stdin, and return
254
+ ``(exit_code, stdout, stderr, timed_out)``.
255
+
256
+ Shared by :func:`run_forked_trial` and :func:`preflight`. stderr is
257
+ PIPED (never DEVNULL) so a pre-``_report`` crash's cause survives.
258
+ ``python_flags`` is an interpreter-flag seam used by tests to
259
+ reproduce a restricted env (``-S``); production passes none.
260
+ """
261
+ proc = subprocess.Popen(
262
+ [sys.executable, *python_flags, "-m", "activegraph.sandbox._child"],
263
+ stdin=subprocess.PIPE,
264
+ stdout=subprocess.PIPE,
265
+ stderr=subprocess.PIPE,
266
+ env=env,
267
+ text=True,
268
+ )
269
+ try:
270
+ stdout, stderr = proc.communicate(
271
+ input=json.dumps(job), timeout=wall_clock
272
+ )
273
+ return proc.returncode, stdout, stderr, False
274
+ except subprocess.TimeoutExpired:
275
+ proc.kill()
276
+ stdout, stderr = proc.communicate()
277
+ return proc.returncode, stdout, stderr, True
278
+
279
+
280
+ def _preflight_with(
281
+ env: dict[str, str],
282
+ *,
283
+ python_flags: tuple[str, ...] = (),
284
+ timeout: float = 30.0,
285
+ ) -> None:
286
+ """The preflight check against a specific ``env`` (the testable
287
+ seam under :func:`preflight`). Raises :class:`SandboxStartupError`
288
+ if a child cannot start and report ``preflight: ok``.
289
+ """
290
+ exit_code, stdout, stderr, timed_out = _run_child(
291
+ {"preflight": True}, env=env, wall_clock=timeout, python_flags=python_flags
292
+ )
293
+ tail = _parse_report_tail(stdout)
294
+ if not timed_out and exit_code == 0 and tail.get("preflight") == "ok":
295
+ return
296
+ cause = _stderr_tail(stderr) or (
297
+ "child timed out" if timed_out else "no preflight tail on stdout"
298
+ )
299
+ raise SandboxStartupError(
300
+ f"trial child could not start (exit {exit_code}): {cause}"
301
+ )
302
+
303
+
304
+ def preflight(*, limits: TrialLimits = TrialLimits(), timeout: float = 30.0) -> None:
305
+ """Verify a trial child can START under the sandbox env on this box.
306
+
307
+ Spawns the child with a null job (no fork, no store, no candidate):
308
+ it imports ``activegraph``, echoes ``preflight: ok``, and exits 0.
309
+ Returns ``None`` on success; raises :class:`SandboxStartupError`
310
+ with the child's stderr tail otherwise. Consumers call this once at
311
+ boot so a broken sandbox env (e.g. a platform whose package
312
+ discovery the allow-list strips) fails LOUD here instead of showing
313
+ up as an opaque ``crashed`` on the first real trial.
314
+ """
315
+ _preflight_with(_child_env(limits), timeout=timeout)
316
+
317
+
318
+ def run_forked_trial(
319
+ store_path: str,
320
+ *,
321
+ parent_run_id: str,
322
+ at_event: str,
323
+ pack_source: PackSource,
324
+ scenario: str = "",
325
+ limits: TrialLimits = TrialLimits(),
326
+ label: str = "trial",
327
+ extra_packs: tuple[PackSource, ...] = (),
328
+ ) -> TrialReport:
329
+ """Fork the parent at ``at_event`` and trial the candidate pack in
330
+ a fresh subprocess. CONTRACT v1.5 #1.
331
+
332
+ The fork is created HERE, in the parent process, with full
333
+ ``fork()`` semantics (lineage recorded, promote-block cut guard);
334
+ the child receives only the fork's run id. ``scenario`` is
335
+ ``"relative/path.py"`` or ``"relative/path.py::func"`` inside the
336
+ CANDIDATE's pack root — the function (default ``main``) is called
337
+ with the fork's ``Runtime`` and drives the trial; empty means
338
+ just ``run_until_idle()``. The scenario contract is
339
+ ``def main(rt): ...``.
340
+
341
+ **The default is candidate-only isolation**: the child loads
342
+ nothing but the candidate, so the trial exercises it apart from
343
+ every other pack's behaviors. ``extra_packs`` is the opt-in for
344
+ cross-pack interaction trials (CONTRACT v1.5 #1 addendum 1b):
345
+ each entry is materialized in the child exactly like the
346
+ candidate — bundle hash verified before import, manifest schema
347
+ + two-way surface check when required — and loaded, in order,
348
+ BEFORE the candidate. Any extra pack failing its pins is
349
+ ``materialization_failed`` for the whole trial; the scenario
350
+ still resolves inside the candidate's root only.
351
+
352
+ Deterministic and key-free by default (``max_llm_calls=0``, empty
353
+ environment pass-through). Returns a :class:`TrialReport`; never
354
+ raises for in-trial failures — those are outcomes. Raises only
355
+ for parent-side setup problems (bad store, bad fork point), with
356
+ the same errors ``Runtime.load`` / ``fork()`` raise.
357
+ """
358
+ from activegraph.runtime.runtime import Runtime
359
+
360
+ parent_rt = Runtime.load(store_path, run_id=parent_run_id, behaviors=[])
361
+ fork_rt = parent_rt.fork(at_event=at_event, label=label, behaviors=[])
362
+ fork_run_id = fork_rt.run_id
363
+ initial_events = len(fork_rt.graph.events)
364
+ del fork_rt # the child owns the fork from here
365
+
366
+ job = {
367
+ "store_path": store_path,
368
+ "fork_run_id": fork_run_id,
369
+ "initial_events": initial_events,
370
+ "pack_root": str(Path(pack_source.root_dir).resolve()),
371
+ "expected_bundle_hash": pack_source.expected_bundle_hash,
372
+ "manifest_required": pack_source.manifest_required,
373
+ "extra_packs": [
374
+ {
375
+ "pack_root": str(Path(p.root_dir).resolve()),
376
+ "expected_bundle_hash": p.expected_bundle_hash,
377
+ "manifest_required": p.manifest_required,
378
+ }
379
+ for p in extra_packs
380
+ ],
381
+ "scenario": scenario,
382
+ "limits": {
383
+ "max_rss_bytes": limits.max_rss_bytes,
384
+ "max_events": limits.max_events,
385
+ "max_llm_calls": limits.max_llm_calls,
386
+ "cpu_seconds": int(limits.wall_clock_seconds) + 5,
387
+ },
388
+ }
389
+ exit_code, stdout, stderr, timed_out = _run_child(
390
+ job, env=_child_env(limits), wall_clock=limits.wall_clock_seconds
391
+ )
392
+ tail = _parse_report_tail(stdout)
393
+
394
+ if timed_out:
395
+ outcome = "limits_exceeded"
396
+ detail = (
397
+ f"wall clock exceeded {limits.wall_clock_seconds}s; child killed"
398
+ )
399
+ elif tail:
400
+ outcome = str(tail.get("outcome", "crashed"))
401
+ if outcome not in TRIAL_OUTCOMES:
402
+ outcome = "crashed"
403
+ detail = str(tail.get("detail", ""))
404
+ else:
405
+ outcome = _EXIT_TO_OUTCOME.get(exit_code or -1, "crashed")
406
+ if outcome == "completed":
407
+ # Exit 0 with no tail is itself suspicious; say so.
408
+ outcome = "crashed"
409
+ detail = f"child exited {exit_code} with no report tail"
410
+
411
+ # The pre-execution gate must never swallow its own failure cause:
412
+ # when the child crashed before it could _report (module-import
413
+ # failure, rlimit setup, anything pre-main), the actual exception
414
+ # is on the child's stderr, not in any tail. Fold it into detail so
415
+ # it reaches the caller's recorded gate_result (v1.7 fix 1).
416
+ if outcome == "crashed":
417
+ err_tail = _stderr_tail(stderr)
418
+ if err_tail:
419
+ detail = f"{detail}; child stderr:\n{err_tail}"
420
+
421
+ # The store is the record: re-read the fork run for the numbers.
422
+ events_appended = 0
423
+ behavior_failures = 0
424
+ try:
425
+ fork_view = Runtime.load(store_path, run_id=fork_run_id, behaviors=[])
426
+ events_appended = max(
427
+ 0, len(fork_view.graph.events) - initial_events
428
+ )
429
+ behavior_failures = len(fork_view.trace.failures())
430
+ except Exception as e: # noqa: BLE001 — report, never mask the trial
431
+ detail = f"{detail} (store re-read failed: {e})".strip()
432
+
433
+ return TrialReport(
434
+ outcome=outcome,
435
+ fork_run_id=fork_run_id,
436
+ events_appended=events_appended,
437
+ behavior_failures=behavior_failures,
438
+ detail=detail,
439
+ exit_code=exit_code,
440
+ )
@@ -62,11 +62,15 @@ def _apply_rlimits(limits: dict[str, Any]) -> None:
62
62
 
63
63
 
64
64
  def _materialize_pack(job: dict[str, Any]) -> Any:
65
- """Verify pins, then import the candidate and return its Pack.
66
-
67
- Order is the design's §3: bundle hash BEFORE any import (the pin
68
- covers manifest.toml per the v1.4 amendment), then manifest schema,
69
- then import, then the two-way surface check against the live Pack.
65
+ """Verify pins, then import one pack source and return its Pack.
66
+
67
+ ``job`` carries ``pack_root`` / ``expected_bundle_hash`` /
68
+ ``manifest_required`` the top-level job spec for the candidate,
69
+ or one ``extra_packs`` entry (v1.7 addendum 1b), which uses the
70
+ identical chain. Order is the design's §3: bundle hash BEFORE any
71
+ import (the pin covers manifest.toml per the v1.4 amendment),
72
+ then manifest schema, then import, then the two-way surface check
73
+ against the live Pack.
70
74
  """
71
75
  from activegraph.packs import Pack
72
76
  from activegraph.packs.manifest import (
@@ -133,6 +137,16 @@ def _resolve_scenario(
133
137
 
134
138
  def main() -> None:
135
139
  job = json.loads(sys.stdin.read())
140
+
141
+ # Preflight (v1.7): a null job that only proves the child could
142
+ # START — i.e. that importing activegraph under the sandbox env
143
+ # succeeded, which happens before this function even runs. Reaching
144
+ # here at all means the heavy import chain worked; echo the marker
145
+ # and exit clean. No fork, no store, no candidate touched.
146
+ if job.get("preflight"):
147
+ print(json.dumps({"preflight": "ok"}), flush=True)
148
+ raise SystemExit(0)
149
+
136
150
  fork_run_id = str(job["fork_run_id"])
137
151
  initial = int(job.get("initial_events", 0))
138
152
  _apply_rlimits(job.get("limits", {}))
@@ -144,6 +158,9 @@ def main() -> None:
144
158
  )
145
159
 
146
160
  try:
161
+ # Extra packs first (addendum 1b): each pinned and verified
162
+ # exactly like the candidate, loaded before it below.
163
+ extra = [_materialize_pack(p) for p in job.get("extra_packs", [])]
147
164
  pack = _materialize_pack(job)
148
165
  except Exception as e: # noqa: BLE001 — outcome, not crash
149
166
  _report(
@@ -174,6 +191,8 @@ def main() -> None:
174
191
  behaviors=[],
175
192
  budget=budget or None,
176
193
  )
194
+ for trusted in extra:
195
+ rt.load_pack(trusted)
177
196
  rt.load_pack(pack)
178
197
 
179
198
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  CONTRACT v1.5 #2, phase 1 of ``compaction-design.md``. Three offline
4
4
  operations over a SQLite store (no self-compaction under a live
5
- dispatcher — run these against a store no runtime is attached to):
5
+ dispatcher — run these against runs no runtime is attached to):
6
6
 
7
7
  * :func:`compact` — emit a ``runtime.snapshot`` event capturing the
8
8
  run's projected state, store the blob in the snapshot sidecar,
@@ -26,6 +26,40 @@ horizon stop at it); there is no CLI yet; and patch records from the
26
26
  archived prefix are not reconstructed after compaction (``compact``
27
27
  refuses runs with patches still in ``proposed`` status, and applied/
28
28
  rejected patch history remains queryable via the archive).
29
+
30
+ **What "offline" means — the attachment rule is per-RUN, not
31
+ per-file** (v1.6.x ruling; CONTRACT v1.5 #2 addendum 2b). These
32
+ operations must not run against a run that has a live runtime
33
+ attached; OTHER runs in the same store file may hold live runtimes
34
+ throughout. Why that is safe, from the implementation: every event-
35
+ row statement is scoped ``WHERE run_id = ?``; the store runs in WAL
36
+ mode, so the readers these functions open never block a live run's
37
+ writer and never see torn state; a live runtime appends via single-
38
+ statement autocommit transactions and never re-reads its own log
39
+ mid-dispatch; and the archive move is one short ``BEGIN IMMEDIATE``
40
+ transaction that a contending writer simply waits out (sqlite3's
41
+ default 5-second busy timeout — exhausted, it raises
42
+ ``OperationalError``, never corrupting). What per-run attachment
43
+ actually protects against is the SAME-run case: a runtime attached
44
+ to the run being compacted mints the same event id as the snapshot
45
+ event (``UNIQUE(id, run_id)`` — ``IntegrityError``) and never learns
46
+ the horizon exists; a runtime attached to a run being retired would
47
+ keep appending to a log the operator believes empty.
48
+
49
+ Two caveats stay the caller's responsibility:
50
+
51
+ * ``pins`` → archive is check-then-act, not one atomic unit. Do
52
+ not race a pin-creating operation against retirement of the
53
+ same run — a promote from the fork, or a new fork of it, landing
54
+ between the pin check and the archive move would be archived-
55
+ while-pinned. Retire after decisions on that run are final. (A
56
+ lost race degrades audit walks — ``causal_chain`` does not read
57
+ the archive — but destroys no bytes: archiving is a move, and
58
+ the rows stay readable via ``iter_archived``.)
59
+ * A very large run's archive move holds the write lock for the
60
+ duration of its one transaction; a concurrent writer that
61
+ exhausts its busy timeout gets ``OperationalError``, not
62
+ corruption. Size housekeeping windows accordingly.
29
63
  """
30
64
 
31
65
  from __future__ import annotations
@@ -148,6 +182,10 @@ def pins(path: str, run_id: str) -> list[str]:
148
182
  Empty list = unpinned. The same computation the refusals in
149
183
  :func:`compact` / :func:`retire` run — design §5's "why can't I
150
184
  retire this run?" API.
185
+
186
+ Read-only, safe to call while any run in the file holds a live
187
+ runtime (WAL readers never block or tear). The answer is
188
+ point-in-time: a pin created after it returns is not in it.
151
189
  """
152
190
  reasons: list[str] = []
153
191
  store = SQLiteEventStore(path, run_id=run_id)
@@ -220,8 +258,12 @@ def compact(path: str, run_id: str) -> str:
220
258
  design's crash-safe one: snapshot event, then blob, then the
221
259
  archive move (idempotent). Returns the snapshot event id.
222
260
 
223
- Offline operation: run against a store no live runtime is
224
- attached to.
261
+ Offline operation, per-RUN: no live runtime may be attached to
262
+ ``run_id`` itself — the snapshot event would collide with the
263
+ ids a live runtime mints (``UNIQUE(id, run_id)``), and that
264
+ runtime's projection would never learn the horizon exists. Other
265
+ runs in the same file may hold live runtimes; see the module
266
+ docstring for the full reasoning.
225
267
  """
226
268
  reasons = pins(path, run_id)
227
269
  if reasons:
@@ -272,6 +314,16 @@ def retire(path: str, run_id: str) -> int:
272
314
 
273
315
  The typical subject is a rejected fork trial that was never
274
316
  promoted. Pinned runs refuse with the full reason list.
317
+
318
+ Offline operation, per-RUN: no live runtime may be attached to
319
+ ``run_id`` itself, and no pin-creating operation (a promote from
320
+ this run, a fork of it) may race the retirement. Other runs in
321
+ the same file may hold live runtimes throughout — retiring a
322
+ finished fork while the parent run's runtime is live is
323
+ sanctioned usage (see the module docstring for the transaction
324
+ reasoning, and ``test_compaction.py``'s
325
+ ``test_retire_fork_per_run_while_parent_runtime_is_live`` for
326
+ the pinned behavior).
275
327
  """
276
328
  reasons = pins(path, run_id)
277
329
  if reasons:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: activegraph
3
- Version: 1.6.0
3
+ Version: 1.7.0
4
4
  Summary: An event-sourced reactive graph runtime for long-running, auditable, agentic systems.
5
5
  Author: Active Graph contributors
6
6
  License-Expression: Apache-2.0
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
7
7
 
8
8
  [project]
9
9
  name = "activegraph"
10
- version = "1.6.0"
10
+ version = "1.7.0"
11
11
  description = "An event-sourced reactive graph runtime for long-running, auditable, agentic systems."
12
12
  readme = "README.md"
13
13
  requires-python = ">=3.11"
@@ -181,6 +181,41 @@ def test_unpromoted_rejected_fork_retires_cleanly(tmp_path):
181
181
  assert len(parent_view.graph.events) == n_parent
182
182
 
183
183
 
184
+ def test_retire_fork_per_run_while_parent_runtime_is_live(tmp_path):
185
+ # The downstream housekeeping pattern (v1.6.x ruling, CONTRACT
186
+ # v1.5 #2 addendum 2b): the offline rule is per-RUN, not
187
+ # per-file. pins()/retire() on a finished fork run while the
188
+ # PARENT run holds a live runtime on the same SQLite file is
189
+ # sanctioned — WAL + per-run row scoping + single-statement
190
+ # appends. What stays forbidden is a runtime attached to the run
191
+ # being retired/compacted itself.
192
+ path, parent = _seeded_runtime(tmp_path)
193
+ rejected = parent.fork(at_event=parent.trace.events()[-1].id)
194
+ rejected.graph.add_object("note", {"text": "never adopted"})
195
+ fork_run = rejected.run_id
196
+ n_fork = len(rejected.graph.events)
197
+ del rejected # trial over: nothing attached to the FORK run
198
+
199
+ # Parent stays LIVE — attached store connection, no teardown.
200
+ assert pins(path, fork_run) == []
201
+ moved = retire(path, fork_run)
202
+ assert moved == n_fork
203
+
204
+ # The live parent keeps working through its open attachment:
205
+ # appends land, save succeeds, nothing about its run was touched.
206
+ parent.run_goal("second")
207
+ parent.save_state(path)
208
+ n_parent = len(parent.graph.events)
209
+
210
+ store = SQLiteEventStore(path, run_id=fork_run)
211
+ assert list(store.iter_events()) == []
212
+ assert store.has_archived() # moved, never deleted
213
+ reloaded = Runtime.load(path, run_id=parent.run_id, behaviors=[])
214
+ assert len(reloaded.graph.events) == n_parent
215
+ # And the retired fork no longer pins the still-live parent.
216
+ assert not any("live-lineage" in r for r in pins(path, parent.run_id))
217
+
218
+
184
219
  def test_live_lineage_pins_the_parent_until_children_retire(tmp_path):
185
220
  path, parent = _seeded_runtime(tmp_path)
186
221
  child = parent.fork(at_event=parent.trace.events()[-1].id)