rootsign 0.2.0__tar.gz → 0.2.1__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 (206) hide show
  1. rootsign-0.2.1/.github/dependabot.yml +31 -0
  2. {rootsign-0.2.0 → rootsign-0.2.1}/.github/workflows/ci.yml +78 -12
  3. {rootsign-0.2.0 → rootsign-0.2.1}/.github/workflows/release.yml +9 -3
  4. {rootsign-0.2.0 → rootsign-0.2.1}/CONTRIBUTING.md +7 -0
  5. {rootsign-0.2.0 → rootsign-0.2.1}/PKG-INFO +3 -3
  6. {rootsign-0.2.0 → rootsign-0.2.1}/README.md +1 -1
  7. {rootsign-0.2.0 → rootsign-0.2.1}/pyproject.toml +1 -1
  8. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/cli.py +38 -7
  9. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/errors.py +31 -0
  10. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/mcp/server.py +14 -7
  11. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/chain.py +4 -1
  12. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/cli.py +57 -10
  13. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/client.py +3 -5
  14. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/decorator.py +54 -4
  15. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/registration.py +9 -6
  16. {rootsign-0.2.0 → rootsign-0.2.1}/tests/conftest.py +24 -0
  17. rootsign-0.2.1/tests/contract/no_extras/test_packaging_isolation.py +273 -0
  18. rootsign-0.2.1/tests/integration/test_cross_backend_hash.py +321 -0
  19. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_ingest.py +6 -32
  20. rootsign-0.2.1/tests/performance/_bench.py +55 -0
  21. rootsign-0.2.1/tests/performance/test_benchmarks.py +178 -0
  22. {rootsign-0.2.0 → rootsign-0.2.1}/tests/performance/test_langgraph_benchmarks.py +8 -0
  23. rootsign-0.2.1/tests/performance/test_roundtrip_benchmark.py +142 -0
  24. {rootsign-0.2.0 → rootsign-0.2.1}/tests/performance/test_verify_benchmarks.py +19 -9
  25. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_admin_cli.py +11 -4
  26. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_hitl_jsonl.py +1 -1
  27. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_jsonl_client.py +9 -6
  28. rootsign-0.2.1/tests/unit/test_log_injection.py +94 -0
  29. rootsign-0.2.0/tests/contract/no_extras/test_packaging_isolation.py +0 -113
  30. rootsign-0.2.0/tests/integration/test_cross_backend_hash.py +0 -133
  31. rootsign-0.2.0/tests/performance/test_benchmarks.py +0 -125
  32. {rootsign-0.2.0 → rootsign-0.2.1}/.env.example +0 -0
  33. {rootsign-0.2.0 → rootsign-0.2.1}/.github/CLA_SIGNED.md +0 -0
  34. {rootsign-0.2.0 → rootsign-0.2.1}/.github/CODEOWNERS +0 -0
  35. {rootsign-0.2.0 → rootsign-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  36. {rootsign-0.2.0 → rootsign-0.2.1}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  37. {rootsign-0.2.0 → rootsign-0.2.1}/.github/ISSUE_TEMPLATE/framework_integration.yml +0 -0
  38. {rootsign-0.2.0 → rootsign-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  39. {rootsign-0.2.0 → rootsign-0.2.1}/.gitignore +0 -0
  40. {rootsign-0.2.0 → rootsign-0.2.1}/.vscode/settings.json +0 -0
  41. {rootsign-0.2.0 → rootsign-0.2.1}/CLA.md +0 -0
  42. {rootsign-0.2.0 → rootsign-0.2.1}/CONTRIBUTORS.md +0 -0
  43. {rootsign-0.2.0 → rootsign-0.2.1}/LICENSE +0 -0
  44. {rootsign-0.2.0 → rootsign-0.2.1}/NOTICE +0 -0
  45. {rootsign-0.2.0 → rootsign-0.2.1}/SECURITY.md +0 -0
  46. {rootsign-0.2.0 → rootsign-0.2.1}/alembic.ini +0 -0
  47. {rootsign-0.2.0 → rootsign-0.2.1}/docker-compose.yml +0 -0
  48. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-001-hash-canonical-spec.md +0 -0
  49. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-002-transport-agnostic-client.md +0 -0
  50. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-003-framework-contract-tests.md +0 -0
  51. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-004-langgraph-interception-strategy.md +0 -0
  52. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-005-crewai-interception-strategy.md +0 -0
  53. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-006-redaction-contract.md +0 -0
  54. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-007-hitl-checkpoint-design.md +0 -0
  55. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-008-decision-capture.md +0 -0
  56. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-009-buffered-ingest-client.md +0 -0
  57. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-010-mcp-interception-strategy.md +0 -0
  58. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-011-jsonl-local-backend.md +0 -0
  59. {rootsign-0.2.0 → rootsign-0.2.1}/docs/adr/ADR-012-init-facade-contextvar-session.md +0 -0
  60. {rootsign-0.2.0 → rootsign-0.2.1}/docs/demo.gif +0 -0
  61. {rootsign-0.2.0 → rootsign-0.2.1}/docs/demo.tape +0 -0
  62. {rootsign-0.2.0 → rootsign-0.2.1}/docs/framework-support.md +0 -0
  63. {rootsign-0.2.0 → rootsign-0.2.1}/docs/releases/v0.1.0.md +0 -0
  64. {rootsign-0.2.0 → rootsign-0.2.1}/docs/releases/v0.1.1.md +0 -0
  65. {rootsign-0.2.0 → rootsign-0.2.1}/docs/releases/v0.1.2.md +0 -0
  66. {rootsign-0.2.0 → rootsign-0.2.1}/docs/releases/v0.1.3.md +0 -0
  67. {rootsign-0.2.0 → rootsign-0.2.1}/docs/releases/v0.1.4.md +0 -0
  68. {rootsign-0.2.0 → rootsign-0.2.1}/docs/releases/v0.1.5.md +0 -0
  69. {rootsign-0.2.0 → rootsign-0.2.1}/docs/releases/v0.2.0.md +0 -0
  70. {rootsign-0.2.0 → rootsign-0.2.1}/docs/rootsign-logo.png +0 -0
  71. {rootsign-0.2.0 → rootsign-0.2.1}/examples/README.md +0 -0
  72. {rootsign-0.2.0 → rootsign-0.2.1}/examples/docker-compose.yml +0 -0
  73. {rootsign-0.2.0 → rootsign-0.2.1}/examples/langgraph-invoice-agent/.env.example +0 -0
  74. {rootsign-0.2.0 → rootsign-0.2.1}/examples/langgraph-invoice-agent/README.md +0 -0
  75. {rootsign-0.2.0 → rootsign-0.2.1}/examples/langgraph-invoice-agent/agent.py +0 -0
  76. {rootsign-0.2.0 → rootsign-0.2.1}/examples/langgraph-invoice-agent/pyproject.toml +0 -0
  77. {rootsign-0.2.0 → rootsign-0.2.1}/examples/langgraph-invoice-agent/verify.sh +0 -0
  78. {rootsign-0.2.0 → rootsign-0.2.1}/examples/quickstart-jsonl/README.md +0 -0
  79. {rootsign-0.2.0 → rootsign-0.2.1}/examples/quickstart-jsonl/quickstart.py +0 -0
  80. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/__init__.py +0 -0
  81. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/__init__.py +0 -0
  82. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/env.py +0 -0
  83. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/script.py.mako +0 -0
  84. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/versions/0001_initial_schema.py +0 -0
  85. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/versions/0002_approval_parent_id.py +0 -0
  86. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/versions/0003_action_timed_out.py +0 -0
  87. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/versions/0004_approval_action_unique.py +0 -0
  88. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_migrations/versions/0005_agents_name_env_unique.py +0 -0
  89. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/_version.py +0 -0
  90. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/config.py +0 -0
  91. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/__init__.py +0 -0
  92. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/action.py +0 -0
  93. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/agent.py +0 -0
  94. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/approval.py +0 -0
  95. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/base.py +0 -0
  96. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/decision.py +0 -0
  97. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/incident.py +0 -0
  98. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/policy.py +0 -0
  99. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/crud/session.py +0 -0
  100. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/database.py +0 -0
  101. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/hashing.py +0 -0
  102. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/ingest/__init__.py +0 -0
  103. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/ingest/handler.py +0 -0
  104. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/ingest/idempotency.py +0 -0
  105. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/ingest/schemas.py +0 -0
  106. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/mcp/__init__.py +0 -0
  107. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/mcp/proxy.py +0 -0
  108. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/__init__.py +0 -0
  109. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/action.py +0 -0
  110. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/agent.py +0 -0
  111. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/approval.py +0 -0
  112. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/decision.py +0 -0
  113. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/incident.py +0 -0
  114. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/policy.py +0 -0
  115. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/models/session.py +0 -0
  116. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/__init__.py +0 -0
  117. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/action.py +0 -0
  118. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/agent.py +0 -0
  119. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/approval.py +0 -0
  120. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/decision.py +0 -0
  121. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/incident.py +0 -0
  122. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/policy.py +0 -0
  123. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/schemas/session.py +0 -0
  124. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/__init__.py +0 -0
  125. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/_async_bridge.py +0 -0
  126. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/buffered_client.py +0 -0
  127. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/config.py +0 -0
  128. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/context.py +0 -0
  129. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/facade.py +0 -0
  130. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/frameworks/__init__.py +0 -0
  131. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/frameworks/crewai.py +0 -0
  132. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/frameworks/langgraph.py +0 -0
  133. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/hashing.py +0 -0
  134. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/hitl.py +0 -0
  135. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/jsonl_client.py +0 -0
  136. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/jsonl_registry.py +0 -0
  137. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/redaction.py +0 -0
  138. {rootsign-0.2.0 → rootsign-0.2.1}/rootsign/sdk/session.py +0 -0
  139. {rootsign-0.2.0 → rootsign-0.2.1}/scripts/demo_for_gif.py +0 -0
  140. {rootsign-0.2.0 → rootsign-0.2.1}/scripts/demo_verify_chain.py +0 -0
  141. {rootsign-0.2.0 → rootsign-0.2.1}/scripts/generate_hash_vectors.py +0 -0
  142. {rootsign-0.2.0 → rootsign-0.2.1}/scripts/init_test_db.sql +0 -0
  143. {rootsign-0.2.0 → rootsign-0.2.1}/tests/__init__.py +0 -0
  144. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/__init__.py +0 -0
  145. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/crewai/__init__.py +0 -0
  146. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/crewai/test_tool_interception.py +0 -0
  147. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/langgraph/__init__.py +0 -0
  148. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/langgraph/test_tool_interception.py +0 -0
  149. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/mcp/__init__.py +0 -0
  150. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/mcp/test_proxy_hitl.py +0 -0
  151. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/mcp/test_proxy_interception.py +0 -0
  152. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/mcp/test_server.py +0 -0
  153. {rootsign-0.2.0 → rootsign-0.2.1}/tests/contract/no_extras/__init__.py +0 -0
  154. {rootsign-0.2.0 → rootsign-0.2.1}/tests/fixtures/hash_vectors.json +0 -0
  155. {rootsign-0.2.0 → rootsign-0.2.1}/tests/fixtures/redaction_vectors.json +0 -0
  156. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/__init__.py +0 -0
  157. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_approval_concurrency.py +0 -0
  158. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_approve_cli.py +0 -0
  159. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_buffered_client.py +0 -0
  160. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_crewai_integration.py +0 -0
  161. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_crud.py +0 -0
  162. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_decision_capture.py +0 -0
  163. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_decision_ingest.py +0 -0
  164. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_facade_implicit_tracers.py +0 -0
  165. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_facade_postgres.py +0 -0
  166. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_jsonl_buffered.py +0 -0
  167. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_langgraph_integration.py +0 -0
  168. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_mcp_proxy.py +0 -0
  169. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_mcp_server.py +0 -0
  170. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_payload_binding.py +0 -0
  171. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_register_agent.py +0 -0
  172. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_relationships.py +0 -0
  173. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_sdk_smoke.py +0 -0
  174. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_session_context_manager.py +0 -0
  175. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_show_hn_quickstart.py +0 -0
  176. {rootsign-0.2.0 → rootsign-0.2.1}/tests/integration/test_verify_cli.py +0 -0
  177. {rootsign-0.2.0 → rootsign-0.2.1}/tests/performance/__init__.py +0 -0
  178. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/__init__.py +0 -0
  179. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_action_record_decision_id.py +0 -0
  180. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_action_sequence_counter.py +0 -0
  181. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_buffered_client.py +0 -0
  182. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_chain.py +0 -0
  183. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_config_guard.py +0 -0
  184. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_context.py +0 -0
  185. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_crewai_tracer.py +0 -0
  186. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_crud_approval.py +0 -0
  187. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_emit_action_record_json_safe.py +0 -0
  188. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_emit_approval_record.py +0 -0
  189. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_emit_decision_record.py +0 -0
  190. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_errors.py +0 -0
  191. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_facade.py +0 -0
  192. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_hashing.py +0 -0
  193. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_hitl.py +0 -0
  194. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_idempotency_bounds.py +0 -0
  195. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_ingest_client.py +0 -0
  196. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_input_payload_override.py +0 -0
  197. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_jsonl_registry.py +0 -0
  198. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_langgraph_tracer.py +0 -0
  199. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_redaction.py +0 -0
  200. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_redaction_pii.py +0 -0
  201. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_schemas.py +0 -0
  202. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_sdk_config.py +0 -0
  203. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_sdk_hashing.py +0 -0
  204. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_session_buffered_flush.py +0 -0
  205. {rootsign-0.2.0 → rootsign-0.2.1}/tests/unit/test_trace_hitl.py +0 -0
  206. {rootsign-0.2.0 → rootsign-0.2.1}/uv.lock +0 -0
@@ -0,0 +1,31 @@
1
+ # Dependabot keeps the SHA-pinned GitHub Actions fresh.
2
+ #
3
+ # Why this file exists: `.github/workflows/release.yml` pins
4
+ # pypa/gh-action-pypi-publish to an immutable commit SHA rather than the
5
+ # mutable `release/v1` branch (CodeQL actions/unpinned-tag). That closes the
6
+ # supply-chain hole on the step that mints our PyPI Trusted Publishing OIDC
7
+ # token — but a hand-pinned SHA rots silently, which is its own risk. Dependabot
8
+ # bumps the pin and rewrites the trailing `# vX.Y.Z` comment for us, so the pin
9
+ # stays both immutable and current.
10
+ #
11
+ # Scope note: only `github-actions` is enabled. The Python dependency surface is
12
+ # deliberately left to security alerts rather than routine version-update PRs —
13
+ # the framework-contract CI jobs pin langgraph/crewai to specific minor ranges
14
+ # on purpose, and version-bump PRs against those would be noise.
15
+ version: 2
16
+ updates:
17
+ - package-ecosystem: github-actions
18
+ directory: /
19
+ schedule:
20
+ interval: weekly
21
+ commit-message:
22
+ # Matches the repo convention: type(scope): short description
23
+ prefix: "chore(ci)"
24
+ labels:
25
+ - dependencies
26
+ groups:
27
+ # One PR per week for routine action bumps instead of one per action.
28
+ # Security-relevant bumps still arrive on their own via alerts.
29
+ github-actions:
30
+ patterns:
31
+ - "*"
@@ -6,6 +6,13 @@ on:
6
6
  pull_request:
7
7
  branches: [main]
8
8
 
9
+ # Least privilege for every job below. CI only ever reads the repo — no job
10
+ # publishes, comments, or writes checks — so the read-only default holds for
11
+ # all of them and no job needs an override. Without this block the workflow
12
+ # inherits the repo-wide default token scopes (CodeQL actions/missing-workflow-permissions).
13
+ permissions:
14
+ contents: read
15
+
9
16
  jobs:
10
17
  unit-tests:
11
18
  name: Unit tests (Python ${{ matrix.python-version }})
@@ -38,8 +45,8 @@ jobs:
38
45
  TEST_DATABASE_URL: postgresql+asyncpg://rootsign:rootsign@localhost:5432/rootsign_test
39
46
  TEST_DATABASE_URL_SYNC: postgresql+psycopg2://rootsign:rootsign@localhost:5432/rootsign_test
40
47
  steps:
41
- - uses: actions/checkout@v5
42
- - uses: actions/setup-python@v6
48
+ - uses: actions/checkout@v7
49
+ - uses: actions/setup-python@v7
43
50
  with:
44
51
  python-version: ${{ matrix.python-version }}
45
52
  - name: Install
@@ -81,8 +88,8 @@ jobs:
81
88
  TEST_DATABASE_URL: postgresql+asyncpg://rootsign:rootsign@localhost:5432/rootsign_test
82
89
  TEST_DATABASE_URL_SYNC: postgresql+psycopg2://rootsign:rootsign@localhost:5432/rootsign_test
83
90
  steps:
84
- - uses: actions/checkout@v5
85
- - uses: actions/setup-python@v6
91
+ - uses: actions/checkout@v7
92
+ - uses: actions/setup-python@v7
86
93
  with:
87
94
  python-version: '3.12'
88
95
  # mcp extra so the DB-backed MCP proxy integration test
@@ -118,8 +125,8 @@ jobs:
118
125
  env:
119
126
  ROOTSIGN_SKIP_DB_BOOTSTRAP: '1'
120
127
  steps:
121
- - uses: actions/checkout@v5
122
- - uses: actions/setup-python@v6
128
+ - uses: actions/checkout@v7
129
+ - uses: actions/setup-python@v7
123
130
  with:
124
131
  python-version: '3.12'
125
132
  - name: Install with langgraph extra
@@ -146,8 +153,8 @@ jobs:
146
153
  env:
147
154
  ROOTSIGN_SKIP_DB_BOOTSTRAP: '1'
148
155
  steps:
149
- - uses: actions/checkout@v5
150
- - uses: actions/setup-python@v6
156
+ - uses: actions/checkout@v7
157
+ - uses: actions/setup-python@v7
151
158
  with:
152
159
  python-version: '3.12'
153
160
  - name: Install with crewai extra
@@ -170,8 +177,8 @@ jobs:
170
177
  env:
171
178
  ROOTSIGN_SKIP_DB_BOOTSTRAP: '1'
172
179
  steps:
173
- - uses: actions/checkout@v5
174
- - uses: actions/setup-python@v6
180
+ - uses: actions/checkout@v7
181
+ - uses: actions/setup-python@v7
175
182
  with:
176
183
  python-version: '3.12'
177
184
  - name: Install with mcp extra
@@ -188,8 +195,8 @@ jobs:
188
195
  name: No-extras install (DB-free core)
189
196
  runs-on: ubuntu-latest
190
197
  steps:
191
- - uses: actions/checkout@v5
192
- - uses: actions/setup-python@v6
198
+ - uses: actions/checkout@v7
199
+ - uses: actions/setup-python@v7
193
200
  with:
194
201
  python-version: '3.12'
195
202
  - name: Install core only (no extras)
@@ -231,6 +238,65 @@ jobs:
231
238
  assert "rootsign[postgres]" in str(e), e
232
239
  print("RootSignPostgresExtraRequired raised OK")
233
240
  PY
241
+ - name: Console scripts run DB-free (CLI tripwire)
242
+ # `import rootsign` passing is NOT sufficient. The console script entry
243
+ # point imports `rootsign.sdk.cli`, a different import graph that the
244
+ # checks above never touch — a module-level `from rootsign.database
245
+ # import AsyncSessionLocal` there crashed EVERY `rootsign ...`
246
+ # invocation on a bare install (even `version` and `verify --local`,
247
+ # neither of which needs a DB) while this job stayed green. This step
248
+ # exercises the script itself so that class of regression fails here.
249
+ run: |
250
+ set -euo pipefail
251
+ rootsign version
252
+ rootsign --help > /dev/null
253
+ # The operator CLI is a separate entry point (rootsign.cli:app) with
254
+ # its own import graph. --help and the docker-only subcommands must
255
+ # work without the postgres extra.
256
+ rootsign-admin --help > /dev/null
257
+ rootsign-admin start-db --help > /dev/null
258
+ rootsign-admin stop-db --help > /dev/null
259
+ # Store-exported fixture, not a checked-in file: let the JSONL backend
260
+ # write a real session so the chain is produced by the same code path
261
+ # that verifies it. Never hand-construct a self_hash (ADR-001).
262
+ SESSION_ID="$(python - <<'PY'
263
+ import asyncio, rootsign
264
+
265
+ rootsign.init(agent="ci-tripwire")
266
+
267
+ @rootsign.trace()
268
+ async def add(a: int, b: int) -> int:
269
+ return a + b
270
+
271
+ async def main():
272
+ async with rootsign.session() as ctx:
273
+ await add(2, 3)
274
+ return ctx.session_id
275
+
276
+ print(asyncio.run(main()))
277
+ PY
278
+ )"
279
+ rootsign verify --local "$HOME/.rootsign/sessions/$SESSION_ID.jsonl"
280
+ echo "console scripts OK, DB-free"
281
+ - name: DB-backed subcommands fail with the install hint, not a traceback
282
+ # The other half of the same bug: these commands legitimately need the
283
+ # extra, but must say so in one line and exit 1 rather than dumping a
284
+ # ModuleNotFoundError traceback at the user.
285
+ run: |
286
+ set -uo pipefail
287
+ # Every DB-backed subcommand across BOTH console scripts.
288
+ for cmd in "rootsign verify 550e8400-e29b-41d4-a716-446655440001" \
289
+ "rootsign approve --list" \
290
+ "rootsign approve 550e8400-e29b-41d4-a716-446655440001" \
291
+ "rootsign-admin status" \
292
+ "rootsign-admin init"; do
293
+ out="$($cmd 2>&1)" && { echo "expected failure: $cmd"; exit 1; }
294
+ echo "$out" | grep -q "rootsign\[postgres\]" || {
295
+ echo "missing install hint for: $cmd"; echo "$out"; exit 1; }
296
+ echo "$out" | grep -qi "Traceback" && {
297
+ echo "leaked traceback for: $cmd"; echo "$out"; exit 1; }
298
+ echo "$cmd -> actionable error OK"
299
+ done
234
300
  # NOTE: we deliberately do NOT run `pytest tests/contract/no_extras/`
235
301
  # here — the root tests/conftest.py imports sqlalchemy at module level
236
302
  # (for the DB fixtures), so pytest cannot even collect in a DB-free env.
@@ -22,8 +22,8 @@ jobs:
22
22
  permissions:
23
23
  id-token: write # required for Trusted Publishing (OIDC)
24
24
  steps:
25
- - uses: actions/checkout@v5
26
- - uses: actions/setup-python@v6
25
+ - uses: actions/checkout@v7
26
+ - uses: actions/setup-python@v7
27
27
  with:
28
28
  python-version: '3.12'
29
29
 
@@ -48,4 +48,10 @@ jobs:
48
48
  twine check dist/*
49
49
 
50
50
  - name: Publish to PyPI
51
- uses: pypa/gh-action-pypi-publish@release/v1
51
+ # Pinned to an immutable commit SHA, not the mutable `release/v1`
52
+ # branch. This step runs with `id-token: write` and mints the OIDC
53
+ # token that PyPI Trusted Publishing exchanges for upload rights to
54
+ # the `rootsign` project — anyone able to move that branch could
55
+ # publish a release under our name. SHA below == tag v1.14.2.
56
+ # Bump deliberately; verify the new SHA against the release tag.
57
+ uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -20,6 +20,7 @@ docker-compose up -d db # PostgreSQL + TimescaleDB
20
20
  rootsign-admin init # alembic upgrade head
21
21
  python -m pytest tests/unit/ -v # unit tests (no DB needed at runtime — see note)
22
22
  python -m pytest tests/integration/ -v # integration tests (needs DB)
23
+ python -m pytest -m benchmark -v # performance budgets (opt-in — see note)
23
24
  ```
24
25
 
25
26
  > **Python:** RootSign requires Python 3.11 or 3.12. We do not support 3.10 or below.
@@ -29,6 +30,12 @@ python -m pytest tests/integration/ -v # integration tests (needs DB)
29
30
  > **Always invoke pytest as `python -m pytest`.** If you `brew install`-ed pytest, the system binary will resolve ahead of the venv's pytest on PATH and run under the system Python — which does not see your venv's site-packages and will fail with confusing `ModuleNotFoundError` (typically on `sqlalchemy` first). `python -m pytest` always uses the venv's interpreter.
30
31
  >
31
32
  > **Note on unit tests:** The session-scoped `_bootstrap_test_db` fixture in `tests/conftest.py` runs alembic against the test DB before any test (including unit tests). Bring `docker-compose up -d db` up first.
33
+ >
34
+ > **Performance benchmarks are opt-in.** Everything under `tests/performance/` is marked `benchmark` and is **skipped** by a default `python -m pytest` run; pass `-m benchmark` to execute it. These tests assert wall-clock budgets, so they are the most hardware-sensitive part of the suite — `test_1000_actions_under_2_seconds` has only ~1.36x headroom on a developer laptop. They are deliberately kept out of every CI gating job: a timing budget that reddens `main` for reasons unrelated to the diff teaches people to ignore red. Run them locally (or on purpose in CI) when touching the ingest, hashing, or verify paths.
35
+ >
36
+ > **The thresholds are ACs and do not move.** To keep them honest without making them flaky, each budget is sampled several times and asserted on the **median** (`tests/performance/_bench.py`). A single stalled run barely shifts a median, while a real regression shifts every sample — so the AC stays exactly as specified and the measurement stops reacting to background load, cold caches, or a noisy neighbour. Failures print every sample, not just the median, so you can see the spread. `test_p99_overhead_under_5ms` is the exception: it already asserts on the p99 of 1,000 calls, so it is a distribution by construction.
37
+ >
38
+ > These numbers are still **hardware- and environment-dependent** — they measure your disk, your Postgres tuning, and whatever else the machine is doing. Treat a failure as a prompt to investigate on an idle machine, not as proof of a regression. For scale: a trial run of `test_1000_actions_under_2_seconds` produced samples of 1.6s / 2.4s / 1.4s on an idle laptop; the 2.4s sample alone exceeds the 2.0s budget, which is exactly why the median exists.
32
39
 
33
40
  ## Branch naming
34
41
 
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: rootsign
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: RootSign — tamper-evident provenance logging for AI agents (powered by Providex AI)
5
5
  Author: Providex AI Inc.
6
6
  License: Apache-2.0
@@ -89,7 +89,7 @@ Compliance-grade audit trails. Zero changes to your agent code.
89
89
 
90
90
  ## Status
91
91
 
92
- **v0.2.0.** `pip install rootsign` → a verified hash chain in under a minute: no Docker, no database, no plumbing. LangGraph + CrewAI integrations, a framework-agnostic MCP proxy, `rootsign verify` CLI, PII redaction, human-in-the-loop checkpoints, opt-in decision capture (PRD-19 / ADR-008), and opt-in SDK micro-batching are all shipping.
92
+ **v0.2.1.** `pip install rootsign` → a verified hash chain in under a minute: no Docker, no database, no plumbing. LangGraph + CrewAI integrations, a framework-agnostic MCP proxy, `rootsign verify` CLI, PII redaction, human-in-the-loop checkpoints, opt-in decision capture (PRD-19 / ADR-008), and opt-in SDK micro-batching are all shipping.
93
93
 
94
94
  | Phase | Scope | Status |
95
95
  |---|---|---|
@@ -27,7 +27,7 @@ Compliance-grade audit trails. Zero changes to your agent code.
27
27
 
28
28
  ## Status
29
29
 
30
- **v0.2.0.** `pip install rootsign` → a verified hash chain in under a minute: no Docker, no database, no plumbing. LangGraph + CrewAI integrations, a framework-agnostic MCP proxy, `rootsign verify` CLI, PII redaction, human-in-the-loop checkpoints, opt-in decision capture (PRD-19 / ADR-008), and opt-in SDK micro-batching are all shipping.
30
+ **v0.2.1.** `pip install rootsign` → a verified hash chain in under a minute: no Docker, no database, no plumbing. LangGraph + CrewAI integrations, a framework-agnostic MCP proxy, `rootsign verify` CLI, PII redaction, human-in-the-loop checkpoints, opt-in decision capture (PRD-19 / ADR-008), and opt-in SDK micro-batching are all shipping.
31
31
 
32
32
  | Phase | Scope | Status |
33
33
  |---|---|---|
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "rootsign"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "RootSign — tamper-evident provenance logging for AI agents (powered by Providex AI)"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11,<3.15"
@@ -18,16 +18,24 @@ import re
18
18
  import shutil
19
19
  import subprocess
20
20
  import time
21
- from importlib import resources
21
+ from importlib import import_module, resources
22
22
  from pathlib import Path
23
+ from typing import TYPE_CHECKING, Any
23
24
 
24
- import psycopg2
25
25
  import typer
26
- from alembic import command
27
- from alembic.config import Config
28
26
 
29
27
  from rootsign.config import settings
30
28
 
29
+ if TYPE_CHECKING:
30
+ from alembic.config import Config
31
+
32
+ # psycopg2 and alembic live in the optional `postgres` extra (ADR-011), so they
33
+ # are imported inside the commands that need them via `_require()`. At module
34
+ # level they made `rootsign-admin` unusable on a bare install — even
35
+ # `rootsign-admin --help` and the docker-only `start-db` / `stop-db` died with
36
+ # ModuleNotFoundError before Typer could dispatch. Same defect class as the
37
+ # `rootsign` console script (rootsign/sdk/cli.py).
38
+
31
39
  app = typer.Typer(
32
40
  name="rootsign-admin",
33
41
  help="RootSign Phase 0 — schema and storage operations (powered by Providex AI).",
@@ -35,6 +43,24 @@ app = typer.Typer(
35
43
  no_args_is_help=True,
36
44
  )
37
45
 
46
+
47
+ def _require(module: str) -> Any:
48
+ """Import an optional DB-stack module, or exit with the install hint.
49
+
50
+ Mirrors `rootsign.sdk.cli._session_factory`: a missing `postgres` extra
51
+ becomes a one-line actionable error and exit 1, never a raw traceback.
52
+ """
53
+ try:
54
+ return import_module(module)
55
+ except ModuleNotFoundError as exc:
56
+ from rootsign.errors import RootSignPostgresExtraRequired
57
+
58
+ typer.echo(
59
+ f"Error: {RootSignPostgresExtraRequired(f'missing module: {exc.name}')}",
60
+ err=True,
61
+ )
62
+ raise typer.Exit(code=1) from exc
63
+
38
64
  # Default container name + image. Mirrors docker-compose.yml so devs can swap
39
65
  # freely between `rootsign-admin start-db` and `docker-compose up -d db`.
40
66
  _DB_CONTAINER = "rootsign-timescaledb"
@@ -61,7 +87,7 @@ def _alembic_config(sync_url: str | None = None) -> Config:
61
87
  are set directly, so PyPI users (who have no alembic.ini) and devs in the
62
88
  repo root behave identically.
63
89
  """
64
- cfg = Config()
90
+ cfg = _require("alembic.config").Config()
65
91
  cfg.set_main_option("script_location", str(_migrations_dir()))
66
92
  cfg.set_main_option("sqlalchemy.url", sync_url or settings.DATABASE_URL_SYNC)
67
93
  return cfg
@@ -77,7 +103,8 @@ def _dsn_role() -> str:
77
103
 
78
104
 
79
105
  def _drop_public_schema() -> None:
80
- from psycopg2 import sql
106
+ psycopg2 = _require("psycopg2")
107
+ sql = _require("psycopg2.sql")
81
108
 
82
109
  dsn = _strip_driver(settings.DATABASE_URL_SYNC)
83
110
  role = sql.Identifier(_dsn_role())
@@ -211,6 +238,9 @@ def init(
211
238
  ),
212
239
  ) -> None:
213
240
  """Apply migrations to the dev database. Use --reset to start from scratch."""
241
+ # Resolve the DB stack before any output or the destructive --reset prompt,
242
+ # so a missing extra fails cleanly instead of half-way through.
243
+ alembic_command = _require("alembic.command")
214
244
  start = time.perf_counter()
215
245
  if reset:
216
246
  # audit #11b: --reset drops the ENTIRE public schema. Require an
@@ -225,7 +255,7 @@ def init(
225
255
  typer.echo("Dropping public schema...")
226
256
  _drop_public_schema()
227
257
  typer.echo("Running alembic upgrade head...")
228
- command.upgrade(_alembic_config(), "head")
258
+ alembic_command.upgrade(_alembic_config(), "head")
229
259
  elapsed = time.perf_counter() - start
230
260
  typer.echo(f"Done in {elapsed:.2f}s")
231
261
 
@@ -233,6 +263,7 @@ def init(
233
263
  @app.command()
234
264
  def status() -> None:
235
265
  """Print row counts for every RootSign table."""
266
+ psycopg2 = _require("psycopg2")
236
267
  dsn = _strip_driver(settings.DATABASE_URL_SYNC)
237
268
  conn = psycopg2.connect(dsn)
238
269
  try:
@@ -24,6 +24,8 @@ import from here without back-edges.
24
24
 
25
25
  from __future__ import annotations
26
26
 
27
+ from contextlib import contextmanager
28
+ from collections.abc import Iterator
27
29
  from uuid import UUID
28
30
 
29
31
 
@@ -194,6 +196,35 @@ class RootSignPostgresExtraRequired(RootSignError):
194
196
  super().__init__(message)
195
197
 
196
198
 
199
+ @contextmanager
200
+ def postgres_extra_required() -> "Iterator[None]":
201
+ """Translate a missing DB stack into `RootSignPostgresExtraRequired`.
202
+
203
+ Wrap the lazy imports of any code path that needs Postgres::
204
+
205
+ with postgres_extra_required():
206
+ from sqlalchemy import select
207
+
208
+ from rootsign.database import AsyncSessionLocal
209
+
210
+ Since ADR-011 those imports are deferred into function bodies so the core
211
+ install stays DB-free. Deferring alone only moves *where* the failure
212
+ happens, though — without this, a user on `pip install rootsign` who calls
213
+ a DB-backed path gets `ModuleNotFoundError: No module named 'sqlalchemy'`
214
+ from somewhere deep in the import graph, with no hint that an extra exists.
215
+ `get_ingest_client` already translated; several SDK entry points did not,
216
+ so the same mistake produced a helpful message or a bare traceback
217
+ depending on which door you came through.
218
+
219
+ Only wrap the imports themselves. A `ModuleNotFoundError` from unrelated
220
+ application code inside the block would be mislabelled as a missing extra.
221
+ """
222
+ try:
223
+ yield
224
+ except ModuleNotFoundError as exc:
225
+ raise RootSignPostgresExtraRequired(f"missing module: {exc.name}") from exc
226
+
227
+
197
228
  class HiTLUnsupportedBackendError(RootSignError):
198
229
  """`require_approval=True` used with a backend that can't support it here.
199
230
 
@@ -30,6 +30,8 @@ import logging
30
30
  from typing import TYPE_CHECKING, Any, Callable
31
31
  from uuid import UUID
32
32
 
33
+ from rootsign.errors import postgres_extra_required
34
+
33
35
  if TYPE_CHECKING:
34
36
  from contextlib import AbstractAsyncContextManager
35
37
 
@@ -45,7 +47,8 @@ logger = logging.getLogger("rootsign.mcp.server")
45
47
  def _default_session_factory() -> Any:
46
48
  """Lazily resolve the app's AsyncSessionLocal so importing this module
47
49
  never constructs a DB engine. Only touched when a tool actually runs."""
48
- from rootsign.database import AsyncSessionLocal
50
+ with postgres_extra_required():
51
+ from rootsign.database import AsyncSessionLocal
49
52
 
50
53
  return AsyncSessionLocal()
51
54
 
@@ -63,9 +66,10 @@ async def _list_sessions(
63
66
  limit: int = 20,
64
67
  ) -> list[dict[str, Any]]:
65
68
  """Most-recent sessions, optionally filtered by agent, newest first."""
66
- from sqlalchemy import select
69
+ with postgres_extra_required():
70
+ from sqlalchemy import select
67
71
 
68
- from rootsign.models.session import AgentSession
72
+ from rootsign.models.session import AgentSession
69
73
 
70
74
  async with session_factory() as db:
71
75
  stmt = select(AgentSession).order_by(AgentSession.start_time.desc()).limit(limit)
@@ -92,7 +96,8 @@ async def _query_session_chain(
92
96
  session_id: str,
93
97
  ) -> list[dict[str, Any]]:
94
98
  """The ordered Action chain for a session (metadata + hashes, no payloads)."""
95
- from rootsign.crud import action as action_crud
99
+ with postgres_extra_required():
100
+ from rootsign.crud import action as action_crud
96
101
 
97
102
  async with session_factory() as db:
98
103
  actions = await action_crud.get_session_chain(db, session_id=UUID(session_id))
@@ -118,7 +123,8 @@ async def _verify_session_chain(
118
123
  session_id: str,
119
124
  ) -> dict[str, Any]:
120
125
  """Recompute + verify the chain — identical result to `rootsign verify`."""
121
- from rootsign.crud import action as action_crud
126
+ with postgres_extra_required():
127
+ from rootsign.crud import action as action_crud
122
128
 
123
129
  async with session_factory() as db:
124
130
  return await action_crud.verify_chain(db, session_id=UUID(session_id))
@@ -130,9 +136,10 @@ async def _get_approval_records(
130
136
  action_id: str,
131
137
  ) -> list[dict[str, Any]]:
132
138
  """All approval rows for an action (a list — escalation can yield several)."""
133
- from sqlalchemy import select
139
+ with postgres_extra_required():
140
+ from sqlalchemy import select
134
141
 
135
- from rootsign.models.approval import Approval
142
+ from rootsign.models.approval import Approval
136
143
 
137
144
  async with session_factory() as db:
138
145
  stmt = (
@@ -20,6 +20,8 @@ from pathlib import Path
20
20
  from typing import Any
21
21
  from uuid import UUID
22
22
 
23
+ from rootsign.errors import postgres_extra_required
24
+
23
25
 
24
26
  @dataclass
25
27
  class VerifyResult:
@@ -53,7 +55,8 @@ async def verify_session(session_id: UUID, db: Any) -> VerifyResult:
53
55
  `db` is an `AsyncSession`. Returns a `VerifyResult`. Never raises on
54
56
  chain inconsistency — the result's `valid` field is the verdict.
55
57
  """
56
- from rootsign.crud import action as action_crud
58
+ with postgres_extra_required():
59
+ from rootsign.crud import action as action_crud
57
60
 
58
61
  raw = await action_crud.verify_chain(db, session_id=session_id)
59
62
  return VerifyResult(
@@ -20,15 +20,25 @@ from __future__ import annotations
20
20
 
21
21
  import asyncio
22
22
  from pathlib import Path
23
- from typing import Optional
23
+ from typing import Any, Optional
24
24
  from uuid import UUID
25
25
 
26
26
  import typer
27
27
 
28
- # Module-level so tests can monkeypatch it to bind to the test engine.
29
- # Production code goes through `_verify_remote`, which calls this factory
30
- # to open a fresh AsyncSession per invocation.
31
- from rootsign.database import AsyncSessionLocal # noqa: E402
28
+ # A test seam, deliberately NOT an import. Tests monkeypatch
29
+ # `rootsign.sdk.cli.AsyncSessionLocal` to bind the CLI to the test engine
30
+ # (tests/integration/test_verify_cli.py, test_approve_cli.py,
31
+ # test_show_hn_quickstart.py), so the name has to exist at module scope.
32
+ #
33
+ # Importing `rootsign.database` here to provide it would drag SQLAlchemy in at
34
+ # module-import time, and since ADR-011 the DB stack lives in the optional
35
+ # `postgres` extra. On a no-extras install that made EVERY `rootsign ...`
36
+ # invocation die with ModuleNotFoundError before Typer could dispatch —
37
+ # including `rootsign version` and `rootsign verify --local`, neither of which
38
+ # touches a database. That violates T1.2 / exit criterion 1.
39
+ #
40
+ # `_session_factory()` resolves the real factory on demand instead.
41
+ AsyncSessionLocal: Any = None
32
42
 
33
43
  app = typer.Typer(
34
44
  name="rootsign",
@@ -38,6 +48,31 @@ app = typer.Typer(
38
48
  )
39
49
 
40
50
 
51
+ def _session_factory() -> Any:
52
+ """Return the AsyncSession factory, importing the DB stack on demand.
53
+
54
+ Mirrors `LocalIngestClient._factory` (rootsign/sdk/client.py): a missing
55
+ `postgres` extra becomes the actionable install hint rather than a bare
56
+ ModuleNotFoundError from deep in the import graph. Only the DB-backed
57
+ subcommands call this, so DB-free ones stay usable without the extra.
58
+ """
59
+ if AsyncSessionLocal is not None:
60
+ # Monkeypatched by tests, or already resolved.
61
+ return AsyncSessionLocal
62
+ try:
63
+ from rootsign.database import AsyncSessionLocal as factory
64
+ except ModuleNotFoundError as exc:
65
+ from rootsign.errors import RootSignPostgresExtraRequired
66
+
67
+ # Reuse the canonical error so the CLI hint can never drift from the SDK's.
68
+ typer.echo(
69
+ f"Error: {RootSignPostgresExtraRequired(f'missing module: {exc.name}')}",
70
+ err=True,
71
+ )
72
+ raise typer.Exit(code=1) from exc
73
+ return factory
74
+
75
+
41
76
  @app.command()
42
77
  def version() -> None:
43
78
  """Print the installed RootSign SDK version."""
@@ -99,12 +134,17 @@ def _verify_local(path: Path, verbose: bool) -> None:
99
134
 
100
135
 
101
136
  def _verify_remote(session_id: UUID, verbose: bool) -> None:
102
- # Imported at module level (below) so tests can monkeypatch the symbol
103
- # to point at a test engine. See tests/integration/test_verify_cli.py.
137
+ # The session factory is resolved lazily via `_session_factory()`; tests
138
+ # monkeypatch the module-level `AsyncSessionLocal` seam to point at a test
139
+ # engine. See tests/integration/test_verify_cli.py.
104
140
  from rootsign.sdk.chain import verify_session
105
141
 
142
+ # Resolve the factory up front: a missing `postgres` extra should print the
143
+ # install hint and exit, not surface from inside asyncio.run().
144
+ factory = _session_factory()
145
+
106
146
  async def _run():
107
- async with AsyncSessionLocal() as db:
147
+ async with factory() as db:
108
148
  return await verify_session(session_id, db)
109
149
 
110
150
  result = asyncio.run(_run())
@@ -204,12 +244,16 @@ def _list_pending_approvals() -> None:
204
244
  Phase 2 will add `--session` and `--owner` filters once multi-tenant
205
245
  auth ships.
206
246
  """
247
+ # Before any DB import — otherwise a no-extras install tracebacks on
248
+ # `from sqlalchemy import select` instead of getting the install hint.
249
+ factory = _session_factory()
250
+
207
251
  from sqlalchemy import select
208
252
 
209
253
  from rootsign.models.action import Action
210
254
 
211
255
  async def _run() -> list[Action]:
212
- async with AsyncSessionLocal() as db:
256
+ async with factory() as db:
213
257
  result = await db.execute(
214
258
  select(Action)
215
259
  .where(Action.authorization_status == "pending")
@@ -241,6 +285,9 @@ def _submit_approval(*, action_id: UUID, decision: str, reason: str | None) -> N
241
285
  """
242
286
  import getpass
243
287
 
288
+ # Before any DB import — see _list_pending_approvals.
289
+ factory = _session_factory()
290
+
244
291
  from sqlalchemy import select
245
292
 
246
293
  from rootsign.crud.approval import approval as approval_crud
@@ -256,7 +303,7 @@ def _submit_approval(*, action_id: UUID, decision: str, reason: str | None) -> N
256
303
  approver_id = "cli:operator"
257
304
 
258
305
  async def _run() -> None:
259
- async with AsyncSessionLocal() as db:
306
+ async with factory() as db:
260
307
  result = await db.execute(
261
308
  select(Action).where(
262
309
  Action.action_id == action_id,
@@ -141,12 +141,10 @@ class ManagedLocalIngestClient(IngestClient):
141
141
  return self._session_factory
142
142
  # Lazy — the DB stack is the postgres extra (ADR-011). Translate a
143
143
  # missing extra into the actionable error, same as get_ingest_client.
144
- try:
145
- from rootsign.database import AsyncSessionLocal
146
- except ModuleNotFoundError as exc:
147
- from rootsign.errors import RootSignPostgresExtraRequired
144
+ from rootsign.errors import postgres_extra_required
148
145
 
149
- raise RootSignPostgresExtraRequired(f"missing module: {exc.name}") from exc
146
+ with postgres_extra_required():
147
+ from rootsign.database import AsyncSessionLocal
150
148
  self._session_factory = AsyncSessionLocal
151
149
  return self._session_factory
152
150