activegraph 1.5.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 (194) hide show
  1. {activegraph-1.5.0/activegraph.egg-info → activegraph-1.7.0}/PKG-INFO +1 -1
  2. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/__init__.py +1 -1
  3. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/loader.py +108 -0
  4. activegraph-1.7.0/activegraph/sandbox/__init__.py +440 -0
  5. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/sandbox/_child.py +24 -5
  6. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/retention.py +55 -3
  7. {activegraph-1.5.0 → activegraph-1.7.0/activegraph.egg-info}/PKG-INFO +1 -1
  8. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph.egg-info/SOURCES.txt +1 -0
  9. {activegraph-1.5.0 → activegraph-1.7.0}/pyproject.toml +1 -1
  10. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_compaction.py +35 -0
  11. activegraph-1.7.0/tests/test_manifest_warning_tier.py +171 -0
  12. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_promote.py +61 -0
  13. activegraph-1.7.0/tests/test_sandbox_trial.py +600 -0
  14. activegraph-1.5.0/activegraph/sandbox/__init__.py +0 -257
  15. activegraph-1.5.0/tests/test_sandbox_trial.py +0 -260
  16. {activegraph-1.5.0 → activegraph-1.7.0}/LICENSE +0 -0
  17. {activegraph-1.5.0 → activegraph-1.7.0}/NOTICE +0 -0
  18. {activegraph-1.5.0 → activegraph-1.7.0}/README.md +0 -0
  19. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/__main__.py +0 -0
  20. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/_signature.py +0 -0
  21. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/behaviors/__init__.py +0 -0
  22. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/behaviors/base.py +0 -0
  23. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/behaviors/decorators.py +0 -0
  24. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/cli/__init__.py +0 -0
  25. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/cli/main.py +0 -0
  26. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/cli/quickstart.py +0 -0
  27. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/cli/renderers.py +0 -0
  28. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/__init__.py +0 -0
  29. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/clock.py +0 -0
  30. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/event.py +0 -0
  31. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/graph.py +0 -0
  32. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/graph_store.py +0 -0
  33. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/ids.py +0 -0
  34. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/patch.py +0 -0
  35. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/core/view.py +0 -0
  36. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/errors.py +0 -0
  37. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/frame.py +0 -0
  38. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/__init__.py +0 -0
  39. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/anthropic.py +0 -0
  40. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/cache.py +0 -0
  41. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/embedding.py +0 -0
  42. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/errors.py +0 -0
  43. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/native.py +0 -0
  44. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/openai.py +0 -0
  45. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/parsing.py +0 -0
  46. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/prompt.py +0 -0
  47. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/provider.py +0 -0
  48. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/recorded.py +0 -0
  49. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/types.py +0 -0
  50. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/llm/wire.py +0 -0
  51. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/observability/__init__.py +0 -0
  52. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/observability/logging.py +0 -0
  53. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/observability/metrics.py +0 -0
  54. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/observability/migration.py +0 -0
  55. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/observability/otel.py +0 -0
  56. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/observability/prometheus.py +0 -0
  57. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/observability/status.py +0 -0
  58. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/__init__.py +0 -0
  59. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/__init__.py +0 -0
  60. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/behaviors.py +0 -0
  61. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/fixtures/__init__.py +0 -0
  62. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/fixtures/companies.py +0 -0
  63. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/object_types.py +0 -0
  64. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/document_researcher.md +0 -0
  65. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/memo_synthesizer.md +0 -0
  66. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/question_generator.md +0 -0
  67. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/prompts/risk_identifier.md +0 -0
  68. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/settings.py +0 -0
  69. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/diligence/tools.py +0 -0
  70. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/manifest.py +0 -0
  71. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/packs/scaffold.py +0 -0
  72. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/policy.py +0 -0
  73. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/__init__.py +0 -0
  74. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/_live.py +0 -0
  75. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/behavior_graph.py +0 -0
  76. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/budget.py +0 -0
  77. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/config_errors.py +0 -0
  78. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/diff.py +0 -0
  79. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/errors.py +0 -0
  80. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/exec_errors.py +0 -0
  81. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/patterns.py +0 -0
  82. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/promote.py +0 -0
  83. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/queue.py +0 -0
  84. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/registration_errors.py +0 -0
  85. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/registry.py +0 -0
  86. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/runtime.py +0 -0
  87. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/scheduler.py +0 -0
  88. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/runtime/view_builder.py +0 -0
  89. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/__init__.py +0 -0
  90. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/base.py +0 -0
  91. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/conformance.py +0 -0
  92. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/errors.py +0 -0
  93. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/falkordb.py +0 -0
  94. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/graph_conformance.py +0 -0
  95. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/memory.py +0 -0
  96. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/postgres.py +0 -0
  97. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/serde.py +0 -0
  98. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/sqlite.py +0 -0
  99. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/store/url.py +0 -0
  100. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/__init__.py +0 -0
  101. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/base.py +0 -0
  102. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/cache.py +0 -0
  103. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/context.py +0 -0
  104. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/decorators.py +0 -0
  105. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/errors.py +0 -0
  106. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/graph_query.py +0 -0
  107. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/recorded.py +0 -0
  108. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/tools/web_fetch.py +0 -0
  109. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/trace/__init__.py +0 -0
  110. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/trace/causal.py +0 -0
  111. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph/trace/printer.py +0 -0
  112. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph.egg-info/dependency_links.txt +0 -0
  113. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph.egg-info/entry_points.txt +0 -0
  114. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph.egg-info/requires.txt +0 -0
  115. {activegraph-1.5.0 → activegraph-1.7.0}/activegraph.egg-info/top_level.txt +0 -0
  116. {activegraph-1.5.0 → activegraph-1.7.0}/setup.cfg +0 -0
  117. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_activate_after.py +0 -0
  118. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_causal_cross_tool.py +0 -0
  119. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_cli.py +0 -0
  120. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_cli_docs_flags.py +0 -0
  121. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_clock.py +0 -0
  122. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_diff.py +0 -0
  123. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_diligence_pack.py +0 -0
  124. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_diligence_with_tools.py +0 -0
  125. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_disable_pack.py +0 -0
  126. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_doc_links.py +0 -0
  127. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_doc_python_snippets.py +0 -0
  128. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_doc_site_reachable.py +0 -0
  129. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_embedding_provider.py +0 -0
  130. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_errors_format.py +0 -0
  131. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_event.py +0 -0
  132. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_falkordb_store.py +0 -0
  133. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_fork.py +0 -0
  134. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_graph.py +0 -0
  135. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_graph_store.py +0 -0
  136. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_ids.py +0 -0
  137. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_license.py +0 -0
  138. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_anthropic.py +0 -0
  139. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_behavior.py +0 -0
  140. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_budget.py +0 -0
  141. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_causal.py +0 -0
  142. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_claim_extraction.py +0 -0
  143. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_default_model.py +0 -0
  144. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_determinism.py +0 -0
  145. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_failure.py +0 -0
  146. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_native_structured_output.py +0 -0
  147. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_openai.py +0 -0
  148. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_prompt.py +0 -0
  149. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_provider_fixtures.py +0 -0
  150. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_replay.py +0 -0
  151. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_tool_loop.py +0 -0
  152. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_trace.py +0 -0
  153. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_trace_snapshot.py +0 -0
  154. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_types.py +0 -0
  155. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llm_wire.py +0 -0
  156. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_llms_txt.py +0 -0
  157. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_migration.py +0 -0
  158. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_observability_logging.py +0 -0
  159. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_observability_metrics.py +0 -0
  160. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_operate_example.py +0 -0
  161. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_pack_manifest.py +0 -0
  162. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_pack_scaffold.py +0 -0
  163. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_packs.py +0 -0
  164. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_patch.py +0 -0
  165. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_pattern_matcher.py +0 -0
  166. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_pattern_parser.py +0 -0
  167. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_pattern_subscriptions.py +0 -0
  168. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_persistence.py +0 -0
  169. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_postgres_store.py +0 -0
  170. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_quickstart.py +0 -0
  171. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_quickstart_snapshot.py +0 -0
  172. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_reason_codes_docs.py +0 -0
  173. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_registration_validation.py +0 -0
  174. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_replay.py +0 -0
  175. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_replay_trace_snapshot.py +0 -0
  176. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_requeue_unfired.py +0 -0
  177. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_resume_example.py +0 -0
  178. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_runtime.py +0 -0
  179. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_runtime_status.py +0 -0
  180. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_serde.py +0 -0
  181. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_store_conformance.py +0 -0
  182. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_store_url.py +0 -0
  183. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_tool_replay.py +0 -0
  184. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_tool_trace_snapshot.py +0 -0
  185. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_tools.py +0 -0
  186. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_trace.py +0 -0
  187. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_trace_accessors.py +0 -0
  188. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_tutorial_snippets.py +0 -0
  189. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_v1_0_1_patches.py +0 -0
  190. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_v1_0_3_behavior_failed_ux.py +0 -0
  191. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_v1_0_3_tool_multiturn.py +0 -0
  192. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_version_sync.py +0 -0
  193. {activegraph-1.5.0 → activegraph-1.7.0}/tests/test_view.py +0 -0
  194. {activegraph-1.5.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.5.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.5.0"
245
+ __version__ = "1.7.0"
@@ -27,6 +27,9 @@ from __future__ import annotations
27
27
  import copy
28
28
  import inspect
29
29
  import json
30
+ import logging
31
+ import sys
32
+ from pathlib import Path
30
33
  from typing import TYPE_CHECKING, Any, Callable, Optional
31
34
 
32
35
  from pydantic import BaseModel, ValidationError
@@ -309,9 +312,114 @@ def load_pack_into_runtime(
309
312
  timestamp=rt.graph.clock.now(),
310
313
  )
311
314
  )
315
+
316
+ # ---- 6. manifest warning tier (CONTRACT v1.6 #1) ----------------------
317
+ _warn_on_manifest_violations(pack)
312
318
  return True
313
319
 
314
320
 
321
+ # --------------------------------------------- manifest warning tier
322
+ # CONTRACT v1.6 #1: when a manifest.toml is discoverable next to the
323
+ # pack's defining modules, load_pack runs load_manifest +
324
+ # verify_surface and WARNs on violations — structured, once per pack
325
+ # per process, never an error before 2.0. Absent manifest: silent.
326
+ # Hash verification (content/bundle) stays host/CI territory: the
327
+ # loader never re-hashes a pack directory on the hot path.
328
+
329
+ _manifest_log = logging.getLogger("activegraph.packs.manifest")
330
+
331
+ # (pack name, pack version, manifest path) triples already validated
332
+ # this process — clean or not, the tier runs once per pack.
333
+ _manifest_checked: set[tuple[str, str, str]] = set()
334
+
335
+
336
+ def _locate_pack_manifest(pack: Pack) -> Optional[Path]:
337
+ """Best-effort pack-root discovery: resolve the modules that define
338
+ the pack's components and walk up (while still inside the package)
339
+ looking for a sibling ``manifest.toml``. Returns None when the
340
+ pack has no discoverable manifest — which is not a violation.
341
+ """
342
+ components: list[Any] = [getattr(b, "fn", None) for b in pack.behaviors]
343
+ components += [getattr(t, "fn", None) for t in pack.tools]
344
+ if pack.settings_schema is not EmptySettings:
345
+ components.append(pack.settings_schema)
346
+ components += [ot.schema for ot in pack.object_types]
347
+
348
+ seen_modules: set[str] = set()
349
+ for comp in components:
350
+ mod_name = getattr(comp, "__module__", None)
351
+ if not mod_name or mod_name == "__main__" or mod_name in seen_modules:
352
+ continue
353
+ seen_modules.add(mod_name)
354
+ mod_file = getattr(sys.modules.get(mod_name), "__file__", None)
355
+ if not mod_file:
356
+ continue
357
+ directory = Path(mod_file).resolve().parent
358
+ while True:
359
+ candidate = directory / "manifest.toml"
360
+ if candidate.is_file():
361
+ return candidate
362
+ # Stop once we step outside the package: the manifest
363
+ # lives at the pack root, and the pack root is a package.
364
+ if not (directory / "__init__.py").is_file():
365
+ break
366
+ if directory.parent == directory:
367
+ break
368
+ directory = directory.parent
369
+ return None
370
+
371
+
372
+ def _warn_on_manifest_violations(pack: Pack) -> None:
373
+ """The warning tier itself. Never raises: a broken manifest (or a
374
+ bug in discovery) must not break ``load_pack`` before 2.0.
375
+ """
376
+ try:
377
+ manifest_path = _locate_pack_manifest(pack)
378
+ if manifest_path is None:
379
+ return # no manifest: silent, per the Q2 schedule
380
+ key = (pack.name, pack.version, str(manifest_path))
381
+ if key in _manifest_checked:
382
+ return
383
+ _manifest_checked.add(key)
384
+ from activegraph.packs.manifest import (
385
+ PackManifestError,
386
+ load_manifest,
387
+ verify_surface,
388
+ )
389
+
390
+ try:
391
+ manifest = load_manifest(manifest_path)
392
+ verify_surface(manifest, pack)
393
+ except PackManifestError as err:
394
+ _manifest_log.warning(
395
+ "pack %s@%s: manifest.toml found at %s but validation "
396
+ "failed with %d violation(s). The pack still loads — "
397
+ "this stays a warning until activegraph 2.0. First "
398
+ "violation: %s",
399
+ pack.name,
400
+ pack.version,
401
+ manifest_path,
402
+ len(err.violations),
403
+ err.violations[0] if err.violations else "",
404
+ extra={
405
+ "pack": pack.name,
406
+ "pack_version": pack.version,
407
+ "manifest_path": str(manifest_path),
408
+ "violations": list(err.violations),
409
+ "reason": "pack.manifest_invalid",
410
+ },
411
+ )
412
+ except Exception:
413
+ # The tier is advisory; discovery/IO surprises are debug noise,
414
+ # not load failures.
415
+ _manifest_log.debug(
416
+ "manifest warning tier errored for pack %s@%s",
417
+ pack.name,
418
+ pack.version,
419
+ exc_info=True,
420
+ )
421
+
422
+
315
423
  # ---------------------------------------------------------------- state
316
424
 
317
425
 
@@ -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: